Skip to content

Commit e4cea7c

Browse files
authored
Merge pull request #284 from UC-Davis-molecular-computing/283-deal-with-non-unique-modification-vendor-codes
fixes #283: deal with non-unique Modification vendor codes
2 parents fc30862 + 37fa350 commit e4cea7c

2 files changed

Lines changed: 113 additions & 49 deletions

File tree

scadnano/scadnano.py

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -858,7 +858,10 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str:
858858
scaffold_key = 'scaffold'
859859
helices_view_order_key = 'helices_view_order'
860860
is_origami_key = 'is_origami'
861-
design_modifications_key = 'modifications_in_design'
861+
design_modifications_key = 'modifications_in_design' # legacy key for when we stored all mods in one dict
862+
design_modifications_5p_key = 'modifications_5p_in_design'
863+
design_modifications_3p_key = 'modifications_3p_in_design'
864+
design_modifications_int_key = 'modifications_int_in_design'
862865
geometry_key = 'geometry'
863866
groups_key = 'groups'
864867

@@ -966,12 +969,22 @@ class ModificationType(enum.Enum):
966969
five_prime = "5'"
967970
"""5' modification type"""
968971

969-
three_prime = "5'"
972+
three_prime = "3'"
970973
"""3' modification type"""
971974

972975
internal = "internal"
973976
"""internal modification type"""
974977

978+
def key(self) -> str:
979+
if self == ModificationType.five_prime:
980+
return design_modifications_5p_key
981+
elif self == ModificationType.three_prime:
982+
return design_modifications_3p_key
983+
elif self == ModificationType.internal:
984+
return design_modifications_int_key
985+
else:
986+
raise AssertionError(f'unknown ModificationType {self}')
987+
975988

976989
@dataclass(frozen=True, eq=True)
977990
class Modification(_JSONSerializable, ABC):
@@ -3962,16 +3975,20 @@ def default_export_name(self, unique_names: bool = False) -> str:
39623975
name = f'{start_helix}[{start_offset}]{forward_str}{end_helix}[{end_offset}]'
39633976
return f'SCAF{name}' if self.is_scaffold else f'ST{name}'
39643977

3965-
def set_modification_5p(self, mod: Modification5Prime = None) -> None:
3966-
"""Sets 5' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
3967-
if self.circular and mod is not None:
3978+
def set_modification_5p(self, mod: Modification5Prime) -> None:
3979+
"""Sets 5' modification to be `mod`. :any:`Strand.circular` must be False."""
3980+
if self.circular:
39683981
raise StrandError(self, "cannot have a 5' modification on a circular strand")
3982+
if not isinstance(mod, Modification5Prime):
3983+
raise TypeError(f'mod must be a Modification5Prime but it is type {type(mod)}: {mod}')
39693984
self.modification_5p = mod
39703985

3971-
def set_modification_3p(self, mod: Modification3Prime = None) -> None:
3972-
"""Sets 3' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
3986+
def set_modification_3p(self, mod: Modification3Prime) -> None:
3987+
"""Sets 3' modification to be `mod`. :any:`Strand.circular` must be False."""
39733988
if self.circular and mod is not None:
39743989
raise StrandError(self, "cannot have a 3' modification on a circular strand")
3990+
if not isinstance(mod, Modification3Prime):
3991+
raise TypeError(f'mod must be a Modification3Prime but it is type {type(mod)}: {mod}')
39753992
self.modification_3p = mod
39763993

39773994
def remove_modification_5p(self) -> None:
@@ -3999,6 +4016,8 @@ def set_modification_internal(self, idx: int, mod: ModificationInternal,
39994016
elif warn_on_no_dna:
40004017
print('WARNING: no DNA sequence has been assigned, so certain error checks on the internal '
40014018
'modification were not done. To be safe, first assign DNA, then add the modifications.')
4019+
if not isinstance(mod, ModificationInternal):
4020+
raise TypeError(f'mod must be a ModificationInternal but it is type {type(mod)}: {mod}')
40024021
self.modifications_int[idx] = mod
40034022

40044023
def remove_modification_internal(self, idx: int) -> None:
@@ -5763,10 +5782,27 @@ def from_scadnano_json_map(
57635782
strand = Strand.from_json(strand_json)
57645783
strands.append(strand)
57655784

5766-
# modifications in whole design
5785+
mods_5p: Dict[str, Modification5Prime] = {}
5786+
mods_3p: Dict[str, Modification3Prime] = {}
5787+
mods_int: Dict[str, ModificationInternal] = {}
5788+
for all_mods_key, mods in zip([design_modifications_5p_key,
5789+
design_modifications_3p_key,
5790+
design_modifications_int_key], [mods_5p, mods_3p, mods_int]):
5791+
if all_mods_key in json_map:
5792+
all_mods_json = json_map[all_mods_key]
5793+
for mod_key, mod_json in all_mods_json.items():
5794+
mod = Modification.from_json(mod_json)
5795+
if mod_key != mod.vendor_code:
5796+
print(f'WARNING: key {mod_key} does not match vendor_code field {mod.vendor_code}'
5797+
f'for modification {mod}\n'
5798+
f'replacing with key = {mod.vendor_code}')
5799+
mod = dataclasses.replace(mod, vendor_code=mod_key)
5800+
mods[mod_key] = mod
5801+
5802+
# legacy code; now we stored modifications in 3 separate dicts depending on 5', 3', internal
5803+
all_mods: Dict[str, Modification] = {}
57675804
if design_modifications_key in json_map:
57685805
all_mods_json = json_map[design_modifications_key]
5769-
all_mods = {}
57705806
for mod_key, mod_json in all_mods_json.items():
57715807
mod = Modification.from_json(mod_json)
57725808
if mod_key != mod.vendor_code:
@@ -5775,7 +5811,8 @@ def from_scadnano_json_map(
57755811
f'replacing with key = {mod.vendor_code}')
57765812
mod = dataclasses.replace(mod, vendor_code=mod_key)
57775813
all_mods[mod_key] = mod
5778-
Design.assign_modifications_to_strands(strands, strand_jsons, all_mods)
5814+
5815+
Design.assign_modifications_to_strands(strands, strand_jsons, mods_5p, mods_3p, mods_int, all_mods)
57795816

57805817
geometry = None
57815818
if geometry_key in json_map:
@@ -5831,19 +5868,25 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> D
58315868
self.helices_view_order) if suppress_indent else self.helices_view_order
58325869

58335870
# modifications
5834-
mods = self.modifications()
5835-
if len(mods) > 0:
5836-
mods_dict = {}
5837-
for mod in mods:
5838-
if mod.vendor_code not in mods_dict:
5839-
mods_dict[mod.vendor_code] = mod.to_json_serializable(suppress_indent)
5840-
else:
5841-
if mod != mods_dict[mod.vendor_code]:
5842-
raise IllegalDesignError(f"Modifications must have unique vendor codes, but I found"
5843-
f"two different Modifications that share vendor code "
5844-
f"{mod.vendor_code}:\n{mod}\nand\n"
5845-
f"{mods_dict[mod.vendor_code]}")
5846-
dct[design_modifications_key] = mods_dict
5871+
for mod_type in [ModificationType.five_prime,
5872+
ModificationType.three_prime,
5873+
ModificationType.internal]:
5874+
mods = self.modifications(mod_type)
5875+
mod_key = mod_type.key()
5876+
if len(mods) > 0:
5877+
mods_dict = {}
5878+
for mod in mods:
5879+
if mod.vendor_code not in mods_dict:
5880+
mods_dict[mod.vendor_code] = mod.to_json_serializable(suppress_indent)
5881+
else:
5882+
if mod != mods_dict[mod.vendor_code]:
5883+
raise IllegalDesignError(
5884+
f"Modifications of type {mod_type} must have unique vendor codes, "
5885+
f"but I foundtwo different Modifications of that type "
5886+
f"that share vendor code "
5887+
f"{mod.vendor_code}:\n{mod}\nand\n"
5888+
f"{mods_dict[mod.vendor_code]}")
5889+
dct[mod_key] = mods_dict
58475890

58485891
dct[strands_key] = [strand.to_json_serializable(suppress_indent) for strand in self.strands]
58495892

@@ -5940,19 +5983,34 @@ def base_pairs(self, allow_mismatches: bool = False) -> Dict[int, List[int]]:
59405983

59415984
@staticmethod
59425985
def assign_modifications_to_strands(strands: List[Strand], strand_jsons: List[dict],
5986+
mods_5p: Dict[str, Modification5Prime],
5987+
mods_3p: Dict[str, Modification3Prime],
5988+
mods_int: Dict[str, ModificationInternal],
59435989
all_mods: Dict[str, Modification]) -> None:
5990+
if len(all_mods) > 0: # legacy code for when modifications were stored in a single dict
5991+
assert len(mods_5p) == 0 and len(mods_3p) == 0 and len(mods_int) == 0
5992+
legacy = True
5993+
elif len(mods_5p) > 0 or len(mods_3p) > 0 or len(mods_int) > 0:
5994+
assert len(all_mods) == 0
5995+
legacy = False
5996+
else: # no modifications
5997+
return
5998+
59445999
for strand, strand_json in zip(strands, strand_jsons):
59456000
if modification_5p_key in strand_json:
5946-
mod_name = strand_json[modification_5p_key]
5947-
strand.modification_5p = cast(Modification5Prime, all_mods[mod_name])
6001+
mod_code = strand_json[modification_5p_key]
6002+
strand.modification_5p = cast(Modification5Prime, all_mods[mod_code]) \
6003+
if legacy else mods_5p[mod_code]
59486004
if modification_3p_key in strand_json:
5949-
mod_name = strand_json[modification_3p_key]
5950-
strand.modification_3p = cast(Modification3Prime, all_mods[mod_name])
6005+
mod_code = strand_json[modification_3p_key]
6006+
strand.modification_3p = cast(Modification3Prime, all_mods[mod_code]) \
6007+
if legacy else mods_3p[mod_code]
59516008
if modifications_int_key in strand_json:
59526009
mod_names_by_offset = strand_json[modifications_int_key]
5953-
for offset_str, mod_name in mod_names_by_offset.items():
6010+
for offset_str, mod_code in mod_names_by_offset.items():
59546011
offset = int(offset_str)
5955-
strand.modifications_int[offset] = cast(ModificationInternal, all_mods[mod_name])
6012+
strand.modifications_int[offset] = cast(ModificationInternal, all_mods[mod_code]) \
6013+
if legacy else mods_int[mod_code]
59566014

59576015
@staticmethod
59586016
def _cadnano_v2_import_find_5_end(vstrands: VStrands, strand_type: str, helix_num: int, base_id: int,
@@ -6079,7 +6137,7 @@ def _cadnano_v2_import_explore_domains(vstrands: VStrands, seen: Dict[Tuple[int,
60796137
@staticmethod
60806138
def _cadnano_v2_import_circular_strands_merge_first_last_domains(domains: List[Domain]) -> None:
60816139
""" When we create domains for circular strands in the cadnano import routine, we may end up
6082-
with a fake crossover if first and last domain are on same helix, we have to merge them
6140+
with a fake crossover if first and last domain are on same helix, we have to merge them
60836141
if it is the case.
60846142
"""
60856143
if domains[0].helix != domains[-1].helix:
@@ -6210,9 +6268,9 @@ def from_cadnano_v2(directory: str = '', filename: Optional[str] = None,
62106268
# TS: Dave, I have thorougly checked the code of Design constructor and the order of the helices
62116269
# IS lost even if the helices were give as a list.
62126270
# Indeed, you very early call `_normalize_helices_as_dict` in the constructor the order is lost.
6213-
# Later in the code, if no view order was given the code will choose the identity
6271+
# Later in the code, if no view order was given the code will choose the identity
62146272
# in function `_check_helices_view_order_and_return`.
6215-
# Conclusion: do not assume that your constructor code deals with the ordering, even if
6273+
# Conclusion: do not assume that your constructor code deals with the ordering, even if
62166274
# input helices is a list. I am un commenting the below:
62176275
design.set_helices_view_order([num for num in helices])
62186276

@@ -7641,7 +7699,7 @@ def _write_plates_default(self, directory: str, filename: Optional[str], strands
76417699

76427700
# IDT charges extra for a plate with < 24 strands for 96-well plate
76437701
# or < 96 strands for 384-well plate.
7644-
# So if we would have fewer than that many on the last plate,
7702+
# So if we would have fewer than that many on the last plate,
76457703
# shift some from the penultimate plate.
76467704
if not on_final_plate and \
76477705
final_plate_less_than_min_required and \
@@ -7670,7 +7728,7 @@ def to_oxview_format(self, warn_duplicate_strand_names: bool = True,
76707728
have duplicate names. (default: True)
76717729
:param use_strand_colors:
76727730
if True (default), sets the color of each nucleotide in a strand in oxView to the color
7673-
of the strand.
7731+
of the strand.
76747732
"""
76757733
import datetime
76767734
self._check_legal_design(warn_duplicate_strand_names)
@@ -8184,7 +8242,8 @@ def ligate(self, helix: int, offset: int, forward: bool) -> None:
81848242
strand_3p.domains.append(dom_new)
81858243
strand_3p.domains.extend(strand_5p.domains[1:])
81868244
strand_3p.is_scaffold = strand_left.is_scaffold or strand_right.is_scaffold
8187-
strand_3p.set_modification_3p(strand_5p.modification_3p)
8245+
if strand_5p.modification_3p is not None:
8246+
strand_3p.set_modification_3p(strand_5p.modification_3p)
81888247
for idx, mod in strand_5p.modifications_int.items():
81898248
new_idx = idx + strand_3p.dna_length()
81908249
strand_3p.set_modification_internal(new_idx, mod)

tests/scadnano_tests.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -637,16 +637,17 @@ def test_to_json__names_unique_for_modifications_raises_no_error(self) -> None:
637637
sc.Modification3Prime(display_text=name, vendor_code=name + '3'))
638638
design.to_json(True)
639639

640-
def test_to_json__names_not_unique_for_modifications_raises_error(self) -> None:
640+
def test_to_json__names_not_unique_for_modifications_5p_raises_error(self) -> None:
641641
helices = [sc.Helix(max_offset=100)]
642642
design: sc.Design = sc.Design(helices=helices, strands=[], grid=sc.square)
643-
name = 'mod_name'
643+
code1 = 'mod_code1'
644+
code2 = 'mod_code2'
644645
design.draw_strand(0, 0).move(5).with_modification_5p(
645-
sc.Modification5Prime(display_text=name, vendor_code=name))
646-
design.draw_strand(0, 5).move(5).with_modification_3p(
647-
sc.Modification3Prime(display_text=name, vendor_code=name))
646+
sc.Modification5Prime(display_text=code1, vendor_code=code1))
647+
design.draw_strand(0, 5).move(5).with_modification_5p(
648+
sc.Modification5Prime(display_text=code2, vendor_code=code1))
648649
with self.assertRaises(sc.IllegalDesignError):
649-
design.to_json(True)
650+
design.to_json(False)
650651

651652
def test_mod_illegal_exceptions_raised(self) -> None:
652653
strand = sc.Strand(domains=[sc.Domain(0, True, 0, 5)], dna_sequence='AATGC')
@@ -793,18 +794,22 @@ def test_to_json_serializable(self) -> None:
793794
# print(design.to_json())
794795

795796
json_dict = design.to_json_serializable(suppress_indent=False)
796-
self.assertTrue(sc.design_modifications_key in json_dict)
797-
mods_dict = json_dict[sc.design_modifications_key]
798-
self.assertTrue(r'/5Biosg/' in mods_dict)
799-
self.assertTrue(r'/3Bio/' in mods_dict)
800-
self.assertTrue(r'/iBiodT/' in mods_dict)
801-
802-
biotin5_json = mods_dict[r'/5Biosg/']
797+
self.assertTrue(sc.design_modifications_5p_key in json_dict)
798+
self.assertTrue(sc.design_modifications_3p_key in json_dict)
799+
self.assertTrue(sc.design_modifications_int_key in json_dict)
800+
mods_5p_dict = json_dict[sc.design_modifications_5p_key]
801+
self.assertTrue(r'/5Biosg/' in mods_5p_dict)
802+
mods_3p_dict = json_dict[sc.design_modifications_3p_key]
803+
self.assertTrue(r'/3Bio/' in mods_3p_dict)
804+
mods_int_dict = json_dict[sc.design_modifications_int_key]
805+
self.assertTrue(r'/iBiodT/' in mods_int_dict)
806+
807+
biotin5_json = mods_5p_dict[r'/5Biosg/']
803808
self.assertEqual('/5Biosg/', biotin5_json[sc.mod_vendor_code_key])
804809
self.assertEqual('B', biotin5_json[sc.mod_display_text_key])
805810
self.assertEqual(6, biotin5_json[sc.mod_connector_length_key])
806811

807-
biotin3_json = mods_dict[r'/3Bio/']
812+
biotin3_json = mods_3p_dict[r'/3Bio/']
808813
self.assertEqual('/3Bio/', biotin3_json[sc.mod_vendor_code_key])
809814
self.assertEqual('B', biotin3_json[sc.mod_display_text_key])
810815
self.assertNotIn(sc.mod_connector_length_key, biotin3_json)

0 commit comments

Comments
 (0)