Skip to content

Commit

Permalink
added move method to StrandBuilder and move parameter to StrandBuilde…
Browse files Browse the repository at this point in the history
…r.cross and StrandBuilder.loopout to enable relative (instead of absolute) specification of offsets when creating Domains through chained methodso
  • Loading branch information
dave-doty committed Aug 25, 2020
1 parent 0808d88 commit 2499733
Showing 1 changed file with 75 additions and 24 deletions.
99 changes: 75 additions & 24 deletions scadnano/scadnano.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class Color(_JSONSerializable):
Optional if :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b` are all given."""

def __post_init__(self, hex_string):
def __post_init__(self, hex_string: str) -> None:
if hex_string is None:
assert (self.r is not None and self.g is not None and self.b is not None)
else:
Expand Down Expand Up @@ -1187,7 +1187,7 @@ class Helix(_JSONSerializable):
# for optimization; list of domains on that Helix
_domains: List['Domain'] = field(default_factory=list)

def __post_init__(self):
def __post_init__(self) -> None:
if self.major_tick_start is None: # type: ignore
self.major_tick_start = self.min_offset # type: ignore
if self.grid_position is not None and self.position is not None:
Expand All @@ -1200,7 +1200,7 @@ def __post_init__(self):
f'outside the range of available offsets since max_offset = '
f'{self.max_offset}')

def to_json_serializable(self, suppress_indent: bool = True, **kwargs):
def to_json_serializable(self, suppress_indent: bool = True, **kwargs) -> dict:
dct: Any = dict()

grid: Grid = kwargs['grid']
Expand All @@ -1221,9 +1221,13 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs):
dct[max_offset_key] = self.max_offset

if self.position is None:
if grid == Grid.none:
raise IllegalDesignError('cannot have Helix.position == None when grid is None')
dct[grid_position_key] = NoIndent(
self.grid_position) if suppress_indent and not use_no_indent_helix else self.grid_position
else:
if grid != Grid.none:
raise IllegalDesignError('cannot have Helix.position != None when grid is not None')
pos = self.position.to_json_serializable(suppress_indent)
dct[position_key] = NoIndent(pos) if suppress_indent and not use_no_indent_helix else pos

Expand Down Expand Up @@ -1251,7 +1255,7 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs):
return NoIndent(dct) if suppress_indent and use_no_indent_helix else dct

@staticmethod
def from_json(json_map: dict) -> 'Helix': # remove quotes when Python 3.6 support dropped
def from_json(json_map: dict, **kwargs) -> 'Helix': # remove quotes when Python 3.6 support dropped
grid_position = None
if grid_position_key in json_map:
gp_list = json_map[grid_position_key]
Expand Down Expand Up @@ -1926,39 +1930,79 @@ def __init__(self, design: 'Design', helix: int, offset: int):
self.last_domain: Optional[Domain] = None

# remove quotes when Python 3.6 support dropped
def cross(self, helix: int, offset: int = None) -> 'StrandBuilder':
def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = None) -> 'StrandBuilder':
"""
Add crossover. Must be followed by call to :py:meth:`StrandBuilder.to` to have any effect.
:param helix: :any:`Helix` to crossover to
:param offset: new offset on `helix`. If not specified, defaults to current offset.
(i.e., a "vertical" crossover)
Mutually excusive with `move`.
:param move:
Relative distance to new offset on `helix` from current offset.
If not specified, defaults to using parameter `offset`.
Mutually excusive with `offset`.
:return: self
"""
if move is not None and offset is not None:
raise IllegalDesignError('move and offset cannot both be specified:\n'
f'move: {move}\n'
f'offset: {offset}')
self.last_domain = None
self.current_helix = helix
if offset is not None:
self.current_offset = offset
elif move is not None:
self.current_offset += move
return self

# remove quotes when Python 3.6 support dropped
def loopout(self, helix: int, length: int, offset: int = None) -> 'StrandBuilder':
def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \
-> 'StrandBuilder':
"""
Like :py:meth:`StrandBuilder.cross`, but creates a :any:`Loopout` instead of a crossover.
:param helix: :any:`Helix` to crossover to
:param length: length of :any:`Loopout` to add
:param offset: new offset on `helix`. If not specified, defaults to current offset.
(i.e., a "vertical" crossover)
(i.e., a "vertical" loopout)
Mutually excusive with `move`.
:param move:
Relative distance to new offset on `helix` from current offset.
If not specified, defaults to using parameter `offset`.
Mutually excusive with `offset`.
:return: self
"""
self.loopout_length = length
return self.cross(helix, offset)
return self.cross(helix, offset=offset, move=move)

def move(self, delta: int) -> 'StrandBuilder': # remove quotes when Python 3.6 support dropped
"""
Extends this :any:`StrandBuilder` on the current helix to offset given by the current offset
plus `delta`, which adds a new :any:`Domain` to the :any:`Strand` being built. This is a
"relative move", whereas :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to`
are "absolute moves".
This updates the underlying :any:`Design` with a new :any:`Domain`,
and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
also a new :any:`Loopout`.
If two instances of :py:meth:`StrandBuilder.move` are chained together, this creates two domains
on the same helix. The two offsets must move in the same direction. In other words, if we call
``.move(o1).to(o2)``, then ``o1`` and ``o2`` must be either both negative or both positive.
:param delta:
Distance to new offset to extend to, compared to current offset.
If less than current offset, the new :any:`Domain` is reverse, otherwise it is forward.
:return: self
"""
return self.to(self.current_offset + delta)

def to(self, offset: int) -> 'StrandBuilder': # remove quotes when Python 3.6 support dropped
"""
Extends this :any:`StrandBuilder` on the current helix to offset `offset`,
which adds a new :any:`Domain` to the :any:`Strand` being built.
which adds a new :any:`Domain` to the :any:`Strand` being built. This is an
"absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move".
This updates the underlying :any:`Design` with a new :any:`Domain`,
and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
Expand Down Expand Up @@ -2012,7 +2056,8 @@ def update_to(self, offset: int) -> 'StrandBuilder': # remove quotes when Pytho
"""
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.
: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".
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.
Expand Down Expand Up @@ -2986,9 +3031,12 @@ class Geometry(_JSONSerializable):
minor_groove_angle: float = 150.0
"""Minor groove angle in degrees."""

inter_helix_gap: float = 0.5
inter_helix_gap: float = 1.0
"""Gap between helices in nanometers (due to electrostatic repulsion; needed to display to scale)."""

def distance_between_helices(self) -> float:
return 2 * self.helix_radius + self.inter_helix_gap

def is_default(self):
return self == _default_geometry

Expand Down Expand Up @@ -3184,7 +3232,7 @@ def __post_init__(self):
# XXX: exact order of these calls is important
self._ensure_helices_distinct_objects()
self._ensure_strands_distinct_objects()
self._set_helices_grid_positions()
self._set_helices_grid_positions_or_positions()
self._build_domains_on_helix_lists()
self._set_helices_min_max_offsets(update=False)
self._ensure_helix_groups_exist()
Expand Down Expand Up @@ -3960,15 +4008,18 @@ def _default_group(self) -> HelixGroup:
groups_list = list(self.groups.values())
return groups_list[0]

def _set_helices_grid_positions(self):
def _set_helices_grid_positions_or_positions(self) -> None:
for name, group in self.groups.items():
if group.grid != Grid.none:
for idx in self.helices_idxs_in_group(name):
helix = self.helices[idx]
if helix.grid_position is None:
helix.grid_position = (0, group.helices_view_order_inverse(idx))

def _set_helices_min_max_offsets(self, update: bool):
for idx in self.helices_idxs_in_group(name):
helix = self.helices[idx]
if group.grid != Grid.none and helix.grid_position is None:
helix.grid_position = (0, group.helices_view_order_inverse(idx))
elif group.grid == Grid.none and helix.position is None:
y_delta = self.geometry.distance_between_helices()
y = y_delta * group.helices_view_order_inverse(idx)
helix.position = Position3D(x=0, y=y, z=0)

def _set_helices_min_max_offsets(self, update: bool) -> None:
"""update = whether to overwrite existing Helix.max_offset and Helix.min_offset.
Don't do this when Design is first created, but do it later when updating."""
for helix in self.helices.values():
Expand All @@ -3987,7 +4038,7 @@ def _set_helices_min_max_offsets(self, update: bool):
min_offset = 0
helix.min_offset = min_offset

def set_default_idt(self, use_default_idt: bool = True):
def set_default_idt(self, use_default_idt: bool = True) -> None:
"""If ``True``, sets :py:data:`Strand.use_default_idt` to ``True`` for every :any:`Strand` in this
:any:`Design` and calls :py:meth:`Strand.set_default_idt` on each of them to assign a
default idt field.
Expand All @@ -4006,14 +4057,14 @@ def strands_ending_on_helix(self, helix: int) -> List[Strand]:
on the :any:`Helix` with index `helix`."""
return [strand for strand in self.strands if strand.domains[-1].helix == helix]

def _check_legal_design(self):
def _check_legal_design(self) -> None:
self._check_helix_offsets()
self._check_strands_reference_helices_legally()
self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
self._check_strands_overlap_legally()

# TODO: come up with reasonable default behavior when no strands are on helix and max_offset not given
def _check_helix_offsets(self):
def _check_helix_offsets(self) -> None:
for helix in self.helices.values():
if helix.min_offset is not None \
and helix.max_offset is not None \
Expand Down Expand Up @@ -4774,7 +4825,7 @@ def add_nick(self, helix: int, offset: int, forward: bool, new_color: bool = Tru
strand_after = Strand(domains=[domain_to_add_after] + domains_after,
dna_sequence=dna_sequence_after_whole,
color=color_after, use_default_idt=idt_present)

self.helices[helix].domains.remove(domain_to_remove)
self.helices[helix].domains.extend([domain_to_add_before, domain_to_add_after])

Expand Down

0 comments on commit 2499733

Please sign in to comment.