diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py index 7f4f50af..d7a0f9eb 100644 --- a/scadnano/scadnano.py +++ b/scadnano/scadnano.py @@ -1046,6 +1046,7 @@ def helices_view_order_inverse(self, idx: int) -> int: """ return self.helices_view_order.index(idx) + # def in_browser() -> bool: # """Test if this code is running in the browser. # @@ -1080,17 +1081,30 @@ class Helix(_JSONSerializable): max_offset: int = None # type: ignore """Maximum offset (exclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. + + Optional field. If unspecified, it is calculated when the :any:`Design` is instantiated as the largest :any:`Domain.end` offset of any :any:`Domain` in the design. """ min_offset: int = 0 """Minimum offset (inclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. - If unspecified, it is set to 0. + + Optional field. Default value 0. """ + major_tick_start: int = None # type: ignore + """Offset of first major tick when not specifying :py:data:`Helix.major_ticks`. + Used in combination with either + :py:data:`Helix.major_tick_distance` or + :py:data:`Helix.major_tick_periodic_distances`. + + Optional field. + If not specified, is initialized to value :py:data:`Helix.min_offset`.""" + major_tick_distance: Optional[int] = None - """Distance between major ticks (bold) delimiting boundaries between bases. + """Distance between major ticks (bold) delimiting boundaries between bases. Major ticks will appear + in the visual interface at positions Optional field. If 0 then no major ticks are drawn. @@ -1098,8 +1112,26 @@ class Helix(_JSONSerializable): If the grid is :any:`Grid.square` then the default value is 8. If the grid is :any:`Grid.hex` or :any:`Grid.honeycomb` then the default value is 7.""" + major_tick_periodic_distances: Optional[List[int]] = None + """Periodic distances between major ticks. For example, setting + :py:data:`Helix.major_tick_periodic_distances` = [2, 3] and + :py:data:`Helix.major_tick_start` = 10 means that major ticks will appear at + 12, + 15, + 17, + 20, + 22, + 25, + 27, + 30, ... + + Optional field. + :py:data:`Helix.major_tick_distance` is equivalent to + the setting :py:data:`Helix.major_tick_periodic_distances` = [:py:data:`Helix.major_tick_distance`]. + """ + major_ticks: Optional[List[int]] = None # type: ignore - """If not ``None``, overrides :any:`Design.major_tick_distance` and :any:`Helix.major_tick_distance` + """If not ``None``, overrides :any:`Helix.major_tick_distance` to specify a list of offsets at which to put major ticks.""" grid_position: Optional[Tuple[int, int]] = None # type: ignore @@ -1159,6 +1191,8 @@ class Helix(_JSONSerializable): _domains: List['Domain'] = field(default_factory=list) def __post_init__(self): + 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: raise IllegalDesignError('exactly one of grid_position or position must be specified, ' 'but both are specified') @@ -1172,6 +1206,8 @@ def __post_init__(self): def to_json_serializable(self, suppress_indent: bool = True, **kwargs): dct: Any = dict() + grid: Grid = kwargs['grid'] + # if we have major ticks or position, it's harder to read Helix on one line, # so don't wrap it in NoIndent, but still wrap longer sub-objects in them use_no_indent_helix: bool = not (self.major_ticks is not None or self.position is not None) @@ -1179,9 +1215,12 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs): if self.group != default_group_name: dct[group_key] = self.group - if self.min_offset != 0: + if not self.min_offset_is_default(): dct[min_offset_key] = self.min_offset + if not self.major_tick_start_is_default(): + dct[major_tick_start_key] = self.major_tick_start + dct[max_offset_key] = self.max_offset if self.position is None: @@ -1198,36 +1237,22 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs): if not _is_close(self.yaw, default_yaw): dct[yaw_key] = self.yaw - if self.major_tick_distance is not None and self.major_tick_distance > 0: + if not self.major_tick_distance_is_default(grid): dct[major_tick_distance_key] = self.major_tick_distance - if self.major_ticks is not None: - ticks = self.major_ticks - dct[major_ticks_key] = NoIndent(ticks) if suppress_indent and not use_no_indent_helix else ticks + if not self.major_tick_periodic_distances_is_default(): + dct[major_tick_periodic_distances_key] = NoIndent( + self.major_tick_periodic_distances) if suppress_indent and not use_no_indent_helix else \ + self.major_tick_periodic_distances + + if not self.major_ticks_is_default(): + dct[major_ticks_key] = NoIndent( + self.major_ticks) if suppress_indent and not use_no_indent_helix else self.major_ticks dct[idx_on_helix_key] = self.idx return NoIndent(dct) if suppress_indent and use_no_indent_helix else dct - def default_grid_position(self) -> Tuple[int, int]: - if self.idx is None: - raise AssertionError('cannot call default_grid_position when idx is None') - return 0, self.idx - - def calculate_major_ticks(self, grid: Grid) -> List[int]: - """ - Calculates full list of major tick marks, whether using `default_major_tick_distance` (from - :any:`Design`), :py:data:`Helix.major_tick_distance`, or :py:data:`Helix.major_ticks`. - They are used in reverse order to determine precedence. (e.g., :py:data:`Helix.major_ticks` - overrides :py:data:`Helix.major_tick_distance`, which overrides - `default_major_tick_distance` from :any:`Design`. - """ - if self.major_ticks is not None: - return self.major_ticks - distance = default_major_tick_distance( - grid) if self.major_tick_distance is None else self.major_tick_distance - return list(range(self.min_offset, self.max_offset + 1, distance)) - @staticmethod def from_json(json_map: dict) -> 'Helix': # remove quotes when Python 3.6 support dropped grid_position = None @@ -1242,7 +1267,9 @@ def from_json(json_map: dict) -> 'Helix': # remove quotes when Python 3.6 suppo major_tick_distance = json_map.get(major_tick_distance_key) major_ticks = json_map.get(major_ticks_key) - min_offset = json_map.get(min_offset_key) + major_tick_start = json_map.get(major_tick_start_key) + major_tick_periodic_distances = json_map.get(major_tick_periodic_distances_key) + min_offset = optional_field(0, json_map, min_offset_key) max_offset = json_map.get(max_offset_key) idx = json_map.get(idx_on_helix_key) @@ -1258,6 +1285,8 @@ def from_json(json_map: dict) -> 'Helix': # remove quotes when Python 3.6 suppo return Helix( major_tick_distance=major_tick_distance, major_ticks=major_ticks, + major_tick_start=major_tick_start, + major_tick_periodic_distances=major_tick_periodic_distances, grid_position=grid_position, min_offset=min_offset, max_offset=max_offset, @@ -1269,6 +1298,38 @@ def from_json(json_map: dict) -> 'Helix': # remove quotes when Python 3.6 suppo group=group, ) + def default_grid_position(self) -> Tuple[int, int]: + if self.idx is None: + raise AssertionError('cannot call default_grid_position when idx is None') + return 0, self.idx + + def calculate_major_ticks(self, grid: Grid) -> List[int]: + """ + Calculates full list of major tick marks, whether using `default_major_tick_distance` (from + :any:`Design`), :py:data:`Helix.major_tick_distance`, or :py:data:`Helix.major_ticks`. + They are used in reverse order to determine precedence. (e.g., :py:data:`Helix.major_ticks` + overrides :py:data:`Helix.major_tick_distance`, which overrides + `default_major_tick_distance` from :any:`Design`. + """ + if self.major_tick_start is None: + raise AssertionError('major_tick_start should never be None') + if self.major_ticks is not None: + return self.major_ticks + elif self.major_tick_distance is not None: + return list(range(self.major_tick_start, self.max_offset + 1, self.major_tick_distance)) + elif self.major_tick_periodic_distances is not None: + ticks = [] + tick = self.major_tick_start + idx_period = 0 + while tick <= self.max_offset: + ticks.append(tick) + tick += self.major_tick_periodic_distances[idx_period] + idx_period = (idx_period + 1) % len(self.major_tick_periodic_distances) + return ticks + else: + distance = default_major_tick_distance(grid) + return list(range(self.major_tick_start, self.max_offset + 1, distance)) + @property def domains(self): """ @@ -1279,6 +1340,22 @@ def domains(self): """ return self._domains + def min_offset_is_default(self) -> bool: + return self.min_offset == 0 + + def major_tick_start_is_default(self) -> bool: + return self.major_tick_start == self.min_offset + + def major_tick_distance_is_default(self, grid: Grid) -> bool: + return (self.major_tick_distance is None + or default_major_tick_distance(grid) == self.major_tick_distance) + + def major_tick_periodic_distances_is_default(self) -> bool: + return self.major_tick_periodic_distances is None + + def major_ticks_is_default(self) -> bool: + return self.major_ticks is None + def _is_close(x1: float, x2: float): return abs(x1 - x2) < 0.00000001 @@ -3002,7 +3079,7 @@ class Design(_JSONSerializable): stored in any :any:`Domain` in :py:data:`Design.strands`.""" - groups: Optional[Dict[str, HelixGroup]] = None + groups: Dict[str, HelixGroup] = None # type: ignore """:any:`HelixGroup`'s in this :any:`Design`.""" geometry: Geometry = field(default_factory=lambda: Geometry()) @@ -3046,7 +3123,7 @@ def __init__(self, *, """ using_groups = groups is not None - if helices_view_order is not None and groups is not None: + if helices_view_order is not None and using_groups: raise IllegalDesignError('Design.helices_view_order and Design.groups are mutually exclusive. ' 'Set at most one of them.') @@ -3059,13 +3136,13 @@ def __init__(self, *, grid = Grid.none self.strands = [] if strands is None else strands - self.groups = groups self.color_cycler = ColorCycler() self.geometry = Geometry() if geometry is None else geometry - if not using_groups: + if groups is None: self.groups = {default_group_name: HelixGroup()} else: + self.groups = groups if grid is not None: raise IllegalDesignError('cannot use a non-none grid for whole Design when helix groups are ' 'used; only the HelixGroups can have non-none grids in this case') @@ -3145,8 +3222,7 @@ def grid(self) -> Grid: def _get_default_group(self): # Gets default group and raise exception if default group is not being used if not self._has_default_groups(): - raise ValueError('cannot access Design.helices_view_order when groups are used. ' - 'Access group.helices_view_order for each group instead.') + raise ValueError('The default group is not being used for this design.') if self.groups is None: raise AssertionError('Design.groups should not be None by this point') groups: List[HelixGroup] = list(self.groups.values()) @@ -3318,8 +3394,12 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs): helix_idxs=helix_idxs_in_group) dct[groups_key] = group_map - dct[helices_key] = [helix.to_json_serializable(suppress_indent) for helix in - self.helices.values()] + helices_json = [] + for helix in self.helices.values(): + group = self.groups[helix.group] + helix_json = helix.to_json_serializable(suppress_indent, grid=group.grid) + helices_json.append(helix_json) + dct[helices_key] = helices_json # remove idx key from list of helices if they have the default index unwrapped_helices = list(dct[helices_key]) @@ -4097,7 +4177,7 @@ def add_strand(self, strand: Strand): self._check_strand_references_legal_helices(strand) self.strands.append(strand) for domain in strand.domains: - if domain.is_domain(): + if isinstance(domain, Domain): self.helices[domain.helix].domains.append(domain) self._check_strands_overlap_legally(domain_to_check=domain) if self.automatically_assign_color: @@ -4107,7 +4187,7 @@ def remove_strand(self, strand: Strand): """Remove `strand` from this design.""" self.strands.remove(strand) for domain in strand.domains: - if domain.is_domain(): + if isinstance(domain, Domain): self.helices[domain.helix].domains.remove(domain) def append_domain(self, strand: Strand, domain: Union[Domain[DomainLabel], Loopout]): diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py index ad015854..1b520a23 100644 --- a/tests/scadnano_tests.py +++ b/tests/scadnano_tests.py @@ -2444,6 +2444,88 @@ def test_JSON_bad_no_groups_but_helices_reference_groups(self): class TestJSON(unittest.TestCase): + def test_Helix_major_tick_start_default_min_offset(self): + helices = [ + sc.Helix(min_offset=10, max_offset=100), + sc.Helix(max_offset=100), + sc.Helix(major_tick_start=15), + ] + design = sc.Design(helices=helices, strands=[], grid=sc.square) + self.assertEqual(10, design.helices[0].major_tick_start) + self.assertEqual(0, design.helices[1].major_tick_start) + self.assertEqual(15, design.helices[2].major_tick_start) + + design_json_map = design.to_json_serializable(suppress_indent=False) + self.assertNotIn(sc.major_tick_start_key, design_json_map['helices'][0]) + self.assertNotIn(sc.major_tick_start_key, design_json_map['helices'][1]) + self.assertIn(sc.major_tick_start_key, design_json_map['helices'][2]) + self.assertEqual(15, design_json_map['helices'][2][sc.major_tick_start_key]) + + # this isn't related to major_tick_start, but it failed for some reason so let's check it + self.assertIn(sc.grid_position_key, design_json_map['helices'][0]) + self.assertIn(sc.grid_position_key, design_json_map['helices'][1]) + self.assertIn(sc.grid_position_key, design_json_map['helices'][2]) + self.assertSequenceEqual([0, 0], design_json_map['helices'][0][sc.grid_position_key]) + self.assertSequenceEqual([0, 1], design_json_map['helices'][1][sc.grid_position_key]) + self.assertSequenceEqual([0, 2], design_json_map['helices'][2][sc.grid_position_key]) + + design_json_str = json.dumps(design_json_map) + design = sc.Design.from_scadnano_json_str(design_json_str) + self.assertEqual(10, design.helices[0].major_tick_start) + self.assertEqual(0, design.helices[1].major_tick_start) + self.assertEqual(15, design.helices[2].major_tick_start) + + def test_Helix_major_tick_periodic_distances(self): + grid = sc.square + helices = [ + sc.Helix(major_tick_start=10, max_offset=30, major_tick_distance=5), + sc.Helix(major_tick_start=10, max_offset=30, major_tick_periodic_distances=[2, 3]), + sc.Helix(major_tick_start=10, max_offset=30, major_ticks=[10, 20, 30]), + sc.Helix(major_tick_start=10, max_offset=30), + sc.Helix(max_offset=30), + ] + design = sc.Design(helices=helices, strands=[], grid=grid) + self.assertEqual(10, design.helices[0].major_tick_start) + + self.assertSequenceEqual([10, 15, 20, 25, 30], design.helices[0].calculate_major_ticks(grid)) + self.assertSequenceEqual([10, 12, 15, 17, 20, 22, 25, 27, 30], + design.helices[1].calculate_major_ticks(grid)) + self.assertSequenceEqual([10, 20, 30], design.helices[2].calculate_major_ticks(grid)) + self.assertSequenceEqual([10, 18, 26], design.helices[3].calculate_major_ticks(grid)) + self.assertSequenceEqual([0, 8, 16, 24], design.helices[4].calculate_major_ticks(grid)) + + design_json_map = design.to_json_serializable(suppress_indent=False) + + h0 = design_json_map['helices'][0] + self.assertNotIn(sc.major_ticks_key, h0) + self.assertNotIn(sc.major_tick_periodic_distances_key, h0) + self.assertIn(sc.major_tick_distance_key, h0) + self.assertEqual(5, h0[sc.major_tick_distance_key]) + + h1 = design_json_map['helices'][1] + self.assertNotIn(sc.major_ticks_key, h1) + self.assertIn(sc.major_tick_periodic_distances_key, h1) + self.assertNotIn(sc.major_tick_distance_key, h1) + self.assertSequenceEqual([2, 3], h1[sc.major_tick_periodic_distances_key]) + + h2 = design_json_map['helices'][2] + self.assertIn(sc.major_ticks_key, h2) + self.assertNotIn(sc.major_tick_distance_key, h2) + self.assertNotIn(sc.major_tick_periodic_distances_key, h2) + self.assertSequenceEqual([10, 20, 30], h2[sc.major_ticks_key]) + + h3 = design_json_map['helices'][3] + self.assertNotIn(sc.major_ticks_key, h3) + self.assertNotIn(sc.major_tick_distance_key, h3) + self.assertNotIn(sc.major_tick_periodic_distances_key, h3) + self.assertIn(sc.major_tick_start_key, h3) + + h4 = design_json_map['helices'][4] + self.assertNotIn(sc.major_ticks_key, h4) + self.assertNotIn(sc.major_tick_distance_key, h4) + self.assertNotIn(sc.major_tick_periodic_distances_key, h4) + self.assertNotIn(sc.major_tick_start_key, h4) + def test_default_helices_view_order_with_nondefault_helix_idxs_in_default_order(self): helices = [sc.Helix(idx=1, max_offset=100), sc.Helix(idx=3, max_offset=100)] design = sc.Design(helices=helices, strands=[])