From 1efd9ad18f3e8ef864036e4400512548f0b88b1c Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Thu, 10 Mar 2022 19:15:51 -0800 Subject: [PATCH 01/19] Closes #149; store DNA sequence in domains and loopouts, not in strand (#223) * SAVE progress * Set dna sequence properly in ligate * Fix domain UT * Fix test * Fix test * Fix test_add_nick__small_design_H0_forward * Fix test_add_half_crossover__small_design_H0_reverse_8 * Fix test_add_half_crossover__small_design_H0_reverse_15 * Fix test_add_half_crossover__small_design_H0_reverse_0 * Fix test_add_full_crossover__small_design_H0_reverse * Fix test_add_full_crossover__small_design_H0_forward * Fix test_2_staple_2_helix_origami_deletions_insertions * Fix insert_domain * Fix test_insert_domain_with_sequence test * Fix typo in domain.length -> domain.dna_length() * Fix remove_domain --- scadnano/scadnano.py | 125 ++++++++++++++----------- tests/scadnano_tests.py | 199 ++++++++++++++++++++++++++-------------- 2 files changed, 201 insertions(+), 123 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6bd9c7cd..b876d20c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -440,17 +440,17 @@ class M13Variant(enum.Enum): def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: """ The M13mp18 DNA sequence (commonly called simply M13). - + By default, starts from cyclic rotation 5587 (with 0-based indexing; commonly this is called rotation 5588, which assumes that indexing begins at 1), as defined in `GenBank `_. - + By default, returns the "standard" variant of consisting of 7249 bases, sold by companies such as `Tilibit `_. and `New England Biolabs `_ - + The actual M13 DNA strand itself is circular, so assigning this sequence to the scaffold :any:`Strand` in a :any:`Design` means that the "5' end" of the scaffold :any:`Strand` @@ -464,7 +464,7 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: `Supplementary Note S8 `_ in [`Folding DNA to create nanoscale shapes and patterns. Paul W. K. Rothemund, Nature 440:297-302 (2006) `_]. - + :param rotation: rotation of circular strand. Valid values are 0 through length-1. :param variant: variant of M13 strand to use :return: M13 strand sequence @@ -845,7 +845,7 @@ class Modification(_JSONSerializable, ABC): Use concrete subclasses :any:`Modification3Prime`, :any:`Modification5Prime`, or :any:`ModificationInternal` to instantiate. - + If :data:`Modification.id` is not specified, then :data:`Modification.idt_text` is used as the unique ID. Each :data:`Modification.id` must be unique. For example if you create a 5' "modification" to represent 6 T bases: ``t6_5p = Modification5Prime(display_text='6T', idt_text='TTTTTT')`` @@ -1600,6 +1600,10 @@ class Domain(_JSONSerializable, Generic[DomainLabel]): on the object should succeed without having to specify a custom encoder.) """ + dna_sequence: Optional[str] = None + """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned + to this :any:`Domain`'s :any:`Strand`.""" + # not serialized; for efficiency # remove quotes when Py3.6 support dropped _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None) @@ -1757,10 +1761,6 @@ def visual_length(self) -> int: This can be more or less than the :meth:`Domain.dna_length` due to insertions and deletions.""" return self.end - self.start - def dna_sequence(self) -> Optional[str]: - """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned - to this :any:`Domain`'s :any:`Strand`.""" - return self.dna_sequence_in(self.start, self.end - 1) def dna_sequence_in(self, offset_left: int, offset_right: int) -> Optional[str]: """Return DNA sequence of this Domain in the interval of offsets given by @@ -1972,6 +1972,9 @@ class Loopout(_JSONSerializable, Generic[DomainLabel]): on the object should succeed without having to specify a custom encoder.) """ + dna_sequence: Optional[str] = None + """Return DNA sequence of this :any:`Loopout`, 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) @@ -2029,20 +2032,6 @@ def dna_length(self) -> int: """Length of this :any:`Loopout`; same as field :py:data:`Loopout.length`.""" return self.length - def dna_sequence(self) -> Optional[str]: - """Return DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned - to the :any:`Strand` of this :any:`Loopout`.""" - if self._parent_strand is None: - raise ValueError('_parent_strand has not been set') - strand_seq = self._parent_strand.dna_sequence - if strand_seq is None: - return None - - str_idx_left = self.get_seq_start_idx() - str_idx_right = str_idx_left + self.length # EXCLUSIVE (unlike similar code for Domain) - subseq = strand_seq[str_idx_left:str_idx_right] - return subseq - def get_seq_start_idx(self) -> int: """Starting DNA subsequence index for first base of this :any:`Loopout` on its :any:`Strand`'s DNA sequence.""" @@ -2638,10 +2627,17 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): should result in a functionally equivalent :any:`Strand`. It is illegal to have a :any:`Modification5Prime` or :any:`Modification3Prime` on a circular :any:`Strand`.""" - dna_sequence: Optional[str] = None - """Do not assign directly to this field. Always use :any:`Design.assign_dna` - (for complementarity checking) or :any:`Strand.set_dna_sequence` - (without complementarity checking, to allow mismatches).""" + @property + def dna_sequence(self) -> Optional[str]: + """Do not assign directly to this field. Always use :any:`Design.assign_dna` + (for complementarity checking) or :any:`Strand.set_dna_sequence` + (without complementarity checking, to allow mismatches).""" + sequence = '' + for domain in self.domains: + if domain.dna_sequence is None: + return None + sequence += domain.dna_sequence + return sequence color: Optional[Color] = None """Color to show this strand in the main view. If not specified in the constructor, @@ -2707,6 +2703,32 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): _helix_idx_domain_map: Dict[int, List[Domain[DomainLabel]]] = field( init=False, repr=False, compare=False, default_factory=dict) + def __init__(self, + domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel]]], + circular: bool = False, color: Optional[Color] = None, + idt: Optional[IDTFields] = None, + is_scaffold: bool = False, modification_5p: Optional[Modification5Prime] = None, + modification_3p: Optional[Modification3Prime] = None, + modifications_int: Optional[Dict[int, ModificationInternal]] = None, + name: Optional[str] = None, + label: Optional[StrandLabel] = None, + _helix_idx_domain_map: Dict[int, List[Domain[DomainLabel]]] = None, + dna_sequence: Optional[str] = None): + self.domains = domains + self.circular = circular + self.color = color + self.idt = idt + self.is_scaffold = is_scaffold + self.modification_5p = modification_5p + self.modification_3p = modification_3p + self.modifications_int = modifications_int if modifications_int is not None else dict() + self.name = name + self.label = label + self._helix_idx_domain_map = _helix_idx_domain_map if _helix_idx_domain_map is not None else dict() + if dna_sequence is not None: + self.set_dna_sequence(dna_sequence) + self.__post_init__() + def __post_init__(self) -> None: self._ensure_domains_not_none() self._helix_idx_domain_map = defaultdict(list) @@ -2818,11 +2840,11 @@ def rotate_domains(self, rotation: int, forward: bool = True) -> None: A, B, C, D, E, F then ``strand.rotate_domains(2)`` makes the :any:`Strand` have the same domains, but in this order: - + E, F, A, B, C, D - + and ``strand.rotate_domains(2, forward=False)`` makes - + C, D, E, F, A, B :param rotation: @@ -3033,7 +3055,7 @@ def dna_sequence_delimited(self, delimiter: str) -> str: DNA sequence of this :any:`Strand`, with `delimiter` in between DNA sequences of each :any:`Domain` or :any:`Loopout`. """ - result = [substrand.dna_sequence() for substrand in self.domains] + result = [substrand.dna_sequence for substrand in self.domains] return delimiter.join(result) def set_dna_sequence(self, sequence: str) -> None: @@ -3057,7 +3079,13 @@ def set_dna_sequence(self, sequence: str) -> None: raise StrandError(self, f"strand starting at helix {domain.helix} offset {domain.offset_5p()} " f"has length {self.dna_length()}, but you attempted to assign a " f"DNA sequence of length {len(trimmed_seq)}: {sequence}") - self.dna_sequence = trimmed_seq + + start_idx_ss = 0 + for d in self.domains: + end_idx_ss = start_idx_ss + d.dna_length() + dna_subseq = sequence[start_idx_ss:end_idx_ss] + d.dna_sequence = dna_subseq + start_idx_ss = end_idx_ss def dna_length(self) -> int: """Return sum of DNA length of :any:`Domain`'s and :any:`Loopout`'s of this :any:`Strand`.""" @@ -3110,7 +3138,7 @@ def assign_dna_complement_from(self, other: 'Strand') -> None: # remove quotes strand_complement_builder: List[str] = [] if already_assigned: for domain in self.domains: - domain_seq = domain.dna_sequence() + domain_seq = domain.dna_sequence if domain_seq is None: raise ValueError(f'no DNA sequence has been assigned to {self}') strand_complement_builder.append(domain_seq) @@ -3198,32 +3226,18 @@ def assign_dna_complement_from(self, other: 'Strand') -> None: # remove quotes # self.dna_sequence = _pad_dna(new_dna_sequence, self.dna_length()) def insert_domain(self, order: int, domain: Union[Domain, Loopout]) -> 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() + # Only intended to be called by Design.insert_domain self.domains.insert(order, domain) domain._parent_strand = self if isinstance(domain, Domain): self._helix_idx_domain_map[domain.helix].append(domain) - # add wildcard symbols to DNA sequence to maintain its length - if self.dna_sequence is not None: - start_idx = self.dna_index_start_domain(domain) - end_idx = start_idx + domain.dna_length() - prefix = self.dna_sequence[:start_idx] - suffix = self.dna_sequence[start_idx:] - new_wildcards = DNA_base_wildcard * (end_idx - start_idx) - self.dna_sequence = prefix + new_wildcards + suffix - def remove_domain(self, domain: Union[Domain, Loopout]) -> None: # Only intended to be called by Design.remove_domain - - # remove relevant portion of DNA sequence to maintain its length - if self.dna_sequence is not None: - start_idx = self.dna_index_start_domain(domain) - end_idx = start_idx + domain.dna_length() - prefix = self.dna_sequence[:start_idx] - suffix = self.dna_sequence[end_idx:] - self.dna_sequence = prefix + suffix - self.domains.remove(domain) domain._parent_strand = None if isinstance(domain, Domain): @@ -6504,6 +6518,9 @@ def ligate(self, helix: int, offset: int, forward: bool) -> None: else: # join strands + old_strand_3p_dna_sequence = strand_3p.dna_sequence + old_strand_5p_dna_sequence = strand_5p.dna_sequence + strand_3p.domains.pop() strand_3p.domains.append(dom_new) strand_3p.domains.extend(strand_5p.domains[1:]) @@ -6512,8 +6529,8 @@ def ligate(self, helix: int, offset: int, forward: bool) -> None: for idx, mod in strand_5p.modifications_int.items(): new_idx = idx + strand_3p.dna_length() strand_3p.set_modification_internal(new_idx, mod) - if strand_3p.dna_sequence is not None and strand_5p.dna_sequence is not None: - strand_3p.dna_sequence += strand_5p.dna_sequence + if old_strand_3p_dna_sequence is not None and old_strand_5p_dna_sequence is not None: + strand_3p.set_dna_sequence(old_strand_3p_dna_sequence + old_strand_5p_dna_sequence) if strand_5p.is_scaffold and not strand_3p.is_scaffold and strand_5p.color is not None: strand_3p.set_color(strand_5p.color) self.strands.remove(strand_5p) @@ -7223,7 +7240,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem: dom_strands: List[Tuple[_OxdnaStrand, bool]] = [] for domain in strand.domains: dom_strand = _OxdnaStrand() - seq = domain.dna_sequence() + seq = domain.dna_sequence if seq is None: seq = 'T' * domain.dna_length() diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index db749245..e20779c1 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -1150,7 +1150,6 @@ def test_2_staple_2_helix_origami_deletions_insertions(self) -> None: design.add_insertion(helix=1, offset=18, length=4) # also assigns complement to strands other than scaf bound to it - design.assign_dna(scaf, 'AACGT' * 18) output_json = design.to_cadnano_v2_json() output_design = sc.Design.from_cadnano_v2(json_dict=json.loads(output_json)) @@ -2134,10 +2133,18 @@ def test_add_nick__small_design_no_nicks_added_yet(self) -> None: TTTGGGCC AAACCCGG """ self.assertEqual(4, len(self.small_design.strands)) - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), self.small_design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16)]), self.small_design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), self.small_design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), self.small_design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA'))]), + self.small_design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TACCGGTT TCGTACGT'))]), + self.small_design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC'))]), + self.small_design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, False, 0, 16, dna_sequence=remove_whitespace('GGCCCAAA CCGGGTTT'))]), + self.small_design.strands) # DNA strand = strand_matching(self.small_design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2163,14 +2170,14 @@ def test_ligate__small_nicked_design_no_ligation_yet(self) -> None: """ design = self.small_nicked_design self.assertEqual(8, len(design.strands)) - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, True, 8, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 8, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 8, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 8, 16)]), design.strands) + self.assertIn(sc.Strand([sc.Domain(0, True, 0, 8, dna_sequence='ACGTACGA')]), design.strands) + self.assertIn(sc.Strand([sc.Domain(0, False, 0, 8, dna_sequence='TGCATGCT'[::-1]), ]), design.strands) + self.assertIn(sc.Strand([sc.Domain(1, True, 0, 8, dna_sequence='AAACCCGG')]), design.strands) + self.assertIn(sc.Strand([sc.Domain(1, False, 0, 8, dna_sequence='TTTGGGCC'[::-1])]), design.strands) + self.assertIn(sc.Strand([sc.Domain(0, True, 8, 16, dna_sequence='AACCGGTA')]), design.strands) + self.assertIn(sc.Strand([sc.Domain(0, False, 8, 16, dna_sequence='TTGGCCAT'[::-1])]), design.strands) + self.assertIn(sc.Strand([sc.Domain(1, True, 8, 16, dna_sequence='TTTGGGCC')]), design.strands) + self.assertIn(sc.Strand([sc.Domain(1, False, 8, 16, dna_sequence='AAACCCGG'[::-1])]), design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 8) self.assertEqual(remove_whitespace('ACGTACGA'), strand.dna_sequence) @@ -2210,12 +2217,12 @@ def test_add_nick__small_design_H0_forward(self) -> None: design.add_nick(helix=0, offset=8, forward=True) self.assertEqual(5, len(design.strands)) # two new Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, True, 8, 16)]), design.strands) + 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)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), 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) @@ -2247,10 +2254,14 @@ def test_ligate__small_nicked_design_ligate_all(self) -> None: design.ligate(1, 8, True) design.ligate(1, 8, False) self.assertEqual(4, len(design.strands)) - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16, dna_sequence='ACGTACGAAACCGGTA')]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence='TGCATGCTTTGGCCAT'[:: -1])]), + design.strands) + self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16, dna_sequence='AAACCCGGTTTGGGCC')]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, False, 0, 16, dna_sequence='TTTGGGCCAAACCCGG'[:: -1])]), + design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2278,12 +2289,22 @@ def test_add_nick__small_design_H0_reverse(self) -> None: design.add_nick(helix=0, offset=8, forward=False) self.assertEqual(5, len(design.strands)) # two new Strands - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 8, 16)]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 8, dna_sequence=remove_whitespace('TCGTACGT'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 8, 16, dna_sequence=remove_whitespace('TACCGGTT'))]), + design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA'))]), + 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, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2313,12 +2334,22 @@ def test_add_nick__small_design_H1_forward(self) -> None: design.add_nick(helix=1, offset=8, forward=True) self.assertEqual(5, len(design.strands)) # two new Strands - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 8, 16)]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, True, 0, 8, dna_sequence=remove_whitespace('AAACCCGG'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, True, 8, 16, dna_sequence=remove_whitespace('TTTGGGCC'))]), + design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16)]), 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, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TACCGGTT TCGTACGT'))]), + design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2348,12 +2379,22 @@ def test_add_nick__small_design_H1_reverse(self) -> None: design.add_nick(helix=1, offset=8, forward=False) self.assertEqual(5, len(design.strands)) # two new Strands - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 8)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 8, 16)]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, False, 0, 8, dna_sequence=remove_whitespace('CCGGGTTT'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(1, False, 8, 16, dna_sequence=remove_whitespace('GGCCCAAA'))]), + design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16)]), 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(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA'))]), + design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TACCGGTT TCGTACGT'))]), + design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2384,16 +2425,20 @@ def test_add_full_crossover__small_design_H0_forward(self) -> None: self.assertEqual(4, len(design.strands)) # two new Strands self.assertIn(sc.Strand([ - sc.Domain(0, True, 0, 8), - sc.Domain(1, False, 0, 8), + sc.Domain(0, True, 0, 8, dna_sequence=remove_whitespace('ACGTACGA')), + sc.Domain(1, False, 0, 8, dna_sequence=remove_whitespace('TTTGGGCC'[::-1])), ]), design.strands) self.assertIn(sc.Strand([ - sc.Domain(1, False, 8, 16), - sc.Domain(0, True, 8, 16), + sc.Domain(1, False, 8, 16, dna_sequence=remove_whitespace('AAACCCGG'[::-1])), + sc.Domain(0, True, 8, 16, dna_sequence=remove_whitespace('AACCGGTA')), ]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, False, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, True, 0, 16)]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TGCATGCT TTGGCCAT'[::-1])) + ]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC')) + ]), design.strands) # DNA strand = strand_matching(design.strands, 0, False, 0, 16) self.assertEqual(remove_whitespace('TACCGGTT TCGTACGT'), strand.dna_sequence) @@ -2422,16 +2467,20 @@ def test_add_full_crossover__small_design_H0_reverse(self) -> None: self.assertEqual(4, len(design.strands)) # two new Strands self.assertIn(sc.Strand([ - sc.Domain(1, True, 0, 8), - sc.Domain(0, False, 0, 8), + sc.Domain(1, True, 0, 8, dna_sequence='AAACCCGG'), + sc.Domain(0, False, 0, 8, dna_sequence='TGCATGCT'[::-1]), ]), design.strands) self.assertIn(sc.Strand([ - sc.Domain(0, False, 8, 16), - sc.Domain(1, True, 8, 16), + sc.Domain(0, False, 8, 16, dna_sequence='TTGGCCAT'[::-1]), + sc.Domain(1, True, 8, 16, dna_sequence='TTTGGGCC'), ]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(0, True, 0, 16, dna_sequence='ACGTACGAAACCGGTA') + ]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(1, False, 0, 16, dna_sequence='TTTGGGCCAAACCCGG'[:: -1]) + ]), design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2462,18 +2511,22 @@ def test_add_half_crossover__small_design_H0_reverse_8(self) -> None: self.assertEqual(5, len(design.strands)) # three new Strands self.assertIn(sc.Strand([ - sc.Domain(1, True, 0, 8), + sc.Domain(1, True, 0, 8, dna_sequence=remove_whitespace('AAACCCGG')), ]), design.strands) self.assertIn(sc.Strand([ - sc.Domain(0, False, 0, 8), + sc.Domain(0, False, 0, 8, dna_sequence=remove_whitespace('TCGTACGT')), ]), design.strands) self.assertIn(sc.Strand([ - sc.Domain(0, False, 8, 16), - sc.Domain(1, True, 8, 16), + sc.Domain(0, False, 8, 16, dna_sequence=remove_whitespace('TACCGGTT')), + sc.Domain(1, True, 8, 16, dna_sequence=remove_whitespace('TTTGGGCC')), ]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA')), + ]), 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, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2572,12 +2625,16 @@ def test_add_half_crossover__small_design_H0_reverse_0(self) -> None: self.assertEqual(3, len(design.strands)) # one new Strand self.assertIn(sc.Strand([ - sc.Domain(0, False, 0, 16), - sc.Domain(1, True, 0, 16), + sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TGCATGCT TTGGCCAT'[::-1])), + sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC')), ]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA')), + ]), design.strands) + self.assertIn(sc.Strand([ + sc.Domain(1, False, 0, 16, dna_sequence=remove_whitespace('TTTGGGCC AAACCCGG'[::-1])), + ]), design.strands) # DNA strand = strand_matching(design.strands, 0, True, 0, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -2604,12 +2661,16 @@ def test_add_half_crossover__small_design_H0_reverse_15(self) -> None: self.assertEqual(3, len(design.strands)) # one new Strand self.assertIn(sc.Strand([ - sc.Domain(1, True, 0, 16), - sc.Domain(0, False, 0, 16), + sc.Domain(1, True, 0, 16, dna_sequence=remove_whitespace('AAACCCGG TTTGGGCC')), + sc.Domain(0, False, 0, 16, dna_sequence=remove_whitespace('TGCATGCT TTGGCCAT'[::-1])), ]), design.strands) # existing Strands - self.assertIn(sc.Strand([sc.Domain(0, True, 0, 16)]), design.strands) - self.assertIn(sc.Strand([sc.Domain(1, False, 0, 16)]), design.strands) + self.assertIn( + sc.Strand([sc.Domain(0, True, 0, 16, dna_sequence=remove_whitespace('ACGTACGA AACCGGTA'))]), + 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, 16) self.assertEqual(remove_whitespace('ACGTACGA AACCGGTA'), strand.dna_sequence) @@ -4515,7 +4576,7 @@ def test_insert_domain_with_sequence(self) -> None: sc.Domain(0, True, 0, 3), sc.Domain(1, False, 0, 3), sc.Domain(3, True, 0, 3), - ]) # , dna_sequence='ACA TCT GTG'.replace(' ', '')) + ], dna_sequence='ACA TCT GTG'.replace(' ', '')) self.assertEqual(expected_strand_before, design.strands[0]) domain = sc.Domain(2, True, 0, 3) @@ -4560,7 +4621,7 @@ def test_remove_last_domain_with_sequence(self) -> None: expected_strand = sc.Strand([ sc.Domain(0, True, 0, 3), sc.Domain(1, False, 0, 3), - ], dna_sequence='ACA TCT GTG'.replace(' ', '')) + ], dna_sequence='ACA TCT'.replace(' ', '')) self.assertEqual(expected_strand, self.strand) @@ -5905,7 +5966,7 @@ def test_dna_sequence_in__right_then_left(self) -> None: ss0 = sc.Domain(0, True, 0, 10) ss1 = sc.Domain(1, False, 0, 10) strand = sc.Strand([ss0, ss1]) - strand.dna_sequence = "AAAACCCCGGGGTTTTACGT" + strand.set_dna_sequence("AAAACCCCGGGGTTTTACGT") # offset: 0 1 2 3 4 5 6 7 8 9 # index: 0 1 2 3 4 5 6 7 8 9 # A A A A C C C C G G @@ -5941,7 +6002,7 @@ def test_dna_sequence_in__right_then_left_deletions(self) -> None: ss0 = sc.Domain(0, True, 0, 10, deletions=[2, 5, 6]) ss1 = sc.Domain(1, False, 0, 10, deletions=[2, 6, 7]) strand = sc.Strand([ss0, ss1]) - strand.dna_sequence = "AAACCGGGGTTAGT" + strand.set_dna_sequence("AAACCGGGGTTAGT") # offset: 0 1 D2 3 4 D5 D6 7 8 9 # index: 0 1 2 3 4 5 6 # A A A C C G G @@ -5983,7 +6044,7 @@ def test_dna_sequence_in__right_then_left_insertions(self) -> None: ss0 = sc.Domain(0, True, 0, 10, insertions=[(2, 1), (6, 2)]) ss1 = sc.Domain(1, False, 0, 10, insertions=[(2, 1), (6, 2)]) strand = sc.Strand([ss0, ss1]) - strand.dna_sequence = "AAAACCCCGGGGTTTTACGTACGTAC" + strand.set_dna_sequence("AAAACCCCGGGGTTTTACGTACGTAC") # offset: 0 1 2 I 3 4 5 6 I I 7 8 9 # index: 0 1 2 3 4 5 6 7 8 9 10 11 12 # A A A A C C C C G G G G T @@ -6025,7 +6086,7 @@ def test_dna_sequence_in__right_then_left_deletions_and_insertions(self) -> None ss0 = sc.Domain(0, True, 0, 10, deletions=[4], insertions=[(2, 1), (6, 2)]) ss1 = sc.Domain(1, False, 0, 10, deletions=[4], insertions=[(2, 1), (6, 2)]) strand = sc.Strand([ss0, ss1]) - strand.dna_sequence = "AAAACCCCGGGGTTTTACGTACGTAC" + strand.set_dna_sequence("AAAACCCCGGGGTTTTACGTACGT") # offset: 0 1 2 I 3 D4 5 6 I I 7 8 9 # index: 0 1 2 3 4 5 6 7 8 9 10 11 # A A A A C C C C G G G G From 9e1a287db52f7fb76aa11119153c0857a7e5886e Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 17:56:19 +0000 Subject: [PATCH 02/19] corrected docstring for `Design.draw_strand` --- scadnano/scadnano.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index b876d20c..8b4861e5 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -4873,8 +4873,7 @@ def _ensure_mods_unique_names(all_mods: Set[Modification]) -> None: f'one is\n {mod}\nand the other is\n {other_mod}') def draw_strand(self, helix: int, offset: int) -> StrandBuilder: - """Used for chained method building by calling - :py:meth:`Design.strand` to build the :any:`Strand` domain by domain, in order from 5' to 3'. + """Used for chained method building the :any:`Strand` domain by domain, in order from 5' to 3'. For example .. code-block:: Python @@ -4913,11 +4912,13 @@ def draw_strand(self, helix: int, offset: int) -> StrandBuilder: :py:meth:`StrandBuilder.cross`, :py:meth:`StrandBuilder.loopout`, :py:meth:`StrandBuilder.to` + :py:meth:`StrandBuilder.move` :py:meth:`StrandBuilder.update_to`, returns a :any:`StrandBuilder` object. Each call to :py:meth:`StrandBuilder.to`, + :py:meth:`StrandBuilder.move`, :py:meth:`StrandBuilder.update_to`, or :py:meth:`StrandBuilder.loopout` @@ -4936,7 +4937,8 @@ def strand(self, helix: int, offset: int) -> StrandBuilder: Same functionality as :meth:`Design.draw_strand`. .. deprecated:: 0.17.2 - Use :meth:`Design.draw_strand` instead. This method will be removed in a future version. + Use :meth:`Design.draw_strand` instead, which is a better name. + This method will be removed in a future version. """ print('WARNING: The method Design.strand is deprecated. Use Design.draw_strand instead, ' 'which has the same functionality. Design.strand will be removed in a future version.') From 243562d332f64e472018ce59e2209b92eab67c22 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:40:55 +0000 Subject: [PATCH 03/19] Update scadnano.py --- scadnano/scadnano.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 8b4861e5..2321996c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -447,9 +447,9 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str: `GenBank `_. By default, returns the "standard" variant of consisting of 7249 bases, sold by companies such as - `Tilibit `_. + `Tilibit `_ and - `New England Biolabs `_ + `New England Biolabs `_. The actual M13 DNA strand itself is circular, so assigning this sequence to the scaffold :any:`Strand` in a :any:`Design` From 7ed1610a9bc6e239110f659f48834996bd371353 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:45:14 +0000 Subject: [PATCH 04/19] Update tutorial.md --- tutorial/tutorial.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 68da5cc6..3ef338f0 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -354,6 +354,8 @@ Now the design should look like this: ![](images/scaffold_complete.png) +We've completed the scaffold strand! On to the staple strands. + From f9e10b66ade7f819ed20cc9b2eea8ef3bcc5daf2 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:46:13 +0000 Subject: [PATCH 05/19] Update tutorial.md --- tutorial/tutorial.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 3ef338f0..2ced2e51 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -363,8 +363,10 @@ We've completed the scaffold strand! On to the staple strands. ## Add staple precursors -We used chained method calls to create scaffold precursor strands. -It is also possible, though typically more verbose, to explicitly create `Domain` objects, to be passed into the `Strand` constructor. +We used chained method calls to create scaffold precursor strands, +i.e., one long strand per helix, going the opposite direction as the scaffold. +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. Each `Strand` is specified primarily by a list of `Domain`'s, and each `Domain` is specified primarily by 4 fields: From 45bf3ed599c95f3f532218c28ff1b0b3cce449d0 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:47:16 +0000 Subject: [PATCH 06/19] Update tutorial.md --- tutorial/tutorial.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 2ced2e51..ecf918d1 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -363,8 +363,10 @@ We've completed the scaffold strand! On to the staple strands. ## Add staple precursors -We used chained method calls to create scaffold precursor strands, +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. 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. From 48f6b54ee86b95cf2c2aa030c31012cdc984ec7d Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:48:25 +0000 Subject: [PATCH 07/19] Update tutorial.md --- tutorial/tutorial.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index ecf918d1..8d7b531f 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -373,8 +373,8 @@ For the staple precursor strands we do this to show how it works. 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), -Boolean `forward` (direction of the `Domain`, i.e., is its 3' end at a higher or lower offset than its 5' end?), -integer `start` and `end` offsets. +Boolean `forward`: direction of the `Domain`, i.e., is its 3' end at a higher or lower offset than its 5' end? In other words is the strand arrow pointing to the right (forward) or left (reverse), +and integer `start` and `end` offsets. From 6610ef3e7b593ef4b09f50dc88d1efa36f3b1e88 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:49:13 +0000 Subject: [PATCH 08/19] Update tutorial.md --- tutorial/tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 8d7b531f..65a955de 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -378,7 +378,7 @@ and integer `start` and `end` offsets. -To add staples, we use the method `design.add_strand`. In general, modifying an existing design should always be done through methods rather than modifying the fields directly, although it is safe to access the fields read-only. +To add staples, we use the method `design.add_strand`. In general, modifying an existing design should always be done through methods rather than modifying the fields directly. In other words, don't write to the list `design.strands` directly, although it is safe to access the fields read-only. ```python def create_design() -> sc.Design: From d6aa2ba6265fcd52b47b44c721c43bb47b9a8dd5 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:50:40 +0000 Subject: [PATCH 09/19] Update tutorial.md --- tutorial/tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 65a955de..0f36a89f 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -526,7 +526,7 @@ The design now looks like it did at the top: Finally, we complete the design by assigning a DNA sequence to the scaffold, which will assign the complementary sequence to the appropriate staples. This is, in a sense, the primary function of [cadnano](https://cadnano.org/) and scadnano: to translate a desired abstract strand design, together with knowledge of a concrete DNA sequence for the scaffold, into the appropriate sequences for the staples to enable them to bind to the scaffold where we want. -If you have a particular strand and sequence you would like to assign, you can call [`Design.assign_dna`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.assign_dna). However, in the common case that your design has exactly one scaffold, and you want to assign the sequence of [M13mp18](https://www.ncbi.nlm.nih.gov/nuccore/X02513.1) to it, there is a convenience method [Design.assign_m13_to_scaffold](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.assign_m13_to_scaffold): +If you have a particular strand and sequence you would like to assign, you can call [`Design.assign_dna`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.assign_dna). However, in the common case that your design has exactly one scaffold, and you want to assign the sequence of [M13mp18](https://www.ncbi.nlm.nih.gov/nuccore/X02513.1) to it, there is a convenience method [`Design.assign_m13_to_scaffold`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.assign_m13_to_scaffold): ```python def create_design() -> sc.Design: From 0addd6dc5eb24ccdf3bad060b055b692463ae84a Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:54:10 +0000 Subject: [PATCH 10/19] Update tutorial.md --- tutorial/tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index 0f36a89f..a295ddbc 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -583,7 +583,7 @@ The Excel file should look similar to this: ![](images/excel_file.png) -To customize further (e.g., purification, synthesis scale), one can write to the field `Strand.idt`, of type [IDTFields](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.IDTFields). +To customize further (e.g., purification, synthesis scale), one can write to the field `Strand.idt`, of type [IDTFields](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.IDTFields). Strands can also be given custom names through the field [`Strand.name`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Strand.name); if the Strand has no name, it is assigned one using the cadnano convention (see Excel screenshot above) based on the helix and offset of the strand's 5' and 3' ends. From 9ab30af4421fb9b77078898e878c6cdb7565d176 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sat, 19 Mar 2022 18:54:54 +0000 Subject: [PATCH 11/19] Update tutorial.md --- tutorial/tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md index a295ddbc..adb7df66 100644 --- a/tutorial/tutorial.md +++ b/tutorial/tutorial.md @@ -583,7 +583,7 @@ The Excel file should look similar to this: ![](images/excel_file.png) -To customize further (e.g., purification, synthesis scale), one can write to the field `Strand.idt`, of type [IDTFields](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.IDTFields). Strands can also be given custom names through the field [`Strand.name`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Strand.name); if the Strand has no name, it is assigned one using the cadnano convention (see Excel screenshot above) based on the helix and offset of the strand's 5' and 3' ends. +To customize further (e.g., purification, synthesis scale), one can write to the field [`Strand.idt`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Strand.idt), of type [IDTFields](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.IDTFields). Strands can also be given custom names through the field [`Strand.name`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Strand.name); if the Strand has no name, it is assigned one using the cadnano convention (see Excel screenshot above) based on the helix and offset of the strand's 5' and 3' ends. From b09632f3f4b372259c7f942a6646b426df8d29f7 Mon Sep 17 00:00:00 2001 From: David Doty Date: Sun, 20 Mar 2022 09:17:30 +0000 Subject: [PATCH 12/19] Update scadnano.py --- scadnano/scadnano.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 2321996c..98742d8c 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -5152,7 +5152,9 @@ def to_cadnano_v2_serializable(self, name: str = '') -> Dict[str, Any]: the cadnano c2 format; this is essentially what is done in :meth:`Design.to_cadnano_v2_json`. - Please see the spec `misc/cadnano-format-specs/v2.txt` for more info on that 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. :param name: @@ -5236,7 +5238,9 @@ def to_cadnano_v2_serializable(self, name: str = '') -> Dict[str, Any]: def to_cadnano_v2_json(self, name: str = '') -> str: """Converts the design to the cadnano v2 format. - Please see the spec `misc/cadnano-format-specs/v2.txt` for more info on that 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. :param name: Name of the design. From 3fd2a86f5325d33dbefc3ec9c80bab1a5942be7e Mon Sep 17 00:00:00 2001 From: David Doty Date: Mon, 28 Mar 2022 14:01:01 +0100 Subject: [PATCH 13/19] 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 98742d8c..155fe4e4 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -2652,7 +2652,8 @@ def dna_sequence(self) -> Optional[str]: https://www.idtdna.com/site/order/oligoentry, as can the method :py:meth:`Design.write_idt_plate_excel_file` for writing a Microsoft Excel file that can be uploaded to IDT's website for describing DNA sequences to be ordered in 96-well - or 384-well plates.""" + or 384-well plates: + https://www.idtdna.com/site/order/plate/index/dna/1800""" is_scaffold: bool = False """Indicates whether this :any:`Strand` is a scaffold for a DNA origami. If any :any:`Strand` in a From e9ab4bca5e6e07d475915afb313cf2368a2e245a Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 10:54:44 +0100 Subject: [PATCH 14/19] Update scadnano.py --- scadnano/scadnano.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 155fe4e4..6d442ddc 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -4693,7 +4693,8 @@ def from_cadnano_v2(directory: str = '', filename: Optional[str] = None, return design - def plate_maps_markdown(self, warn_duplicate_strand_names: bool = True, + def plate_maps_markdown(self, + warn_duplicate_strand_names: bool = True, plate_type: PlateType = PlateType.wells96, strands: Optional[Iterable[Strand]] = None, well_marker: Optional[str] = None) -> Dict[str, str]: @@ -4729,6 +4730,9 @@ def plate_maps_markdown(self, warn_duplicate_strand_names: bool = True, | G | | | | | | | | | | | | | | H | | | | | | | | | | | | | + + If `well_marker` is not specified, then each strand must have a name. + 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. @@ -4760,8 +4764,9 @@ def plate_maps_markdown(self, warn_duplicate_strand_names: bool = True, for strand in strands: if strand.idt is not None and strand.idt.plate is not None: plate_names_to_strands[strand.idt.plate].append(strand) - if strand.name is None: - raise ValueError(f'strand {strand} has no name, but has a plate, which is not allowed') + if strand.name is None and well_marker is None: + raise ValueError(f'strand {strand} has no name, but has a plate, which is not allowed ' + f'unless well_marker is specified') if strand.name in strand_names_to_plate_and_well: if warn_duplicate_strand_names: print(f'WARNING: found duplicate instance of strand with name {strand.name}') From 7d9764b58585128756d1cdad3c78c7ca3ae328f6 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 11:10:32 +0100 Subject: [PATCH 15/19] added documentation about idt_dna_sequence in docstring for `Strand.dna_sequence` property. --- scadnano/scadnano.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 6d442ddc..0880f450 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1601,7 +1601,7 @@ class Domain(_JSONSerializable, Generic[DomainLabel]): """ dna_sequence: Optional[str] = None - """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned + """DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned to this :any:`Domain`'s :any:`Strand`.""" # not serialized; for efficiency @@ -1973,7 +1973,7 @@ class Loopout(_JSONSerializable, Generic[DomainLabel]): """ dna_sequence: Optional[str] = None - """Return DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned.""" + """DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned.""" # not serialized; for efficiency # remove quotes when Py3.6 support dropped @@ -2631,7 +2631,10 @@ class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]): def dna_sequence(self) -> Optional[str]: """Do not assign directly to this field. Always use :any:`Design.assign_dna` (for complementarity checking) or :any:`Strand.set_dna_sequence` - (without complementarity checking, to allow mismatches).""" + (without complementarity checking, to allow mismatches). + + Note that this does not include any IDT codes for :any:`Modification`'s. + To include those call :meth:`Strand.idt_dna_sequence`.""" sequence = '' for domain in self.domains: if domain.dna_sequence is None: From 1150ea0174593ffb84e4c9c355e54b8ce24e1f28 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:21:07 +0100 Subject: [PATCH 16/19] closes #224: allow other table formats besides Markdown in `Design.plate_maps` and closes #222: allow `Design.plate_maps` parameter `well_marker` to be function of well position --- scadnano/scadnano.py | 482 ++++++++++++++++++++++++++++++++++------ setup.py | 2 +- tests/scadnano_tests.py | 31 +++ 3 files changed, 450 insertions(+), 65 deletions(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 0880f450..99287c16 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1761,7 +1761,6 @@ def visual_length(self) -> int: This can be more or less than the :meth:`Domain.dna_length` due to insertions and deletions.""" return self.end - self.start - def dna_sequence_in(self, offset_left: int, offset_right: int) -> Optional[str]: """Return DNA sequence of this Domain in the interval of offsets given by [`offset_left`, `offset_right`], INCLUSIVE, or ``None`` if no DNA sequence has been assigned @@ -3638,7 +3637,7 @@ def __init__(self, plate_type: PlateType) -> None: self._row_idx: int = 0 self._col_idx: int = 0 - def increment(self) -> None: + def advance(self) -> None: self._row_idx += 1 if self._row_idx == len(self._plate_type.rows()): self.advance_to_next_col() @@ -3667,6 +3666,16 @@ def advance_to_next_plate(self): self._col_idx = 0 self._plate += 1 + def is_last(self) -> bool: + """ + :return: + whether WellPos is the last well on this type of plate + """ + rows = _96WELL_PLATE_ROWS if self._plate_type == PlateType.wells96 else _384WELL_PLATE_ROWS + cols = _96WELL_PLATE_COLS if self._plate_type == PlateType.wells96 else _384WELL_PLATE_COLS + return self.row == len(rows) and self.col == len(cols) + + def remove_helix_idxs_if_default(helices: List[Dict]) -> None: # removes indices from each helix if they are the default (order of appearance in list) @@ -3785,6 +3794,384 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> D _default_geometry = Geometry() +############################################################################## +# plate maps + + +cell_with_border_css_class = "cell-with-border" + + +# https://bitbucket.org/astanin/python-tabulate/issues/57/html-class-options-for-tables +def _html_row_with_attrs( + celltag: str, + cell_values: Sequence[str], + colwidths: Sequence[int], # noqa + colaligns: Sequence[str], +) -> str: + alignment = { + "left": "", + "right": ' style="text-align: right;"', + "center": ' style="text-align: center;"', + "decimal": ' style="text-align: right;"', + } + values_with_attrs = [ + f"<{celltag}{alignment.get(a, '')} class=\"{cell_with_border_css_class}\">{c}" + for c, a in zip(cell_values, colaligns) + ] + return "" + "".join(values_with_attrs).rstrip() + "" + + +# we can't declare a return type because it's TableFormat, +# and we don't want to force the user to install the tabulate package just to import scadnano +def create_html_with_borders_tablefmt(): # type: ignore + from functools import partial + from tabulate import TableFormat, Line + + html_with_borders_tablefmt = TableFormat( + lineabove=Line( + f"""\ + + \ + """, + "", + "", + "", + ), + linebelowheader=None, + linebetweenrows=None, + linebelow=Line("
", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th"), # type: ignore + datarow=partial(_html_row_with_attrs, "td"), # type: ignore + padding=0, + with_header_hide=None, + ) + return html_with_borders_tablefmt + + +_ALL_TABLEFMTS = [ + "plain", + "simple", + "github", + "grid", + "fancy_grid", + "pipe", + "orgtbl", + "jira", + "presto", + "pretty", + "psql", + "rst", + "mediawiki", + "moinmoin", + "youtrack", + "html", + "unsafehtml", + "latex", + "latex_raw", + "latex_booktabs", + "latex_longtable", + "textile", + "tsv", +] + +_SUPPORTED_TABLEFMTS_TITLE = [ + "github", + "pipe", + "simple", + "grid", + "html", + "unsafehtml", + "rst", + "latex", + "latex_raw", + "latex_booktabs", + "latex_longtable", +] + + +@dataclass +class PlateMap: + """ + Represents a "plate map", i.e., a drawing of a 96-well or 384-well plate, indicating which subset + of wells in the plate have strands. It is an intermediate representation of structured data about + the plate map that is converted to a visual form, such as Markdown, via the export_* methods. + """ + + plate_name: str + """Name of this plate.""" + + plate_type: PlateType + """Type of this plate (96-well or 384-well).""" + + well_to_strand: Dict[str, Strand] + """dictionary mapping the name of each well (e.g., "C4") to the strand in that well. + + Wells with no strand in the PlateMap are not keys in the dictionary.""" + + def __str__(self) -> str: + return self.to_table() + + def _repr_markdown_(self) -> str: + return self.to_table(tablefmt="pipe") + + def to_table( + self, + well_marker: Optional[Union[str, Callable[[str], str]]] = None, + title_level: int = 3, + warn_unsupported_title_format: bool = True, + vertical_borders: bool = False, + tablefmt: str = "pipe", + stralign: str = "default", + missingval: str = "", + showindex: str = "default", + disable_numparse: bool = False, + colalign: bool = None, + ) -> str: + """ + Exports this plate map to string format, with a header indicating information such as the + plate's name and volume to pipette. By default the text format is Markdown, which can be + rendered in a jupyter notebook using ``display`` and ``Markdown`` from the package + IPython.display: + + .. code-block:: python + + plate_maps = design.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)) + + It 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'`, + which creates a Markdown format. To create other formats such as HTML, change the value of + `tablefmt`; see https://github.com/astanin/python-tabulate#readme for other possible formats. + + :param well_marker: + By default the strand's name is put in the relevant plate entry. If `well_marker` is specified + and is a string, then that string is put into every well with a strand in the plate map instead. + This is useful for printing plate maps that just put, + for instance, an `'X'` in the well to pipette (e.g., specify ``well_marker='X'``), + e.g., for experimental mixes that use only some strands in the plate. + To enable the string to depend on the well position + (instead of being the same string in every well), `well_marker` can also be a function + that takes as input a string representing the well (such as ``"B3"`` or ``"E11"``), + and outputs a string. For example, giving the identity function + ``mix.to_table(well_marker=lambda x: x)`` puts the well address itself in the well. + :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). + :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', + 'latex', 'latex_raw', 'latex_booktabs', "latex_longtable". If `tablefmt` is another valid + option, then the title will be the Markdown format, i.e., same as for `tablefmt` = 'github'. + :param vertical_borders: + If true, then `tablefmt` must be set to `html` or `unsafehtml`, and the returned + HTML will use inline styles to ensure there are vertical borders between columns of the table. + The vertical borders make it easier to see which column a well is in. + This is useful when rendering in a Jupyter notebook, since the inline styles will be + preserved when saving the Jupyter notebook using the nbconvert tool: + https://nbconvert.readthedocs.io/en/latest/ + :param tablefmt: + By default set to `'pipe'` to create a Markdown table. For other options see + https://github.com/astanin/python-tabulate#readme + :param stralign: + See https://github.com/astanin/python-tabulate#readme + :param missingval: + See https://github.com/astanin/python-tabulate#readme + :param showindex: + See https://github.com/astanin/python-tabulate#readme + :param disable_numparse: + See https://github.com/astanin/python-tabulate#readme + :param colalign: + See https://github.com/astanin/python-tabulate#readme + :return: + a string representation of this plate map + """ + if title_level not in [1, 2, 3, 4, 5, 6]: + raise ValueError( + f"title_level must be integer from 1 to 6 but is {title_level}" + ) + + if tablefmt not in _ALL_TABLEFMTS: + raise ValueError( + f"tablefmt {tablefmt} not recognized; " + f'choose one of {", ".join(_ALL_TABLEFMTS)}' + ) + elif ( + tablefmt not in _SUPPORTED_TABLEFMTS_TITLE and warn_unsupported_title_format + ): + print( + f'{"*" * 99}\n* WARNING: title formatting not supported for tablefmt = {tablefmt}; ' + f'using Markdown format\n{"*" * 99}' + ) + + if vertical_borders and tablefmt not in ['html', 'unsafehtml']: + raise ValueError('parameter vertical_borders only supported when tablefmt is ' + f'"html" or "unsafehtml", but tablefmt = {tablefmt}') + + num_rows = len(self.plate_type.rows()) + num_cols = len(self.plate_type.cols()) + table = [[" " for _ in range(num_cols + 1)] for _ in range(num_rows)] + + for r in range(num_rows): + table[r][0] = self.plate_type.rows()[r] + + coord = PlateCoordinate(self.plate_type) + for c in range(1, num_cols + 1): + for r in range(num_rows): + well = coord.well() + if well in self.well_to_strand: + strand = self.well_to_strand[well] + if isinstance(well_marker, str): + well_marker_to_use = well_marker + elif callable(well_marker): + well_marker_to_use = well_marker(well) + elif well_marker is None and strand.name is not None: + well_marker_to_use = strand.name + elif well_marker is None and strand.name is None: + raise ValueError(f"strand {strand} has no name, and well_marker was not specified, " + f"but well_marker must be specified if including a nameless " + f"strand in the plate map") + else: + raise ValueError(f'invalid type of well_marker = {well_marker}; must be a ' + f'string or a function mapping strings to strings, but is a ' + f'{type(well_marker)}') + table[r][c] = well_marker_to_use + if not coord.is_last(): + coord.advance() + + raw_title = f'plate "{self.plate_name}"' + title = _format_title(raw_title, title_level, tablefmt) + + header = [" "] + [str(col) for col in self.plate_type.cols()] + + # wait until just before we need it to produce non-str TableFormat, so that we don't need + # to require the TableFormat type in type declarations, which would add a tabulate dependency + # for scadnano users even if they don't use plate maps + from tabulate import TableFormat, tabulate + tablefmt_typed: Union[str, TableFormat] = tablefmt + if vertical_borders: + tablefmt_typed = create_html_with_borders_tablefmt() + + out_table = tabulate( + tabular_data=table, + headers=header, + tablefmt=tablefmt_typed, + stralign=stralign, + missingval=missingval, + showindex=showindex, + disable_numparse=disable_numparse, + colalign=colalign, + ) + table_with_title = f"{title}\n{out_table}" + return table_with_title + + +def _plate_map(plate_name: str, strands_in_plate: List[Strand], plate_type: PlateType) -> PlateMap: + """ + Generates plate map from this :any:`Design` for plate with name `plate_name`. + + All :any:`Strand`'s in the design that have a field :data:`Strand.idt` with :data:`Strand.idt.plate` + with value `plate_name` are exported. + + :return: + plate map for :any:`Strand`'s with :data:`Strand.idt.plate` equal to`plate_name` + """ + well_to_strand = {} + for strand in strands_in_plate: + if strand.idt is None: + raise ValueError(f'strand {strand} has no idt field, so cannot be included in the plate map') + elif strand.idt.well is None: + raise ValueError(f'strand {strand} has no idt.well field, so cannot be included in the plate map') + well_to_strand[strand.idt.well] = strand + + plate_map = PlateMap( + plate_name=plate_name, + plate_type=plate_type, + well_to_strand=well_to_strand, + ) + return plate_map + + +def _format_title( + raw_title: str, + level: int, + tablefmt: str, +) -> str: + # formats a title for a table produced using tabulate, + # in the formats tabulate understands + if tablefmt in ["html", "unsafehtml"]: + title = f"{raw_title}" + elif tablefmt == "rst": + # https://draft-edx-style-guide.readthedocs.io/en/latest/ExampleRSTFile.html#heading-levels + # ############# + # Heading 1 + # ############# + # + # ************* + # Heading 2 + # ************* + # + # =========== + # Heading 3 + # =========== + # + # Heading 4 + # ************ + # + # Heading 5 + # =========== + # + # Heading 6 + # ~~~~~~~~~~~ + raw_title_width = len(raw_title) + if level == 1: + line = "#" * raw_title_width + elif level in [2, 4]: + line = "*" * raw_title_width + elif level in [3, 5]: + line = "=" * raw_title_width + else: + line = "~" * raw_title_width + + if level in [1, 2, 3]: + title = f"{line}\n{raw_title}\n{line}" + else: + title = f"{raw_title}\n{line}" + elif tablefmt in ["latex", "latex_raw", "latex_booktabs", "latex_longtable"]: + if level == 1: + size = r"\Huge" + elif level == 2: + size = r"\huge" + elif level == 3: + size = r"\LARGE" + elif level == 4: + size = r"\Large" + elif level == 5: + size = r"\large" + elif level == 6: + size = r"\normalsize" + else: + assert False + newline = r"\\" + noindent = r"\noindent" + title = f"{noindent} {{ {size} {raw_title} }} {newline}" + else: # use the title for tablefmt == "pipe" + hashes = "#" * level + title = f"{hashes} {raw_title}" + return title + + +# end plate maps +############################################################################# + def _check_helices_view_order_and_return( helices_view_order: Optional[List[int]], helix_idxs: Iterable[int]) -> List[int]: @@ -4696,11 +5083,11 @@ def from_cadnano_v2(directory: str = '', filename: Optional[str] = None, return design - def plate_maps_markdown(self, - warn_duplicate_strand_names: bool = True, - plate_type: PlateType = PlateType.wells96, - strands: Optional[Iterable[Strand]] = None, - well_marker: Optional[str] = None) -> Dict[str, str]: + def plate_maps(self, + warn_duplicate_strand_names: bool = True, + plate_type: PlateType = PlateType.wells96, + strands: Optional[Iterable[Strand]] = None, + ) -> List[PlateMap]: """ Generates plate maps from this :any:`Design` in Markdown format, for example: @@ -4734,7 +5121,23 @@ def plate_maps_markdown(self, | H | | | | | | | | | | | | | - If `well_marker` is not specified, then each strand must have a name. + 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 | | | | | | | | | | | | | + 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 @@ -4751,11 +5154,6 @@ def plate_maps_markdown(self, Type of plate: 96 or 384 well. :param strands: If specified, only the :any:`Strand`'s in `strands` are put in the plate map. - :param well_marker: - By default the strand's name is put in the relevant plate entry. If `well_marker` is specified, - then it is put there instead. This is useful for printing plate maps that just put, - for instance, an `'X'` in the well to pipette (e.g., specify `well_marker` = `'X'`), - e.g., for experimental mixes that use only some strands in the plate. :return: dict mapping plate names to markdown strings specifying plate maps for :any:`Strand`'s in this design with IDT plates specified @@ -4767,10 +5165,7 @@ def plate_maps_markdown(self, for strand in strands: if strand.idt is not None and strand.idt.plate is not None: plate_names_to_strands[strand.idt.plate].append(strand) - if strand.name is None and well_marker is None: - raise ValueError(f'strand {strand} has no name, but has a plate, which is not allowed ' - f'unless well_marker is specified') - if strand.name in strand_names_to_plate_and_well: + if strand.name is not None and strand.name in strand_names_to_plate_and_well: if warn_duplicate_strand_names: print(f'WARNING: found duplicate instance of strand with name {strand.name}') plate, well = strand_names_to_plate_and_well[strand.name] @@ -4787,51 +5182,10 @@ def plate_maps_markdown(self, else: strand_names_to_plate_and_well[strand.name] = (strand.idt.plate, strand.idt.well) - maps = {name: self._plate_map_markdown(name, strands_in_plate, plate_type, well_marker) - for name, strands_in_plate in plate_names_to_strands.items()} - - return maps - - def _plate_map_markdown(self, plate_name: str, strands_in_plate: List[Strand], - plate_type: PlateType, well_marker: Optional[str]) -> str: - """ - Generates plate map from this :any:`Design` for plate with name `plate_name`. - - All :any:`Strand`'s in the design that have a field :data:`Strand.idt` with :data:`Strand.idt.plate` - with value `plate_name` are exported. - - :param warn_duplicate_strand_names: - If True, prints a warning to the screen if multiple :any:`Strand`'s exist with the same value - for :data:`Strand.name`. - :return: - markdown string specifying plate map for :any:`Strand`'s with - :data:`Strand.idt.plate` equal to`plate_name` - """ - well_to_strand = {} - for strand in strands_in_plate: - well_to_strand[strand.idt.well] = strand - - num_rows = len(plate_type.rows()) - num_cols = len(plate_type.cols()) - header = [' '] + [str(col) for col in plate_type.cols()] - table = [[' ' for _ in range(num_cols + 1)] for _ in range(num_rows)] - - for r in range(num_rows): - table[r][0] = plate_type.rows()[r] - - plate_coord = PlateCoordinate(plate_type) - for c in range(1, num_cols + 1): - for r in range(num_rows): - well = plate_coord.well() - plate_coord.increment() - if well in well_to_strand: - strand = well_to_strand[well] - well_marker_to_use = well_marker if well_marker is not None else strand.name - table[r][c] = well_marker_to_use + plate_maps = [_plate_map(name, strands_in_plate, plate_type) + for name, strands_in_plate in plate_names_to_strands.items()] - from tabulate import tabulate - md_table = tabulate(table, header, tablefmt='github') - return f'## {plate_name}\n{md_table}' + return plate_maps def modifications(self, mod_type: Optional[ModificationType] = None) -> Set[Modification]: """ @@ -6146,7 +6500,7 @@ def _write_plates_default(self, directory: str, filename: Optional[str], strands num_strands_remaining == min_strands_per_plate: plate_coord.advance_to_next_plate() else: - plate_coord.increment() + plate_coord.advance() if plate != plate_coord.plate(): workbook.save(filename_plate) @@ -6364,8 +6718,8 @@ def add_nick(self, helix: int, offset: int, forward: bool, new_color: bool = Tru seq_before_whole: Optional[str] seq_after_whole: Optional[str] if strand.dna_sequence is not None: - seq_before: str = ''.join(domain.dna_sequence() for domain in domains_before) # type: ignore - seq_after: str = ''.join(domain.dna_sequence() for domain in domains_after) # type: ignore + seq_before: str = ''.join(domain.dna_sequence for domain in domains_before) # type: ignore + seq_after: str = ''.join(domain.dna_sequence for domain in domains_after) # type: ignore seq_on_domain_left: str = domain_to_remove.dna_sequence_in( # type: ignore domain_to_remove.start, offset - 1) diff --git a/setup.py b/setup.py index a59a1bb5..0266f9fd 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,7 @@ def extract_version(filename: str): python_requires='>=3.6', install_requires=[ 'xlwt', - 'dataclasses>=0.6;python_version<"3.7"', + 'dataclasses>=0.6; python_version < "3.7"', 'tabulate', ] ) diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index e20779c1..7bc6ee9e 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -6826,3 +6826,34 @@ def test_loopout_design(self) -> None: sqr_dist2 = sum([x ** 2 for x in diff2]) self.assertAlmostEqual(self.EXPECTED_ADJ_NUC_CM_DIST2, sqr_dist2) + +class TestPlateMaps(unittest.TestCase): + + def setUp(self) -> None: + helices = [sc.Helix(max_offset=100)] + self.design = sc.Design(helices=helices, strands=[], grid=sc.square) + self.design.draw_strand(0, 0).move(10).with_name('strand 0').with_idt(plate='plate 1', well='A1') + self.design.draw_strand(0, 10).move(10).with_name('strand 1').with_idt(plate='plate 1', well='A2') + self.design.draw_strand(0, 20).move(10).with_name('strand 2').with_idt(plate='plate 1', well='B2') + self.design.draw_strand(0, 30).move(10).with_name('strand 3').with_idt(plate='plate 1', well='B3') + self.design.draw_strand(0, 40).move(10).with_name('strand 4').with_idt(plate='plate 1', well='D7') + + def test_plate_map_markdown(self) -> None: + plate_maps = self.design.plate_maps() + self.assertEqual(1, len(plate_maps)) + plate_map = plate_maps[0] + actual_md = plate_map.to_table().strip() + expected_md = """ +### plate "plate 1" +| | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | +|:----|:---------|:---------|:---------|:----|:----|:----|:---------|:----|:----|:-----|:-----|:-----| +| A | strand 0 | strand 1 | | | | | | | | | | | +| B | | strand 2 | strand 3 | | | | | | | | | | +| C | | | | | | | | | | | | | +| D | | | | | | | strand 4 | | | | | | +| E | | | | | | | | | | | | | +| F | | | | | | | | | | | | | +| G | | | | | | | | | | | | | +| H | | | | | | | | | | | | | +""".strip() + self.assertEqual(expected_md, actual_md) \ No newline at end of file From 9ec66bb514d179e628544b33f76f70b39de843f6 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:26:33 +0100 Subject: [PATCH 17/19] added tabulate as dependency to tests --- .github/workflows/run_unit_tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index 3c6d52c8..a89fd3c3 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -20,8 +20,8 @@ jobs: uses: s-weigand/setup-conda@v1 with: activate-conda: true - - name: Install xlwt and xlrd with conda - run: conda install xlwt=1.3.0 xlrd=2.0.1 + - name: Install xlwt, xlrd, tabulate with conda + run: conda install xlwt=1.3.0 xlrd=2.0.1 tabulate=0.8.9 - name: Install docutils with conda run: conda install docutils=0.16 - name: Test with unittest From 08bbf35d92826e093238476e23ce812fe4fbb396 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:33:58 +0100 Subject: [PATCH 18/19] Update scadnano.py --- scadnano/scadnano.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 99287c16..7daa47ca 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -3676,7 +3676,6 @@ def is_last(self) -> bool: return self.row == len(rows) and self.col == len(cols) - def remove_helix_idxs_if_default(helices: List[Dict]) -> None: # removes indices from each helix if they are the default (order of appearance in list) default = True From 2fb3d35e12b4a62d9437484250fb9b01706e8520 Mon Sep 17 00:00:00 2001 From: David Doty Date: Tue, 29 Mar 2022 14:34:15 +0100 Subject: [PATCH 19/19] bumped version --- scadnano/scadnano.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 7daa47ca..bbd2f4e4 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.2" # 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