5353# needed to use forward annotations: https://docs.python.org/3/whatsnew/3.7.html#whatsnew37-pep563
5454from __future__ import annotations
5555
56- __version__ = "0.18.1 " # version line; WARNING: do not remove or change this line or comment
56+ __version__ = "0.18.2 " # version line; WARNING: do not remove or change this line or comment
5757
5858import collections
5959import dataclasses
@@ -485,11 +485,12 @@ def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str:
485485 so assigning this sequence to the scaffold :any:`Strand` in a :any:`Design`
486486 means that the "5' end" of the scaffold :any:`Strand`
487487 (which is a fiction since the actual circular DNA strand has no endpoint)
488- will have the sequence starting at position 5587 starting at the displayed 5' in scadnano,
489- assigned until the displayed 3' end.
488+ will have the sequence starting at position 5587 (if another value for `rotation` is not specified)
489+ starting at the displayed 5' in scadnano, assigned until the displayed 3' end.
490490 Assuming the displayed scaffold :any:`Strand` has length :math:`n < 7249`, then a loopout of length
491491 :math:`7249 - n` consisting of the undisplayed bases will be present in the actual DNA structure.
492- For a more detailed discussion of why this particular rotation of M13 is chosen,
492+
493+ For a more detailed discussion of why this particular rotation of M13 is chosen as the default,
493494 see
494495 `Supplementary Note S8 <http://www.dna.caltech.edu/Papers/DNAorigami-supp1.linux.pdf>`_
495496 in
@@ -1763,56 +1764,92 @@ def backbone_angle_at_offset(self, offset: int, forward: bool, geometry: Geometr
17631764 angle %= 360
17641765 return angle
17651766
1766- def crossovers (self ) -> List [Tuple [int , int , bool ]]:
1767+ def crossover_addresses (self ,
1768+ helices : Dict [int , Helix ],
1769+ allow_intrahelix : bool = True ,
1770+ allow_intergroup : bool = True ) -> List [Tuple [int , int , bool ]]:
17671771 """
1772+ :param helices:
1773+ The dict of helices in which this :any:`Helix` is contained, that contains other helices
1774+ to which it might be connected by crossovers.
1775+ :param allow_intrahelix:
1776+ if ``False``, then do not return crossovers to the same :any:`Helix` as this :any:`Helix`
1777+ :param allow_intergroup:
1778+ if ``False``, then do not return crossovers to a :any:`Helix` in a different helix group
1779+ as this :any:`Helix`
17681780 :return:
1769- list of triples (`offset `, `helix_idx `, `forward`) of all crossovers incident to this
1781+ list of triples (`helix_idx `, `offset `, `forward`) of all crossovers incident to this
17701782 :any:`Helix`, where `offset` is the offset of the crossover and `helix_idx` is the
17711783 :data:`Helix.idx` of the other :any:`Helix` incident to the crossover.
17721784 """
1773- crossovers : List [Tuple [int , int , bool ]] = []
1785+
1786+ def allow_crossover_to (helix2 : Helix ) -> bool :
1787+ if not allow_intrahelix and helix2 .idx == self .idx :
1788+ return False
1789+ if not allow_intergroup and helix2 .group != self .group :
1790+ return False
1791+ return True
1792+
1793+ addresses : List [Tuple [int , int , bool ]] = []
17741794 for domain in self .domains :
17751795 strand = domain .strand ()
1776- domains = strand .bound_domains ()
1777- num_domains = len (domains )
1778- domain_idx = domains .index (domain )
1796+ domains_on_strand = strand .bound_domains ()
1797+ num_domains = len (domains_on_strand )
1798+ domain_idx = domains_on_strand .index (domain )
1799+ domain_idx_in_substrands = strand .domains .index (domain )
17791800
17801801 # if not first domain, then there is a crossover to the previous domain
17811802 if domain_idx > 0 :
1782- offset = domain .offset_5p ()
1783- other_domain = domains [domain_idx - 1 ]
1784- other_helix_idx = other_domain .helix
1785- crossovers .append ((offset , other_helix_idx , domain .forward ))
1803+ # ... unless there's a loopout between them
1804+ previous_substrand = strand .domains [domain_idx_in_substrands - 1 ]
1805+ if isinstance (previous_substrand , Domain ):
1806+ offset = domain .offset_5p ()
1807+ other_domain = domains_on_strand [domain_idx - 1 ]
1808+ assert previous_substrand == other_domain
1809+ other_helix_idx = other_domain .helix
1810+ other_helix = helices [other_helix_idx ]
1811+ if allow_crossover_to (other_helix ):
1812+ addresses .append ((other_helix_idx , offset , domain .forward ))
17861813
17871814 # if not last domain, then there is a crossover to the next domain
17881815 if domain_idx < num_domains - 1 :
1789- offset = domain .offset_3p ()
1790- other_domain = domains [domain_idx + 1 ]
1791- other_helix_idx = other_domain .helix
1792- crossovers .append ((offset , other_helix_idx , domain .forward ))
1793-
1794- return crossovers
1816+ # ... unless there's a loopout between them
1817+ next_substrand = strand .domains [domain_idx_in_substrands + 1 ]
1818+ if isinstance (next_substrand , Domain ):
1819+ offset = domain .offset_3p ()
1820+ other_domain = domains_on_strand [domain_idx + 1 ]
1821+ assert next_substrand == other_domain
1822+ other_helix_idx = other_domain .helix
1823+ other_helix = helices [other_helix_idx ]
1824+ if allow_crossover_to (other_helix ):
1825+ addresses .append ((other_helix_idx , offset , domain .forward ))
1826+
1827+ return addresses
17951828
17961829 def relax_roll (self , helices : Dict [int , Helix ], grid : Grid , geometry : Geometry ) -> None :
17971830 """
17981831 Like :meth:`Design.relax_helix_rolls`, but only for this :any:`Helix`.
17991832 """
1800- angle = self .compute_relaxed_roll (helices , grid , geometry )
1801- self .roll = angle
1833+ roll_delta = self .compute_relaxed_roll_delta (helices , grid , geometry )
1834+ self .roll += roll_delta
18021835
1803- def compute_relaxed_roll (self , helices : Dict [int , Helix ], grid : Grid , geometry : Geometry ) -> float :
1836+ def compute_relaxed_roll_delta (self , helices : Dict [int , Helix ], grid : Grid , geometry : Geometry ) -> float :
18041837 """
1805- Like :meth:`Helix.relax_roll`, but just returns the new roll without altering this :any:`Helix` ,
1806- rather than changing the field :data:`Helix.roll`.
1838+ Like :meth:`Helix.relax_roll`, but just returns the amount by which to rotate the current roll ,
1839+ without actually altering the field :data:`Helix.roll`.
18071840 """
18081841 angles = []
1809- for offset , helix_idx , forward in self .crossovers ():
1842+ addresses = self .crossover_addresses (helices , allow_intrahelix = False , allow_intergroup = False )
1843+ for helix_idx , offset , forward in addresses :
18101844 other_helix = helices [helix_idx ]
18111845 angle_of_other_helix = angle_from_helix_to_helix (self , other_helix , grid , geometry )
18121846 crossover_angle = self .backbone_angle_at_offset (offset , forward , geometry )
18131847 relative_angle = (crossover_angle , angle_of_other_helix )
18141848 angles .append (relative_angle )
1815- angle = minimum_strain_angle (angles )
1849+ if len (angles ) == 0 :
1850+ angle = 0.0
1851+ else :
1852+ angle = minimum_strain_angle (angles )
18161853 return angle
18171854
18181855
@@ -1875,6 +1912,8 @@ def minimum_strain_angle(relative_angles: List[Tuple[float, float]]) -> float:
18751912 (but not changing any "zero angle" :math:`\mu_i`)
18761913 such that :math:`\sum_i [(\theta + \theta_i) - \mu_i]^2` is minimized.
18771914 """
1915+ if len (relative_angles ) == 0 :
1916+ raise ValueError ('cannot find minimum strain angle unless relative_angles is nonempty' )
18781917 adjusted_angles = [angle - zero_angle for angle , zero_angle in relative_angles ]
18791918 ave_angle = average_angle (adjusted_angles )
18801919 min_strain_angle = - ave_angle
@@ -1917,6 +1956,8 @@ def average_angle(angles: List[float]) -> float:
19171956 average angle of the list of angles, normalized to be between 0 and 360.
19181957 """
19191958 num_angles = len (angles )
1959+ if num_angles == 0 :
1960+ raise ValueError ('cannot take average of empty list of angles' )
19201961 mean_angle = sum (angles ) / num_angles
19211962 min_dist = float ('inf' )
19221963 optimal_angle = 0
@@ -2775,8 +2816,8 @@ def strand(self) -> Strand:
27752816 def cross (self , helix : int , offset : Optional [int ] = None , move : Optional [int ] = None ) \
27762817 -> StrandBuilder :
27772818 """
2778- Add crossover. To have any effect, must be followed by call to :py: meth:`StrandBuilder.to`
2779- or :py: meth:`StrandBuilder.move`.
2819+ Add crossover. To have any effect, must be followed by call to :meth:`StrandBuilder.to`
2820+ or :meth:`StrandBuilder.move`.
27802821
27812822 :param helix: :any:`Helix` to crossover to
27822823 :param offset: new offset on `helix`. If not specified, defaults to current offset.
@@ -2788,8 +2829,13 @@ def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] =
27882829 Mutually excusive with `offset`.
27892830 :return: self
27902831 """
2832+ if helix not in self .design .helices :
2833+ helix_idxs_str = ", " .join (str (idx ) for idx in self .design .helices .keys ())
2834+ raise IllegalDesignError (f'cannot cross to helix { helix } since it does not exist;\n '
2835+ f'valid helix indices: { helix_idxs_str } ' )
27912836 if self ._strand is None :
2792- raise ValueError ('no Strand created yet; make at least one domain first' )
2837+ raise ValueError ('cannot cross because no Strand created yet; make at least one domain first '
2838+ 'using move() or to()' )
27932839 if self ._most_recently_added_substrand_is_extension ():
27942840 raise IllegalDesignError ('Cannot cross after an extension.' )
27952841 if move is not None and offset is not None :
@@ -2826,8 +2872,14 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O
28262872 Mutually excusive with `offset`.
28272873 :return: self
28282874 """
2875+ if helix not in self .design .helices :
2876+ helix_idxs_str = ", " .join (str (idx ) for idx in self .design .helices .keys ())
2877+ raise IllegalDesignError (f'cannot loopout to helix { helix } since it does not exist;\n '
2878+ f'valid helix indices: { helix_idxs_str } ' )
2879+
28292880 if self ._strand is None :
2830- raise ValueError ('no Strand created yet; make at least one domain first' )
2881+ raise ValueError ('cannot loopout because no Strand created yet; make at least one domain first '
2882+ 'using move() or to()' )
28312883 self .cross (helix , offset = offset , move = move )
28322884 self .design .append_domain (self ._strand , Loopout (length ))
28332885 return self
@@ -2906,6 +2958,12 @@ def move(self, delta: int) -> StrandBuilder:
29062958 "relative move", whereas :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to`
29072959 are "absolute moves".
29082960
2961+ **NOTE**: The parameter `delta` does not indicate how much we move from the current offset.
2962+ It indicates the total length of the domain after the move. For instance, if we are currently
2963+ on offset 10, and we call ``move(5)``, this will create a domain starting at offset 10 and ending
2964+ at offset 14, for a total length of 5, occuping 5 offsets: 10, 11, 12, 13, 14. (But if we imagine
2965+ moving from offset 10, we've only moved by 4 offsets to arrive at 14, not 5 offsets.)
2966+
29092967 This updates the underlying :any:`Design` with a new :any:`Domain`,
29102968 and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
29112969 also a new :any:`Loopout`.
@@ -3514,7 +3572,7 @@ def dna_sequence(self) -> Optional[str]:
35143572 nums = json.loads(strand.label) # nums is now the list [1, 2, 3]
35153573 """
35163574
3517- # not serialized; efficient way to see a list of all domains on a given helix
3575+ # not serialized; efficient way to see a list of all domains on a given helix on this strand
35183576 _helix_idx_domain_map : Dict [int , List [Domain ]] = field (
35193577 init = False , repr = False , compare = False , default_factory = dict )
35203578
@@ -3539,14 +3597,14 @@ def __init__(self,
35393597 self .modifications_int = modifications_int if modifications_int is not None else dict ()
35403598 self .name = name
35413599 self .label = label
3542- self ._helix_idx_domain_map = _helix_idx_domain_map if _helix_idx_domain_map is not None else dict ()
3600+ self ._helix_idx_domain_map = _helix_idx_domain_map if _helix_idx_domain_map is not None \
3601+ else defaultdict (list )
35433602 if dna_sequence is not None :
35443603 self .set_dna_sequence (dna_sequence )
35453604 self .__post_init__ ()
35463605
35473606 def __post_init__ (self ) -> None :
35483607 self ._ensure_domains_not_none ()
3549- self ._helix_idx_domain_map = defaultdict (list )
35503608
35513609 self .set_domains (self .domains )
35523610
@@ -6885,7 +6943,17 @@ def insert_domain(self, strand: Strand, order: int, domain: Union[Domain, Loopou
68856943 self ._check_strand_references_legal_helices (strand )
68866944 self ._check_loopouts_not_consecutive_or_singletons_or_zero_length ()
68876945 if isinstance (domain , Domain ):
6888- self .helices [domain .helix ].domains .append (domain )
6946+ domains_on_helix = self .helices [domain .helix ].domains
6947+ if len (domains_on_helix ) == 0 :
6948+ domains_on_helix .append (domain )
6949+ else :
6950+ i = 0
6951+ while (i < len (domains_on_helix ) and
6952+ (domains_on_helix [i ].start , domains_on_helix [i ].forward ) <
6953+ (domain .start , domain .forward )):
6954+ i += 1
6955+ domains_on_helix .insert (i , domain )
6956+ # self.helices[domain.helix].domains.append(domain)
68896957 self ._check_strands_overlap_legally (domain_to_check = domain )
68906958
68916959 def remove_domain (self , strand : Strand , domain : Union [Domain , Loopout ]) -> None :
0 commit comments