diff --git a/examples/circular.py b/examples/circular.py new file mode 100644 index 00000000..21168368 --- /dev/null +++ b/examples/circular.py @@ -0,0 +1,18 @@ +import scadnano as sc +import modifications as mod +import dataclasses + +def create_design(): + helices = [sc.Helix(max_offset=30) for _ in range(2)] + design = sc.Design(helices=helices, grid=sc.square) + + design.strand(0,0).move(10).cross(1).move(-10).as_circular() + design.strand(0,10).move(10).loopout(1, 5).move(-10).as_circular() + design.strand(0,20).move(10).cross(1).move(-10) + + return design + + +if __name__ == '__main__': + design = create_design() + design.write_scadnano_file(directory='output_designs') diff --git a/examples/output_designs/circular.sc b/examples/output_designs/circular.sc new file mode 100644 index 00000000..82f0bd44 --- /dev/null +++ b/examples/output_designs/circular.sc @@ -0,0 +1,34 @@ +{ + "version": "0.13.0", + "grid": "square", + "helices": [ + {"grid_position": [0, 0]}, + {"grid_position": [0, 1]} + ], + "strands": [ + { + "circular": true, + "color": "#f74308", + "domains": [ + {"helix": 0, "forward": true, "start": 0, "end": 10}, + {"helix": 1, "forward": false, "start": 0, "end": 10} + ] + }, + { + "circular": true, + "color": "#57bb00", + "domains": [ + {"helix": 0, "forward": true, "start": 10, "end": 20}, + {"loopout": 5}, + {"helix": 1, "forward": false, "start": 10, "end": 20} + ] + }, + { + "color": "#888888", + "domains": [ + {"helix": 0, "forward": true, "start": 20, "end": 30}, + {"helix": 1, "forward": false, "start": 20, "end": 30} + ] + } + ] +} \ No newline at end of file diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index a0b0459c..79fa077d 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -749,6 +749,7 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: # Strand keys strand_name_key = 'name' +circular_key = 'circular' color_key = 'color' dna_sequence_key = 'sequence' legacy_dna_sequence_keys = ['dna_sequence'] # support legacy names for these ideas @@ -2201,6 +2202,17 @@ def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': return self + def as_circular(self) -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + Makes :any:`Strand` being built circular. + + :return: self + """ + if self._strand is None: + raise ValueError('no Strand created yet; make at least one domain first') + self._strand.set_circular() + return self + # remove quotes when Py3.6 support dropped def as_scaffold(self) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ @@ -2477,6 +2489,13 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): and could be either single-stranded or double-stranded, whereas each :any:`Loopout` is single-stranded and has no associated :any:`Helix`.""" + circular: bool = False + """If True, this :any:`Strand` is circular and has no 5' or 3' end. Although there is still a + first and last :any:`Domain`, we interpret there to be a crossover from the 3' end of the last domain + to the 5' end of the first domain, and any circular permutation of :py:data:`Strand.domains` + should result in a functionally equivalent :any:`Strand`. It is illegal to have a + :any:`Modification5Prime` or :any:`Modification3Prime` on a circular :any:`Strand`.""" + dna_sequence: Optional[str] = None """Do not assign directly to this field. Always use :any:`Design.assign_dna` (for complementarity checking) or :any:`Strand.set_dna_sequence` @@ -2509,10 +2528,14 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): :any:`Design` is a scaffold, then the design is considered a DNA origami design.""" modification_5p: Optional[Modification5Prime] = None - """5' modification; None if there is no 5' modification.""" + """ + 5' modification; None if there is no 5' modification. Illegal to have if :py:`Strand.circular` is True. + """ modification_3p: Optional[Modification3Prime] = None - """3' modification; None if there is no 5' modification.""" + """ + 3' modification; None if there is no 3' modification. Illegal to have if :py:`Strand.circular` is True. + """ modifications_int: Dict[int, ModificationInternal] = field(default_factory=dict) """:any:`Modification`'s to the DNA sequence (e.g., biotin, Cy3/Cy5 fluorphores). Maps offset to @@ -2574,6 +2597,8 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> D dct: Dict[str, Any] = OrderedDict() if self.name is not None: dct[strand_name_key] = self.name + if self.circular: + dct[circular_key] = self.circular if self.color is not None: dct[color_key] = self.color.to_json_serializable(suppress_indent) if self.dna_sequence is not None: @@ -2617,6 +2642,7 @@ def from_json(json_map: dict) -> 'Strand': # remove quotes when Py3.6 support d raise IllegalDesignError('Loopout at end of Strand not supported') is_scaffold = json_map.get(is_scaffold_key, False) + circular = json_map.get(circular_key, False) dna_sequence = optional_field(None, json_map, dna_sequence_key, *legacy_dna_sequence_keys) @@ -2638,6 +2664,7 @@ def decimal_int_to_hex(d: int) -> str: return Strand( domains=domains, dna_sequence=dna_sequence, + circular=circular, color=color, idt=idt, is_scaffold=is_scaffold, @@ -2699,6 +2726,28 @@ def set_color(self, color: Color) -> None: """Sets color of this :any:`Strand`.""" self.color = color + def set_circular(self, circular: bool = True) -> None: + """ + Sets this to be a circular :any:`Strand` (or non-circular if optional parameter is False. + + :param circular: + whether to make this :any:`Strand` circular (True) or linear (False) + :raises StrandError: + if this :any:`Strand` has a 5' or 3' modification + """ + if circular and self.modification_5p is not None: + raise StrandError(self, "cannot have a 5' modification on a circular strand") + if circular and self.modification_3p is not None: + raise StrandError(self, "cannot have a 3' modification on a circular strand") + self.circular = circular + + def set_linear(self) -> None: + """ + Makes this a linear (non-circular) :any:`Strand`. Equivalent to calling + `self.set_circular(False)`. + """ + self.set_circular(False) + def set_default_idt(self, use_default_idt: bool = True, skip_scaffold: bool = True, unique_names: bool = False) -> None: """ @@ -2744,11 +2793,15 @@ def set_default_idt(self, use_default_idt: bool = True, skip_scaffold: bool = Tr self.idt = None def set_modification_5p(self, mod: Modification5Prime = None) -> None: - """Sets 5' modification to be `mod`.""" + """Sets 5' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True.""" + if self.circular and mod is not None: + raise StrandError(self, "cannot have a 5' modification on a circular strand") self.modification_5p = mod def set_modification_3p(self, mod: Modification3Prime = None) -> None: - """Sets 3' modification to be `mod`.""" + """Sets 3' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True.""" + if self.circular and mod is not None: + raise StrandError(self, "cannot have a 3' modification on a circular strand") self.modification_3p = mod def remove_modification_5p(self) -> None: diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index fc584bd0..bf58fc15 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3601,6 +3601,46 @@ def set_colors_black(*strands) -> None: strand.set_color(sc.Color(r=0, g=0, b=0)) +class TestCircularStrands(unittest.TestCase): + + def setUp(self) -> None: + helices = [sc.Helix(max_offset=10) for _ in range(2)] + self.design = sc.Design(helices=helices, strands=[]) + self.design.strand(0, 0).move(10).cross(1).move(-10) + self.strand = self.design.strands[0] + r''' + 0 [--------\ + | + 1 <--------/ + ''' + + def test_can_add_internal_mod_to_circular_strand(self) -> None: + self.strand.set_circular() + self.assertEqual(True, self.strand.circular) + self.strand.set_modification_internal(2, mod.biotin_int) + self.assertEqual(1, len(self.strand.modifications_int)) + + def test_cannot_make_strand_circular_if_5p_mod(self) -> None: + self.strand.set_modification_5p(mod.biotin_5p) + with self.assertRaises(sc.StrandError): + self.strand.set_circular(True) + + def test_cannot_make_strand_circular_if_3p_mod(self) -> None: + self.strand.set_modification_3p(mod.biotin_3p) + with self.assertRaises(sc.StrandError): + self.strand.set_circular(True) + + def test_add_5p_mod_to_circular_strand(self) -> None: + self.strand.set_circular(True) + with self.assertRaises(sc.StrandError): + self.strand.set_modification_5p(mod.biotin_5p) + + def test_add_3p_mod_to_circular_strand(self) -> None: + self.strand.set_circular(True) + with self.assertRaises(sc.StrandError): + self.strand.set_modification_3p(mod.biotin_3p) + + class TestAddStrand(unittest.TestCase): def test_add_strand__with_loopout(self) -> None: