From c8a8970061540e1a829979b6b55ef4d34bf4a5a6 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:42:58 +0100 Subject: [PATCH 01/73] Update scadnano.py --- scadnano/scadnano.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index bbd2f4e4..071b25ad 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -3964,7 +3964,8 @@ def to_table( :param title_level: The "title" is the first line of the returned string, which contains the plate's name and volume to pipette. The `title_level` controls the size, with 1 being the largest size, - (header level 1, e.g., # title in Markdown or

title

in HTML). + (header level 1, e.g., # title in Markdown or

title

in HTML) + and 6 being the smallest size. :param warn_unsupported_title_format: If True, prints a warning if `tablefmt` is a currently unsupported option for the title. The currently supported formats for the title are 'github', 'html', 'unsafehtml', 'rst', From d4820665a2df1a3e876ba8b987451e5fc111cade Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:48:05 +0100 Subject: [PATCH 02/73] fixed docstring with string literal `'\n\n'` --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 071b25ad..40f59bb9 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -3939,7 +3939,7 @@ def to_table( .. code-block:: python plate_maps = design.plate_maps() - maps_strs = '\n\n'.join(plate_map.to_table() for plate_map in plate_maps) + maps_strs = '\\n\\n'.join(plate_map.to_table() for plate_map in plate_maps) from IPython.display import display, Markdown display(Markdown(maps_strs)) From 03f1142bccde53df270a746aa535b0a1cd3c9f20 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 6 Apr 2022 14:04:53 +0100 Subject: [PATCH 03/73] closes #228: option to export cadnano with no whitespace --- scadnano/scadnano.py | 30 +++++++++++++++++++++++++----- tests/scadnano_tests.py | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index bbd2f4e4..805c9e40 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -5597,22 +5597,33 @@ def to_cadnano_v2_serializable(self, name: str = '') -> Dict[str, Any]: return dct - def to_cadnano_v2_json(self, name: str = '') -> str: + def to_cadnano_v2_json(self, name: str = '', whitespace: bool = True) -> str: """Converts the design to the cadnano v2 format. Please see the spec https://github.com/UC-Davis-molecular-computing/scadnano-python-package/blob/main/misc/cadnano-format-specs/v2.txt for more info on that format. + If the cadnano file is intended to be used with CanDo (https://cando-dna-origami.org/), + the optional parameter `whitespace` must be set to False. + :param name: Name of the design. + :param whitespace: + Whether to include whitespace in the exported file. Set to False to use this with CanDo + (https://cando-dna-origami.org/), since that tool generates an error if the cadnano file + contains whitespace. :return: a string in the cadnano v2 format representing this :any:`Design` """ content_serializable = self.to_cadnano_v2_serializable(name) encoder = _SuppressableIndentEncoder - return json.dumps(content_serializable, cls=encoder, indent=2) + content = json.dumps(content_serializable, cls=encoder, indent=2) + if not whitespace: + # remove whitespace + content = ''.join(content.split()) + return content def set_helices_view_order(self, helices_view_order: List[int]) -> None: """ @@ -6623,15 +6634,24 @@ def write_scadnano_file(self, directory: str = '.', filename: Optional[str] = No extension = default_scadnano_file_extension write_file_same_name_as_running_python_script(contents, extension, directory, filename) - def write_cadnano_v2_file(self, directory: str = '.', filename: Optional[str] = None) -> None: + def write_cadnano_v2_file(self, directory: str = '.', filename: Optional[str] = None, + whitespace: bool = True) -> None: """Write ``.json`` file representing this :any:`Design`, suitable for reading by cadnano v2. The string written is that returned by :meth:`Design.to_cadnano_v2`. + If the cadnano file is intended to be used with CanDo (https://cando-dna-origami.org/), + the optional parameter `whitespace` must be set to False. + :param directory: directory in which to place the file, either absolute or relative to the current working directory. Default is the current working directory. + :param whitespace: + Whether to include whitespace in the exported file. Set to False to use this with CanDo + (https://cando-dna-origami.org/), since that tool generates an error if the cadnano file + contains whitespace. + :param filename: The output file has the same name as the running script but with ``.py`` changed to ``.json``, unless `filename` is explicitly specified. @@ -6639,8 +6659,8 @@ def write_cadnano_v2_file(self, directory: str = '.', filename: Optional[str] = then if filename is not specified, the design will be written to ``my_origami.json``. """ name = _get_filename_same_name_as_running_python_script(directory, 'json', filename) - write_file_same_name_as_running_python_script(self.to_cadnano_v2_json(name), 'json', directory, - filename) + content = self.to_cadnano_v2_json(name=name, whitespace=whitespace) + write_file_same_name_as_running_python_script(content, 'json', directory, filename) def add_nick(self, helix: int, offset: int, forward: bool, new_color: bool = True) -> None: """Add nick to :any:`Domain` on :any:`Helix` with index `helix`, diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 7bc6ee9e..07c935c6 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -1179,6 +1179,24 @@ def test_6_helix_origami_rectangle(self) -> None: output_design = sc.Design.from_cadnano_v2(json_dict=json.loads(output_json)) # To help with debugging, uncomment these lines to write out the self.assertEqual(6, len(output_design.helices)) + + # scadnano and/or cadnano file + # + # design.write_scadnano_file(directory=self.input_path, + # filename=f'test_6_helix_origami_rectangle.{self.ext}') + # design.write_cadnano_v2_file(directory=self.output_path, + # filename='test_6_helix_origami_rectangle.json') + + def test_export_no_whitespace(self) -> None: + design = rect.create(num_helices=6, num_cols=10, nick_pattern=rect.staggered, + twist_correction_deletion_spacing=3) + output_json_with_space = design.to_cadnano_v2_json(whitespace=True) + self.assertIn(' ', output_json_with_space) + self.assertIn('\n', output_json_with_space) + output_json_no_space = design.to_cadnano_v2_json(whitespace=False) + self.assertNotIn(' ', output_json_no_space) + self.assertNotIn('\n', output_json_no_space) + # scadnano and/or cadnano file # # design.write_scadnano_file(directory=self.input_path, From f25a28f7673cbfc89e64678ab91ce8b91c9f7dd8 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 12 Apr 2022 14:08:18 +0100 Subject: [PATCH 04/73] added `with_deletions` and `with_insertions` methods to `StrandBuilder` and used them in unit tests --- scadnano/scadnano.py | 108 ++++++++++++++++++++++++++++++++++++---- tests/scadnano_tests.py | 102 +++++++++++++++++-------------------- 2 files changed, 145 insertions(+), 65 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index b41d2f39..1809dbf7 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2529,10 +2529,11 @@ def with_domain_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel Assigns `name` as of the most recently created :any:`Domain` or :any:`Loopout` in the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created via a call to - :py:meth:`StrandBuilder.to`, - :py:meth:`StrandBuilder.update_to`, + :meth:`StrandBuilder.to`, + :meth:`StrandBuilder.move`, + :meth:`StrandBuilder.update_to`, or - :py:meth:`StrandBuilder.loopout`, e.g., + :meth:`StrandBuilder.loopout`, e.g., .. code-block:: Python @@ -2551,17 +2552,20 @@ def with_domain_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel def with_domain_label(self, label: DomainLabel) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ Assigns `label` as label of the most recently created :any:`Domain` or :any:`Loopout` in - the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created - via a call to - :py:meth:`StrandBuilder.to`, - :py:meth:`StrandBuilder.update_to`, + the :any:`Strand` being built. This should be called immediately after + a :any:`Domain` or :any:`Loopout` is created via a call to + :meth:`StrandBuilder.to`, + :meth:`StrandBuilder.move`, + :meth:`StrandBuilder.update_to`, or - :py:meth:`StrandBuilder.loopout`, e.g., + :meth:`StrandBuilder.loopout`, e.g., .. code-block:: Python - design.draw_strand(0, 5).to(8).with_domain_label('domain 1')\\ - .cross(1).to(5).with_domain_label('domain 2')\\ + design.draw_strand(0, 5)\\ + .to(8).with_domain_label('domain 1')\\ + .cross(1)\\ + .to(5).with_domain_label('domain 2')\\ .loopout(2, 4).with_domain_label('domain 3')\\ .to(10).with_domain_label('domain 4') @@ -2574,6 +2578,90 @@ def with_domain_label(self, label: DomainLabel) -> 'StrandBuilder[StrandLabel, D last_domain.set_label(label) return self + def with_deletions(self, + deletions: Union[int, Iterable[int]]) -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + Assigns `deletions` as the deletion(s) of the most recently created + :any:`Domain` the :any:`Strand` being built. This should be called immediately after + a :any:`Domain` is created via a call to + :meth:`StrandBuilder.to`, + :meth:`StrandBuilder.move`, + :meth:`StrandBuilder.update_to`, e.g., + + .. code-block:: Python + + design.draw_strand(0, 0)\\ + .move(8).with_deletions(4)\\ + .cross(1)\\ + .move(-8).with_deletions([2, 3]) + + :param deletions: + a single int, or an Iterable of ints, indicating the offset at which to put the deletion(s) + :return: self + """ + if self._strand is None: + raise ValueError('no Strand created yet; make at least one domain first') + last_domain = self._strand.domains[-1] + + if not isinstance(last_domain, Domain): + raise ValueError(f'can only create a deletion on a bound Domain, not a {type(last_domain)};\n' + f'be sure only to call with_deletions immediately after a call to ' + f'to, move, or update_to') + if not isinstance(deletions, int) and not hasattr(deletions, '__iter__'): + raise ValueError(f'deletions must be a single int or an iterable of ints, ' + f'but it is {type(deletions)}') + if isinstance(deletions, int): + last_domain.deletions = [deletions] + else: + last_domain.deletions = list(deletions) + + return self + + def with_insertions(self, insertions: Union[Tuple[int, int], Iterable[Tuple[int, int]]]) \ + -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + Assigns `insertions` as the insertion(s) of the most recently created + :any:`Domain` the :any:`Strand` being built. This should be called immediately after + a :any:`Domain` is created via a call to + :meth:`StrandBuilder.to`, + :meth:`StrandBuilder.move`, + :meth:`StrandBuilder.update_to`, e.g., + + .. code-block:: Python + + design.draw_strand(0, 0)\\ + .move(8).with_insertions((4, 2))\\ + .cross(1)\\ + .move(-8).with_insertions([(2, 3), (3, 3)]) + + :param insertions: + a single pair of ints (tuple), or an Iterable of pairs of ints (tuples) + indicating the offset at which to put the insertion(s) + :return: self + """ + if self._strand is None: + raise ValueError('no Strand created yet; make at least one domain first') + last_domain = self._strand.domains[-1] + if not isinstance(last_domain, Domain): + raise ValueError(f'can only create an insertion on a bound Domain, not a {type(last_domain)};\n' + f'be sure only to call with_insertions immediately after a call to ' + f'to, move, or update_to') + + type_msg = (f'insertions must be a single pair of ints or an iterable of pairs of ints, ' + f'but it is {type(insertions)}') + + if not hasattr(insertions, '__iter__'): + raise ValueError(type_msg) + if isinstance(insertions, tuple) and len(insertions) > 0 and isinstance(insertions[0], int): + last_domain.insertions = [insertions] + else: + for ins in insertions: + if not (isinstance(ins, tuple) and len(ins) > 0 and isinstance(ins[0], int)): + raise ValueError(type_msg) + last_domain.insertions = list(insertions) + + return self + @dataclass class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 07c935c6..931993a4 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -1710,6 +1710,26 @@ class TestInlineInsDel(unittest.TestCase): Tests inlining of insertions/deletions. """ + def setUp(self) -> None: + self.design = sc.Design( + helices=[sc.Helix(max_offset=24, major_tick_distance=8)], + strands=[], + grid=sc.square) + + + def test_no_deletion_after_loopout(self) -> None: + # not really a test of inlining, but I added the with_deletions and with_insertions to help these + # tests, so easier just to test this behavior here + with self.assertRaises(ValueError): + self.design.draw_strand(0, 0).move(8).loopout(0, 5, 10).with_deletions(4) + + def test_no_insertion_after_loopout(self) -> None: + # not really a test of inlining, but I added the with_deletions and with_insertions to help these + # tests, so easier just to test this behavior here + with self.assertRaises(ValueError): + self.design.draw_strand(0, 0).move(8).loopout(0, 5, 10).with_insertions((4, 2)) + + def test_inline_deletions_insertions__one_deletion(self) -> None: """ before @@ -1722,10 +1742,8 @@ def test_inline_deletions_insertions__one_deletion(self) -> None: | | | | 0 [-----> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[4])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(8).with_deletions(4) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=7) @@ -1753,10 +1771,8 @@ def test_inline_deletions_insertions__two_deletions(self) -> None: | | | | 0 [----> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[2, 4])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(8).with_deletions([2, 4]) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=22, major_ticks=[0, 6, 14, 22], start=0, end=6) @@ -1772,10 +1788,8 @@ def test_inline_deletions_insertions__one_insertion(self) -> None: | | | | 0 [-------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 8, insertions=[(4, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(8).with_insertions((4, 1)) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=9) @@ -1791,10 +1805,8 @@ def test_inline_deletions_insertions__two_insertions(self) -> None: | | | | 0 [----------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 8, insertions=[(2, 3), (4, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(8).with_insertions([(2, 3), (4, 1)]) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=28, major_ticks=[0, 12, 20, 28], start=0, end=12) @@ -1810,10 +1822,8 @@ def test_inline_deletions_insertions__one_deletion_one_insertion(self) -> None: | | | | 0 [--------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[4], insertions=[(2, 3)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(8).with_deletions(4).with_insertions((2, 3)) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=26, major_ticks=[0, 10, 18, 26], start=0, end=10) @@ -1829,10 +1839,8 @@ def test_inline_deletions_insertions__one_deletion_right_of_major_tick(self) -> | | | | 0 [---------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[9])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_deletions(9) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) @@ -1849,10 +1857,8 @@ def test_inline_deletions_insertions__one_deletion_on_major_tick(self) -> None: | . . . . . . . | . . . . . . | . . . . . . . | [ - - - - - - - - - > """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[8])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_deletions(8) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) @@ -1868,10 +1874,8 @@ def test_inline_deletions_insertions__one_deletion_left_of_major_tick(self) -> N | | | | 0 [---------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[7])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_deletions(7) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=11) @@ -1887,10 +1891,8 @@ def test_inline_deletions_insertions__one_insertion_right_of_major_tick(self) -> | | | | 0 [-----------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(9, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_insertions((9, 1)) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) @@ -1906,10 +1908,8 @@ def test_inline_deletions_insertions__one_insertion_on_major_tick(self) -> None: | | | | 0 [-----------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(8, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_insertions((8, 1)) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) @@ -1925,10 +1925,8 @@ def test_inline_deletions_insertions__one_insertion_left_of_major_tick(self) -> | | | | 0 [-----------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(7, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(12).with_insertions((7, 1)) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=13) @@ -1944,10 +1942,8 @@ def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains(s | | | | 0 [-------------------------> """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[sc.Strand([sc.Domain(0, True, 0, 24, deletions=[19], insertions=[(5, 2), (11, 1)])])], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(24).with_deletions(19).with_insertions([(5, 2), (11, 1)]) design.inline_deletions_insertions() self.helix0_strand0_inlined_test(design, max_offset=26, major_ticks=[0, 10, 19, 26], start=0, end=26) @@ -1964,13 +1960,9 @@ def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains_t | . . . . . . . . | . . . . . . . . | . . . . . . | [ - - - - - - - - - - - - - - > [ - - - - - - - > """ - design = sc.Design( - helices=[sc.Helix(max_offset=24, major_tick_distance=8)], - strands=[ - sc.Strand([sc.Domain(0, True, 0, 14, deletions=[2], insertions=[(5, 2), (10, 1)])]), - sc.Strand([sc.Domain(0, True, 14, 24, deletions=[19])]), - ], - grid=sc.square) + design = self.design + design.draw_strand(0, 0).move(14).with_deletions(2).with_insertions([(5, 2), (10, 1)]) + design.draw_strand(0, 14).to(24).with_deletions(19) design.inline_deletions_insertions() self.assertEqual(1, len(design.helices)) self.assertEqual(2, len(design.strands)) From 00451a88075f288d633ccf556f7976fab19bafd1 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 12 Apr 2022 14:32:01 +0100 Subject: [PATCH 05/73] added check for deletion/insertion in range in `StrandBuilder.with_deletions` and `StrandBuilder.with_insertions` --- scadnano/scadnano.py | 13 ++++++++++++ tests/scadnano_tests.py | 47 ++++++++++++++++++++++++++++++----------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 1809dbf7..fddf5f32 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2615,6 +2615,12 @@ def with_deletions(self, else: last_domain.deletions = list(deletions) + for deletion in last_domain.deletions: + if not last_domain.start <= deletion < last_domain.end: + raise IllegalDesignError(f'all deletions must be between start={last_domain.start} ' + f'and end={last_domain.end}, but deletion={deletion} is outside ' + f'that range') + return self def with_insertions(self, insertions: Union[Tuple[int, int], Iterable[Tuple[int, int]]]) \ @@ -2660,6 +2666,13 @@ def with_insertions(self, insertions: Union[Tuple[int, int], Iterable[Tuple[int, raise ValueError(type_msg) last_domain.insertions = list(insertions) + for insertion in last_domain.insertions: + insertion_offset, _ = insertion + if not last_domain.start <= insertion_offset < last_domain.end: + raise IllegalDesignError(f'all insertions must be between start={last_domain.start} ' + f'and end={last_domain.end}, but insertion={insertion} at offset ' + f'{insertion_offset} is outside that range') + return self diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 931993a4..0badbcdb 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -380,7 +380,8 @@ def test_to_json__names_unique_for_modifications_raises_no_error(self) -> None: helices = [sc.Helix(max_offset=100)] design: sc.Design = sc.Design(helices=helices, strands=[], grid=sc.square) name = 'mod_name' - design.draw_strand(0, 0).move(5).with_modification_5p(sc.Modification5Prime(display_text=name, id=name)) + design.draw_strand(0, 0).move(5).with_modification_5p( + sc.Modification5Prime(display_text=name, id=name)) design.draw_strand(0, 5).move(5).with_modification_3p( sc.Modification3Prime(display_text=name, id=name + '3')) design.to_json(True) @@ -389,8 +390,10 @@ def test_to_json__names_not_unique_for_modifications_raises_error(self) -> None: helices = [sc.Helix(max_offset=100)] design: sc.Design = sc.Design(helices=helices, strands=[], grid=sc.square) name = 'mod_name' - design.draw_strand(0, 0).move(5).with_modification_5p(sc.Modification5Prime(display_text=name, id=name)) - design.draw_strand(0, 5).move(5).with_modification_3p(sc.Modification3Prime(display_text=name, id=name)) + design.draw_strand(0, 0).move(5).with_modification_5p( + sc.Modification5Prime(display_text=name, id=name)) + design.draw_strand(0, 5).move(5).with_modification_3p( + sc.Modification3Prime(display_text=name, id=name)) with self.assertRaises(sc.IllegalDesignError): design.to_json(True) @@ -513,7 +516,7 @@ def test_biotin(self) -> None: def test_to_json_serializable(self) -> None: biotin5 = mod.biotin_5p - biotin5 = dataclasses.replace(biotin5, connector_length = 6) + biotin5 = dataclasses.replace(biotin5, connector_length=6) self.assertEqual(r'/5Biosg/', biotin5.idt_text) self.assertEqual(r'/5Biosg/', biotin5.id) self.assertEqual('B', biotin5.display_text) @@ -694,14 +697,13 @@ def test_paranemic_crossover(self) -> None: # design.write_scadnano_file(directory=self.output_path, filename=f'{file_name}.{sc.default_scadnano_file_extension}') - + def test_same_helix_crossover(self) -> None: file_name = "test_paranemic_crossover" design = sc.Design.from_cadnano_v2(directory=self.input_path, filename=file_name + ".json") self.assertEqual(4, len(design.helices)) - def test_2_stape_2_helix_origami_deletions_insertions(self) -> None: file_name = "test_2_stape_2_helix_origami_deletions_insertions" design = sc.Design.from_cadnano_v2(directory=self.input_path, @@ -1716,7 +1718,6 @@ def setUp(self) -> None: strands=[], grid=sc.square) - def test_no_deletion_after_loopout(self) -> None: # not really a test of inlining, but I added the with_deletions and with_insertions to help these # tests, so easier just to test this behavior here @@ -1729,6 +1730,21 @@ def test_no_insertion_after_loopout(self) -> None: with self.assertRaises(ValueError): self.design.draw_strand(0, 0).move(8).loopout(0, 5, 10).with_insertions((4, 2)) + def test_deletion_below_range(self) -> None: + with self.assertRaises(ValueError): + self.design.draw_strand(0, 4).move(4).with_deletions(2) + + def test_deletion_above_range(self) -> None: + with self.assertRaises(ValueError): + self.design.draw_strand(0, 0).move(4).with_deletions(6) + + def test_insertion_below_range(self) -> None: + with self.assertRaises(ValueError): + self.design.draw_strand(0, 4).move(4).with_insertions((2, 1)) + + def test_insertion_above_range(self) -> None: + with self.assertRaises(ValueError): + self.design.draw_strand(0, 0).move(4).with_insertions((6, 1)) def test_inline_deletions_insertions__one_deletion(self) -> None: """ @@ -2230,9 +2246,15 @@ def test_add_nick__small_design_H0_forward(self) -> None: self.assertIn(sc.Strand([sc.Domain(0, True, 0, 8, dna_sequence='ACGTACGA')]), design.strands) self.assertIn(sc.Strand([sc.Domain(0, True, 8, 16, dna_sequence='AACCGGTA')]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TACCGGTT TCGTACGT'))]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC'))]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16, dna_sequence=remove_whitespace('GGCCCAAA CCGGGTTT'))]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TACCGGTT TCGTACGT'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, False, 0, 16, dna_sequence=remove_whitespace('GGCCCAAA CCGGGTTT'))]), + design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 8) self.assertEqual(remove_whitespace('ACGTACGA'), strand.dna_sequence) @@ -6315,7 +6337,7 @@ def test_helix_groups(self) -> None: } design = sc.Design(helices=helices, groups=groups) design.draw_strand(0, 0).move(7).cross(1).move(-7) - design.draw_strand(2, 7).move(-7).cross(3).move(7) # unlike basic design, put strand on helices 2,3 + design.draw_strand(2, 7).move(-7).cross(3).move(7) # unlike basic design, put strand on helices 2,3 # expected values for verification expected_num_nucleotides = 7 * 4 @@ -6837,6 +6859,7 @@ def test_loopout_design(self) -> None: self.assertAlmostEqual(self.EXPECTED_ADJ_NUC_CM_DIST2, sqr_dist2) + class TestPlateMaps(unittest.TestCase): def setUp(self) -> None: @@ -6866,4 +6889,4 @@ def test_plate_map_markdown(self) -> None: | G | | | | | | | | | | | | | | H | | | | | | | | | | | | | """.strip() - self.assertEqual(expected_md, actual_md) \ No newline at end of file + self.assertEqual(expected_md, actual_md) From 4e60f9b766c51d22e9f552b620f6fda1c3d380e8 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 14 Apr 2022 20:39:29 -0700 Subject: [PATCH 06/73] Add UT for chained methods for DNA extensions --- scadnano/scadnano.py | 22 ++++- tests/scadnano_tests.py | 197 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 203 insertions(+), 16 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 40f59bb9..3d8f294c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2045,6 +2045,16 @@ def get_seq_start_idx(self) -> int: return self_seq_idx_start +@dataclass +class Extension(_JSONSerializable, Generic[DomainLabel]): + length: int + label: Optional[DomainLabel] = None + name: Optional[str] = None + dna_sequence: Optional[str] = None + + def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: + raise NotImplementedError() + _wctable = str.maketrans('ACGTacgt', 'TGCAtgca') @@ -2225,6 +2235,12 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O self.design.append_domain(self._strand, Loopout(length)) return self + def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + TODO: write doc + """ + return self + # remove quotes when Py3.6 support dropped def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ @@ -2613,7 +2629,7 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): uses for the scaffold. """ - domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel]]] + domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel], Extension[DomainLabel]]] """:any:`Domain`'s (or :any:`Loopout`'s) composing this Strand. Each :any:`Domain` is contiguous on a single :any:`Helix` and could be either single-stranded or double-stranded, @@ -2707,7 +2723,7 @@ def dna_sequence(self) -> Optional[str]: init=False, repr=False, compare=False, default_factory=dict) def __init__(self, - domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel]]], + domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel], Extension[DomainLabel]]], circular: bool = False, color: Optional[Color] = None, idt: Optional[IDTFields] = None, is_scaffold: bool = False, modification_5p: Optional[Modification5Prime] = None, @@ -6027,7 +6043,7 @@ def move_strands_on_helices(self, delta: int) -> None: self._check_strands_reference_helices_legally() def assign_dna(self, strand: Strand, sequence: str, assign_complement: bool = True, - domain: Union[Domain, Loopout] = None, check_length: bool = False) -> None: + domain: Union[Domain, Loopout, Extension] = None, check_length: bool = False) -> None: """ Assigns `sequence` as DNA sequence of `strand`. diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 7bc6ee9e..c299d00d 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -91,6 +91,177 @@ def test_strand__loopouts_with_labels_to_json(self) -> None: self.assertEqual(1, len(design_from_json.strands)) self.assertEqual(expected_strand, design_from_json.strands[0]) + def test_strand__extension_3p_from_to(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + + sb.extension(5) + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10), + sc.Extension(5), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__extension_5p(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + + sb.extension(5) + sb.to(10) + + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5), + sc.Domain(0, True, 0, 10), + ]) + + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__update_to_after_extension_5p_ok(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + + sb.extension(5) + sb.to(10) + sb.update_to(15) + + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5), + sc.Domain(0, True, 0, 15), + ]) + + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__lone_extension_should_not_add_strand(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + + sb.extension(5) + + self.assertEqual(0, len(design.strands)) + + def test_strand__to_after_non_5p_extension_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(5) + + with self.assertRaises(sc.IllegalDesignError): + sb.to(15) + + def test_strand__cross_after_extension_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.extension(5) + + with self.assertRaises(sc.IllegalDesignError): + sb.cross(1) + + def test_strand__extension_after_loopout_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.loopout(1, 3) + + with self.assertRaises(sc.IllegalDesignError): + sb.extension(5) + + def test_strand__extension_after_extension_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(4) + + with self.assertRaises(sc.IllegalDesignError): + sb.extension(5) + + def test_strand__update_to_after_non_5p_extension_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(4) + + with self.assertRaises(sc.IllegalDesignError): + sb.update_to(15) + + def test_strand__as_circular_with_extension_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(4) + + with self.assertRaises(sc.IllegalDesignError): + sb.as_circular() + + def test_strand__extension_on_circular_strand_should_raise_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.as_circular() + + with self.assertRaises(sc.IllegalDesignError): + sb.extension(4) + + def test_strand__extension_with_label(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(5) + sb.with_domain_label("ext1") + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10), + sc.Extension(5, label="ext1"), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__with_sequence_on_extension(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(5) + sb.with_sequence("A"*10 + "G"*5) + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10, dna_sequence="A"*10), + sc.Extension(5, dna_sequence="G"*5), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__with_domain_sequence_on_extension(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(5) + sb.with_domain_sequence("G"*5) + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10, dna_sequence="?"*10), + sc.Extension(5, dna_sequence="G"*5), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__extension_with_name(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + sb = design.draw_strand(0, 0) + sb.to(10) + sb.extension(5) + sb.with_domain_name("ext1") + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10), + sc.Extension(5, name="ext1"), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + def test_strand__0_0_to_10_cross_1_to_5(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) @@ -1709,9 +1880,9 @@ def test_inline_deletions_insertions__one_deletion(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[4])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=7) + self.assert_helix0_strand0_inlined(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=7) - def helix0_strand0_inlined_test(self, design, max_offset, major_ticks, start, end): + def assert_helix0_strand0_inlined(self, design, max_offset, major_ticks, start, end): self.assertEqual(1, len(design.helices)) self.assertEqual(1, len(design.strands)) helix = design.helices[0] @@ -1740,7 +1911,7 @@ def test_inline_deletions_insertions__two_deletions(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[2, 4])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=22, major_ticks=[0, 6, 14, 22], start=0, end=6) + self.assert_helix0_strand0_inlined(design, max_offset=22, major_ticks=[0, 6, 14, 22], start=0, end=6) def test_inline_deletions_insertions__one_insertion(self) -> None: """ @@ -1759,7 +1930,7 @@ def test_inline_deletions_insertions__one_insertion(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 8, insertions=[(4, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=9) + self.assert_helix0_strand0_inlined(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=9) def test_inline_deletions_insertions__two_insertions(self) -> None: """ @@ -1778,7 +1949,7 @@ def test_inline_deletions_insertions__two_insertions(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 8, insertions=[(2, 3), (4, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=28, major_ticks=[0, 12, 20, 28], start=0, end=12) + self.assert_helix0_strand0_inlined(design, max_offset=28, major_ticks=[0, 12, 20, 28], start=0, end=12) def test_inline_deletions_insertions__one_deletion_one_insertion(self) -> None: """ @@ -1797,7 +1968,7 @@ def test_inline_deletions_insertions__one_deletion_one_insertion(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 8, deletions=[4], insertions=[(2, 3)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=26, major_ticks=[0, 10, 18, 26], start=0, end=10) + self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 18, 26], start=0, end=10) def test_inline_deletions_insertions__one_deletion_right_of_major_tick(self) -> None: """ @@ -1816,7 +1987,7 @@ def test_inline_deletions_insertions__one_deletion_right_of_major_tick(self) -> strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[9])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) + self.assert_helix0_strand0_inlined(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) def test_inline_deletions_insertions__one_deletion_on_major_tick(self) -> None: """ @@ -1836,7 +2007,7 @@ def test_inline_deletions_insertions__one_deletion_on_major_tick(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[8])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) + self.assert_helix0_strand0_inlined(design, max_offset=23, major_ticks=[0, 8, 15, 23], start=0, end=11) def test_inline_deletions_insertions__one_deletion_left_of_major_tick(self) -> None: """ @@ -1855,7 +2026,7 @@ def test_inline_deletions_insertions__one_deletion_left_of_major_tick(self) -> N strands=[sc.Strand([sc.Domain(0, True, 0, 12, deletions=[7])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=11) + self.assert_helix0_strand0_inlined(design, max_offset=23, major_ticks=[0, 7, 15, 23], start=0, end=11) def test_inline_deletions_insertions__one_insertion_right_of_major_tick(self) -> None: """ @@ -1874,7 +2045,7 @@ def test_inline_deletions_insertions__one_insertion_right_of_major_tick(self) -> strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(9, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) + self.assert_helix0_strand0_inlined(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) def test_inline_deletions_insertions__one_insertion_on_major_tick(self) -> None: """ @@ -1893,7 +2064,7 @@ def test_inline_deletions_insertions__one_insertion_on_major_tick(self) -> None: strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(8, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) + self.assert_helix0_strand0_inlined(design, max_offset=25, major_ticks=[0, 8, 17, 25], start=0, end=13) def test_inline_deletions_insertions__one_insertion_left_of_major_tick(self) -> None: """ @@ -1912,7 +2083,7 @@ def test_inline_deletions_insertions__one_insertion_left_of_major_tick(self) -> strands=[sc.Strand([sc.Domain(0, True, 0, 12, insertions=[(7, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=13) + self.assert_helix0_strand0_inlined(design, max_offset=25, major_ticks=[0, 9, 17, 25], start=0, end=13) def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains(self) -> None: """ @@ -1931,7 +2102,7 @@ def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains(s strands=[sc.Strand([sc.Domain(0, True, 0, 24, deletions=[19], insertions=[(5, 2), (11, 1)])])], grid=sc.square) design.inline_deletions_insertions() - self.helix0_strand0_inlined_test(design, max_offset=26, major_ticks=[0, 10, 19, 26], start=0, end=26) + self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 19, 26], start=0, end=26) def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains_two_strands(self) -> None: """ From 78c716db99695396fd475af72832bc68fc3805eb Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 14 Apr 2022 21:43:42 -0700 Subject: [PATCH 07/73] Add TestExportCadnanoV2.test_extension --- tests/scadnano_tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index c299d00d..ddef1885 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -1500,6 +1500,19 @@ def test_loopout(self) -> None: design.to_cadnano_v2_json() self.assertTrue('Loopouts' in context.exception.args[0]) + def test_extension(self) -> None: + """ We do not handle Extensions + """ + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)], grid=Grid.square) + sb = design.draw_strand(0, 0) + + sb.extension(5) + sb.to(10) + + with self.assertRaises(ValueError) as context: + design.to_cadnano_v2_json() + self.assertTrue('Extensions' in context.exception.args[0]) + class TestDesignFromJson(unittest.TestCase): """ From f16b629053699028094fa774b4a3ae6f176486ea Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 15 Apr 2022 23:04:31 -0700 Subject: [PATCH 08/73] Use self.design_6helix for extension chain method tests --- tests/scadnano_tests.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index ddef1885..a815398a 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -92,7 +92,7 @@ def test_strand__loopouts_with_labels_to_json(self) -> None: self.assertEqual(expected_strand, design_from_json.strands[0]) def test_strand__extension_3p_from_to(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) @@ -106,7 +106,7 @@ def test_strand__extension_3p_from_to(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__extension_5p(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.extension(5) @@ -121,7 +121,7 @@ def test_strand__extension_5p(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__update_to_after_extension_5p_ok(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.extension(5) @@ -137,7 +137,7 @@ def test_strand__update_to_after_extension_5p_ok(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__lone_extension_should_not_add_strand(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.extension(5) @@ -145,7 +145,7 @@ def test_strand__lone_extension_should_not_add_strand(self) -> None: self.assertEqual(0, len(design.strands)) def test_strand__to_after_non_5p_extension_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(5) @@ -154,7 +154,7 @@ def test_strand__to_after_non_5p_extension_should_raise_error(self) -> None: sb.to(15) def test_strand__cross_after_extension_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.extension(5) @@ -162,7 +162,7 @@ def test_strand__cross_after_extension_should_raise_error(self) -> None: sb.cross(1) def test_strand__extension_after_loopout_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.loopout(1, 3) @@ -171,7 +171,7 @@ def test_strand__extension_after_loopout_should_raise_error(self) -> None: sb.extension(5) def test_strand__extension_after_extension_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(4) @@ -180,7 +180,7 @@ def test_strand__extension_after_extension_should_raise_error(self) -> None: sb.extension(5) def test_strand__update_to_after_non_5p_extension_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(4) @@ -189,7 +189,7 @@ def test_strand__update_to_after_non_5p_extension_should_raise_error(self) -> No sb.update_to(15) def test_strand__as_circular_with_extension_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(4) @@ -198,7 +198,7 @@ def test_strand__as_circular_with_extension_should_raise_error(self) -> None: sb.as_circular() def test_strand__extension_on_circular_strand_should_raise_error(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.as_circular() @@ -207,7 +207,7 @@ def test_strand__extension_on_circular_strand_should_raise_error(self) -> None: sb.extension(4) def test_strand__extension_with_label(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(5) @@ -221,7 +221,7 @@ def test_strand__extension_with_label(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__with_sequence_on_extension(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(5) @@ -235,7 +235,7 @@ def test_strand__with_sequence_on_extension(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__with_domain_sequence_on_extension(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(5) @@ -249,7 +249,7 @@ def test_strand__with_domain_sequence_on_extension(self) -> None: self.assertEqual(expected_strand, design.strands[0]) def test_strand__extension_with_name(self) -> None: - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.extension(5) From 2cdf0234fbf65f2ed4e8638ec256ece7b2d3f63b Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 15 Apr 2022 23:11:56 -0700 Subject: [PATCH 09/73] Add move extension move test --- tests/scadnano_tests.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index a815398a..4eac821c 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -91,7 +91,7 @@ def test_strand__loopouts_with_labels_to_json(self) -> None: self.assertEqual(1, len(design_from_json.strands)) self.assertEqual(expected_strand, design_from_json.strands[0]) - def test_strand__extension_3p_from_to(self) -> None: + def test_strand__3p_extension(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) @@ -105,7 +105,7 @@ def test_strand__extension_3p_from_to(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__extension_5p(self) -> None: + def test_strand__5p_extension(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) @@ -120,7 +120,7 @@ def test_strand__extension_5p(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__update_to_after_extension_5p_ok(self) -> None: + def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) @@ -136,6 +136,21 @@ def test_strand__update_to_after_extension_5p_ok(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_strand__move_after_5p_extension_ok(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + + sb.extension(5) + sb.move(15) + + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5), + sc.Domain(0, True, 0, 15), + ]) + + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + def test_strand__lone_extension_should_not_add_strand(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) @@ -153,6 +168,15 @@ def test_strand__to_after_non_5p_extension_should_raise_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): sb.to(15) + def test_strand__move_after_non_5p_extension_should_raise_error(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + sb.move(10) + sb.extension(5) + + with self.assertRaises(sc.IllegalDesignError): + sb.move(5) + def test_strand__cross_after_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 28b0ded846ca714a7f5187e25d0e24aeaf98f46a Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sat, 16 Apr 2022 15:17:46 -0700 Subject: [PATCH 10/73] Add extension ligate test --- tests/scadnano_tests.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 4eac821c..e6d3f58f 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3221,6 +3221,25 @@ def test_add_nick_then_add_crossovers__6_helix_rectangle(self) -> None: ]) self.assertIn(scaf, self.origami.strands) + def test_ligate_on_extension_side_should_error(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design.draw_strand(0, 0).to(10).extension(5) + design.draw_strand(0, 10).to(20) + with self.assertRaises(sc.IllegalDesignError): + design.ligate(0, 10, True) + + def test_ligate_on_non_extension_side_ok(self) -> None: + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) + design.draw_strand(0, 0).extension(5).to(10) + design.draw_strand(0, 10).to(20) + design.ligate(0, 10, True) + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5), + sc.Domain(0, True, 0, 20) + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + class TestAutocalculatedData(unittest.TestCase): From a0170e9c291452e7158b05f476ea2d7efa43e84c Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sat, 16 Apr 2022 17:04:39 -0700 Subject: [PATCH 11/73] Add crossover test --- tests/scadnano_tests.py | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index e6d3f58f..83471375 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3240,6 +3240,51 @@ def test_ligate_on_non_extension_side_ok(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_add_full_crossover_extension_ok(self) -> None: + """ + Before: + ↗ + / + / + / + 0 [------- -------- + + 1 <------- -------] + + After: + + ↗ + / + / + / + 0 [------+ +------- + | | + 1 <------+ +------] + """ + # Setup + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(0, 0).to(16).extension(5) + design.draw_strand(1, 16).to(0) + + # Action + design.add_full_crossover(0, 1, 8, True) + + # Validation + expected_strand_0: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 8), + sc.Domain(1, False, 0, 8) + ]) + expected_strand_1: sc.Strand = sc.Strand([ + sc.Domain(1, False, 8, 16), + sc.Domain(0, True, 8, 16), + sc.Extension(5) + ]) + self.assertEqual(2, len(design.strands)) + self.assertIn(expected_strand_0, design.strands) + self.assertIn(expected_strand_1, design.strands) + class TestAutocalculatedData(unittest.TestCase): From e8725ccca2768c5b7b73c418a8a3d9ff83231115 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 17 Apr 2022 19:54:54 -0700 Subject: [PATCH 12/73] test_add_full_crossover_on_extension_error --- tests/scadnano_tests.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 83471375..6a3bd133 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3285,6 +3285,28 @@ def test_add_full_crossover_extension_ok(self) -> None: self.assertIn(expected_strand_0, design.strands) self.assertIn(expected_strand_1, design.strands) + def test_add_full_crossover_on_extension_error(self) -> None: + """ + Before: + ↗ + / + / + / + 0 [------- [------> + + 1 <------] <------] + """ + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(0, 0).to(8).extension(5) + design.draw_strand(0, 8).to(16) + design.draw_strand(1, 8).to(0) + design.draw_strand(1, 16).to(8) + + with self.assertRaises(sc.IllegalDesignError): + design.add_full_crossover(0, 1, 8, True) + class TestAutocalculatedData(unittest.TestCase): From 738979ba064367d2c0b357e80e37bfac4a3c8087 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 17 Apr 2022 20:56:01 -0700 Subject: [PATCH 13/73] test_add_half_crossover_on_extension_ok --- tests/scadnano_tests.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 6a3bd133..d5ae8ee3 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3307,6 +3307,42 @@ def test_add_full_crossover_on_extension_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.add_full_crossover(0, 1, 8, True) + def test_add_half_crossover_on_extension_ok(self) -> None: + """ + Before: + □ + \ + \ + 0 -------> + + 1 <------] + + After: + □ + \ + \ + 0 -------+ + | + 1 <------+ + """ + # Setup + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(0, 0).extension(5).to(8) + design.draw_strand(1, 8).to(0) + + # Action + design.add_half_crossover(0, 1, 8, True) + + # Validation + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5), + sc.Domain(0, True, 0, 8), + sc.Domain(1, False, 0, 8) + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) class TestAutocalculatedData(unittest.TestCase): From 3615afbc2214dbc4f18e11ce34b99ff0503c2ae9 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 22 Apr 2022 18:00:15 -0700 Subject: [PATCH 14/73] test_add_half_crossover_on_extension_error --- tests/scadnano_tests.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index d5ae8ee3..141a7c63 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3295,6 +3295,15 @@ def test_add_full_crossover_on_extension_error(self) -> None: 0 [------- [------> 1 <------] <------] + + Error: + ↗ + / + / + / + 0 [------+ +------> + | | + 1 <------+ +------] """ design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] @@ -3344,6 +3353,35 @@ def test_add_half_crossover_on_extension_ok(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_add_half_crossover_on_extension_error(self) -> None: + """ + Before: + □ + \ + \ + 0 -------> + + 1 <------] + + Error: + □ + \ + \ + 0 +------> + | + 1 +------] + """ + # Setup + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(0, 0).extension(5).to(8) + design.draw_strand(1, 8).to(0) + + with self.assertRaises(sc.IllegalDesignError): + design.add_half_crossover(0, 1, 0, True) + + class TestAutocalculatedData(unittest.TestCase): def test_helix_min_max_offsets_illegal_explicitly_specified(self) -> None: From 2feed8afc42c743bb9410e3f34a8b1925ff19fbe Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 22 Apr 2022 19:33:20 -0700 Subject: [PATCH 15/73] test_nick_on_extension --- tests/scadnano_tests.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 141a7c63..c7cc1491 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3381,6 +3381,40 @@ def test_add_half_crossover_on_extension_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.add_half_crossover(0, 1, 0, True) + def test_nick_on_extension(self) -> None: + """ + Before: + ↗ + / + / + / + 0 [------- + + After: + ↗ + / + / + / + 0 [-->[--- + """ + # Setup + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)]) + design.draw_strand(0, 0).extension(5).to(8) + + # Nick + design.add_nick(0, 4, True) + + # Verification + expected_strand1: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 4), + ]) + expected_strand2: sc.Strand = sc.Strand([ + sc.Domain(0, True, 4, 8), + sc.Extension(5) + ]) + self.assertEquals(2, len(design.strands)) + self.assertIn(expected_strand1, design.strands) + self.assertIn(expected_strand2, design.strands) class TestAutocalculatedData(unittest.TestCase): From 1433022fd7f1f8fa11e58da0389c5fa4711c08f2 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 22 Apr 2022 20:41:40 -0700 Subject: [PATCH 16/73] test_from_json_extension_design --- tests/scadnano_tests.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index c7cc1491..5de54dd0 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -4784,6 +4784,28 @@ def test_to_json__roll(self) -> None: design.to_json() # should be no error getting here + def test_from_json_extension_design(self) -> None: + json_str = """ + { + "version": "0.17.3", + "grid": "square", + "helices": [ + {"grid_position": [0, 0], "max_offset": 100} + ], + "strands": [ + { + "domains": [ + {"helix": 0, "forward": true, "start": 0, "end": 10}, + {"extension": 5} + ], + "is_scaffold": true + } + ] + } + """ + design = sc.Design.from_scadnano_json_str(json_str) + self.assertEqual(sc.Extension(5), design.strands[0].domains[1]) + class TestIllegalStructuresPrevented(unittest.TestCase): From de44f4710183faa498e50abbeb61c72334e9a83b Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 24 Apr 2022 20:00:44 -0700 Subject: [PATCH 17/73] test_to_json_extension_design --- tests/scadnano_tests.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 5de54dd0..69c13feb 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -4806,6 +4806,20 @@ def test_from_json_extension_design(self) -> None: design = sc.Design.from_scadnano_json_str(json_str) self.assertEqual(sc.Extension(5), design.strands[0].domains[1]) + def test_to_json_extension_design(self) -> None: + # Setup + design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) + design.draw_strand(0, 0).to(10).extension(5) + + # Action + result = design.to_json() + + # Verify + document = json.loads(result) + self.assertEqual(2, len(document["strands"][0]["domains"])) + self.assertIn("extension") + self.assertEqual(5, document["strands"][0]["domains"][1]["extension"]) + class TestIllegalStructuresPrevented(unittest.TestCase): From e06ac96ff6d786ef150f994e671a26ca43007d66 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 17:22:47 -0700 Subject: [PATCH 18/73] Add "relative_offset" field --- scadnano/scadnano.py | 1 + tests/scadnano_tests.py | 64 +++++++++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 3d8f294c..6f3cb30c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2048,6 +2048,7 @@ def get_seq_start_idx(self) -> int: @dataclass class Extension(_JSONSerializable, Generic[DomainLabel]): length: int + relative_offset: Tuple[float, float] label: Optional[DomainLabel] = None name: Optional[str] = None dna_sequence: Optional[str] = None diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 69c13feb..caa83e8d 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -100,7 +100,7 @@ def test_strand__3p_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5), + sc.Extension(5, (1, -1)), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -113,7 +113,7 @@ def test_strand__5p_extension(self) -> None: sb.to(10) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5), + sc.Extension(5, (-1, -1)), sc.Domain(0, True, 0, 10), ]) @@ -129,7 +129,7 @@ def test_strand__update_to_after_5p_extension_ok(self) -> None: sb.update_to(15) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5), + sc.Extension(5, (-1, -1)), sc.Domain(0, True, 0, 15), ]) @@ -144,7 +144,7 @@ def test_strand__move_after_5p_extension_ok(self) -> None: sb.move(15) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5), + sc.Extension(5, (-1, -1)), sc.Domain(0, True, 0, 15), ]) @@ -239,7 +239,7 @@ def test_strand__extension_with_label(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, label="ext1"), + sc.Extension(5, (1, -1), label="ext1"), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -253,7 +253,7 @@ def test_strand__with_sequence_on_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10, dna_sequence="A"*10), - sc.Extension(5, dna_sequence="G"*5), + sc.Extension(5, (1, -1), dna_sequence="G"*5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -267,7 +267,7 @@ def test_strand__with_domain_sequence_on_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10, dna_sequence="?"*10), - sc.Extension(5, dna_sequence="G"*5), + sc.Extension(5, (1, -1), dna_sequence="G"*5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -281,7 +281,7 @@ def test_strand__extension_with_name(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, name="ext1"), + sc.Extension(5, (1, -1), name="ext1"), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -889,14 +889,13 @@ def test_paranemic_crossover(self) -> None: # design.write_scadnano_file(directory=self.output_path, filename=f'{file_name}.{sc.default_scadnano_file_extension}') - + def test_same_helix_crossover(self) -> None: file_name = "test_paranemic_crossover" design = sc.Design.from_cadnano_v2(directory=self.input_path, filename=file_name + ".json") self.assertEqual(4, len(design.helices)) - def test_2_stape_2_helix_origami_deletions_insertions(self) -> None: file_name = "test_2_stape_2_helix_origami_deletions_insertions" design = sc.Design.from_cadnano_v2(directory=self.input_path, @@ -3229,12 +3228,25 @@ def test_ligate_on_extension_side_should_error(self) -> None: design.ligate(0, 10, True) def test_ligate_on_non_extension_side_ok(self) -> None: + """ + Before: + □ + \ + \ + 0 --------->[--------> + After: + Before: + □ + \ + \ + 0 -------------------> + """ design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) design.draw_strand(0, 0).extension(5).to(10) design.draw_strand(0, 10).to(20) design.ligate(0, 10, True) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5), + sc.Extension(5, (-1, -1)), sc.Domain(0, True, 0, 20) ]) self.assertEqual(1, len(design.strands)) @@ -3279,7 +3291,7 @@ def test_add_full_crossover_extension_ok(self) -> None: expected_strand_1: sc.Strand = sc.Strand([ sc.Domain(1, False, 8, 16), sc.Domain(0, True, 8, 16), - sc.Extension(5) + sc.Extension(5, (1, -1)) ]) self.assertEqual(2, len(design.strands)) self.assertIn(expected_strand_0, design.strands) @@ -3346,7 +3358,7 @@ def test_add_half_crossover_on_extension_ok(self) -> None: # Validation expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5), + sc.Extension(5, (-1, -1)), sc.Domain(0, True, 0, 8), sc.Domain(1, False, 0, 8) ]) @@ -3410,12 +3422,13 @@ def test_nick_on_extension(self) -> None: ]) expected_strand2: sc.Strand = sc.Strand([ sc.Domain(0, True, 4, 8), - sc.Extension(5) + sc.Extension(5, (1, -1)) ]) self.assertEquals(2, len(design.strands)) self.assertIn(expected_strand1, design.strands) self.assertIn(expected_strand2, design.strands) + class TestAutocalculatedData(unittest.TestCase): def test_helix_min_max_offsets_illegal_explicitly_specified(self) -> None: @@ -4796,7 +4809,7 @@ def test_from_json_extension_design(self) -> None: { "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 10}, - {"extension": 5} + {"extension": 5, "relative_offset": [1.4, -0.3]} ], "is_scaffold": true } @@ -4804,9 +4817,9 @@ def test_from_json_extension_design(self) -> None: } """ design = sc.Design.from_scadnano_json_str(json_str) - self.assertEqual(sc.Extension(5), design.strands[0].domains[1]) + self.assertEqual(sc.Extension(5, (1.4, -0.3)), design.strands[0].domains[1]) - def test_to_json_extension_design(self) -> None: + def test_to_json_extension_design__extension(self) -> None: # Setup design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) design.draw_strand(0, 0).to(10).extension(5) @@ -4817,9 +4830,23 @@ def test_to_json_extension_design(self) -> None: # Verify document = json.loads(result) self.assertEqual(2, len(document["strands"][0]["domains"])) - self.assertIn("extension") + self.assertIn("extension", document["strands"][0]["domains"][1]) self.assertEqual(5, document["strands"][0]["domains"][1]["extension"]) + def test_to_json_extension_design__relative_offset(self) -> None: + # Setup + design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) + design.draw_strand(0, 0).to(10).extension(5).with_relative_offset((1.4, -0.3)) + + # Action + result = design.to_json() + + # Verify + document = json.loads(result) + self.assertEqual(2, len(document["strands"][0]["domains"])) + self.assertIn("relative_offset", document["strands"][0]["domains"][1]) + self.assertEqual([1.4, -0.3], document["strands"][0]["domains"][1]["relative_offset"]) + class TestIllegalStructuresPrevented(unittest.TestCase): @@ -7265,6 +7292,7 @@ def test_loopout_design(self) -> None: self.assertAlmostEqual(self.EXPECTED_ADJ_NUC_CM_DIST2, sqr_dist2) + class TestPlateMaps(unittest.TestCase): def setUp(self) -> None: From d2f1afb04e56c05acf9a2db5d0cb2aaa624824d9 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 17:46:18 -0700 Subject: [PATCH 19/73] test_strand__with_relative_offset --- scadnano/scadnano.py | 6 ++++++ tests/scadnano_tests.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6f3cb30c..f7be67eb 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2242,6 +2242,12 @@ def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ return self + def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + TODO: write doc + """ + return self + # remove quotes when Py3.6 support dropped def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index caa83e8d..aa37bf30 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -286,6 +286,20 @@ def test_strand__extension_with_name(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_strand__with_relative_offset(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0).to(10).extension(5) + + sb.with_relative_offset((1.1, -1.4)) + + expected_strand: sc.Strand = sc.Strand([ + sc.Domain(0, True, 0, 10), + sc.Extension(5, (1.1, -1.4)) + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + + def test_strand__0_0_to_10_cross_1_to_5(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From faa0a3357fc17db4d30690c31fd2cef8affb0c64 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 17:58:18 -0700 Subject: [PATCH 20/73] test_strand__with_relative_offset_on_domain_error and test_strand__with_relative_offset_on_empty_error --- tests/scadnano_tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index aa37bf30..2488dd90 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -299,6 +299,19 @@ def test_strand__with_relative_offset(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_strand__with_relative_offset_on_domain_error(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0).to(10) + + with self.assertRaises(ValueError): + sb.with_relative_offset((1.1, -1.4)) + + def test_strand__with_relative_offset_on_empty_error(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + + with self.assertRaises(ValueError): + sb.with_relative_offset((1.1, -1.4)) def test_strand__0_0_to_10_cross_1_to_5(self) -> None: design = self.design_6helix From a1601b7e01ceab60a9d08c7ace79af99c7b8e81f Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 18:07:32 -0700 Subject: [PATCH 21/73] test_strand__3p_extension_forward_default_relative_offset --- tests/scadnano_tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 2488dd90..cca445fa 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -120,6 +120,17 @@ def test_strand__5p_extension(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_strand__3p_extension_forward_default_relative_offset(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + sb.to(10) + + sb.extension(5) + + ext = design.strands[0].domains[1] + assert isinstance(ext, sc.Extension) + self.assertEqual((1, -1), ext.relative_offset) + def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From daad677f26a39ee40768fd48818410bde24e2f75 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 18:11:08 -0700 Subject: [PATCH 22/73] test_strand__5p_extension_forward_default_relative_offset --- tests/scadnano_tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index cca445fa..1a415930 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -131,6 +131,16 @@ def test_strand__3p_extension_forward_default_relative_offset(self) -> None: assert isinstance(ext, sc.Extension) self.assertEqual((1, -1), ext.relative_offset) + def test_strand__5p_extension_forward_default_relative_offset(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + sb.extension(5) + sb.to(10) + + ext = design.strands[0].domains[0] + assert isinstance(ext, sc.Extension) + self.assertEqual((-1, -1), ext.relative_offset) + def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 4c459eae7529965cbdf6113988731fbf9ee072bb Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 18:23:00 -0700 Subject: [PATCH 23/73] test_strand__3p_extension_reverse_default_relative_offset --- tests/scadnano_tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 1a415930..6f47f1a9 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -141,6 +141,14 @@ def test_strand__5p_extension_forward_default_relative_offset(self) -> None: assert isinstance(ext, sc.Extension) self.assertEqual((-1, -1), ext.relative_offset) + def test_strand__3p_extension_reverse_default_relative_offset(self) -> None: + design = self.design_6helix + design.draw_strand(0, 10).to(0).extension(5) + + ext = design.strands[0].domains[1] + assert isinstance(ext, sc.Extension) + self.assertEqual((-1, 1), ext.relative_offset) + def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 0941030c9df7766a262d1f2eca803c6973e26551 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 18:30:13 -0700 Subject: [PATCH 24/73] test_strand__5p_extension_reverse_default_relative_offset --- tests/scadnano_tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 6f47f1a9..daed9e15 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -149,6 +149,14 @@ def test_strand__3p_extension_reverse_default_relative_offset(self) -> None: assert isinstance(ext, sc.Extension) self.assertEqual((-1, 1), ext.relative_offset) + def test_strand__5p_extension_reverse_default_relative_offset(self) -> None: + design = self.design_6helix + design.draw_strand(0, 10).extension(5).to(0) + + ext = design.strands[0].domains[0] + assert isinstance(ext, sc.Extension) + self.assertEqual((1, 1), ext.relative_offset) + def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 8f48617df01e3b8efe9c5dc249e65ce50edde120 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 19:06:58 -0700 Subject: [PATCH 25/73] Implement 3' extension --- scadnano/scadnano.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index f7be67eb..e0872c1c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2052,6 +2052,9 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): label: Optional[DomainLabel] = None name: Optional[str] = None dna_sequence: Optional[str] = None + # not serialized; for efficiency + # remove quotes when Py3.6 support dropped + _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None) def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: raise NotImplementedError() @@ -2240,6 +2243,13 @@ def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ TODO: write doc """ + if self._strand is None: + # 5' extension + pass + else: + # 3' extension + ext: Extension = Extension(length, self._determine_default_relative_offset_for_3p_extension()) + self.design.append_domain(self._strand, ext) return self def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': @@ -2248,6 +2258,13 @@ def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandB """ return self + def _determine_default_relative_offset_for_3p_extension(self) -> Tuple[float, float]: + assert self._strand is not None + last_domain = self._strand.domains[-1] + assert isinstance(last_domain, Domain) + return (1, -1) if last_domain.forward else (-1, 1) + + # remove quotes when Py3.6 support dropped def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ @@ -3251,7 +3268,7 @@ def assign_dna_complement_from(self, other: 'Strand') -> None: # remove quotes self.set_dna_sequence(new_dna_sequence) # self.dna_sequence = _pad_dna(new_dna_sequence, self.dna_length()) - def insert_domain(self, order: int, domain: Union[Domain, Loopout]) -> None: + def insert_domain(self, order: int, domain: Union[Domain, Loopout, Extension]) -> None: # add wildcard symbols to DNA sequence to maintain its length if self.dna_sequence is not None: domain.dna_sequence = DNA_base_wildcard * domain.dna_length() @@ -5917,7 +5934,7 @@ def remove_strand(self, strand: Strand) -> None: if isinstance(domain, Domain): self.helices[domain.helix].domains.remove(domain) - def append_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None: + def append_domain(self, strand: Strand, domain: Union[Domain, Loopout, Extension]) -> None: """ Same as :any:`Design.insert_domain`, but inserts at end. @@ -5926,7 +5943,7 @@ def append_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None: """ self.insert_domain(strand, len(strand.domains), domain) - def insert_domain(self, strand: Strand, order: int, domain: Union[Domain, Loopout]) -> None: + def insert_domain(self, strand: Strand, order: int, domain: Union[Domain, Loopout, Extension]) -> None: """Insert `Domain` into `strand` at index given by `order`. Uses same indexing as Python lists, e.g., ``design.insert_domain(strand, domain, 0)`` inserts ``domain`` as the new first :any:`Domain`.""" From 55743314659a7ef7a5a422c65365bbcc4cf571a7 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 1 May 2022 19:48:26 -0700 Subject: [PATCH 26/73] Implement 5p extension --- scadnano/scadnano.py | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index e0872c1c..9c8d15c5 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2059,6 +2059,20 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: raise NotImplementedError() +@dataclass +class ExtensionBuilder(Generic[DomainLabel]): + length: Optional[int] = None + relative_offset: Optional[Tuple[float, float]] = None + label: Optional[DomainLabel] = None + name: Optional[str] = None + dna_sequence: Optional[str] = None + + def build(self) -> Extension: + assert self.length is not None + assert self.relative_offset is not None + return Extension(self.length, self.relative_offset, self.label, self.name, self.dna_sequence) + + _wctable = str.maketrans('ACGTacgt', 'TGCAtgca') @@ -2178,6 +2192,7 @@ def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offse self._strand: Optional[Strand[StrandLabel, DomainLabel]] = None self.just_moved_to_helix: bool = True self.last_domain: Optional[Domain[DomainLabel]] = None + self.extension_5p_builder: Optional[ExtensionBuilder] = None @property def strand(self) -> 'Strand[StrandLabel, DomainLabel]': @@ -2245,10 +2260,10 @@ def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ if self._strand is None: # 5' extension - pass + self.extension_5p_builder = ExtensionBuilder(length) else: # 3' extension - ext: Extension = Extension(length, self._determine_default_relative_offset_for_3p_extension()) + ext: Extension = Extension(length, self._determine_default_relative_offset_for_3p_extension_based_on_last_domain()) self.design.append_domain(self._strand, ext) return self @@ -2258,11 +2273,17 @@ def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandB """ return self - def _determine_default_relative_offset_for_3p_extension(self) -> Tuple[float, float]: + def _determine_default_relative_offset_for_3p_extension_based_on_last_domain(self) -> Tuple[float, float]: assert self._strand is not None last_domain = self._strand.domains[-1] assert isinstance(last_domain, Domain) - return (1, -1) if last_domain.forward else (-1, 1) + return self._determine_default_relative_offset_for_3p_extension(last_domain.forward) + + def _determine_default_relative_offset_for_3p_extension(self, forward: bool) -> Tuple[float, float]: + return (1, -1) if forward else (-1, 1) + + def _determine_default_relative_offset_for_5p_extension(self, forward: bool) -> Tuple[float, float]: + return (-1, -1) if forward else (1, 1) # remove quotes when Py3.6 support dropped @@ -2333,13 +2354,24 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': if self._strand is not None: self.design.append_domain(self._strand, domain) else: - self._strand = Strand(domains=[domain]) + self._strand = Strand(domains=self._create_initial_substrands_list(domain)) self.design.add_strand(self._strand) self.current_offset = offset return self + def _create_initial_substrands_list(self, domain: Domain): + domains: List[Union[Domain, Loopout, Extension]] = [] + self._add_extension_5p_if_not_none(domains, domain.forward) + domains.append(domain) + return domains + + def _add_extension_5p_if_not_none(self, substrands: List[Union[Domain, Loopout, Extension]], forward: bool): + if self.extension_5p_builder is not None: + self.extension_5p_builder.relative_offset = (-1, -1) if forward else (1, 1) + substrands.append(self.extension_5p_builder.build()) + # remove quotes when Py3.6 support dropped def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ From 8b6c0604bef76c3fcda89cd6aeae110ca7f84025 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 4 May 2022 15:54:20 -0700 Subject: [PATCH 27/73] Raise StrandError in as_circular if strand contains Extension --- scadnano/scadnano.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 9c8d15c5..269ca63d 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2059,6 +2059,10 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: raise NotImplementedError() + def dna_length(self) -> int: + """Length of this :any:`Extension`; same as field :py:data:`Extension.length`.""" + return self.length + @dataclass class ExtensionBuilder(Generic[DomainLabel]): length: Optional[int] = None @@ -2964,6 +2968,8 @@ def set_circular(self, circular: bool = True) -> 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") + if circular and self.contains_extensions(): + raise StrandError(self, "Cannot have extension on a circular strand") self.circular = circular def set_linear(self) -> None: @@ -3340,6 +3346,10 @@ def contains_loopouts(self) -> bool: return True return False + def contains_extensions(self) -> bool: + assert len(self.domains) > 0 + return isinstance(self.domains[0], Extension) or isinstance(self.domains[-1], Extension) + def first_bound_domain(self) -> Domain: """First :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`. From 04b25df646771d5b3fa4a557cf923b8bb0091897 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 4 May 2022 16:00:22 -0700 Subject: [PATCH 28/73] Add 5p_extension test case for circular strand --- tests/scadnano_tests.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index daed9e15..d49e08da 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -249,7 +249,7 @@ def test_strand__update_to_after_non_5p_extension_should_raise_error(self) -> No with self.assertRaises(sc.IllegalDesignError): sb.update_to(15) - def test_strand__as_circular_with_extension_should_raise_error(self) -> None: + def test_strand__as_circular_with_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) @@ -258,6 +258,15 @@ def test_strand__as_circular_with_extension_should_raise_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): sb.as_circular() + def test_strand__as_circular_with_5p_extension_should_raise_error(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0) + sb.extension(4) + sb.to(10) + + with self.assertRaises(sc.IllegalDesignError): + sb.as_circular() + def test_strand__extension_on_circular_strand_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 6a2c10110823f7450df044d13a9746449e1a0907 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 4 May 2022 16:15:47 -0700 Subject: [PATCH 29/73] Start docstring for extension --- scadnano/scadnano.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 269ca63d..b5db5530 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2260,7 +2260,15 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ - TODO: write doc + Add extension. + + If called before any domains have been created, then the extension will only be added + when a domain has been created. + + If called after domains have been created, then the extension will be added to the end + of the strand. + + Extensions can either be at the start or end of a strand and not in the middle. """ if self._strand is None: # 5' extension From fdbdf1c563ed03e0a9fe9337a57840651c310633 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 5 May 2022 23:23:44 -0700 Subject: [PATCH 30/73] Begin replacing extension -> extension_3p and add extension_5p_length argument to draw_strand --- scadnano/scadnano.py | 22 +++---- tests/scadnano_tests.py | 129 +++++++++++++++++++++++----------------- 2 files changed, 82 insertions(+), 69 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index b5db5530..6d0187a9 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2188,7 +2188,10 @@ class StrandBuilder(Generic[StrandLabel, DomainLabel]): """ # remove quotes when Py3.6 support dropped - def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int): + def __init__( + self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int, + extension_5p_length: int = 0): + #TODO: Document extension_5p_length argument self.design: Design[StrandLabel, DomainLabel] = design self.current_helix: int = helix self.current_offset: int = offset @@ -2258,17 +2261,9 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O self.design.append_domain(self._strand, Loopout(length)) return self - def extension(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': + def extension_3p(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ - Add extension. - - If called before any domains have been created, then the extension will only be added - when a domain has been created. - - If called after domains have been created, then the extension will be added to the end - of the strand. - - Extensions can either be at the start or end of a strand and not in the middle. + Add extension to end of strand. No domains can be added after this function is called. """ if self._strand is None: # 5' extension @@ -5325,7 +5320,7 @@ def _ensure_mods_unique_names(all_mods: Set[Modification]) -> None: raise IllegalDesignError(f'two different modifications share the id {mod.id}; ' f'one is\n {mod}\nand the other is\n {other_mod}') - def draw_strand(self, helix: int, offset: int) -> StrandBuilder: + def draw_strand(self, helix: int, offset: int, extension_5p_length: int = 0) -> StrandBuilder: """Used for chained method building the :any:`Strand` domain by domain, in order from 5' to 3'. For example @@ -5383,7 +5378,8 @@ def draw_strand(self, helix: int, offset: int) -> StrandBuilder: :param offset: starting offset on `helix` :return: :any:`StrandBuilder` object representing the partially completed :any:`Strand` """ - return StrandBuilder(self, helix, offset) + #TODO: Document extension_5p_length argument + return StrandBuilder(self, helix, offset, extension_5p_length) def strand(self, helix: int, offset: int) -> StrandBuilder: """ diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index d49e08da..140552c1 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -96,7 +96,7 @@ def test_strand__3p_extension(self) -> None: sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), @@ -107,9 +107,7 @@ def test_strand__3p_extension(self) -> None: def test_strand__5p_extension(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0) - - sb.extension(5) + sb = design.draw_strand(0, 0, extension_5p_length=5) sb.to(10) expected_strand: sc.Strand = sc.Strand([ @@ -125,7 +123,7 @@ def test_strand__3p_extension_forward_default_relative_offset(self) -> None: sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) ext = design.strands[0].domains[1] assert isinstance(ext, sc.Extension) @@ -133,8 +131,7 @@ def test_strand__3p_extension_forward_default_relative_offset(self) -> None: def test_strand__5p_extension_forward_default_relative_offset(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0) - sb.extension(5) + sb = design.draw_strand(0, 0, extension_5p_length=5) sb.to(10) ext = design.strands[0].domains[0] @@ -143,7 +140,7 @@ def test_strand__5p_extension_forward_default_relative_offset(self) -> None: def test_strand__3p_extension_reverse_default_relative_offset(self) -> None: design = self.design_6helix - design.draw_strand(0, 10).to(0).extension(5) + design.draw_strand(0, 10).to(0).extension_3p(5) ext = design.strands[0].domains[1] assert isinstance(ext, sc.Extension) @@ -151,7 +148,7 @@ def test_strand__3p_extension_reverse_default_relative_offset(self) -> None: def test_strand__5p_extension_reverse_default_relative_offset(self) -> None: design = self.design_6helix - design.draw_strand(0, 10).extension(5).to(0) + design.draw_strand(0, 10, extension_5p_length=5).to(0) ext = design.strands[0].domains[0] assert isinstance(ext, sc.Extension) @@ -159,9 +156,8 @@ def test_strand__5p_extension_reverse_default_relative_offset(self) -> None: def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0) + sb = design.draw_strand(0, 0, extension_5p_length=5) - sb.extension(5) sb.to(10) sb.update_to(15) @@ -175,9 +171,8 @@ def test_strand__update_to_after_5p_extension_ok(self) -> None: def test_strand__move_after_5p_extension_ok(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0) + sb = design.draw_strand(0, 0, extension_5p_length=5) - sb.extension(5) sb.move(15) expected_strand: sc.Strand = sc.Strand([ @@ -188,63 +183,62 @@ def test_strand__move_after_5p_extension_ok(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__lone_extension_should_not_add_strand(self) -> None: - design = self.design_6helix - sb = design.draw_strand(0, 0) - - sb.extension(5) - - self.assertEqual(0, len(design.strands)) - - def test_strand__to_after_non_5p_extension_should_raise_error(self) -> None: + def test_strand__to_after_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) with self.assertRaises(sc.IllegalDesignError): sb.to(15) - def test_strand__move_after_non_5p_extension_should_raise_error(self) -> None: + def test_strand__move_after_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.move(10) - sb.extension(5) + sb.extension_3p(5) with self.assertRaises(sc.IllegalDesignError): sb.move(5) - def test_strand__cross_after_extension_should_raise_error(self) -> None: + def test_strand__cross_after_5p_extension_should_raise_error(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0, extension_5p_length=5) + + with self.assertRaises(sc.IllegalDesignError): + sb.cross(1) + + def test_strand__cross_after_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) - sb.extension(5) + sb.extension_3p(5) with self.assertRaises(sc.IllegalDesignError): sb.cross(1) - def test_strand__extension_after_loopout_should_raise_error(self) -> None: + def test_strand__extension_3p_after_loopout_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.loopout(1, 3) with self.assertRaises(sc.IllegalDesignError): - sb.extension(5) + sb.extension_3p(5) - def test_strand__extension_after_extension_should_raise_error(self) -> None: + def test_strand__extension_3p_after_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(4) + sb.extension_3p(4) with self.assertRaises(sc.IllegalDesignError): - sb.extension(5) + sb.extension_3p(5) - def test_strand__update_to_after_non_5p_extension_should_raise_error(self) -> None: + def test_strand__update_to_after_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(4) + sb.extension_3p(4) with self.assertRaises(sc.IllegalDesignError): sb.update_to(15) @@ -253,34 +247,33 @@ def test_strand__as_circular_with_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(4) + sb.extension_3p(4) with self.assertRaises(sc.IllegalDesignError): sb.as_circular() def test_strand__as_circular_with_5p_extension_should_raise_error(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0) - sb.extension(4) + sb = design.draw_strand(0, 0, extension_5p_length=4) sb.to(10) with self.assertRaises(sc.IllegalDesignError): sb.as_circular() - def test_strand__extension_on_circular_strand_should_raise_error(self) -> None: + def test_strand__extension_3p_on_circular_strand_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) sb.as_circular() with self.assertRaises(sc.IllegalDesignError): - sb.extension(4) + sb.extension_3p(4) - def test_strand__extension_with_label(self) -> None: + def test_strand__extension_3p_with_label(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) sb.with_domain_label("ext1") expected_strand: sc.Strand = sc.Strand([ @@ -290,11 +283,22 @@ def test_strand__extension_with_label(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__with_sequence_on_extension(self) -> None: + def test_strand__extension_5p_with_label(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0, extension_5p_length=5) + sb.with_domain_label("ext1") + sb.to(10) + + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5, (-1, -1), label="ext1"), + sc.Domain(0, True, 0, 10) + ]) + + def test_strand__with_sequence_on_3p_extension(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) sb.with_sequence("A"*10 + "G"*5) expected_strand: sc.Strand = sc.Strand([ @@ -304,11 +308,24 @@ def test_strand__with_sequence_on_extension(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) + def test_strand__with_sequence_on_5p_extension(self) -> None: + design = self.design_6helix + sb = design.draw_strand(0, 0, extension_5p_length=5) + sb.to(10) + sb.with_sequence("C"*5 + "T"*10) + + expected_strand: sc.Strand = sc.Strand([ + sc.Extension(5, (-1, -1), dna_sequence="C"*5), + sc.Domain(0, True, 0, 10, dna_sequence="T"*10), + ]) + self.assertEqual(1, len(design.strands)) + self.assertEqual(expected_strand, design.strands[0]) + def test_strand__with_domain_sequence_on_extension(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) sb.with_domain_sequence("G"*5) expected_strand: sc.Strand = sc.Strand([ @@ -322,7 +339,7 @@ def test_strand__extension_with_name(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) sb.to(10) - sb.extension(5) + sb.extension_3p(5) sb.with_domain_name("ext1") expected_strand: sc.Strand = sc.Strand([ @@ -334,7 +351,7 @@ def test_strand__extension_with_name(self) -> None: def test_strand__with_relative_offset(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0).to(10).extension(5) + sb = design.draw_strand(0, 0).to(10).extension_3p(5) sb.with_relative_offset((1.1, -1.4)) @@ -1602,7 +1619,7 @@ def test_extension(self) -> None: design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)], grid=Grid.square) sb = design.draw_strand(0, 0) - sb.extension(5) + sb.extension_3p(5) sb.to(10) with self.assertRaises(ValueError) as context: @@ -3295,7 +3312,7 @@ def test_add_nick_then_add_crossovers__6_helix_rectangle(self) -> None: def test_ligate_on_extension_side_should_error(self) -> None: design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) - design.draw_strand(0, 0).to(10).extension(5) + design.draw_strand(0, 0).to(10).extension_3p(5) design.draw_strand(0, 10).to(20) with self.assertRaises(sc.IllegalDesignError): design.ligate(0, 10, True) @@ -3315,7 +3332,7 @@ def test_ligate_on_non_extension_side_ok(self) -> None: 0 -------------------> """ design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) - design.draw_strand(0, 0).extension(5).to(10) + design.draw_strand(0, 0).extension_3p(5).to(10) design.draw_strand(0, 10).to(20) design.ligate(0, 10, True) expected_strand: sc.Strand = sc.Strand([ @@ -3350,7 +3367,7 @@ def test_add_full_crossover_extension_ok(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).to(16).extension(5) + design.draw_strand(0, 0).to(16).extension_3p(5) design.draw_strand(1, 16).to(0) # Action @@ -3393,7 +3410,7 @@ def test_add_full_crossover_on_extension_error(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).to(8).extension(5) + design.draw_strand(0, 0).to(8).extension_3p(5) design.draw_strand(0, 8).to(16) design.draw_strand(1, 8).to(0) design.draw_strand(1, 16).to(8) @@ -3423,7 +3440,7 @@ def test_add_half_crossover_on_extension_ok(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).extension(5).to(8) + design.draw_strand(0, 0).extension_3p(5).to(8) design.draw_strand(1, 8).to(0) # Action @@ -3460,7 +3477,7 @@ def test_add_half_crossover_on_extension_error(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).extension(5).to(8) + design.draw_strand(0, 0).extension_3p(5).to(8) design.draw_strand(1, 8).to(0) with self.assertRaises(sc.IllegalDesignError): @@ -3484,7 +3501,7 @@ def test_nick_on_extension(self) -> None: """ # Setup design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)]) - design.draw_strand(0, 0).extension(5).to(8) + design.draw_strand(0, 0).extension_3p(5).to(8) # Nick design.add_nick(0, 4, True) @@ -4895,7 +4912,7 @@ def test_from_json_extension_design(self) -> None: def test_to_json_extension_design__extension(self) -> None: # Setup design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) - design.draw_strand(0, 0).to(10).extension(5) + design.draw_strand(0, 0).to(10).extension_3p(5) # Action result = design.to_json() @@ -4909,7 +4926,7 @@ def test_to_json_extension_design__extension(self) -> None: def test_to_json_extension_design__relative_offset(self) -> None: # Setup design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) - design.draw_strand(0, 0).to(10).extension(5).with_relative_offset((1.4, -0.3)) + design.draw_strand(0, 0).to(10).extension_3p(5).with_relative_offset((1.4, -0.3)) # Action result = design.to_json() From 3048af8a5beb22e90c08e9c13cd5c45f2f28610f Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 11 May 2022 18:24:41 -0700 Subject: [PATCH 31/73] Redo test_strand__3p_extension --- scadnano/scadnano.py | 22 +++++++++++----------- tests/scadnano_tests.py | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6d0187a9..c0ced736 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2047,8 +2047,9 @@ def get_seq_start_idx(self) -> int: @dataclass class Extension(_JSONSerializable, Generic[DomainLabel]): - length: int - relative_offset: Tuple[float, float] + num_bases: int + display_length: float = 1.0 + display_angle: float = 45.0 label: Optional[DomainLabel] = None name: Optional[str] = None dna_sequence: Optional[str] = None @@ -2061,7 +2062,7 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> U def dna_length(self) -> int: """Length of this :any:`Extension`; same as field :py:data:`Extension.length`.""" - return self.length + return self.num_bases @dataclass class ExtensionBuilder(Generic[DomainLabel]): @@ -2261,17 +2262,16 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O self.design.append_domain(self._strand, Loopout(length)) return self - def extension_3p(self, length: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': + def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ Add extension to end of strand. No domains can be added after this function is called. """ if self._strand is None: - # 5' extension - self.extension_5p_builder = ExtensionBuilder(length) - else: - # 3' extension - ext: Extension = Extension(length, self._determine_default_relative_offset_for_3p_extension_based_on_last_domain()) - self.design.append_domain(self._strand, ext) + raise IllegalDesignError( + 'Cannot add a 3\' extension when there are no domains. Did you mean to create a 5\' extension?') + ext: Extension = Extension(num_bases=num_bases, display_length=display_length, + display_angle=display_angle) + self.design.append_domain(self._strand, ext) return self def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': @@ -7755,7 +7755,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: else: # we place the loopout nucleotides at temporary nonsense positions and orientations # these will be updated later, for now we just need the base - for i in range(domain.length): + for i in range(domain.num_bases): base = seq[i] nuc = _OxdnaNucleotide(_OxdnaVector(), _OxdnaVector(0, -1, 0), _OxdnaVector(0, 0, 1), base) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 140552c1..60fd048e 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -100,7 +100,7 @@ def test_strand__3p_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, (1, -1)), + sc.Extension(num_bases=5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -1713,7 +1713,7 @@ def test_from_json__three_strands(self) -> None: scaf_loop = scaf.domains[2] scaf_ss2 = scaf.domains[3] - self.assertEqual(3, scaf_loop.length) + self.assertEqual(3, scaf_loop.num_bases) self.assertEqual(1, st_l_ss0.helix) self.assertEqual(0, st_l_ss1.helix) From 0e4c6949e026b6acddad0e7cdc972b9ef566fa92 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 11 May 2022 18:35:45 -0700 Subject: [PATCH 32/73] Redo test_strand__5p_extension --- scadnano/scadnano.py | 32 +++++++++++++------------------- tests/scadnano_tests.py | 5 +++-- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index c0ced736..6b086c8a 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2189,10 +2189,7 @@ class StrandBuilder(Generic[StrandLabel, DomainLabel]): """ # remove quotes when Py3.6 support dropped - def __init__( - self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int, - extension_5p_length: int = 0): - #TODO: Document extension_5p_length argument + def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int): self.design: Design[StrandLabel, DomainLabel] = design self.current_helix: int = helix self.current_offset: int = offset @@ -2274,6 +2271,15 @@ def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angl self.design.append_domain(self._strand, ext) return self + def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': + if self._strand is not None: + raise IllegalDesignError('Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') + ext: Extension = Extension(num_bases=num_bases, display_length=display_length, + display_angle=display_angle) + self._strand = Strand(domains=[ext]) + self.design.add_strand(self._strand) + return self + def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ TODO: write doc @@ -2361,24 +2367,13 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': if self._strand is not None: self.design.append_domain(self._strand, domain) else: - self._strand = Strand(domains=self._create_initial_substrands_list(domain)) + self._strand = Strand(domains=[domain]) self.design.add_strand(self._strand) self.current_offset = offset return self - def _create_initial_substrands_list(self, domain: Domain): - domains: List[Union[Domain, Loopout, Extension]] = [] - self._add_extension_5p_if_not_none(domains, domain.forward) - domains.append(domain) - return domains - - def _add_extension_5p_if_not_none(self, substrands: List[Union[Domain, Loopout, Extension]], forward: bool): - if self.extension_5p_builder is not None: - self.extension_5p_builder.relative_offset = (-1, -1) if forward else (1, 1) - substrands.append(self.extension_5p_builder.build()) - # remove quotes when Py3.6 support dropped def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ @@ -5320,7 +5315,7 @@ def _ensure_mods_unique_names(all_mods: Set[Modification]) -> None: raise IllegalDesignError(f'two different modifications share the id {mod.id}; ' f'one is\n {mod}\nand the other is\n {other_mod}') - def draw_strand(self, helix: int, offset: int, extension_5p_length: int = 0) -> StrandBuilder: + def draw_strand(self, helix: int, offset: int) -> StrandBuilder: """Used for chained method building the :any:`Strand` domain by domain, in order from 5' to 3'. For example @@ -5378,8 +5373,7 @@ def draw_strand(self, helix: int, offset: int, extension_5p_length: int = 0) -> :param offset: starting offset on `helix` :return: :any:`StrandBuilder` object representing the partially completed :any:`Strand` """ - #TODO: Document extension_5p_length argument - return StrandBuilder(self, helix, offset, extension_5p_length) + return StrandBuilder(self, helix, offset) def strand(self, helix: int, offset: int) -> StrandBuilder: """ diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 60fd048e..8ac60916 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -107,11 +107,12 @@ def test_strand__3p_extension(self) -> None: def test_strand__5p_extension(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) sb.to(10) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1)), + sc.Extension(5), sc.Domain(0, True, 0, 10), ]) From b8077c6ca2b817efd57b89b39f2e67194ea4280e Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 16:26:23 -0700 Subject: [PATCH 33/73] delete default relative_offset tests --- tests/scadnano_tests.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 8ac60916..bf0ca38c 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -119,42 +119,6 @@ def test_strand__5p_extension(self) -> None: self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__3p_extension_forward_default_relative_offset(self) -> None: - design = self.design_6helix - sb = design.draw_strand(0, 0) - sb.to(10) - - sb.extension_3p(5) - - ext = design.strands[0].domains[1] - assert isinstance(ext, sc.Extension) - self.assertEqual((1, -1), ext.relative_offset) - - def test_strand__5p_extension_forward_default_relative_offset(self) -> None: - design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) - sb.to(10) - - ext = design.strands[0].domains[0] - assert isinstance(ext, sc.Extension) - self.assertEqual((-1, -1), ext.relative_offset) - - def test_strand__3p_extension_reverse_default_relative_offset(self) -> None: - design = self.design_6helix - design.draw_strand(0, 10).to(0).extension_3p(5) - - ext = design.strands[0].domains[1] - assert isinstance(ext, sc.Extension) - self.assertEqual((-1, 1), ext.relative_offset) - - def test_strand__5p_extension_reverse_default_relative_offset(self) -> None: - design = self.design_6helix - design.draw_strand(0, 10, extension_5p_length=5).to(0) - - ext = design.strands[0].domains[0] - assert isinstance(ext, sc.Extension) - self.assertEqual((1, 1), ext.relative_offset) - def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0, extension_5p_length=5) From 122445cddf0a67b0d6b44a08074d914fcc22bf8f Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 16:41:49 -0700 Subject: [PATCH 34/73] In to, check for 3' extension; fixed some tests --- scadnano/scadnano.py | 9 +++++++++ tests/scadnano_tests.py | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6b086c8a..ea31a71f 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2351,6 +2351,9 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': '(strictly increasing or strictly decreasing) ' 'when calling to() twice in a row') + if self._contains_extension_3p(): + raise IllegalDesignError('cannot make a new domain once 3\' extension has been added') + if offset > self.current_offset: forward = True start = self.current_offset @@ -2374,6 +2377,12 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': return self + def _contains_extension_3p(self) -> bool: + if self._strand is None: + return False + domains = self._strand.domains + return len(domains) > 1 and isinstance(domains[-1], Extension) + # remove quotes when Py3.6 support dropped def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index bf0ca38c..b7ce7d33 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -121,13 +121,14 @@ def test_strand__5p_extension(self) -> None: def test_strand__update_to_after_5p_extension_ok(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) sb.to(10) sb.update_to(15) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1)), + sc.Extension(5), sc.Domain(0, True, 0, 15), ]) @@ -136,12 +137,13 @@ def test_strand__update_to_after_5p_extension_ok(self) -> None: def test_strand__move_after_5p_extension_ok(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) sb.move(15) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1)), + sc.Extension(5), sc.Domain(0, True, 0, 15), ]) From c45b916f907c2b20324227e88f6cbbd8c9ee59a1 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 17:06:12 -0700 Subject: [PATCH 35/73] Check for extension in cross --- scadnano/scadnano.py | 5 +++++ tests/scadnano_tests.py | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index ea31a71f..03f4175c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2224,6 +2224,8 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = """ if self._strand is None: raise ValueError('no Strand created yet; make at least one domain first') + if self._is_last_domain_an_extension(): + raise IllegalDesignError('Cannot cross after an extension.') if move is not None and offset is not None: raise IllegalDesignError('move and offset cannot both be specified:\n' f'move: {move}\n' @@ -2236,6 +2238,9 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = self.current_offset += move return self + def _is_last_domain_an_extension(self): + return isinstance(self._strand.domains[-1], Extension) + # remove quotes when Py3.6 support dropped def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \ -> 'StrandBuilder[StrandLabel, DomainLabel]': diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index b7ce7d33..87dc5e01 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -170,7 +170,8 @@ def test_strand__move_after_3p_extension_should_raise_error(self) -> None: def test_strand__cross_after_5p_extension_should_raise_error(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) with self.assertRaises(sc.IllegalDesignError): sb.cross(1) @@ -221,7 +222,8 @@ def test_strand__as_circular_with_3p_extension_should_raise_error(self) -> None: def test_strand__as_circular_with_5p_extension_should_raise_error(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=4) + sb = design.draw_strand(0, 0) + sb.extension_5p(4) sb.to(10) with self.assertRaises(sc.IllegalDesignError): From cd8edafb2966230639d6b8948de62ed6027ade0b Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 17:13:52 -0700 Subject: [PATCH 36/73] Fix test_strand__cross_after_3p_extension_should_raise_error --- tests/scadnano_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 87dc5e01..6d05a362 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -179,6 +179,7 @@ def test_strand__cross_after_5p_extension_should_raise_error(self) -> None: def test_strand__cross_after_3p_extension_should_raise_error(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) + sb.to(5) sb.extension_3p(5) with self.assertRaises(sc.IllegalDesignError): From 68466c88932eabe310387508574e8839a26cdf12 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 17:28:12 -0700 Subject: [PATCH 37/73] Pass test_strand__extension_3p_after_loopout_should_raise_error --- scadnano/scadnano.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 03f4175c..1be78ff4 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2238,8 +2238,11 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = self.current_offset += move return self + def _is_last_domain_an_instance_of_class(self, cls: Type) -> bool: + return isinstance(self._strand.domains[-1], cls) + def _is_last_domain_an_extension(self): - return isinstance(self._strand.domains[-1], Extension) + return self._is_last_domain_an_instance_of_class(Extension) # remove quotes when Py3.6 support dropped def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \ @@ -2271,11 +2274,16 @@ def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angl if self._strand is None: raise IllegalDesignError( 'Cannot add a 3\' extension when there are no domains. Did you mean to create a 5\' extension?') + if self._is_last_domain_a_loopout(): + raise IllegalDesignError('Cannot add a 3\' extension immediately after a loopout.') ext: Extension = Extension(num_bases=num_bases, display_length=display_length, display_angle=display_angle) self.design.append_domain(self._strand, ext) return self + def _is_last_domain_a_loopout(self): + return self._is_last_domain_an_instance_of_class(Loopout) + def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': if self._strand is not None: raise IllegalDesignError('Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') From de3175c9647dc635d33af79d87b98abcef7d6407 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 17:36:17 -0700 Subject: [PATCH 38/73] Pass test_strand__extension_3p_after_extension_should_raise_error --- scadnano/scadnano.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 1be78ff4..b50151e3 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2276,6 +2276,8 @@ def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angl 'Cannot add a 3\' extension when there are no domains. Did you mean to create a 5\' extension?') if self._is_last_domain_a_loopout(): raise IllegalDesignError('Cannot add a 3\' extension immediately after a loopout.') + if self._is_last_domain_an_extension_3p(): + raise IllegalDesignError('Cannot add a 3\' extension after another 3\' extension.') ext: Extension = Extension(num_bases=num_bases, display_length=display_length, display_angle=display_angle) self.design.append_domain(self._strand, ext) @@ -2364,7 +2366,7 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': '(strictly increasing or strictly decreasing) ' 'when calling to() twice in a row') - if self._contains_extension_3p(): + if self._is_last_domain_an_extension_3p(): raise IllegalDesignError('cannot make a new domain once 3\' extension has been added') if offset > self.current_offset: @@ -2390,11 +2392,10 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': return self - def _contains_extension_3p(self) -> bool: + def _is_last_domain_an_extension_3p(self) -> bool: if self._strand is None: return False - domains = self._strand.domains - return len(domains) > 1 and isinstance(domains[-1], Extension) + return len(self._strand.domains) > 1 and self._is_last_domain_an_extension() # remove quotes when Py3.6 support dropped def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': From 173fc347b50805c73587ac5deac9ebbeaa5b6ecb Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 17:46:45 -0700 Subject: [PATCH 39/73] Pass test_strand__update_to_after_3p_extension_should_raise_error --- scadnano/scadnano.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index b50151e3..9685c9cd 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2413,6 +2413,8 @@ def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': the new :any:`Domain` is reverse, otherwise it is forward. :return: self """ + if self._is_last_domain_an_extension_3p(): + raise IllegalDesignError('Cannot call update_to after creating extension_3p.') if not self.last_domain: return self.to(offset) From 5610cf854ea254ca7554edffd11d8029926a2c7d Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 20:12:50 -0700 Subject: [PATCH 40/73] Pass test_strand__extension_3p_on_circular_strand_should_raise_error --- scadnano/scadnano.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 9685c9cd..f551d51d 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2271,6 +2271,15 @@ def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angl """ Add extension to end of strand. No domains can be added after this function is called. """ + self._verify_extension_3p_is_valid() + assert self._strand is not None + ext: Extension = Extension(num_bases=num_bases, + display_length=display_length, + display_angle=display_angle) + self.design.append_domain(self._strand, ext) + return self + + def _verify_extension_3p_is_valid(self): if self._strand is None: raise IllegalDesignError( 'Cannot add a 3\' extension when there are no domains. Did you mean to create a 5\' extension?') @@ -2278,23 +2287,29 @@ def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angl raise IllegalDesignError('Cannot add a 3\' extension immediately after a loopout.') if self._is_last_domain_an_extension_3p(): raise IllegalDesignError('Cannot add a 3\' extension after another 3\' extension.') - ext: Extension = Extension(num_bases=num_bases, display_length=display_length, - display_angle=display_angle) - self.design.append_domain(self._strand, ext) - return self + self._verify_strand_is_not_circular() + + def _verify_strand_is_not_circular(self): + if self._strand.circular: + raise IllegalDesignError('Cannot add an extension to a circular strand.') def _is_last_domain_a_loopout(self): return self._is_last_domain_an_instance_of_class(Loopout) def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': - if self._strand is not None: - raise IllegalDesignError('Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') - ext: Extension = Extension(num_bases=num_bases, display_length=display_length, + self._verify_extension_5p_is_valid() + ext: Extension = Extension(num_bases=num_bases, + display_length=display_length, display_angle=display_angle) self._strand = Strand(domains=[ext]) self.design.add_strand(self._strand) return self + def _verify_extension_5p_is_valid(self): + if self._strand is not None: + raise IllegalDesignError( + 'Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') + def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ TODO: write doc From 185d097db30a7674b62f7777f2eaf3e4636bcc55 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 20:26:15 -0700 Subject: [PATCH 41/73] Extension name, label, and sequence --- scadnano/scadnano.py | 8 ++++++++ tests/scadnano_tests.py | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index f551d51d..03cf396e 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2064,6 +2064,14 @@ def dna_length(self) -> int: """Length of this :any:`Extension`; same as field :py:data:`Extension.length`.""" return self.num_bases + def set_label(self, label: Optional[DomainLabel]) -> None: + """Sets label of this :any:`Extension`.""" + self.label = label + + def set_name(self, name: str) -> None: + """Sets name of this :any:`Extension`.""" + self.name = name + @dataclass class ExtensionBuilder(Generic[DomainLabel]): length: Optional[int] = None diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 6d05a362..621b3424 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -248,19 +248,20 @@ def test_strand__extension_3p_with_label(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, (1, -1), label="ext1"), + sc.Extension(5, label="ext1"), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) def test_strand__extension_5p_with_label(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) sb.with_domain_label("ext1") sb.to(10) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1), label="ext1"), + sc.Extension(5, label="ext1"), sc.Domain(0, True, 0, 10) ]) @@ -273,19 +274,20 @@ def test_strand__with_sequence_on_3p_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10, dna_sequence="A"*10), - sc.Extension(5, (1, -1), dna_sequence="G"*5), + sc.Extension(5, dna_sequence="G"*5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) def test_strand__with_sequence_on_5p_extension(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0, extension_5p_length=5) + sb = design.draw_strand(0, 0) + sb.extension_5p(5) sb.to(10) sb.with_sequence("C"*5 + "T"*10) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1), dna_sequence="C"*5), + sc.Extension(5, dna_sequence="C"*5), sc.Domain(0, True, 0, 10, dna_sequence="T"*10), ]) self.assertEqual(1, len(design.strands)) @@ -300,7 +302,7 @@ def test_strand__with_domain_sequence_on_extension(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10, dna_sequence="?"*10), - sc.Extension(5, (1, -1), dna_sequence="G"*5), + sc.Extension(5, dna_sequence="G"*5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -314,7 +316,7 @@ def test_strand__extension_with_name(self) -> None: expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, (1, -1), name="ext1"), + sc.Extension(5, name="ext1"), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) From 43e06bf31aa0a28b86718221c8486c612969ad79 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 12 May 2022 20:57:49 -0700 Subject: [PATCH 42/73] Remove relative_offset and ExtensionBuilder --- scadnano/scadnano.py | 33 --------------------------------- tests/scadnano_tests.py | 20 +++----------------- 2 files changed, 3 insertions(+), 50 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 03cf396e..6a7124a6 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2072,19 +2072,6 @@ def set_name(self, name: str) -> None: """Sets name of this :any:`Extension`.""" self.name = name -@dataclass -class ExtensionBuilder(Generic[DomainLabel]): - length: Optional[int] = None - relative_offset: Optional[Tuple[float, float]] = None - label: Optional[DomainLabel] = None - name: Optional[str] = None - dna_sequence: Optional[str] = None - - def build(self) -> Extension: - assert self.length is not None - assert self.relative_offset is not None - return Extension(self.length, self.relative_offset, self.label, self.name, self.dna_sequence) - _wctable = str.maketrans('ACGTacgt', 'TGCAtgca') @@ -2205,7 +2192,6 @@ def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offse self._strand: Optional[Strand[StrandLabel, DomainLabel]] = None self.just_moved_to_helix: bool = True self.last_domain: Optional[Domain[DomainLabel]] = None - self.extension_5p_builder: Optional[ExtensionBuilder] = None @property def strand(self) -> 'Strand[StrandLabel, DomainLabel]': @@ -2318,25 +2304,6 @@ def _verify_extension_5p_is_valid(self): raise IllegalDesignError( 'Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') - def with_relative_offset(self, relative_offset: Tuple[float, float]) -> 'StrandBuilder[StrandLabel, DomainLabel]': - """ - TODO: write doc - """ - return self - - def _determine_default_relative_offset_for_3p_extension_based_on_last_domain(self) -> Tuple[float, float]: - assert self._strand is not None - last_domain = self._strand.domains[-1] - assert isinstance(last_domain, Domain) - return self._determine_default_relative_offset_for_3p_extension(last_domain.forward) - - def _determine_default_relative_offset_for_3p_extension(self, forward: bool) -> Tuple[float, float]: - return (1, -1) if forward else (-1, 1) - - def _determine_default_relative_offset_for_5p_extension(self, forward: bool) -> Tuple[float, float]: - return (-1, -1) if forward else (1, 1) - - # remove quotes when Py3.6 support dropped def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 621b3424..b3bc2979 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -323,31 +323,17 @@ def test_strand__extension_with_name(self) -> None: def test_strand__with_relative_offset(self) -> None: design = self.design_6helix - sb = design.draw_strand(0, 0).to(10).extension_3p(5) + sb = design.draw_strand(0, 0).to(10) - sb.with_relative_offset((1.1, -1.4)) + sb.extension_3p(5, display_length=1.4, display_angle=30) expected_strand: sc.Strand = sc.Strand([ sc.Domain(0, True, 0, 10), - sc.Extension(5, (1.1, -1.4)) + sc.Extension(5, display_length=1.4, display_angle=30) ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) - def test_strand__with_relative_offset_on_domain_error(self) -> None: - design = self.design_6helix - sb = design.draw_strand(0, 0).to(10) - - with self.assertRaises(ValueError): - sb.with_relative_offset((1.1, -1.4)) - - def test_strand__with_relative_offset_on_empty_error(self) -> None: - design = self.design_6helix - sb = design.draw_strand(0, 0) - - with self.assertRaises(ValueError): - sb.with_relative_offset((1.1, -1.4)) - def test_strand__0_0_to_10_cross_1_to_5(self) -> None: design = self.design_6helix sb = design.draw_strand(0, 0) From 9b9e401b03fcaa0bb603c0860bccdddb8aca8edf Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 13 May 2022 19:56:39 -0700 Subject: [PATCH 43/73] Fix some json and cadnano tests --- scadnano/scadnano.py | 3 +++ tests/scadnano_tests.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6a7124a6..177c42fa 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -5668,6 +5668,9 @@ def to_cadnano_v2_serializable(self, name: str = '') -> Dict[str, Any]: ''' for strand in self.strands: for domain in strand.domains: + if isinstance(domain, Extension): + raise ValueError( + 'We cannot handle designs with Extensions as it is not a cadnano v2 concept') if isinstance(domain, Loopout): raise ValueError( 'We cannot handle designs with Loopouts as it is not a cadnano v2 concept') diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index b3bc2979..489af711 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -1576,9 +1576,9 @@ def test_extension(self) -> None: """ design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)], grid=Grid.square) sb = design.draw_strand(0, 0) - - sb.extension_3p(5) sb.to(10) + sb.as_scaffold() + sb.extension_3p(5) with self.assertRaises(ValueError) as context: design.to_cadnano_v2_json() @@ -1671,7 +1671,7 @@ def test_from_json__three_strands(self) -> None: scaf_loop = scaf.domains[2] scaf_ss2 = scaf.domains[3] - self.assertEqual(3, scaf_loop.num_bases) + self.assertEqual(3, scaf_loop.length) self.assertEqual(1, st_l_ss0.helix) self.assertEqual(0, st_l_ss1.helix) From 819d0d8be7fe50dce3134fb2b78ba4f35c11b81f Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 13 May 2022 22:44:47 -0700 Subject: [PATCH 44/73] Implement Extension from_json, and fix bug with default color from json --- scadnano/scadnano.py | 48 +++++++++++++++++++++++++++++++++-------- tests/scadnano_tests.py | 4 ++-- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 177c42fa..6e03fca7 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -792,6 +792,11 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: # Loopout keys loopout_key = 'loopout' +# Extension keys +extension_key = 'extension' +display_length_key = 'display_length' +display_angle_key = 'display_angle' + # Modification keys mod_location_key = 'location' mod_display_text_key = 'display_text' @@ -2072,6 +2077,26 @@ def set_name(self, name: str) -> None: """Sets name of this :any:`Extension`.""" self.name = name + @staticmethod + def from_json(json_map: Dict[str, Any]) -> 'Extension': + def float_transformer(x): return float(x) + num_bases_str = mandatory_field(Extension, json_map, extension_key) + num_bases = int(num_bases_str) + display_length = optional_field(_default_extension.display_length, + json_map, display_length_key, transformer=float_transformer) + display_angle = optional_field(_default_extension.display_angle, + json_map, display_angle_key, transformer=float_transformer) + name = json_map.get(domain_name_key) + label = json_map.get(domain_label_key) + return Extension( + num_bases=num_bases, + display_length=display_length, + display_angle=display_angle, + label=label, name=name) + + +# Default Extension object to allow for access to default extension values. num_bases is a dummy. +_default_extension: Extension = Extension(num_bases=5) _wctable = str.maketrans('ACGTacgt', 'TGCAtgca') @@ -2867,10 +2892,12 @@ def from_json(json_map: dict) -> 'Strand': # remove quotes when Py3.6 support d if len(domain_jsons) == 0: raise IllegalDesignError(f'{domains_key} list cannot be empty') - domains: List[Union[Domain, Loopout]] = [] + domains: List[Union[Domain, Loopout, Extension]] = [] for domain_json in domain_jsons: if loopout_key in domain_json: domains.append(Loopout.from_json(domain_json)) + elif extension_key in domain_json: + domains.append(Extension.from_json(domain_json)) else: domains.append(Domain.from_json(domain_json)) if isinstance(domains[0], Loopout): @@ -2883,14 +2910,17 @@ def from_json(json_map: dict) -> 'Strand': # remove quotes when Py3.6 support d dna_sequence = optional_field(None, json_map, dna_sequence_key, legacy_keys=legacy_dna_sequence_keys) - color_str = json_map.get(color_key, - default_scaffold_color if is_scaffold else default_strand_color) - if isinstance(color_str, int): - def decimal_int_to_hex(d: int) -> str: - return "#" + "{0:#08x}".format(d, 8)[2:] # type: ignore + color_str = json_map.get(color_key) + + if color_str is None: + color = default_scaffold_color if is_scaffold else default_strand_color + else: + if isinstance(color_str, int): + def decimal_int_to_hex(d: int) -> str: + return "#" + "{0:#08x}".format(d, 8)[2:] # type: ignore - color_str = decimal_int_to_hex(color_str) - color = Color(hex_string=color_str) + color_str = decimal_int_to_hex(color_str) + color = Color(hex_string=color_str) label = json_map.get(strand_label_key) @@ -5798,7 +5828,7 @@ def _check_types(self) -> None: for strand in self.strands: _check_type(strand, Strand) for substrand in strand.domains: - _check_type_is_one_of(substrand, [Domain, Loopout]) + _check_type_is_one_of(substrand, [Domain, Loopout, Extension]) if isinstance(substrand, Domain): _check_type(substrand.helix, int) _check_type(substrand.forward, bool) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 489af711..926f4f64 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -4857,7 +4857,7 @@ def test_from_json_extension_design(self) -> None: { "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 10}, - {"extension": 5, "relative_offset": [1.4, -0.3]} + {"extension": 5, "display_length": 1.4, "display_angle": 50.0} ], "is_scaffold": true } @@ -4865,7 +4865,7 @@ def test_from_json_extension_design(self) -> None: } """ design = sc.Design.from_scadnano_json_str(json_str) - self.assertEqual(sc.Extension(5, (1.4, -0.3)), design.strands[0].domains[1]) + self.assertEqual(sc.Extension(5, display_length=1.4, display_angle=50.0), design.strands[0].domains[1]) def test_to_json_extension_design__extension(self) -> None: # Setup From 860be0682d055f42d7ba5a273049b00779a448b8 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sat, 14 May 2022 21:48:40 -0700 Subject: [PATCH 45/73] Implement Extension.to_json_serializable --- scadnano/scadnano.py | 28 +++++++++++++++++- tests/scadnano_tests.py | 63 +++++++++++++++++++++++++++++++---------- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6e03fca7..8fa1aa71 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2063,7 +2063,12 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None) def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: - raise NotImplementedError() + json_map: Dict[str, Any] = {extension_key: self.num_bases} + self._add_display_length_if_not_default(json_map) + self._add_display_angle_if_not_default(json_map) + self._add_name_if_not_default(json_map) + self._add_label_if_not_default(json_map) + return json_map def dna_length(self) -> int: """Length of this :any:`Extension`; same as field :py:data:`Extension.length`.""" @@ -2094,6 +2099,27 @@ def float_transformer(x): return float(x) display_angle=display_angle, label=label, name=name) + def _add_display_length_if_not_default(self, json_map) -> None: + self._add_key_value_to_json_map_if_not_default( + key=display_length_key, value_callback=lambda x: x.display_length, json_map=json_map) + + def _add_display_angle_if_not_default(self, json_map) -> None: + self._add_key_value_to_json_map_if_not_default( + key=display_angle_key, value_callback=lambda x: x.display_angle, json_map=json_map) + + def _add_name_if_not_default(self, json_map) -> None: + self._add_key_value_to_json_map_if_not_default( + key=domain_name_key, value_callback=lambda x: x.name, json_map=json_map) + + def _add_label_if_not_default(self, json_map) -> None: + self._add_key_value_to_json_map_if_not_default( + key=domain_label_key, value_callback=lambda x: x.label, json_map=json_map) + + def _add_key_value_to_json_map_if_not_default( + self, key: str, value_callback: Callable[["Extension"], Any], json_map: Dict[str, Any]) -> None: + if value_callback(self) != value_callback(_default_extension): + json_map[key] = value_callback(self) + # Default Extension object to allow for access to default extension values. num_bases is a dummy. _default_extension: Extension = Extension(num_bases=5) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 926f4f64..bbccd7a3 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -4881,20 +4881,6 @@ def test_to_json_extension_design__extension(self) -> None: self.assertIn("extension", document["strands"][0]["domains"][1]) self.assertEqual(5, document["strands"][0]["domains"][1]["extension"]) - def test_to_json_extension_design__relative_offset(self) -> None: - # Setup - design = sc.Design(helices=[sc.Helix(max_offset=100)], strands=[], grid=sc.square) - design.draw_strand(0, 0).to(10).extension_3p(5).with_relative_offset((1.4, -0.3)) - - # Action - result = design.to_json() - - # Verify - document = json.loads(result) - self.assertEqual(2, len(document["strands"][0]["domains"])) - self.assertIn("relative_offset", document["strands"][0]["domains"][1]) - self.assertEqual([1.4, -0.3], document["strands"][0]["domains"][1]["relative_offset"]) - class TestIllegalStructuresPrevented(unittest.TestCase): @@ -7370,4 +7356,51 @@ def test_plate_map_markdown(self) -> None: | G | | | | | | | | | | | | | | H | | | | | | | | | | | | | """.strip() - self.assertEqual(expected_md, actual_md) \ No newline at end of file + self.assertEqual(expected_md, actual_md) + + +class TestExtension(unittest.TestCase): + def test_to_json_serializable__extension_key_contains_num_bases(self) -> None: + ext = sc.Extension(5) + result = ext.to_json_serializable() + self.assertEqual(result["extension"], 5) + + def test_to_json_serializable__no_display_length_key_when_default_display_length(self) -> None: + ext = sc.Extension(5) + result = ext.to_json_serializable() + self.assertNotIn("display_length", result) + + def test_to_json_serializable__no_display_angle_key_when_default_display_angle(self) -> None: + ext = sc.Extension(5) + result = ext.to_json_serializable() + self.assertNotIn("display_angle", result) + + def test_to_json_serializable__no_name_key_when_default_name(self) -> None: + ext = sc.Extension(5) + result = ext.to_json_serializable() + self.assertNotIn("name", result) + + def test_to_json_serializable__no_label_key_when_default_label(self) -> None: + ext = sc.Extension(5) + result = ext.to_json_serializable() + self.assertNotIn("label", result) + + def test_to_json_serializable__display_length_key_contains_non_default_display_length(self) -> None: + ext = sc.Extension(5, display_length=1.9) + result = ext.to_json_serializable() + self.assertEqual(result["display_length"], 1.9) + + def test_to_json_serializable__display_angle_key_contains_non_default_display_angle(self) -> None: + ext = sc.Extension(5, display_angle=39.9) + result = ext.to_json_serializable() + self.assertEqual(result["display_angle"], 39.9) + + def test_to_json_serializable__name_key_contains_non_default_name(self) -> None: + ext = sc.Extension(5, name="A") + result = ext.to_json_serializable() + self.assertEqual(result["name"], "A") + + def test_to_json_serializable__label_key_contains_non_default_name(self) -> None: + ext = sc.Extension(5, label="ext1") + result = ext.to_json_serializable() + self.assertEqual(result["label"], "ext1") \ No newline at end of file From 06fe22272f217f3f15b2c0c51b61f6fcf57adc14 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 15 May 2022 22:29:18 -0700 Subject: [PATCH 46/73] Fix nick,ligate,and crossover tests --- tests/scadnano_tests.py | 49 +++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index bbccd7a3..231468c6 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3269,6 +3269,15 @@ def test_add_nick_then_add_crossovers__6_helix_rectangle(self) -> None: self.assertIn(scaf, self.origami.strands) def test_ligate_on_extension_side_should_error(self) -> None: + """ + ↗ + / + / + [-------[-----> + ^ + | + error to ligate here + """ design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) design.draw_strand(0, 0).to(10).extension_3p(5) design.draw_strand(0, 10).to(20) @@ -3281,24 +3290,28 @@ def test_ligate_on_non_extension_side_ok(self) -> None: □ \ \ - 0 --------->[--------> + --------->[--------> + After: - Before: □ \ \ - 0 -------------------> + -------------------> """ + # Setup design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) - design.draw_strand(0, 0).extension_3p(5).to(10) + design.draw_strand(0, 0).extension_5p(5).to(10) design.draw_strand(0, 10).to(20) + + # Action design.ligate(0, 10, True) - expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1)), - sc.Domain(0, True, 0, 20) - ]) + + # Verify self.assertEqual(1, len(design.strands)) - self.assertEqual(expected_strand, design.strands[0]) + actual_substrands = design.strands[0].domains + self.assertEqual(2, len(actual_substrands)) + self.assertEqual(sc.Extension(5), actual_substrands[0]) + self.assertEqual(sc.Domain(0, True, 0, 20), actual_substrands[1]) def test_add_full_crossover_extension_ok(self) -> None: """ @@ -3339,7 +3352,7 @@ def test_add_full_crossover_extension_ok(self) -> None: expected_strand_1: sc.Strand = sc.Strand([ sc.Domain(1, False, 8, 16), sc.Domain(0, True, 8, 16), - sc.Extension(5, (1, -1)) + sc.Extension(5) ]) self.assertEqual(2, len(design.strands)) self.assertIn(expected_strand_0, design.strands) @@ -3398,15 +3411,15 @@ def test_add_half_crossover_on_extension_ok(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).extension_3p(5).to(8) + design.draw_strand(0, 0).extension_5p(5).to(8) design.draw_strand(1, 8).to(0) # Action - design.add_half_crossover(0, 1, 8, True) + design.add_half_crossover(0, 1, 7, True) # Validation expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, (-1, -1)), + sc.Extension(5), sc.Domain(0, True, 0, 8), sc.Domain(1, False, 0, 8) ]) @@ -3435,7 +3448,7 @@ def test_add_half_crossover_on_extension_error(self) -> None: design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(0, 0).extension_3p(5).to(8) + design.draw_strand(0, 0).extension_5p(5).to(8) design.draw_strand(1, 8).to(0) with self.assertRaises(sc.IllegalDesignError): @@ -3459,7 +3472,7 @@ def test_nick_on_extension(self) -> None: """ # Setup design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)]) - design.draw_strand(0, 0).extension_3p(5).to(8) + design.draw_strand(0, 0).to(8).extension_3p(5) # Nick design.add_nick(0, 4, True) @@ -3470,7 +3483,7 @@ def test_nick_on_extension(self) -> None: ]) expected_strand2: sc.Strand = sc.Strand([ sc.Domain(0, True, 4, 8), - sc.Extension(5, (1, -1)) + sc.Extension(5) ]) self.assertEquals(2, len(design.strands)) self.assertIn(expected_strand1, design.strands) @@ -4865,7 +4878,9 @@ def test_from_json_extension_design(self) -> None: } """ design = sc.Design.from_scadnano_json_str(json_str) - self.assertEqual(sc.Extension(5, display_length=1.4, display_angle=50.0), design.strands[0].domains[1]) + self.assertEqual( + sc.Extension(5, display_length=1.4, display_angle=50.0), + design.strands[0].domains[1]) def test_to_json_extension_design__extension(self) -> None: # Setup From 2b485beecad86d0167eba2a532bfa61888221ca9 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 22 May 2022 21:29:45 -0700 Subject: [PATCH 47/73] Add ligate and half crossover error cases --- scadnano/scadnano.py | 2 +- tests/scadnano_tests.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 8fa1aa71..8f5b1db4 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -7823,7 +7823,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: else: # we place the loopout nucleotides at temporary nonsense positions and orientations # these will be updated later, for now we just need the base - for i in range(domain.num_bases): + for i in range(domain.length): base = seq[i] nuc = _OxdnaNucleotide(_OxdnaVector(), _OxdnaVector(0, -1, 0), _OxdnaVector(0, 0, 1), base) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 231468c6..03e85c82 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3268,14 +3268,27 @@ def test_add_nick_then_add_crossovers__6_helix_rectangle(self) -> None: ]) self.assertIn(scaf, self.origami.strands) + def test_ligate_on_middle_domain_should_error(self) -> None: + """ + [-----+[-----> + | + <-----+ + """ + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)]) + design.draw_strand(0, 0).to(10).cross(1).to(0) + design.draw_strand(0, 10).to(20) + + with self.assertRaises(sc.IllegalDesignError): + design.ligate(0, 10, True) + def test_ligate_on_extension_side_should_error(self) -> None: """ ↗ / / [-------[-----> - ^ - | + ^ + | error to ligate here """ design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)]) @@ -3454,6 +3467,24 @@ def test_add_half_crossover_on_extension_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.add_half_crossover(0, 1, 0, True) + def test_add_half_crossover_on_existing_crossover_should_error(self) -> None: + """ + 0 +------] + | + 1 +------> + + 2 <------] + """ + # Setup + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(0, 10).to(0).cross(1).to(10) + design.draw_strand(2, 10).to(0) + + with self.assertRaises(sc.IllegalDesignError): + design.add_half_crossover(1, 2, 0, True) + def test_nick_on_extension(self) -> None: """ Before: From 63fbd534f25583790e996b170413c0121e92b36d Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 22 May 2022 22:19:11 -0700 Subject: [PATCH 48/73] Fix middle domain bug in ligate --- scadnano/scadnano.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 8f5b1db4..197fd80c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -7028,6 +7028,14 @@ def ligate(self, helix: int, offset: int, forward: bool) -> None: strand_5p = strand_right strand_3p = strand_left + if strand_5p.domains[0] != dom_5p: + raise IllegalDesignError(f'Domain to be ligated "{dom_5p.name}"' + f'does not reside on the 5\' end of the strand.') + + if strand_3p.domains[-1] != dom_3p: + raise IllegalDesignError(f'Domain to be ligated "{dom_3p.name}"' + f'does not reside on the 3\' of the strand.') + if strand_left is strand_right: # join domains and make strand circular strand = strand_left From 4a2023a7703ded785df904c72b269568f4c49f6c Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Sun, 22 May 2022 23:53:56 -0700 Subject: [PATCH 49/73] Handle ligate and crossover error case --- scadnano/scadnano.py | 14 ++++++++++++ tests/scadnano_tests.py | 47 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 197fd80c..e1f263f6 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -7134,14 +7134,28 @@ def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool if domain1.offset_3p() == offset and domain2.offset_5p() == offset2: strand_first = strand1 strand_last = strand2 + domain_first = domain1 + domain_last = domain2 elif domain1.offset_5p() == offset and domain2.offset_3p() == offset2: strand_first = strand2 strand_last = strand1 + domain_first = domain2 + domain_last = domain1 else: raise IllegalDesignError("Cannot add half crossover. Must have one domain have its " "5' end at the given offset and the other with its 3' end at the " "given offset, but this is not the case.") + if strand_first.domains[-1] is not domain_first: + raise IllegalDesignError( + f"Domain to add crossover to: {domain_first.name} is expected to be on the 3'" + f"end of the strand, but this is not the case.") + + if strand_last.domains[0] is not domain_last: + raise IllegalDesignError( + f"Domain to add crossover to: {domain_last.name} is expected to be on the 5'" + f"end of the strand, but this is not the case.") + new_domains = strand_first.domains + strand_last.domains if strand_first.dna_sequence is None and strand_last.dna_sequence is None: new_dna = None diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 03e85c82..532343e0 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -3268,8 +3268,11 @@ def test_add_nick_then_add_crossovers__6_helix_rectangle(self) -> None: ]) self.assertIn(scaf, self.origami.strands) - def test_ligate_on_middle_domain_should_error(self) -> None: + def test_ligate_on_middle_domain_should_error_3p_case(self) -> None: """ + Error to ligate here + | + v [-----+[-----> | <-----+ @@ -3281,6 +3284,22 @@ def test_ligate_on_middle_domain_should_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.ligate(0, 10, True) + def test_ligate_on_middle_domain_should_error_5p_case(self) -> None: + """ + Error to ligate here + | + v + [----->+-----> + | + +-----] + """ + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100)]) + design.draw_strand(0, 0).to(10) + design.draw_strand(1, 20).to(10).cross(0).to(20) + + with self.assertRaises(sc.IllegalDesignError): + design.ligate(0, 10, True) + def test_ligate_on_extension_side_should_error(self) -> None: """ ↗ @@ -3467,12 +3486,14 @@ def test_add_half_crossover_on_extension_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.add_half_crossover(0, 1, 0, True) - def test_add_half_crossover_on_existing_crossover_should_error(self) -> None: + def test_add_half_crossover_on_existing_crossover_should_error_5p_case(self) -> None: """ 0 +------] | 1 +------> - + ^ + error to cross here + v 2 <------] """ # Setup @@ -3485,6 +3506,26 @@ def test_add_half_crossover_on_existing_crossover_should_error(self) -> None: with self.assertRaises(sc.IllegalDesignError): design.add_half_crossover(1, 2, 0, True) + def test_add_half_crossover_on_existing_crossover_should_error_3p_case(self) -> None: + """ + 0 <------+ + | + 1 [------+ + ^ + error to cross here + v + 2 <------] + """ + # Setup + design: sc.Design = sc.Design( + helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100), sc.Helix(max_offset=100)] + ) + design.draw_strand(1,0).to(10).cross(0).to(0) + design.draw_strand(2,10).to(0) + + with self.assertRaises(sc.IllegalDesignError): + design.add_half_crossover(1, 2, 9, True) + def test_nick_on_extension(self) -> None: """ Before: From 75e4740073fbfc2e1cf4c65ca9430d08f582e1ff Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 25 May 2022 14:48:59 +0100 Subject: [PATCH 50/73] fixed errors in plate map documentation --- scadnano/scadnano.py | 108 ++++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 42 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index fddf5f32..f38370bd 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -4044,7 +4044,58 @@ def to_table( from IPython.display import display, Markdown display(Markdown(maps_strs)) - It uses the Python tabulate package (https://pypi.org/project/tabulate/). + Markdown format is used by default, generating a string such as this: + + .. code-block:: none + + plate "5 monomer synthesis" + + | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | + |-----|------|--------|--------|------|----------|-----|-----|-----|-----|------|------|------| + | A | mon0 | mon0_F | | adp0 | | | | | | | | | + | B | mon1 | mon1_Q | mon1_F | adp1 | adp_sst1 | | | | | | | | + | C | mon2 | mon2_F | mon2_Q | adp2 | adp_sst2 | | | | | | | | + | D | mon3 | mon3_Q | mon3_F | adp3 | adp_sst3 | | | | | | | | + | E | mon4 | | mon4_Q | adp4 | adp_sst4 | | | | | | | | + | F | | | | adp5 | | | | | | | | | + | G | | | | | | | | | | | | | + | H | | | | | | | | | | | | | + + or, with the :meth:`PlateMap.to_table` parameter `well_marker` set to ``'*'`` + (in case you don't need to see the strand names and just want to see which wells are marked): + + .. code-block:: none + + | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | + |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|------|------| + | A | * | * | | * | | | | | | | | | + | B | * | * | * | * | * | | | | | | | | + | C | * | * | * | * | * | | | | | | | | + | D | * | * | * | * | * | | | | | | | | + | E | * | | * | * | * | | | | | | | | + | F | | | | * | | | | | | | | | + | G | | | | | | | | | | | | | + | H | | | | | | | | | | | | | + + + If `well_marker` is not specified, then each strand must have a name. `well_marker` can also + be a function of the well; for instance, if it is the identity function ``lambda x:x``, then each + well has its own address as the entry: + + .. code-block:: none + + | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | + |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|------|------| + | A | A1 | A2 | | A4 | | | | | | | | | + | B | B1 | B2 | B3 | B4 | B5 | | | | | | | | + | C | C1 | C2 | C3 | C4 | C5 | | | | | | | | + | D | D1 | D2 | D3 | D4 | D5 | | | | | | | | + | E | E1 | | E3 | E4 | E5 | | | | | | | | + | F | | | | F4 | | | | | | | | | + | G | | | | | | | | | | | | | + | H | | | | | | | | | | | | | + + This method uses the Python tabulate package (https://pypi.org/project/tabulate/). The parameters are identical to that of the `tabulate` function and are passed along to it, except for `tabular_data` and `headers`, which are computed from this plate map. In particular, the parameter `tablefmt` has default value `'pipe'`, @@ -5190,9 +5241,13 @@ def plate_maps(self, strands: Optional[Iterable[Strand]] = None, ) -> List[PlateMap]: """ - Generates plate maps from this :any:`Design` in Markdown format, for example: + Returns a list of :any:`PlateMap`'s from this :any:`Design`. Each :any:`PlateMap` can be + exported to a string, in Markdown format by default, by calling :meth:`PlateMap.to_table`, + generating a string such as this: - .. code-block:: + .. code-block:: none + + plate "5 monomer synthesis" | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | |-----|------|--------|--------|------|----------|-----|-----|-----|-----|------|------|------| @@ -5205,44 +5260,13 @@ def plate_maps(self, | G | | | | | | | | | | | | | | H | | | | | | | | | | | | | - or, with `well_marker` set to ``'*'`` (in case you don't need to see the strand names and just - want to see which wells are marked): - - .. code-block:: - - | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | - |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|------|------| - | A | * | * | | * | | | | | | | | | - | B | * | * | * | * | * | | | | | | | | - | C | * | * | * | * | * | | | | | | | | - | D | * | * | * | * | * | | | | | | | | - | E | * | | * | * | * | | | | | | | | - | F | | | | * | | | | | | | | | - | G | | | | | | | | | | | | | - | H | | | | | | | | | | | | | - - - If `well_marker` is not specified, then each strand must have a name. `well_marker` can also - be a function of the well; for instance, if it is the identity function ``lambda x:x``, then each - well has its own address as the entry: - - .. code-block:: - - | | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | - |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|------|------|------| - | A | A1 | A2 | | A4 | | | | | | | | | - | B | B1 | B2 | B3 | B4 | B5 | | | | | | | | - | C | C1 | C2 | C3 | C4 | C5 | | | | | | | | - | D | D1 | D2 | D3 | D4 | D5 | | | | | | | | - | E | E1 | | E3 | E4 | E5 | | | | | | | | - | F | | | | F4 | | | | | | | | | - | G | | | | | | | | | | | | | - | H | | | | | | | | | | | | | - + See the documentation for :meth:`PlateMap.to_table` for more information on configuring the + returned string format. All :any:`Strand`'s in the design that have a field :data:`Strand.idt` with :data:`Strand.idt.plate` - specified are exported. The number of strings in the returned list is equal to the number of - different plate names specified across all :any:`Strand`'s in the design. + specified are included in some returned :any:`PlateMap`. The number of :any:`PlateMap`'s in the + returned list is equal to the number of different plate names specified across all + :any:`Strand`'s in the design. If parameter `strands` is given, then a subset of strands is included. This is useful for specifying a mix of strands for a particular experiment, which come from a plate but does not @@ -5254,10 +5278,10 @@ def plate_maps(self, :param plate_type: Type of plate: 96 or 384 well. :param strands: - If specified, only the :any:`Strand`'s in `strands` are put in the plate map. + If specified, only the :any:`Strand`'s in `strands` are put in the :any:`PlateMap`. :return: - dict mapping plate names to markdown strings specifying plate maps for :any:`Strand`'s - in this design with IDT plates specified + list of :any:`PlateMap`'s for :any:`Strand`'s in this design with IDT plates specified; + length of list is equal to number of unique plate names among all :any:`Strand`'s in this design """ if strands is None: strands = self.strands From 996e59b56d0379617f88e6f423501baa7352352e Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 25 May 2022 15:31:52 +0100 Subject: [PATCH 51/73] Update tutorial.md --- tutorial/tutorial.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index adb7df66..6f8bbd44 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -367,9 +367,13 @@ We used chained method calls to create scaffold "precursor" strands, i.e., one long strand per helix, going the opposite direction as the scaffold. In subsequent sections we will also add nicks and crossovers to these, to create the staple strands. +Above, we called the method +[Design.draw_strand](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.draw_strand) +to create the scaffold precursors. It is also possible, though typically more verbose, to explicitly create `Domain` objects, to be passed into the `Strand` constructor. -For the staple precursor strands we do this to show how it works. +For the staple precursor strands, we do this to show how it works, though it would typically be simpler and easier to read to call +[Design.draw_strand](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.draw_strand). Each `Strand` is specified primarily by a list of `Domain`'s, and each `Domain` is specified primarily by 4 fields: integer `helix` (actually, *index* of a helix), From bb152cd8e525d89167d394a16eadf4c6567fc9bd Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 8 Jun 2022 17:20:15 -0700 Subject: [PATCH 52/73] Add docstring for Extension --- scadnano/scadnano.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 13d688a8..2f7c569b 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1929,7 +1929,7 @@ class Loopout(_JSONSerializable, Generic[DomainLabel]): It is illegal for two consecutive :any:`Domain`'s to both be :any:`Loopout`'s, or for a :any:`Loopout` to occur on either end of the :any:`Strand` - (i.e., each :any:`Strand` must begin and end with a :any:`Domain`). + (i.e., each :any:`Strand` must begin and end with a :any:`Domain` or :any:`Extension`). For example, one use of a loopout is to describe a hairpin (a.k.a., `stem-loop `_). @@ -2052,6 +2052,31 @@ def get_seq_start_idx(self) -> int: @dataclass class Extension(_JSONSerializable, Generic[DomainLabel]): + """Represents a single-stranded extension on either the 3' or 5' + end of :any:`Strand`. + + One could think of a :any:`Extension` as a type of :any:`Domain`, but none of the fields of + :any:`Domain` make sense for :any:`Extension`, so they are not related to each other in the type + hierarchy. It is interpreted that an :any:`Extension` is a single-stranded region that resides on either the 3' or 5' end of the :any:`Strand`. It is illegal for a :any:`Extension` to be placed + in the middle of the :any:`Strand` or for an :any:`Extension` to be adjacent to a :any:`Loopout`. + + .. code-block:: Python + + import scadnano as sc + + domain = sc.Domain(helix=0, forward=True, start=0, end=10) + toehold = sc.Extension(num_bases=5) + strand = sc.Strand([domain, toehold]) + + It can also be created with chained method calls + + .. code-block:: Python + + import scadnano as sc + + design = sc.Design(helices=[sc.Helix(max_offset=10)]) + design.draw_strand(0,0).move(10).extension_3p(5) + """ num_bases: int display_length: float = 1.0 display_angle: float = 45.0 From d52f6cefb7583271d010073564988c2da9c21743 Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Wed, 8 Jun 2022 17:54:34 -0700 Subject: [PATCH 53/73] Add docstrings for rest of Extension fields and functions --- scadnano/scadnano.py | 51 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 2f7c569b..59345bdd 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2055,9 +2055,9 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): """Represents a single-stranded extension on either the 3' or 5' end of :any:`Strand`. - One could think of a :any:`Extension` as a type of :any:`Domain`, but none of the fields of + One could think of an :any:`Extension` as a type of :any:`Domain`, but none of the fields of :any:`Domain` make sense for :any:`Extension`, so they are not related to each other in the type - hierarchy. It is interpreted that an :any:`Extension` is a single-stranded region that resides on either the 3' or 5' end of the :any:`Strand`. It is illegal for a :any:`Extension` to be placed + hierarchy. It is interpreted that an :any:`Extension` is a single-stranded region that resides on either the 3' or 5' end of the :any:`Strand`. It is illegal for an :any:`Extension` to be placed in the middle of the :any:`Strand` or for an :any:`Extension` to be adjacent to a :any:`Loopout`. .. code-block:: Python @@ -2077,12 +2077,42 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): design = sc.Design(helices=[sc.Helix(max_offset=10)]) design.draw_strand(0,0).move(10).extension_3p(5) """ + num_bases: int + """Length (in DNA bases) of this :any:`Loopout`.""" + display_length: float = 1.0 + """Length (in nm) to display in the scadnano web app.""" + display_angle: float = 45.0 + """ + Angle (in degrees) to display in the scadnano web app. + + This angle is relative to the "rotation frame" of the adjacent domain. + 0 degrees means parallel to the adjacent domain. + 90 degrees means pointing away from the helix. + 180 degrees means means antiparallel to the adjacent domain (overlapping). + """ + label: Optional[DomainLabel] = None + """ + Generic "label" object to associate to this :any:`Extension`. + + Useful for associating extra information with the :any:`Extension` that will be serialized, for example, + for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) + that is naturally JSON serializable. (Calling + `json.dumps `_ + on the object should succeed without having to specify a custom encoder.) + """ + name: Optional[str] = None + """ + Optional name to give this :any:`Extension`. + """ + dna_sequence: Optional[str] = None + """DNA sequence of this :any:`Extension`, or ``None`` if no DNA sequence has been assigned.""" + # not serialized; for efficiency # remove quotes when Py3.6 support dropped _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None) @@ -2339,7 +2369,13 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ - Add extension to end of strand. No domains can be added after this function is called. + Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to + the :any:`Strand` as a 3' :any:`Extension`. + + :param num_bases: number of bases of :any:`Extension` to add + :param display_length: display length of :any:`Extension` to add + :param display_angle: display angle of :any:`Extension` to add + :return: self """ self._verify_extension_3p_is_valid() assert self._strand is not None @@ -2367,6 +2403,15 @@ def _is_last_domain_a_loopout(self): return self._is_last_domain_an_instance_of_class(Loopout) def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': + """ + Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to + the :any:`Strand` as a 5' :any:`Extension`. + + :param num_bases: number of bases of :any:`Extension` to add + :param display_length: display length of :any:`Extension` to add + :param display_angle: display angle of :any:`Extension` to add + :return: self + """ self._verify_extension_5p_is_valid() ext: Extension = Extension(num_bases=num_bases, display_length=display_length, From 25b3c56987474e029333879a0304d7fa0ff50c1a Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 28 Jun 2022 17:11:36 -0700 Subject: [PATCH 54/73] fixes #231: Design.add_full_crossover and Design.add_half_crossover should check for existing crossovers --- scadnano/scadnano.py | 41 +++++++++++++++- setup.py | 3 +- tests/scadnano_tests.py | 104 ++++++++++++++++++++++++++++++---------- 3 files changed, 121 insertions(+), 27 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 909fe474..62398b0f 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1917,6 +1917,34 @@ def insertion_offsets(self) -> List[int]: """Return offsets of insertions (but not their lengths).""" return [ins_off for (ins_off, _) in self.insertions] + def is_extreme_domain(self, five_prime: bool) -> bool: + """ + :param five_prime: + whether to ask about 5' end or 3' end + :return: + Whether this :any:`Domain` is the 5' or 3' most :any:`Domain` on its :any:`Strand`. + (which depends on parameter `five_prime` + """ + if self._parent_strand is None: + end = "5'" if five_prime else "3'" + raise ValueError(f'cannot tell if this Domain is {end} since it is not yet assigned to a Strand') + idx = 0 if five_prime else -1 + return self == self._parent_strand.domains[idx] + + def is_5p_domain(self) -> bool: + """ + :return: + Whether this :any:`Domain` is the 5' most :any:`Domain` on its :any:`Strand`. + """ + return self.is_extreme_domain(True) + + def is_3p_domain(self) -> bool: + """ + :return: + Whether this :any:`Domain` is the 3' most :any:`Domain` on its :any:`Strand`. + """ + return self.is_extreme_domain(False) + @dataclass class Loopout(_JSONSerializable, Generic[DomainLabel]): @@ -7363,12 +7391,12 @@ def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool if strand_first.domains[-1] is not domain_first: raise IllegalDesignError( - f"Domain to add crossover to: {domain_first.name} is expected to be on the 3'" + f"Domain to add crossover to: {domain_first} is expected to be on the 3'" f"end of the strand, but this is not the case.") if strand_last.domains[0] is not domain_last: raise IllegalDesignError( - f"Domain to add crossover to: {domain_last.name} is expected to be on the 5'" + f"Domain to add crossover to: {domain_last} is expected to be on the 5'" f"end of the strand, but this is not the case.") new_domains = strand_first.domains + strand_last.domains @@ -7437,8 +7465,17 @@ def _prepare_nicks_for_full_crossover(self, helix: int, forward: bool, offset: i if domain_left == domain_right: self.add_nick(helix, offset, forward) else: + # there's already a nick here, unless either has a crossover assert domain_left.end == domain_right.start + # disallowed situations: + # -->---+^+--->-- not domain_left.is_5p_domain() or not domain_right.is_3p_domain() + # --<---+^+---<-- not domain_left.is_3p_domain() or not domain_right.is_3p_domain() + if (not domain_left.is_5p_domain() or not domain_right.is_3p_domain() or + not domain_left.is_3p_domain() or not domain_right.is_5p_domain()): + raise IllegalDesignError('cannot add crossover at this position ' + 'because there is already a crossover here') + def inline_deletions_insertions(self) -> None: """ Converts deletions and insertions by "inlining" them. Insertions and deletions are removed, diff --git a/setup.py b/setup.py index 0266f9fd..144d4ad5 100644 --- a/setup.py +++ b/setup.py @@ -56,5 +56,6 @@ def extract_version(filename: str): 'xlwt', 'dataclasses>=0.6; python_version < "3.7"', 'tabulate', - ] + ], + tests_require=['xlrd'], ) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 3b23902a..bde9588c 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -6,12 +6,11 @@ import math from typing import Iterable, Union, Dict, Any -import xlrd +import xlrd # type: ignore import scadnano as sc import scadnano.origami_rectangle as rect import scadnano.modifications as mod -from scadnano.scadnano import Grid, Helix def strand_matching(strands: Iterable[sc.Strand], helix: int, forward: bool, start: int, @@ -270,11 +269,11 @@ def test_strand__with_sequence_on_3p_extension(self) -> None: sb = design.draw_strand(0, 0) sb.to(10) sb.extension_3p(5) - sb.with_sequence("A"*10 + "G"*5) + sb.with_sequence("A" * 10 + "G" * 5) expected_strand: sc.Strand = sc.Strand([ - sc.Domain(0, True, 0, 10, dna_sequence="A"*10), - sc.Extension(5, dna_sequence="G"*5), + sc.Domain(0, True, 0, 10, dna_sequence="A" * 10), + sc.Extension(5, dna_sequence="G" * 5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -284,11 +283,11 @@ def test_strand__with_sequence_on_5p_extension(self) -> None: sb = design.draw_strand(0, 0) sb.extension_5p(5) sb.to(10) - sb.with_sequence("C"*5 + "T"*10) + sb.with_sequence("C" * 5 + "T" * 10) expected_strand: sc.Strand = sc.Strand([ - sc.Extension(5, dna_sequence="C"*5), - sc.Domain(0, True, 0, 10, dna_sequence="T"*10), + sc.Extension(5, dna_sequence="C" * 5), + sc.Domain(0, True, 0, 10, dna_sequence="T" * 10), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -298,11 +297,11 @@ def test_strand__with_domain_sequence_on_extension(self) -> None: sb = design.draw_strand(0, 0) sb.to(10) sb.extension_3p(5) - sb.with_domain_sequence("G"*5) + sb.with_domain_sequence("G" * 5) expected_strand: sc.Strand = sc.Strand([ - sc.Domain(0, True, 0, 10, dna_sequence="?"*10), - sc.Extension(5, dna_sequence="G"*5), + sc.Domain(0, True, 0, 10, dna_sequence="?" * 10), + sc.Extension(5, dna_sequence="G" * 5), ]) self.assertEqual(1, len(design.strands)) self.assertEqual(expected_strand, design.strands[0]) @@ -952,7 +951,7 @@ def test_2_stape_2_helix_origami_deletions_insertions(self) -> None: design = sc.Design.from_cadnano_v2(directory=self.input_path, filename=file_name + ".json") self.assertEqual(2, len(design.helices)) - self.assertEqual(design.grid, Grid.square) + self.assertEqual(design.grid, sc.Grid.square) self.assertEqual(2, len(design.helices)) output_helix_0 = design.helices[0] output_helix_1 = design.helices[1] @@ -1077,7 +1076,8 @@ def setUp(self) -> None: for strand in d.strands: d.assign_dna(strand, 'A' * 32, assign_complement=False) - def _get_names_idt(self, design: sc.Design, key: sc.KeyFunction[sc.Strand]) -> str: + @staticmethod + def _get_names_idt(design: sc.Design, key: sc.KeyFunction[sc.Strand]) -> str: # call design.to_idt_bulk_input_format with given key functions, # get IDT names of strands exported, and return them joined into a single string idt_str = design.to_idt_bulk_input_format(key=key) @@ -1399,7 +1399,7 @@ def test_2_staple_2_helix_origami_deletions_insertions(self) -> None: output_design = sc.Design.from_cadnano_v2(json_dict=json.loads(output_json)) # To help with debugging, uncomment these lines to write out the - self.assertEqual(output_design.grid, Grid.square) + self.assertEqual(output_design.grid, sc.Grid.square) self.assertEqual(2, len(output_design.helices)) output_helix_0 = output_design.helices[0] output_helix_1 = output_design.helices[1] @@ -1595,7 +1595,7 @@ def test_loopout(self) -> None: def test_extension(self) -> None: """ We do not handle Extensions """ - design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)], grid=Grid.square) + design: sc.Design = sc.Design(helices=[sc.Helix(max_offset=100)], grid=sc.Grid.square) sb = design.draw_strand(0, 0) sb.to(10) sb.as_scaffold() @@ -2080,7 +2080,8 @@ def test_inline_deletions_insertions__two_insertions(self) -> None: design = self.design design.draw_strand(0, 0).move(8).with_insertions([(2, 3), (4, 1)]) design.inline_deletions_insertions() - self.assert_helix0_strand0_inlined(design, max_offset=28, major_ticks=[0, 12, 20, 28], start=0, end=12) + self.assert_helix0_strand0_inlined(design, max_offset=28, major_ticks=[0, 12, 20, 28], start=0, + end=12) def test_inline_deletions_insertions__one_deletion_one_insertion(self) -> None: """ @@ -2097,7 +2098,8 @@ def test_inline_deletions_insertions__one_deletion_one_insertion(self) -> None: design = self.design design.draw_strand(0, 0).move(8).with_deletions(4).with_insertions((2, 3)) design.inline_deletions_insertions() - self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 18, 26], start=0, end=10) + self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 18, 26], start=0, + end=10) def test_inline_deletions_insertions__one_deletion_right_of_major_tick(self) -> None: """ @@ -2217,7 +2219,8 @@ def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains(s design = self.design design.draw_strand(0, 0).move(24).with_deletions(19).with_insertions([(5, 2), (11, 1)]) design.inline_deletions_insertions() - self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 19, 26], start=0, end=26) + self.assert_helix0_strand0_inlined(design, max_offset=26, major_ticks=[0, 10, 19, 26], start=0, + end=26) def test_inline_deletions_insertions__deletions_insertions_in_multiple_domains_two_strands(self) -> None: """ @@ -2779,6 +2782,59 @@ def test_add_full_crossover__small_design_H0_reverse(self) -> None: strand = strand_matching(design.strands, 1, False, 0, 16) self.assertEqual(remove_whitespace('GGCCCAAA CCGGGTTT'), strand.dna_sequence) + def test_add_full_crossover__horizontal_crossovers_already_there(self) -> None: + """ + 0 8 16 + 0 [------+^+------> + 1 <------+^+------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8).move(8) + design.draw_strand(1, 16).move(-8).move(-8) + + self.assertEqual(2, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_full_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn('already a crossover', msg) + + def test_add_full_crossover__top_horizontal_crossover_already_there(self) -> None: + """ + 0 8 16 + 0 [------+^+------> + 1 <------] <------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8).move(8) + design.draw_strand(1, 16).move(-8) + design.draw_strand(1, 8).move(-8) + + self.assertEqual(3, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_full_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn('already a crossover', msg) + + def test_add_full_crossover__bottom_horizontal_crossover_already_there(self) -> None: + """ + 0 8 16 + 0 [------> [------> + 1 <------+^+------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8) + design.draw_strand(0, 8).move(8) + design.draw_strand(1, 16).move(-8).move(-8) + + self.assertEqual(3, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_full_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn('already a crossover', msg) + def test_add_half_crossover__small_design_H0_reverse_8(self) -> None: """ 0 8 16 @@ -3553,8 +3609,8 @@ def test_add_half_crossover_on_existing_crossover_should_error_3p_case(self) -> design: sc.Design = sc.Design( helices=[sc.Helix(max_offset=100), sc.Helix(max_offset=100), sc.Helix(max_offset=100)] ) - design.draw_strand(1,0).to(10).cross(0).to(0) - design.draw_strand(2,10).to(0) + design.draw_strand(1, 0).to(10).cross(0).to(0) + design.draw_strand(2, 10).to(0) with self.assertRaises(sc.IllegalDesignError): design.add_half_crossover(1, 2, 9, True) @@ -5028,9 +5084,9 @@ class TestIllegalStructuresPrevented(unittest.TestCase): def test_domains_not_none_in_Strand_constructor(self) -> None: with self.assertRaises(sc.IllegalDesignError): - sc.Strand(domains=None) + sc.Strand(domains=None) # type: ignore with self.assertRaises(sc.IllegalDesignError): - sc.Strand(domains=[None]) + sc.Strand(domains=[None]) # type: ignore def test_strands_not_specified_in_Design_constructor(self) -> None: design = sc.Design(helices=[]) @@ -5048,7 +5104,6 @@ def test_strands_and_helices_not_specified_in_Design_constructor(self) -> None: self.assertEqual(0, len(design.strands)) def test_consecutive_domains_loopout(self) -> None: - helices = [sc.Helix(max_offset=10)] ss1 = sc.Domain(0, True, 0, 3) ss2 = sc.Loopout(4) ss3 = sc.Loopout(4) @@ -5059,11 +5114,11 @@ def test_consecutive_domains_loopout(self) -> None: # now the Strand constructor checks, so that means we can't set up a bad Strand for the Design check # strand = sc.Strand([ss1, ss2]) # strand.domains.append(ss3) + # helices = [sc.Helix(max_offset=10)] # with self.assertRaises(sc.IllegalDesignError): # sc.Design(helices=helices, strands=[strand], grid=sc.square) def test_singleton_loopout(self) -> None: - helices = [sc.Helix(max_offset=10)] loopout = sc.Loopout(4) with self.assertRaises(sc.StrandError): sc.Strand([loopout]) @@ -5072,6 +5127,7 @@ def test_singleton_loopout(self) -> None: # now the Strand constructor checks, so that means we can't set up a bad Strand for the Design check # strand = sc.Strand([]) # strand.domains.append(loopout) + # helices = [sc.Helix(max_offset=10)] # with self.assertRaises(sc.StrandError): # sc.Design(helices=helices, strands=[strand], grid=sc.square) From 33b027a8bb02d1c1a8df1c59ce9fc64783cf92ff Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 28 Jun 2022 17:19:07 -0700 Subject: [PATCH 55/73] added unit tests for add_half_crossover --- scadnano/scadnano.py | 4 +-- tests/scadnano_tests.py | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 62398b0f..1eecdf48 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -7391,12 +7391,12 @@ def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool if strand_first.domains[-1] is not domain_first: raise IllegalDesignError( - f"Domain to add crossover to: {domain_first} is expected to be on the 3'" + f"Domain to add crossover to: {domain_first} is expected to be on the 3' " f"end of the strand, but this is not the case.") if strand_last.domains[0] is not domain_last: raise IllegalDesignError( - f"Domain to add crossover to: {domain_last} is expected to be on the 5'" + f"Domain to add crossover to: {domain_last} is expected to be on the 5' " f"end of the strand, but this is not the case.") new_domains = strand_first.domains + strand_last.domains diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index bde9588c..54914664 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -2835,6 +2835,60 @@ def test_add_full_crossover__bottom_horizontal_crossover_already_there(self) -> msg = str(ctx.exception) self.assertIn('already a crossover', msg) + def test_add_half_crossover__horizontal_crossovers_already_there(self) -> None: + """ + 0 8 16 + 0 [------+^+------> + 1 <------+^+------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8).move(8) + design.draw_strand(1, 16).move(-8).move(-8) + + self.assertEqual(2, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_half_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn('is expected to be on the', msg) # both 3' and 5' are problems, so just make sure + self.assertIn('end of the strand', msg) # one of them is mentioned here + + def test_add_half_crossover__top_horizontal_crossover_already_there(self) -> None: + """ + 0 8 16 + 0 [------+^+------> + 1 <------] <------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8).move(8) + design.draw_strand(1, 16).move(-8) + design.draw_strand(1, 8).move(-8) + + self.assertEqual(3, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_half_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn("is expected to be on the 5' end of the strand", msg) + + def test_add_half_crossover__bottom_horizontal_crossover_already_there(self) -> None: + """ + 0 8 16 + 0 [------> [------> + 1 <------+^+------] + """ + design = sc.Design(helices=[sc.Helix(16) for _ in range(2)]) + design.draw_strand(0, 0).move(8) + design.draw_strand(0, 8).move(8) + design.draw_strand(1, 16).move(-8).move(-8) + + self.assertEqual(3, len(design.strands)) + + with self.assertRaises(sc.IllegalDesignError) as ctx: + design.add_half_crossover(helix=0, helix2=1, offset=8, forward=True) + msg = str(ctx.exception) + self.assertIn("is expected to be on the 3' end of the strand", msg) + def test_add_half_crossover__small_design_H0_reverse_8(self) -> None: """ 0 8 16 From 290a8db36e2f67571b7145777cd332eada432c4d Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 29 Jun 2022 09:25:09 -0700 Subject: [PATCH 56/73] added example of consecutive crossovers to examples/ directory and added Address information to error message when calling `Design.add_full_crossover` --- examples/consecutive_domains.py | 28 +++++++++++++++++++ .../output_designs/consecutive_domains.sc | 24 ++++++++++++++++ scadnano/scadnano.py | 5 ++-- 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 examples/consecutive_domains.py create mode 100644 examples/output_designs/consecutive_domains.sc diff --git a/examples/consecutive_domains.py b/examples/consecutive_domains.py new file mode 100644 index 00000000..57dc53ac --- /dev/null +++ b/examples/consecutive_domains.py @@ -0,0 +1,28 @@ +import scadnano as sc + +def create_design() -> sc.Design: + # shows how to make consecutive domains on a helix, separated by a crossover that appears horizontal + # this is useful when doing single-stranded tile designs, for instance, or any other design + # where we have consecutive domains on a single helix. + # + # 0 [------+^+------> + # + # 1 <------+^+------] + design = sc.Design(helices=[sc.Helix(100), sc.Helix(100)], grid=sc.square) + design.draw_strand(0, 0).move(8).move(8) + design.draw_strand(1, 16).move(-8).move(-8) + + # XXX: the following code raises an exception because it tries to add a crossover where + # there already is one. This can be surprising since it would work with the following + # similar-looking design that has a single longer domain per strand + # + # 0 [------------> + # + # 1 <------------] + design.add_full_crossover(helix=0, helix2=1, offset=8, forward=True) + + return design + +if __name__ == '__main__': + d = create_design() + d.write_scadnano_file(directory='output_designs') diff --git a/examples/output_designs/consecutive_domains.sc b/examples/output_designs/consecutive_domains.sc new file mode 100644 index 00000000..15fe5387 --- /dev/null +++ b/examples/output_designs/consecutive_domains.sc @@ -0,0 +1,24 @@ +{ + "version": "0.17.3", + "grid": "square", + "helices": [ + {"max_offset": 100, "grid_position": [0, 0]}, + {"max_offset": 100, "grid_position": [0, 1]} + ], + "strands": [ + { + "color": "#f74308", + "domains": [ + {"helix": 0, "forward": true, "start": 0, "end": 8}, + {"helix": 0, "forward": true, "start": 8, "end": 16} + ] + }, + { + "color": "#57bb00", + "domains": [ + {"helix": 1, "forward": false, "start": 8, "end": 16}, + {"helix": 1, "forward": false, "start": 0, "end": 8} + ] + } + ] +} \ No newline at end of file diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 1eecdf48..4fb155f0 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -7473,8 +7473,9 @@ def _prepare_nicks_for_full_crossover(self, helix: int, forward: bool, offset: i # --<---+^+---<-- not domain_left.is_3p_domain() or not domain_right.is_3p_domain() if (not domain_left.is_5p_domain() or not domain_right.is_3p_domain() or not domain_left.is_3p_domain() or not domain_right.is_5p_domain()): - raise IllegalDesignError('cannot add crossover at this position ' - 'because there is already a crossover here') + raise IllegalDesignError('cannot add crossover at address ' + f'(helix={helix}, offset={offset}, forward={forward}) ' + 'because there is already a crossover there') def inline_deletions_insertions(self) -> None: """ From 0f61dd4b7dda45dde7f791b2efdfcc7663a00367 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 29 Jun 2022 14:39:18 -0700 Subject: [PATCH 57/73] added example of how to assign `StrandBuilder` instance to a variable to use loops to specify a long strand with its methods --- README.md | 40 +++++++--- .../output_designs/strand_builder_loop.sc | 78 +++++++++++++++++++ examples/strand_builder_loop.py | 20 +++++ tutorial/tutorial.md | 1 + 4 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 examples/output_designs/strand_builder_loop.sc create mode 100644 examples/strand_builder_loop.py diff --git a/README.md b/README.md index 5809ab80..8056bcf6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ If you find scadnano useful in a scientific project, please cite its associated * [Installation](#installation) * [Example](#example) * [Abbreviated syntax with chained methods](#abbreviated-syntax-with-chained-methods) +* [StrandBuilder object for iteratively building up strands with many domains](#strandbuilder-object-for-iteratively-building-up-strands-with-many-domains) * [Tutorial](#tutorial) * [API documentation](#api-documentation) * [Other examples](#other-examples) @@ -171,7 +172,7 @@ import scadnano as sc import modifications as mod -def create_design(): +def create_design() -> sc.Design: # helices helices = [sc.Helix(max_offset=48), sc.Helix(max_offset=48)] @@ -216,14 +217,10 @@ Running the code above produces a `.sc` file that, if loaded into scadnano, shou ## Abbreviated syntax with chained methods -Instead of explicitly creating variables and objects representing each domain in each strand, there is a shorter syntax using chained method calls. Instead of the above, create only the helices first, then create the Design. Then strands can be added using a shorter syntax, to describe how to draw the strand starting at the 5' end and moving to the 3' end. The following is a modified version of the above script using these chained methods +Instead of explicitly creating variables and objects representing each domain in each strand, there is a shorter syntax using chained method calls. Instead of the above, create only the helices first, then create the Design. Then strands can be added using a shorter syntax, to describe how to draw the strand starting at the 5' end and moving to the 3' end. The following is a modified version of the above `create_design` function using these chained methods: ```python -import scadnano as sc -import modifications as mod - - -def create_design(): +def create_design() -> sc.Design: # helices helices = [sc.Helix(max_offset=48), sc.Helix(max_offset=48)] @@ -250,16 +247,35 @@ def create_design(): design.assign_dna(design.scaffold, 'AACGT' * 18) return design - - -if __name__ == '__main__': - design = create_design() - design.write_scadnano_file(directory='output_designs') ``` Documentation is available in the [API docs](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.draw_strand). +## StrandBuilder object for iteratively building up strands with many domains + +The method `Design.draw_strand`, as well as all those that follow it in a chained method call (e.g., `move`, `cross`, etc.) all return an instance of the class `StrandBuilder`. +Above, that `StrandBuilder` instance is anonymous, i.e., never assigned to a variable. +Some long strands may be easier to specify with loops, for example an M13 scaffold strand for an origami. +If so, then to use the above methods, assign the `StrandBuilder` object to a variable, and call the relevant methods on that object to build up the strand in each iteration of the loop. +For example, the following modification of the above `create_design` function creates a linear scaffold strand that zig-zags back and forth across 32 helices: + +```python +def create_design() -> sc.Design: + num_helices = 32 + helices = [sc.Helix(max_offset=200) for _ in range(num_helices)] + design = sc.Design(helices=helices, grid=sc.square) + strand_builder = design.draw_strand(0, 0) + for helix in range(num_helices): + # move forward if on an even helix, otherwise move in reverse + move_distance = 200 if helix % 2 == 0 else -200 + strand_builder.move(move_distance) + if helix < 31: # crossover to next helix, unless it's the last helix + strand_builder.cross(helix + 1) + strand_builder.as_scaffold() + return design +``` + diff --git a/examples/output_designs/strand_builder_loop.sc b/examples/output_designs/strand_builder_loop.sc new file mode 100644 index 00000000..3ee2c069 --- /dev/null +++ b/examples/output_designs/strand_builder_loop.sc @@ -0,0 +1,78 @@ +{ + "version": "0.17.3", + "grid": "square", + "helices": [ + {"grid_position": [0, 0]}, + {"grid_position": [0, 1]}, + {"grid_position": [0, 2]}, + {"grid_position": [0, 3]}, + {"grid_position": [0, 4]}, + {"grid_position": [0, 5]}, + {"grid_position": [0, 6]}, + {"grid_position": [0, 7]}, + {"grid_position": [0, 8]}, + {"grid_position": [0, 9]}, + {"grid_position": [0, 10]}, + {"grid_position": [0, 11]}, + {"grid_position": [0, 12]}, + {"grid_position": [0, 13]}, + {"grid_position": [0, 14]}, + {"grid_position": [0, 15]}, + {"grid_position": [0, 16]}, + {"grid_position": [0, 17]}, + {"grid_position": [0, 18]}, + {"grid_position": [0, 19]}, + {"grid_position": [0, 20]}, + {"grid_position": [0, 21]}, + {"grid_position": [0, 22]}, + {"grid_position": [0, 23]}, + {"grid_position": [0, 24]}, + {"grid_position": [0, 25]}, + {"grid_position": [0, 26]}, + {"grid_position": [0, 27]}, + {"grid_position": [0, 28]}, + {"grid_position": [0, 29]}, + {"grid_position": [0, 30]}, + {"grid_position": [0, 31]} + ], + "strands": [ + { + "color": "#0066cc", + "domains": [ + {"helix": 0, "forward": true, "start": 0, "end": 200}, + {"helix": 1, "forward": false, "start": 0, "end": 200}, + {"helix": 2, "forward": true, "start": 0, "end": 200}, + {"helix": 3, "forward": false, "start": 0, "end": 200}, + {"helix": 4, "forward": true, "start": 0, "end": 200}, + {"helix": 5, "forward": false, "start": 0, "end": 200}, + {"helix": 6, "forward": true, "start": 0, "end": 200}, + {"helix": 7, "forward": false, "start": 0, "end": 200}, + {"helix": 8, "forward": true, "start": 0, "end": 200}, + {"helix": 9, "forward": false, "start": 0, "end": 200}, + {"helix": 10, "forward": true, "start": 0, "end": 200}, + {"helix": 11, "forward": false, "start": 0, "end": 200}, + {"helix": 12, "forward": true, "start": 0, "end": 200}, + {"helix": 13, "forward": false, "start": 0, "end": 200}, + {"helix": 14, "forward": true, "start": 0, "end": 200}, + {"helix": 15, "forward": false, "start": 0, "end": 200}, + {"helix": 16, "forward": true, "start": 0, "end": 200}, + {"helix": 17, "forward": false, "start": 0, "end": 200}, + {"helix": 18, "forward": true, "start": 0, "end": 200}, + {"helix": 19, "forward": false, "start": 0, "end": 200}, + {"helix": 20, "forward": true, "start": 0, "end": 200}, + {"helix": 21, "forward": false, "start": 0, "end": 200}, + {"helix": 22, "forward": true, "start": 0, "end": 200}, + {"helix": 23, "forward": false, "start": 0, "end": 200}, + {"helix": 24, "forward": true, "start": 0, "end": 200}, + {"helix": 25, "forward": false, "start": 0, "end": 200}, + {"helix": 26, "forward": true, "start": 0, "end": 200}, + {"helix": 27, "forward": false, "start": 0, "end": 200}, + {"helix": 28, "forward": true, "start": 0, "end": 200}, + {"helix": 29, "forward": false, "start": 0, "end": 200}, + {"helix": 30, "forward": true, "start": 0, "end": 200}, + {"helix": 31, "forward": false, "start": 0, "end": 200} + ], + "is_scaffold": true + } + ] +} \ No newline at end of file diff --git a/examples/strand_builder_loop.py b/examples/strand_builder_loop.py new file mode 100644 index 00000000..2ca80edb --- /dev/null +++ b/examples/strand_builder_loop.py @@ -0,0 +1,20 @@ +import scadnano as sc + +def create_design() -> sc.Design: + num_helices = 32 + helices = [sc.Helix(max_offset=200) for _ in range(num_helices)] + design = sc.Design(helices=helices, grid=sc.square) + strand_builder = design.draw_strand(0, 0) + for helix in range(num_helices): + # move forward if on an even helix, otherwise move in reverse + move_distance = 200 if helix % 2 == 0 else -200 + strand_builder.move(move_distance) + if helix < 31: # crossover to next helix, unless it's the last helix + strand_builder.cross(helix + 1) + strand_builder.as_scaffold() + return design + + +if __name__ == '__main__': + design = create_design() + design.write_scadnano_file(directory='output_designs') \ No newline at end of file diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 6f8bbd44..d11f4faa 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -245,6 +245,7 @@ We drew the scaffold precursor on helix 23 as two strands, each half the length But we could alternately think of it as one strand of length 288 that has had a "nick" created in the middle, so we could have created it similarly to the other helices and then called the method [Design.add_nick](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.add_nick). This is how we will create the staples later, which have many more nicks on each helix than the scaffold. The chained method calls in this case don't show how to create a strand with crossovers; see the [example on the README page](https://github.com/UC-Davis-molecular-computing/scadnano-python-package#abbreviated-syntax-with-chained-methods) for a more representative example of creating a complete strand spanning several helices with chained method calls. +Note also that one may wish to assign the `StrandBuilder` object returned by `Design.draw_strand` to a variable if a strand with many domains is incrementally built up over many iterations of a loop; see the [example on the README page](https://github.com/UC-Davis-molecular-computing/scadnano-python-package#strandbuilder-object-for-iteratively-building-up-strands-with-many-domains) of how to specify a long scaffold strand in this way. Execute the script. The file `24_helix_rectangle.sc` is getting large now, so we won't show the whole thing, but the `strands` field should be non-empty now and start something like this: From c24fbb7ba18dfb58322522c87097e8eceee0e85d Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 29 Jun 2022 14:43:39 -0700 Subject: [PATCH 58/73] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8056bcf6..a04a8be5 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ Documentation is available in the [API docs](https://scadnano-python-package.rea ## StrandBuilder object for iteratively building up strands with many domains -The method `Design.draw_strand`, as well as all those that follow it in a chained method call (e.g., `move`, `cross`, etc.) all return an instance of the class `StrandBuilder`. +The method `Design.draw_strand`, as well as all those that follow it in a chained method call (e.g., `move`, `cross`, etc.) all return an instance of the class [`StrandBuilder`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.StrandBuilder). Above, that `StrandBuilder` instance is anonymous, i.e., never assigned to a variable. Some long strands may be easier to specify with loops, for example an M13 scaffold strand for an origami. If so, then to use the above methods, assign the `StrandBuilder` object to a variable, and call the relevant methods on that object to build up the strand in each iteration of the loop. From da0b08717d1c0c656888a9062254e73da6e88bbf Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 29 Jun 2022 14:46:01 -0700 Subject: [PATCH 59/73] added link to method `Design.draw_strand` in README when discussing `StrandBuilder` --- README.md | 2 +- scadnano/scadnano.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a04a8be5..c1bb48b2 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ Documentation is available in the [API docs](https://scadnano-python-package.rea ## StrandBuilder object for iteratively building up strands with many domains -The method `Design.draw_strand`, as well as all those that follow it in a chained method call (e.g., `move`, `cross`, etc.) all return an instance of the class [`StrandBuilder`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.StrandBuilder). +The method [`Design.draw_strand`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.draw_strand), as well as all those that follow it in a chained method call (e.g., `move`, `cross`, etc.) all return an instance of the class [`StrandBuilder`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.StrandBuilder). Above, that `StrandBuilder` instance is anonymous, i.e., never assigned to a variable. Some long strands may be easier to specify with loops, for example an M13 scaffold strand for an origami. If so, then to use the above methods, assign the `StrandBuilder` object to a variable, and call the relevant methods on that object to build up the strand in each iteration of the loop. diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 4fb155f0..8a0bde71 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2296,7 +2296,7 @@ class StrandBuilder(Generic[StrandLabel, DomainLabel]): Represents a :any:`Strand` that is being built in an existing :any:`Design`. This is an intermediate object created when using chained method building by calling - :py:meth:`Design.strand`, for example + :py:meth:`Design.draw_strand`, for example .. code-block:: Python From 8b520603099569eb687f7e51482d906c6631a518 Mon Sep 17 00:00:00 2001 From: Constantine Evans Date: Thu, 21 Jul 2022 18:59:59 +0100 Subject: [PATCH 60/73] Set Position3D as frozen --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 8a0bde71..35e548a7 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1044,7 +1044,7 @@ def modification_type() -> ModificationType: ########################################################################## -@dataclass +@dataclass(frozen=True) class Position3D(_JSONSerializable): """ Position (x,y,z) in 3D space. From 63da326397be9c5bc75e3570a5414658c2a98b4f Mon Sep 17 00:00:00 2001 From: Dave Doty Date: Wed, 3 Aug 2022 09:10:38 -0700 Subject: [PATCH 61/73] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1bb48b2..58ae2708 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ If you find scadnano useful in a scientific project, please cite its associated This package is used to write Python scripts outputting `.sc` files readable by [scadnano](https://scadnano.org), a web application useful for displaying and manually editing these structures. The purpose of this module is to help automate some of the task of creating DNA designs, as well as making large-scale changes to them that are easier to describe programmatically than to do by hand in the scadnano web interface. -Early versions of this project didn't have well-defined versions. However, we will try to announce breaking changes (and possibly new features) under the [GitHub releases page](https://github.com/UC-Davis-molecular-computing/scadnano-python-package/releases). The version numbers in this Python library repo and the [web interface repo](https://github.com/UC-Davis-molecular-computing/scadnano/releases) won't always advance at the same time. +We will try to announce breaking changes (and possibly new features) under the [GitHub releases page](https://github.com/UC-Davis-molecular-computing/scadnano-python-package/releases). The version numbers in this Python library repo and the [web interface repo](https://github.com/UC-Davis-molecular-computing/scadnano/releases) won't always advance at the same time, and sometimes a feature is supported in one before the other. Following [semantic versioning](https://semver.org/), version numbers are major.minor.patch, i.e., version 0.9.2 has minor version number 9. Prior to version 1.0.0, when a breaking change is made, this will increment the minor version (for example, going from 0.9.4 to 0.10.0). After version 1.0.0, breaking changes will increment the major version. From 3d1b02551252c6bc15e9e0ae9964c7a11a5b42c0 Mon Sep 17 00:00:00 2001 From: Dave Doty Date: Wed, 3 Aug 2022 09:10:45 -0700 Subject: [PATCH 62/73] Update scadnano.py --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 35e548a7..e4585eb7 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -6705,7 +6705,7 @@ def write_idt_bulk_input_file(self, *, directory: str = '.', filename: str = Non extension = 'idt' write_file_same_name_as_running_python_script(contents, extension, directory, filename) - def write_idt_plate_excel_file(self, *, directory: str = '.', filename: str = None, + def write_idt_plate_excel_file(self, *, directory: str = '.', filename: Optional[str] = None, key: Optional[KeyFunction[Strand]] = None, warn_duplicate_name: bool = False, only_strands_with_idt: bool = False, From a15862152ca0e2452296776bb40f587f22626ec6 Mon Sep 17 00:00:00 2001 From: David Doty Date: Thu, 11 Aug 2022 10:35:44 -0600 Subject: [PATCH 63/73] fixed some documentation and added constants for `display_angle` and `display_length` for `Extension` --- scadnano/scadnano.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index e4585eb7..c14cb8a4 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2078,6 +2078,10 @@ def get_seq_start_idx(self) -> int: return self_seq_idx_start +default_display_angle = 45.0 + +default_display_length = 1.0 + @dataclass class Extension(_JSONSerializable, Generic[DomainLabel]): """Represents a single-stranded extension on either the 3' or 5' @@ -2085,7 +2089,8 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): One could think of an :any:`Extension` as a type of :any:`Domain`, but none of the fields of :any:`Domain` make sense for :any:`Extension`, so they are not related to each other in the type - hierarchy. It is interpreted that an :any:`Extension` is a single-stranded region that resides on either the 3' or 5' end of the :any:`Strand`. It is illegal for an :any:`Extension` to be placed + hierarchy. It is interpreted that an :any:`Extension` is a single-stranded region that resides on either + the 3' or 5' end of the :any:`Strand`. It is illegal for an :any:`Extension` to be placed in the middle of the :any:`Strand` or for an :any:`Extension` to be adjacent to a :any:`Loopout`. .. code-block:: Python @@ -2109,10 +2114,10 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): num_bases: int """Length (in DNA bases) of this :any:`Loopout`.""" - display_length: float = 1.0 + display_length: float = default_display_length """Length (in nm) to display in the scadnano web app.""" - display_angle: float = 45.0 + display_angle: float = default_display_angle """ Angle (in degrees) to display in the scadnano web app. @@ -2451,7 +2456,8 @@ def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angl def _verify_extension_5p_is_valid(self): if self._strand is not None: raise IllegalDesignError( - 'Cannot add a 5\' extension when there are already domains. Did you mean to create a 3\' extension?') + 'Cannot add a 5\' extension when there are already domains. ' + 'Did you mean to create a 3\' extension?') # remove quotes when Py3.6 support dropped def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': @@ -2541,14 +2547,16 @@ def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ Like :py:meth:`StrandBuilder.to`, but changes the current offset without creating a new :any:`Domain`. So unlike :py:meth:`StrandBuilder.to`, several consecutive calls to - :py:meth:`StrandBuilder.update_to` are equivalent to only making the final call. This is an - "absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move". + :meth:`StrandBuilder.update_to` are equivalent to only making the final call. - If :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout` was just called, - then :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to` have the same effect. + Generally there's no point in calling :meth:`StrandBuilder.update_to` in one line of code. + It is intended to help when a large, complex strand is being constructed in a loop. + + If :meth:`StrandBuilder.cross` or :meth:`StrandBuilder.loopout` was just called, + then :meth:`StrandBuilder.to` and :meth:`StrandBuilder.update_to` have the same effect. :param offset: new offset to extend to. If less than offset of the last call to - :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout`, + :meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout`, the new :any:`Domain` is reverse, otherwise it is forward. :return: self """ @@ -2704,15 +2712,21 @@ def with_domain_sequence(self, sequence: str, assign_complement: bool = True) \ via a call to :py:meth:`StrandBuilder.to`, :py:meth:`StrandBuilder.update_to`, + :py:meth:`StrandBuilder.move`, + :py:meth:`StrandBuilder.extension_5p`, + :py:meth:`StrandBuilder.extension_3p`, or :py:meth:`StrandBuilder.loopout`, e.g., .. code-block:: Python - design.draw_strand(0, 5).to(8).with_domain_sequence('AAA')\\ - .cross(1).to(5).with_domain_sequence('TTT')\\ + design.draw_strand(0, 5)\\ + .extension_5p(2).with_domain_sequence('TT')\\ + .to(8).with_domain_sequence('AAA')\\ + .cross(1).move(-3).with_domain_sequence('TTT')\\ .loopout(2, 4).with_domain_sequence('CCCC')\\ - .to(10).with_domain_sequence('GGGGG') + .to(10).with_domain_sequence('GGGGG')\\ + .extension_3p(4).with_domain_sequence('AAAA') :param sequence: the DNA sequence to assign to the :any:`Domain` :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s From 73cd06cc79cee6a5811e35b7fb0fe525f4e511fc Mon Sep 17 00:00:00 2001 From: David Doty Date: Sun, 14 Aug 2022 13:01:33 -0600 Subject: [PATCH 64/73] added extensions example --- examples/extensions.py | 16 ++++++ examples/output_designs/extensions.sc | 22 ++++++++ scadnano/scadnano.py | 81 ++++++++++++++++----------- 3 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 examples/extensions.py create mode 100644 examples/output_designs/extensions.sc diff --git a/examples/extensions.py b/examples/extensions.py new file mode 100644 index 00000000..ddce96d4 --- /dev/null +++ b/examples/extensions.py @@ -0,0 +1,16 @@ +import scadnano as sc + + +def create_design() -> sc.Design: + helices = [sc.Helix(max_offset=16) for _ in range(3)] + design = sc.Design(helices=helices, grid=sc.square) + + design.draw_strand(0, 0).extension_5p(5, display_length=2, display_angle=30)\ + .move(16).cross(1).move(-16).loopout(2, 3).move(16).extension_3p(7).with_domain_name("ext_3p") + + return design + + +if __name__ == '__main__': + d = create_design() + d.write_scadnano_file(directory='output_designs') diff --git a/examples/output_designs/extensions.sc b/examples/output_designs/extensions.sc new file mode 100644 index 00000000..53965a39 --- /dev/null +++ b/examples/output_designs/extensions.sc @@ -0,0 +1,22 @@ +{ + "version": "0.17.3", + "grid": "square", + "helices": [ + {"grid_position": [0, 0]}, + {"grid_position": [0, 1]}, + {"grid_position": [0, 2]} + ], + "strands": [ + { + "color": "#f74308", + "domains": [ + {"extension_num_bases": 5, "display_length": 2, "display_angle": 30}, + {"helix": 0, "forward": true, "start": 0, "end": 16}, + {"helix": 1, "forward": false, "start": 0, "end": 16}, + {"loopout": 3}, + {"helix": 2, "forward": true, "start": 0, "end": 16}, + {"extension_num_bases": 7, "name": "ext_3p"} + ] + } + ] +} \ No newline at end of file diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index c14cb8a4..4bd00cb2 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -793,7 +793,7 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: loopout_key = 'loopout' # Extension keys -extension_key = 'extension' +extension_key = 'extension_num_bases' display_length_key = 'display_length' display_angle_key = 'display_angle' @@ -2082,6 +2082,7 @@ def get_seq_start_idx(self) -> int: default_display_length = 1.0 + @dataclass class Extension(_JSONSerializable, Generic[DomainLabel]): """Represents a single-stranded extension on either the 3' or 5' @@ -2150,13 +2151,14 @@ class Extension(_JSONSerializable, Generic[DomainLabel]): # remove quotes when Py3.6 support dropped _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None) - def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Union[Dict[str, Any], NoIndent]: + def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) \ + -> Union[Dict[str, Any], NoIndent]: json_map: Dict[str, Any] = {extension_key: self.num_bases} self._add_display_length_if_not_default(json_map) self._add_display_angle_if_not_default(json_map) self._add_name_if_not_default(json_map) self._add_label_if_not_default(json_map) - return json_map + return NoIndent(json_map) if suppress_indent else json_map def dna_length(self) -> int: """Length of this :any:`Extension`; same as field :py:data:`Extension.length`.""" @@ -2173,12 +2175,13 @@ def set_name(self, name: str) -> None: @staticmethod def from_json(json_map: Dict[str, Any]) -> 'Extension': def float_transformer(x): return float(x) + num_bases_str = mandatory_field(Extension, json_map, extension_key) num_bases = int(num_bases_str) display_length = optional_field(_default_extension.display_length, - json_map, display_length_key, transformer=float_transformer) + json_map, display_length_key, transformer=float_transformer) display_angle = optional_field(_default_extension.display_angle, - json_map, display_angle_key, transformer=float_transformer) + json_map, display_angle_key, transformer=float_transformer) name = json_map.get(domain_name_key) label = json_map.get(domain_label_key) return Extension( @@ -2357,7 +2360,7 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = """ if self._strand is None: raise ValueError('no Strand created yet; make at least one domain first') - if self._is_last_domain_an_extension(): + if self._most_recently_added_substrand_is_extension(): raise IllegalDesignError('Cannot cross after an extension.') if move is not None and offset is not None: raise IllegalDesignError('move and offset cannot both be specified:\n' @@ -2371,11 +2374,11 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = self.current_offset += move return self - def _is_last_domain_an_instance_of_class(self, cls: Type) -> bool: + def _most_recently_added_substrand_is_instance_of_class(self, cls: Type) -> bool: return isinstance(self._strand.domains[-1], cls) - def _is_last_domain_an_extension(self): - return self._is_last_domain_an_instance_of_class(Extension) + def _most_recently_added_substrand_is_extension(self): + return self._most_recently_added_substrand_is_instance_of_class(Extension) # remove quotes when Py3.6 support dropped def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \ @@ -2400,7 +2403,8 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O self.design.append_domain(self._strand, Loopout(length)) return self - def extension_3p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': + def extension_3p(self, num_bases: int, display_length: float = 1.0, + display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to the :any:`Strand` as a 3' :any:`Extension`. @@ -2422,9 +2426,9 @@ def _verify_extension_3p_is_valid(self): if self._strand is None: raise IllegalDesignError( 'Cannot add a 3\' extension when there are no domains. Did you mean to create a 5\' extension?') - if self._is_last_domain_a_loopout(): + if self._most_recently_added_substrand_is_loopout(): raise IllegalDesignError('Cannot add a 3\' extension immediately after a loopout.') - if self._is_last_domain_an_extension_3p(): + if self._most_recently_added_substrand_is_extension_3p(): raise IllegalDesignError('Cannot add a 3\' extension after another 3\' extension.') self._verify_strand_is_not_circular() @@ -2432,10 +2436,11 @@ def _verify_strand_is_not_circular(self): if self._strand.circular: raise IllegalDesignError('Cannot add an extension to a circular strand.') - def _is_last_domain_a_loopout(self): - return self._is_last_domain_an_instance_of_class(Loopout) + def _most_recently_added_substrand_is_loopout(self): + return self._most_recently_added_substrand_is_instance_of_class(Loopout) - def extension_5p(self, num_bases: int, display_length: float = 1.0, display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': + def extension_5p(self, num_bases: int, display_length: float = 1.0, + display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]': """ Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to the :any:`Strand` as a 5' :any:`Extension`. @@ -2511,7 +2516,7 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': '(strictly increasing or strictly decreasing) ' 'when calling to() twice in a row') - if self._is_last_domain_an_extension_3p(): + if self._most_recently_added_substrand_is_extension_3p(): raise IllegalDesignError('cannot make a new domain once 3\' extension has been added') if offset > self.current_offset: @@ -2537,10 +2542,10 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': return self - def _is_last_domain_an_extension_3p(self) -> bool: + def _most_recently_added_substrand_is_extension_3p(self) -> bool: if self._strand is None: return False - return len(self._strand.domains) > 1 and self._is_last_domain_an_extension() + return len(self._strand.domains) > 1 and self._most_recently_added_substrand_is_extension() # remove quotes when Py3.6 support dropped def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': @@ -2560,7 +2565,7 @@ def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]': the new :any:`Domain` is reverse, otherwise it is forward. :return: self """ - if self._is_last_domain_an_extension_3p(): + if self._most_recently_added_substrand_is_extension_3p(): raise IllegalDesignError('Cannot call update_to after creating extension_3p.') if not self.last_domain: return self.to(offset) @@ -3127,21 +3132,27 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> D @staticmethod def from_json(json_map: dict) -> 'Strand': # remove quotes when Py3.6 support dropped - domain_jsons = mandatory_field(Strand, json_map, domains_key, legacy_keys=legacy_domains_keys) - if len(domain_jsons) == 0: + substrand_jsons = mandatory_field(Strand, json_map, domains_key, legacy_keys=legacy_domains_keys) + if len(substrand_jsons) == 0: raise IllegalDesignError(f'{domains_key} list cannot be empty') - domains: List[Union[Domain, Loopout, Extension]] = [] - for domain_json in domain_jsons: - if loopout_key in domain_json: - domains.append(Loopout.from_json(domain_json)) - elif extension_key in domain_json: - domains.append(Extension.from_json(domain_json)) + substrands: List[Union[Domain, Loopout, Extension]] = [] + for substrand_json in substrand_jsons: + if loopout_key in substrand_json: + substrands.append(Loopout.from_json(substrand_json)) + elif extension_key in substrand_json: + substrands.append(Extension.from_json(substrand_json)) + elif helix_idx_key in substrand_json: + substrands.append(Domain.from_json(substrand_json)) else: - domains.append(Domain.from_json(domain_json)) - if isinstance(domains[0], Loopout): + raise IllegalDesignError('unrecognized substrand; does not have any of these keys:\n' + f'{extension_key} for an Extension, ' + f'{loopout_key} for a Loopout, or' + f'{helix_idx_key} for a Domain.\n' + f'JSON: {substrand_json}') + if isinstance(substrands[0], Loopout): raise IllegalDesignError('Loopout at beginning of Strand not supported') - if isinstance(domains[-1], Loopout): + if isinstance(substrands[-1], Loopout): raise IllegalDesignError('Loopout at end of Strand not supported') is_scaffold = json_map.get(is_scaffold_key, False) @@ -3173,7 +3184,7 @@ def decimal_int_to_hex(d: int) -> str: name = idt_dict[idt_name_key] return Strand( - domains=domains, + domains=substrands, dna_sequence=dna_sequence, circular=circular, color=color, @@ -7486,7 +7497,7 @@ def _prepare_nicks_for_full_crossover(self, helix: int, forward: bool, offset: i # -->---+^+--->-- not domain_left.is_5p_domain() or not domain_right.is_3p_domain() # --<---+^+---<-- not domain_left.is_3p_domain() or not domain_right.is_3p_domain() if (not domain_left.is_5p_domain() or not domain_right.is_3p_domain() or - not domain_left.is_3p_domain() or not domain_right.is_5p_domain()): + not domain_left.is_3p_domain() or not domain_right.is_5p_domain()): raise IllegalDesignError('cannot add crossover at address ' f'(helix={helix}, offset={offset}, forward={forward}) ' 'because there is already a crossover there') @@ -8114,8 +8125,10 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: # these will be updated later, for now we just need the base for i in range(domain.length): base = seq[i] - nuc = _OxdnaNucleotide(_OxdnaVector(), _OxdnaVector(0, -1, 0), _OxdnaVector(0, 0, 1), - base) + center = _OxdnaVector() + normal = _OxdnaVector(0, -1, 0) + forward = _OxdnaVector(0, 0, 1) + nuc = _OxdnaNucleotide(center, normal, forward, base) dom_strand.nucleotides.append(nuc) dom_strands.append((dom_strand, True)) From 380321e1eb690b783d67ee1b4847d2a73266ba5e Mon Sep 17 00:00:00 2001 From: David Doty Date: Sun, 14 Aug 2022 14:56:39 -0600 Subject: [PATCH 65/73] updated extensions example --- examples/extensions.py | 4 +++- examples/output_designs/extensions.sc | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/extensions.py b/examples/extensions.py index ddce96d4..6d0b4545 100644 --- a/examples/extensions.py +++ b/examples/extensions.py @@ -2,11 +2,13 @@ def create_design() -> sc.Design: + width = 8 helices = [sc.Helix(max_offset=16) for _ in range(3)] design = sc.Design(helices=helices, grid=sc.square) design.draw_strand(0, 0).extension_5p(5, display_length=2, display_angle=30)\ - .move(16).cross(1).move(-16).loopout(2, 3).move(16).extension_3p(7).with_domain_name("ext_3p") + .move(width).cross(1).move(-width).loopout(2, 3).move(width)\ + .extension_3p(7).with_domain_name("ext_3p") return design diff --git a/examples/output_designs/extensions.sc b/examples/output_designs/extensions.sc index 53965a39..ec076cf2 100644 --- a/examples/output_designs/extensions.sc +++ b/examples/output_designs/extensions.sc @@ -2,19 +2,19 @@ "version": "0.17.3", "grid": "square", "helices": [ - {"grid_position": [0, 0]}, - {"grid_position": [0, 1]}, - {"grid_position": [0, 2]} + {"max_offset": 16, "grid_position": [0, 0]}, + {"max_offset": 16, "grid_position": [0, 1]}, + {"max_offset": 16, "grid_position": [0, 2]} ], "strands": [ { "color": "#f74308", "domains": [ {"extension_num_bases": 5, "display_length": 2, "display_angle": 30}, - {"helix": 0, "forward": true, "start": 0, "end": 16}, - {"helix": 1, "forward": false, "start": 0, "end": 16}, + {"helix": 0, "forward": true, "start": 0, "end": 8}, + {"helix": 1, "forward": false, "start": 0, "end": 8}, {"loopout": 3}, - {"helix": 2, "forward": true, "start": 0, "end": 16}, + {"helix": 2, "forward": true, "start": 0, "end": 8}, {"extension_num_bases": 7, "name": "ext_3p"} ] } From f23b5f89da084888931af83c7d7c1c5ce02eb3f6 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 16 Aug 2022 06:58:45 -0600 Subject: [PATCH 66/73] fixed oxDNA export bug that made length-0 normal vectors --- examples/extensions.py | 8 +++-- examples/output_designs/extensions.sc | 19 +++++++--- scadnano/scadnano.py | 52 ++++++++++++++++----------- 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/examples/extensions.py b/examples/extensions.py index 6d0b4545..3c259cd7 100644 --- a/examples/extensions.py +++ b/examples/extensions.py @@ -3,13 +3,17 @@ def create_design() -> sc.Design: width = 8 - helices = [sc.Helix(max_offset=16) for _ in range(3)] + helices = [sc.Helix(max_offset=32) for _ in range(3)] design = sc.Design(helices=helices, grid=sc.square) - design.draw_strand(0, 0).extension_5p(5, display_length=2, display_angle=30)\ + design.draw_strand(0, 0).extension_5p(5, display_length=2.5, display_angle=45)\ .move(width).cross(1).move(-width).loopout(2, 3).move(width)\ .extension_3p(7).with_domain_name("ext_3p") + design.draw_strand(0, 24).extension_5p(5, display_length=3.5, display_angle=60)\ + .move(-width).cross(1).move(width).loopout(2, 3).move(-width)\ + .extension_3p(7).with_domain_name("ext_3p_top") + return design diff --git a/examples/output_designs/extensions.sc b/examples/output_designs/extensions.sc index ec076cf2..80d0ee2a 100644 --- a/examples/output_designs/extensions.sc +++ b/examples/output_designs/extensions.sc @@ -2,21 +2,32 @@ "version": "0.17.3", "grid": "square", "helices": [ - {"max_offset": 16, "grid_position": [0, 0]}, - {"max_offset": 16, "grid_position": [0, 1]}, - {"max_offset": 16, "grid_position": [0, 2]} + {"max_offset": 32, "grid_position": [0, 0]}, + {"max_offset": 32, "grid_position": [0, 1]}, + {"max_offset": 32, "grid_position": [0, 2]} ], "strands": [ { "color": "#f74308", "domains": [ - {"extension_num_bases": 5, "display_length": 2, "display_angle": 30}, + {"extension_num_bases": 5, "display_length": 2.5}, {"helix": 0, "forward": true, "start": 0, "end": 8}, {"helix": 1, "forward": false, "start": 0, "end": 8}, {"loopout": 3}, {"helix": 2, "forward": true, "start": 0, "end": 8}, {"extension_num_bases": 7, "name": "ext_3p"} ] + }, + { + "color": "#57bb00", + "domains": [ + {"extension_num_bases": 5, "display_length": 3.5, "display_angle": 60}, + {"helix": 0, "forward": false, "start": 16, "end": 24}, + {"helix": 1, "forward": true, "start": 16, "end": 24}, + {"loopout": 3}, + {"helix": 2, "forward": false, "start": 16, "end": 24}, + {"extension_num_bases": 7, "name": "ext_3p_top"} + ] } ] } \ No newline at end of file diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 4bd00cb2..ff3f4ee5 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -8024,6 +8024,14 @@ def _oxdna_random_sequence(length: int) -> str: return seq +def get_normal_vector_to(vec: _OxdnaVector) -> _OxdnaVector: + unit = _OxdnaVector(1, 0, 0) + normalized_vec = vec.normalize() + if abs(1 - normalized_vec.dot(unit)) < 0.001: + unit = _OxdnaVector(0, 1, 0) + return unit.cross(vec) + + def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: system = _OxdnaSystem() geometry = design.geometry @@ -8060,9 +8068,9 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: design.helices.items()} for strand in design.strands: - dom_strands: List[Tuple[_OxdnaStrand, bool]] = [] + strand_domains: List[Tuple[_OxdnaStrand, bool]] = [] for domain in strand.domains: - dom_strand = _OxdnaStrand() + ox_strand = _OxdnaStrand() seq = domain.dna_sequence if seq is None: seq = 'T' * domain.dna_length() @@ -8098,25 +8106,27 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: if offset in insertions: num = insertions[offset] for i in range(num): - r = origin_ + forward * ( + cen = origin_ + forward * ( offset + mod - num + i) * geometry.rise_per_base_pair * NM_TO_OX_UNITS - b = normal.rotate(step_rot * (offset + mod - num + i), forward) - n = -forward if domain.forward else forward # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector - nuc = _OxdnaNucleotide(r, b, n, seq[index]) - dom_strand.nucleotides.append(nuc) + norm = normal.rotate(step_rot * (offset + mod - num + i), forward) + # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector + forw = -forward if domain.forward else forward + nuc = _OxdnaNucleotide(cen, norm, forw, seq[index]) + ox_strand.nucleotides.append(nuc) index += 1 - r = origin_ + forward * (offset + mod) * geometry.rise_per_base_pair * NM_TO_OX_UNITS - b = normal.rotate(step_rot * (offset + mod), forward) - n = -forward if domain.forward else forward # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector - nuc = _OxdnaNucleotide(r, b, n, seq[index]) - dom_strand.nucleotides.append(nuc) + cen = origin_ + forward * (offset + mod) * geometry.rise_per_base_pair * NM_TO_OX_UNITS + norm = normal.rotate(step_rot * (offset + mod), forward) + # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector + forw = -forward if domain.forward else forward + nuc = _OxdnaNucleotide(cen, norm, forw, seq[index]) + ox_strand.nucleotides.append(nuc) index += 1 # strands are stored from 5' to 3' end if not domain.forward: - dom_strand.nucleotides.reverse() - dom_strands.append((dom_strand, False)) + ox_strand.nucleotides.reverse() + strand_domains.append((ox_strand, False)) # because we need to know the positions of nucleotides before and after the loopout # we temporarily store domain strands with a boolean that is true if it's a loopout # handle loopouts @@ -8129,23 +8139,23 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: normal = _OxdnaVector(0, -1, 0) forward = _OxdnaVector(0, 0, 1) nuc = _OxdnaNucleotide(center, normal, forward, base) - dom_strand.nucleotides.append(nuc) - dom_strands.append((dom_strand, True)) + ox_strand.nucleotides.append(nuc) + strand_domains.append((ox_strand, True)) sstrand = _OxdnaStrand() # process loopouts and join strands - for i in range(len(dom_strands)): - dstrand, is_loopout = dom_strands[i] + for i in range(len(strand_domains)): + dstrand, is_loopout = strand_domains[i] if is_loopout: - prev_nuc = dom_strands[i - 1][0].nucleotides[-1] - next_nuc = dom_strands[i + 1][0].nucleotides[0] + prev_nuc = strand_domains[i - 1][0].nucleotides[-1] + next_nuc = strand_domains[i + 1][0].nucleotides[0] strand_length = len(dstrand.nucleotides) # now we position loopouts relative to the previous and next strand # for now we use a linear interpolation forward = next_nuc.center - prev_nuc.center - normal = (prev_nuc.forward + next_nuc.forward) * 0.5 + normal = get_normal_vector_to(forward) for loopout_idx in range(strand_length): pos = prev_nuc.center + forward * ((loopout_idx + 1) / (strand_length + 1)) From 9c93c1ae3374cd801d757aa8d9125d166d2fed8f Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 16 Aug 2022 07:00:08 -0600 Subject: [PATCH 67/73] added no_git examples subfolder for examples that I don't want on the git repo --- examples/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/.gitignore b/examples/.gitignore index 06aaf414..1a542e4f 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -7,3 +7,4 @@ /seed_sst.py /16_helix_origami_barrel_from_algoSST_paper-rotator.py /2_staple_2_helix_origami_deletions_lots_of_insertions.py +/no_git/ From 36e1725150ada365a41903b09d745ca5a768304d Mon Sep 17 00:00:00 2001 From: Daniel Hader Date: Tue, 16 Aug 2022 15:42:10 -0500 Subject: [PATCH 68/73] fixed issue where get_normal_vector_to was being calculated incorrectly and loopout normal vectors weren't being normalized --- scadnano/scadnano.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index ff3f4ee5..edb90969 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -8027,7 +8027,7 @@ def _oxdna_random_sequence(length: int) -> str: def get_normal_vector_to(vec: _OxdnaVector) -> _OxdnaVector: unit = _OxdnaVector(1, 0, 0) normalized_vec = vec.normalize() - if abs(1 - normalized_vec.dot(unit)) < 0.001: + if 1 - abs(normalized_vec.dot(unit)) < 0.001: unit = _OxdnaVector(0, 1, 0) return unit.cross(vec) @@ -8160,7 +8160,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: for loopout_idx in range(strand_length): pos = prev_nuc.center + forward * ((loopout_idx + 1) / (strand_length + 1)) old_nuc = dstrand.nucleotides[loopout_idx] - new_nuc = _OxdnaNucleotide(pos, normal, forward.normalize(), old_nuc.base) + new_nuc = _OxdnaNucleotide(pos, normal.normalize(), forward.normalize(), old_nuc.base) dstrand.nucleotides[loopout_idx] = new_nuc sstrand = sstrand.join(dstrand) From 62f626d6ff0e1954ac07ccc8ed78f50c20ade899 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 17 Aug 2022 09:36:34 -0600 Subject: [PATCH 69/73] bumped version --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index edb90969..7251a4d1 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -54,7 +54,7 @@ # commented out for now to support Py3.6, which does not support this feature # from __future__ import annotations -__version__ = "0.17.3" # version line; WARNING: do not remove or change this line or comment +__version__ = "0.17.5" # version line; WARNING: do not remove or change this line or comment import dataclasses from abc import abstractmethod, ABC, ABCMeta From bd63724f9829722f92a52d82ee11950c00e126b1 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 17 Aug 2022 09:37:11 -0600 Subject: [PATCH 70/73] bumped version --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 7251a4d1..edb90969 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -54,7 +54,7 @@ # commented out for now to support Py3.6, which does not support this feature # from __future__ import annotations -__version__ = "0.17.5" # version line; WARNING: do not remove or change this line or comment +__version__ = "0.17.3" # version line; WARNING: do not remove or change this line or comment import dataclasses from abc import abstractmethod, ABC, ABCMeta From b1ff5b8ccaf64a98c581d7aab4299e97d298b0c7 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 17 Aug 2022 09:39:08 -0600 Subject: [PATCH 71/73] bumped version --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index edb90969..7251a4d1 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -54,7 +54,7 @@ # commented out for now to support Py3.6, which does not support this feature # from __future__ import annotations -__version__ = "0.17.3" # version line; WARNING: do not remove or change this line or comment +__version__ = "0.17.5" # version line; WARNING: do not remove or change this line or comment import dataclasses from abc import abstractmethod, ABC, ABCMeta From 20f50d5f5fb3fa3ccdbfdfad956166e919ad8dbc Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 17 Aug 2022 09:50:42 -0600 Subject: [PATCH 72/73] fixed unit tests --- tests/scadnano_tests.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index 54914664..b17ac560 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -5085,7 +5085,7 @@ def test_from_json_extension_design(self) -> None: { "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 10}, - {"extension": 5, "display_length": 1.4, "display_angle": 50.0} + {"extension_num_bases": 5, "display_length": 1.4, "display_angle": 50.0} ], "is_scaffold": true } @@ -5108,7 +5108,7 @@ def test_to_json_extension_design__extension(self) -> None: # Verify document = json.loads(result) self.assertEqual(2, len(document["strands"][0]["domains"])) - self.assertIn("extension", document["strands"][0]["domains"][1]) + self.assertIn(sc.extension_key, document["strands"][0]["domains"][1]) self.assertEqual(5, document["strands"][0]["domains"][1]["extension"]) @@ -7592,45 +7592,45 @@ def test_plate_map_markdown(self) -> None: class TestExtension(unittest.TestCase): def test_to_json_serializable__extension_key_contains_num_bases(self) -> None: ext = sc.Extension(5) - result = ext.to_json_serializable() - self.assertEqual(result["extension"], 5) + result = ext.to_json_serializable(False) + self.assertEqual(result[sc.extension_key], 5) def test_to_json_serializable__no_display_length_key_when_default_display_length(self) -> None: ext = sc.Extension(5) - result = ext.to_json_serializable() - self.assertNotIn("display_length", result) + result = ext.to_json_serializable(False) + self.assertNotIn(sc.display_length_key, result) def test_to_json_serializable__no_display_angle_key_when_default_display_angle(self) -> None: ext = sc.Extension(5) - result = ext.to_json_serializable() - self.assertNotIn("display_angle", result) + result = ext.to_json_serializable(False) + self.assertNotIn(sc.display_angle_key, result) def test_to_json_serializable__no_name_key_when_default_name(self) -> None: ext = sc.Extension(5) - result = ext.to_json_serializable() - self.assertNotIn("name", result) + result = ext.to_json_serializable(False) + self.assertNotIn(sc.domain_name_key, result) def test_to_json_serializable__no_label_key_when_default_label(self) -> None: ext = sc.Extension(5) - result = ext.to_json_serializable() - self.assertNotIn("label", result) + result = ext.to_json_serializable(False) + self.assertNotIn(sc.domain_label_key, result) def test_to_json_serializable__display_length_key_contains_non_default_display_length(self) -> None: ext = sc.Extension(5, display_length=1.9) - result = ext.to_json_serializable() - self.assertEqual(result["display_length"], 1.9) + result = ext.to_json_serializable(False) + self.assertEqual(result[sc.display_length_key], 1.9) def test_to_json_serializable__display_angle_key_contains_non_default_display_angle(self) -> None: ext = sc.Extension(5, display_angle=39.9) - result = ext.to_json_serializable() - self.assertEqual(result["display_angle"], 39.9) + result = ext.to_json_serializable(False) + self.assertEqual(result[sc.display_angle_key], 39.9) def test_to_json_serializable__name_key_contains_non_default_name(self) -> None: ext = sc.Extension(5, name="A") - result = ext.to_json_serializable() - self.assertEqual(result["name"], "A") + result = ext.to_json_serializable(False) + self.assertEqual(result[sc.domain_name_key], "A") def test_to_json_serializable__label_key_contains_non_default_name(self) -> None: ext = sc.Extension(5, label="ext1") - result = ext.to_json_serializable() - self.assertEqual(result["label"], "ext1") + result = ext.to_json_serializable(False) + self.assertEqual(result[sc.domain_label_key], "ext1") From badd6b45689de43efe873182ed2decace62ffc85 Mon Sep 17 00:00:00 2001 From: David Doty Date: Wed, 17 Aug 2022 09:55:27 -0600 Subject: [PATCH 73/73] fixed last unit test --- tests/scadnano_tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index b17ac560..cd41d2e2 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -2850,8 +2850,8 @@ def test_add_half_crossover__horizontal_crossovers_already_there(self) -> None: with self.assertRaises(sc.IllegalDesignError) as ctx: design.add_half_crossover(helix=0, helix2=1, offset=8, forward=True) msg = str(ctx.exception) - self.assertIn('is expected to be on the', msg) # both 3' and 5' are problems, so just make sure - self.assertIn('end of the strand', msg) # one of them is mentioned here + self.assertIn('is expected to be on the', msg) # both 3' and 5' are problems, so just make sure + self.assertIn('end of the strand', msg) # one of them is mentioned here def test_add_half_crossover__top_horizontal_crossover_already_there(self) -> None: """ @@ -5109,7 +5109,7 @@ def test_to_json_extension_design__extension(self) -> None: document = json.loads(result) self.assertEqual(2, len(document["strands"][0]["domains"])) self.assertIn(sc.extension_key, document["strands"][0]["domains"][1]) - self.assertEqual(5, document["strands"][0]["domains"][1]["extension"]) + self.assertEqual(5, document["strands"][0]["domains"][1][sc.extension_key]) class TestIllegalStructuresPrevented(unittest.TestCase):