From bb402e849cff585fb7f565cb2927f3e8bf11b1e6 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Fri, 8 May 2026 15:35:53 -0500 Subject: [PATCH 01/11] Done with _orient_crossings, to implement _build_components Co-authored-by: Copilot --- spherogram_src/links/links_base.py | 15 +- spherogram_src/links/tangles.py | 214 ++++++++++++++++++++++++++++- spherogram_src/version.py | 2 +- 3 files changed, 222 insertions(+), 9 deletions(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index 61c5473..b3b3a9f 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -124,6 +124,16 @@ def make_tail(self, a): raise ValueError("Can only orient a strand once.") self.directions.add(b) + def make_head(self, a): + """ + Orients the strand joining input "a" to input" a+2" to start at "a" and end at + "a+2". + """ + b = ((a + 2) % 4, a) + if (b[1], b[0]) in self.directions: + raise ValueError("Can only orient a strand once.") + self.directions.add(b) + def rotate(self, s): """ Rotate the incoming connections by 90*s degrees anticlockwise. @@ -515,7 +525,7 @@ def __init__(self, crossings=None, braid_closure=None, check_planarity=True, bui if not all(isinstance(c, Crossing) for c, _ in s.adjacent): raise ValueError("Strands with a component index must be in the same" " component as a crossing") - # Go through the component strands to construct component_starts + # Go through the component strands to construct component_spec if component_strands: component_spec = [] for s in component_strands: @@ -884,8 +894,7 @@ def _build_components(self, component_starts=None): # and turn them into CrossingEntryPoints component_starts = [cs.crossing.entry_points()[cs.strand_index % 2] for cs in component_starts] - remaining, components = OrderedSet( - self.crossing_entries()), LinkComponents() + remaining, components = OrderedSet(self.crossing_entries()), LinkComponents() other_crossing_entries = [] self.labels = labels = Labels() for c in self.crossings: diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 5dc201d..750e166 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -21,7 +21,9 @@ """ import pickle -from .links import Crossing, Strand, Link +from collections import OrderedDict +from .ordered_set import OrderedSet +from .links import Crossing, Strand, Link, Labels from . import planar_isotopy @@ -76,9 +78,12 @@ def decode_boundary(boundary): raise ValueError("Number of top boundary strands cannot be negative") return (m, n) +class TangleComponents: + #TODO + pass class Tangle: - def __init__(self, boundary=2, crossings=None, entry_points=None, label=None): + def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, label=None): """ A tangle is a fragment of a Link with some number of boundary strands. Tangles can be composed in various ways along their boundary strands, @@ -106,12 +111,21 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, label=None): """ m, n = decode_boundary(boundary) + component_starts = None + start_orientations = None if crossings is None: crossings = [] - for c in crossings: - if not isinstance(c, (Crossing, Strand)): - raise ValueError("Every element of crossings must be a Crossing or a Strand") + else: + if isinstance(crossings, str): + raise NotImplementedError("Not Implemented. If you are trying to create a tangle from a PD code, input the PD code as a list instead.") + + if len(crossings) > 0 and not isinstance(crossings[0], (Strand, Crossing)): + crossings, component_starts, entry_points = self._crossings_from_PD_code(crossings, entry_points) + start_orientations = component_starts[:] + + if not all(isinstance(c, (Crossing, Strand)) for c in crossings): + raise ValueError("Every element of crossings must be a Crossing or a Strand") self.crossings = crossings # the pair for the number of lower strands and the number of upper strands @@ -126,11 +140,201 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, label=None): if len(entry_points) != m + n: raise ValueError("The number of boundary strands is not equal to the length" " of entry_points") + + if build: + if start_orientations is None: + # By default, orient the components so that the Tangle is upward pointing + start_orientations = [(c, (i + 2) % 4) for (c,i) in entry_points[:m]] + + self._build(start_orientations, component_starts) + for i, e in enumerate(entry_points): + # TODO: make it so that the entry points are attached to Strands join_strands((self, i), e) self.label = label + def _build(self, start_orientations=None, component_starts=None): + self._orient_crossings(start_orientations=start_orientations) + self._build_components(component_starts=component_starts) + + def _orient_crossings(self, start_orientations=None): + if self.all_crossings_oriented(): + return + if start_orientations is None: + start_orientations = list() + else: # copy as algorithm modifies this list + start_orientations = list(start_orientations) + + remaining = OrderedSet( + [(c, i) for c in self.crossings for i in range(4) if c.sign == 0]) + + while len(remaining): + if len(start_orientations) > 0: + c, i = start = start_orientations.pop() + else: + c, i = start = remaining.pop() + + reversed = False + finished = False + while not finished: + if reversed: + c.make_tail(i) + else: + c.make_head(i) + + if c.adjacent[i] is not None: + d, j = c.adjacent[i] + remaining.discard((c, i)), remaining.discard((d, j)) + c, i = d, (j + 2) % 4 + finished = (c, i) == start + else: + if reversed: + # Hit the boundary of the tangle from both sides, + # done with this component + finished = True + else: + # Hit the boundary of the tangle, + # now go back and orient reversely + reversed = True + c, i = start + c, i = c, (i + 2) % 4 + + for c in self.crossings: + c.orient() + + def _build_components(self, component_starts=None): + #TODO + pass + + def crossing_entries(self): + ans = [] + for C in self.crossings: + if isinstance(C, Crossing): + ans += C.entry_points() + return ans + + def _crossings_from_PD_code(self, code, entry_points): + """ + entry_points as INPUT: a list of labels of arcs left open in the tangle + as OUTPUT: a list of CrossingStrands + """ + labels = set() + for X in code: + for i in X: + labels.add(i) + + gluings = OrderedDict() + + for c, X in enumerate(code): + for i, x in enumerate(X): + if x in gluings: + gluings[x].append((c, i)) + else: + gluings[x] = [(c, i)] + + if any(len(v) > 2 for v in gluings.values()): + raise ValueError("PD code isn't consistent") + + component_starts = self._component_starts_from_PD( + code, labels, gluings) + + crossings = [Crossing(i) for i, d in enumerate(code)] + + for item in gluings.values(): + if len(item) > 1: + (c, i), (d, j) = item + crossings[c][i] = crossings[d][j] + + entry_points = [crossings[gluings[x][0]].crossing_strands()[gluings[x][1]] + for x in entry_points] + + component_starts = [crossings[c].crossing_strands()[i] + for (c, i) in component_starts] + + return crossings, component_starts, entry_points + + def _component_starts_from_PD(self, code, labels, gluings): + """ + A PD code determines an order and orientation on the tangle + components as follows, where we view the code as labels on the + strands at the point where two crossings are stuck together. + + 1. The minimum label on each component is used to order the + components. + + 2. Each component is oriented by finding its minimal label, + looking at the labels of its two neighbors, and then + orienting the component towards the smaller of those two. + + This is designed so that a PLink-generated PD_code results in a + link with the same component order and orientation. + """ + starts = [] + while labels: + m = min(labels) + labels.remove(m) + + if len(gluings[m]) == 1: + # entrance strand of the tangle + [(c, index)] = gluings[m] + + j = (index + 2) % 4 + next_label = code[c][j] + direction = (c, j) + + starts.append(direction) + else: + (c1, index1), (c2, index2) = gluings[m] + if c1 == c2: + # loop at strand, take next strand to be next smallest label + # on crossing + next_label = min(set(code[c1]) - {m}) + direction = (c1, code[c1].index(next_label)) + starts.append(direction) + else: + # strand connects two different crossings, take next strand to + # be next smallest label on two 'opposite' strands + j1, j2 = (index1 + 2) % 4, (index2 + 2) % 4 + l1, l2 = code[c1][j1], code[c2][j2] + if l1 < l2: + next_label = l1 + direction = (c1, j1) + elif l2 < l1: + next_label = l2 + direction = (c2, j2) + else: + # We have a component of length 2, so now rely on + # the convention that the first position at a PD + # crossing is a directed entry point. (If both + # crossings are over or both under, the + # orientation is arbitrary anyway.) + next_label = l1 + + # The strand labeled m is oriented c2 --> c1 if + # and only if either l1 = l2 is the incoming + # understrand of c2 or m is incoming understrand + # at c1. + if code[c2][0] == l1 or code[c1][0] == m: + direction = (c1, j1) + else: + direction = (c2, j2) + + starts.append(direction) + + # Component start recorded. Erase the rest of the component + # by traversing along it and remove the labels + while next_label != m: + labels.remove(next_label) + g = gluings[next_label] + if len(g) == 1: + break + other_direction = g[1 - g.index(direction)] + direction = (other_direction[0], (other_direction[1] + 2) % 4) + next_label = code[direction[0]][direction[1]] + + return starts + def __add__(self, other): """Put self to left of other and fuse the top-right strand of self to the top-left strand of other and the bottom-right strand of self to the bottom-left strand of other. diff --git a/spherogram_src/version.py b/spherogram_src/version.py index 7501145..dc4633d 100644 --- a/spherogram_src/version.py +++ b/spherogram_src/version.py @@ -1 +1 @@ -version = '2.4.1' +version = '2.4.2b' From 04222fa591eaa47ae05061cf8abab12069ff7111 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Mon, 11 May 2026 02:10:10 -0500 Subject: [PATCH 02/11] First running version with components and PD_code for Tangles More tests pending Co-authored-by: Copilot --- spherogram_src/links/links_base.py | 106 +++++++++++++-- spherogram_src/links/tangles.py | 198 +++++++++++++++++++++++++---- 2 files changed, 265 insertions(+), 39 deletions(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index b3b3a9f..a94cda6 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -276,7 +276,6 @@ def oriented(self): def __repr__(self): return "" % (self.crossing, self.strand_index) - class CrossingEntryPoint(CrossingStrand): """ One of the two entry points of an oriented crossing @@ -288,13 +287,21 @@ def next(self): return CrossingEntryPoint(*c.adjacent[(e + s) % (2 * s)]) def previous(self): - s = self.crossing._adjacent_len // 2 - return CrossingEntryPoint(*self.opposite().rotate(s)) + d, j = self.opposite() + + if isinstance(d, (Crossing, Strand)): + s = d._adjacent_len // 2 + return CrossingEntryPoint(*self.opposite().rotate(s)) + else: + return CrossingEntryPoint(d, j) def other(self): - nonzero_entry_point = 1 if self.crossing.sign == -1 else 3 - other = nonzero_entry_point if self.strand_index == 0 else 0 - return CrossingEntryPoint(self.crossing, other) + if isinstance(self.crossing, Crossing): + nonzero_entry_point = 1 if self.crossing.sign == -1 else 3 + other = nonzero_entry_point if self.strand_index == 0 else 0 + return CrossingEntryPoint(self.crossing, other) + else: + return None def is_under_crossing(self): return self.strand_index == 0 @@ -304,12 +311,27 @@ def is_over_crossing(self): def component(self): ans = [self] + + reversed = False while True: - next = ans[-1].next() - if next == self: + if reversed: + d = ans[0].previous() + else: + d = ans[-1].next() + + if d == self: break else: - ans.append(next) + if reversed: + ans.insert(0, d) + else: + ans.append(d) + + if not isinstance(d.crossing, (Crossing, Strand)): + if reversed: + break + else: + reversed = True return ans @@ -318,9 +340,15 @@ def component_label(self): def label_crossing(self, comp, labels): c, e = self.crossing, self.strand_index - f = (e + 2) % 4 - c.strand_labels[e], c.strand_components[e] = labels[self], comp - c.strand_labels[f], c.strand_components[f] = labels[self.next()], comp + + if isinstance(c, Crossing): + f = (e + 2) % 4 + c.strand_labels[e], c.strand_components[e] = labels[self], comp + c.strand_labels[f], c.strand_components[f] = labels[self.next()], comp + elif isinstance(c, Strand): + c.strand_label, c.strand_component = labels[self], comp + else: + c.strand_labels[e], c.strand_components[e] = labels[self], comp def __repr__(self): return "" % (self.crossing, self.strand_index) @@ -348,6 +376,7 @@ class Strand: def __init__(self, label=None, component_idx=None): self.label = label self.adjacent = [None, None] + self._clear() self._adjacent_len = 2 self.component_idx = component_idx @@ -380,6 +409,59 @@ def format_adjacent(a): def is_loop(self): return self == self.adjacent[0][0] + def _clear(self): + self.sign, self.direction = 0, None + self._clear_strand_info() + + def _clear_strand_info(self): + self.strand_label = None + self.strand_component = None + + def make_tail(self, a): + """ + Orients the strand joining input "a" to input" a+1" to start at "a" and end at + "a+1". + """ + b = (a, (a + 1) % 2) + if self.direction: + raise ValueError("Can only orient a strand once.") + self.direction = b + + def make_head(self, a): + """ + Orients the strand joining input "a" to input" a+1" to start at "a" and end at + "a+1". + """ + b = ((a + 1) % 2, a) + if self.direction: + raise ValueError("Can only orient a strand once.") + self.direction = b + + def rotate(self, s): + """ + Rotate the incoming connections by 180*s degrees anticlockwise. + """ + def rotate(v): + return (v + s) % 2 + new_adjacent = [self.adjacent[rotate(i)] for i in range(4)] + for i, (o, j) in enumerate(new_adjacent): + if o != self: + o.adjacent[j] = (self, i) + self.adjacent[i] = (o, j) + else: + self.adjacent[i] = (self, (j - s) % 2) + + a,b = self.direction + self.direction = (rotate(a), rotate(b)) + + def orient(self): + if self.direction == (1, 0): + self.rotate(1) + + self.sign = 1 + + def entry_points(self): + return [CrossingEntryPoint(self, 0)] def enumerate_lists(lists, n=0, filter=lambda x: True): ans = [] diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 750e166..975a898 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -23,9 +23,15 @@ from collections import OrderedDict from .ordered_set import OrderedSet -from .links import Crossing, Strand, Link, Labels +from .links_base import Crossing, Strand, Link from . import planar_isotopy +class CyclicList(list): + def __init__(self, iterable): + super().__init__(iterable) + + def __getitem__(self, i): + return super().__getitem__(i % len(self)) def join_strands(x, y): """ @@ -78,9 +84,25 @@ def decode_boundary(boundary): raise ValueError("Number of top boundary strands cannot be negative") return (m, n) -class TangleComponents: - #TODO - pass +class ArcLabels(OrderedDict): + def __init__(self, iterable = []): + super().__init__(iterable) + + self.counter = 0 + + def add(self, c, advance): + if c not in self: + self[c] = self.counter + if advance: + self.counter += 1 + else: + raise ValueError("Each CEP should only be labeled once") + +class TangleComponents(list): + def add(self, c): + component = c.component() + self.append(component) + return component class Tangle: def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, label=None): @@ -113,6 +135,8 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, m, n = decode_boundary(boundary) component_starts = None start_orientations = None + self.strand_labels = CyclicList(m * [None] + n * [None]) + self.strand_components = CyclicList(m * [None] + n * [None]) if crossings is None: crossings = [] @@ -126,10 +150,13 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, if not all(isinstance(c, (Crossing, Strand)) for c in crossings): raise ValueError("Every element of crossings must be a Crossing or a Strand") + # Note that crossings in Tangle can contain Strands self.crossings = crossings # the pair for the number of lower strands and the number of upper strands self.boundary = (m, n) + # -1 if entering Tangle, 1 if exiting Tangle, 0 if not yet oriented. + self.boundary_signs = CyclicList(m * [0] + n * [0]) # a list of (c, i) pairs for the boundary strands. Each c will reciprocally # contain (self, j) where j is the strand number. The fact this is called @@ -140,34 +167,66 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, if len(entry_points) != m + n: raise ValueError("The number of boundary strands is not equal to the length" " of entry_points") - - if build: - if start_orientations is None: - # By default, orient the components so that the Tangle is upward pointing - start_orientations = [(c, (i + 2) % 4) for (c,i) in entry_points[:m]] - - self._build(start_orientations, component_starts) for i, e in enumerate(entry_points): - # TODO: make it so that the entry points are attached to Strands + # TODO: make it so that the entry points are attached to Strands? join_strands((self, i), e) + if build: + self._build(start_orientations, component_starts, entry_points=entry_points) + assert self.is_oriented() + self.label = label - def _build(self, start_orientations=None, component_starts=None): - self._orient_crossings(start_orientations=start_orientations) + def is_upward(self): + return self.boundary_signs == CyclicList([-1] * self.boundary[0] + [1] * self.boundary[1]) + + def is_downward(self): + return self.boundary_signs == CyclicList([1] * self.boundary[0] + [-1] * self.boundary[1]) + + def is_oriented(self): + return all(s != 0 for s in self.boundary_signs) + + def _build(self, start_orientations=None, component_starts=None, entry_points = None): + self._orient_crossings(start_orientations=start_orientations, entry_points=entry_points) self._build_components(component_starts=component_starts) - def _orient_crossings(self, start_orientations=None): + def _rebuild(self, same_components_and_orientations = False): + if same_components_and_orientations: + # Hopefully we have enough of the original components left + # to figure out what this is. Otherwise, new choices will + # be made as in the default algorithm. + start_css = [] + for comp in self.components: + for cs in comp: + if cs.crossing in self.crossings: + start_css.append(cs) + break + self.components = None + for c in self.crossings: + c._clear() + if same_components_and_orientations: + self._build(start_orientations=start_css, + component_starts=start_css) + else: + self._build() + + + def all_crossings_oriented(self): + return all(c.sign != 0 for c in self.crossings) + + def _orient_crossings(self, start_orientations=None, entry_points = None): if self.all_crossings_oriented(): return if start_orientations is None: start_orientations = list() + remaining = OrderedSet( + sorted([(c, i) for c in self.crossings for i in range(c._adjacent_len) if c.sign == 0], + key = lambda x : any(x == (d, j) for d, j in entry_points[:self.boundary[0]]))) else: # copy as algorithm modifies this list start_orientations = list(start_orientations) - - remaining = OrderedSet( - [(c, i) for c in self.crossings for i in range(4) if c.sign == 0]) + remaining = OrderedSet( + [(c, i) for c in self.crossings for i in range(c._adjacent_len) if c.sign == 0]) while len(remaining): if len(start_orientations) > 0: @@ -182,11 +241,15 @@ def _orient_crossings(self, start_orientations=None): c.make_tail(i) else: c.make_head(i) + + remaining.discard((c, i)) - if c.adjacent[i] is not None: + if not c.adjacent[i][0] == self: d, j = c.adjacent[i] - remaining.discard((c, i)), remaining.discard((d, j)) - c, i = d, (j + 2) % 4 + remaining.discard((d, j)) + s = d._adjacent_len // 2 + c, i = d, (j + s) % (2 * s) + finished = (c, i) == start else: if reversed: @@ -198,20 +261,85 @@ def _orient_crossings(self, start_orientations=None): # now go back and orient reversely reversed = True c, i = start - c, i = c, (i + 2) % 4 + + s = c._adjacent_len // 2 + c, i = c, (i + s) % (2 * s) for c in self.crossings: c.orient() def _build_components(self, component_starts=None): - #TODO - pass + if component_starts is not None: + # Take all CrossingStrand and CrossingEntryPoint objects + # and turn them into CrossingEntryPoints + component_starts = [cs.crossing.entry_points()[cs.strand_index % 2] + for cs in component_starts] + remaining, components = OrderedSet(self.crossing_entries()), TangleComponents() + other_crossing_entries = [] + self.labels = labels = ArcLabels() + for c in self.crossings: + c._clear_strand_info() + + while len(remaining): + if component_starts: + d = component_starts[len(components)] + elif len(components) == 0: + d = remaining.pop() + else: # prioritize labeling crossing strands that are adjacent to already labeled ones + found, comp_index = False, 0 + while not found and comp_index < len(components): + others = other_crossing_entries[comp_index] + if others: + for j, d in enumerate(others): + if d.component_label() is None: + if labels[d.other()] % 2 == 0: + d = d.next() + found = True + break + other_crossing_entries[comp_index] = others[j:] + comp_index += 1 + + if not found: + d = remaining.pop() + + component = components.add(d) + + # Label arcs along the component + for i, c in enumerate(component): + if isinstance(c.crossing, Tangle): + assert len(component) > 2 + assert self.boundary_signs[c.strand_index] == 0 + + if i == 0: + self.boundary_signs[c.strand_index] = -1 + else: + assert i == len(component) - 1 + self.boundary_signs[c.strand_index] = 1 + + # if is a Crossing or at the end of the component, advance the label + # otherwise, don't advance the label since we will still be on the same arc + if isinstance(c.crossing, Crossing) or i == len(component) - 1: + advance = True + else: + advance = False + + labels.add(c, advance) + + others = [] + for c in component: + c.label_crossing(len(components) - 1, labels) + o = c.other() + if o is not None and o.component_label() is None: + others.append(o) + other_crossing_entries.append(others) + remaining.difference_update(component) + + self.components = components def crossing_entries(self): ans = [] for C in self.crossings: - if isinstance(C, Crossing): - ans += C.entry_points() + ans += C.entry_points() return ans def _crossings_from_PD_code(self, code, entry_points): @@ -246,14 +374,30 @@ def _crossings_from_PD_code(self, code, entry_points): (c, i), (d, j) = item crossings[c][i] = crossings[d][j] - entry_points = [crossings[gluings[x][0]].crossing_strands()[gluings[x][1]] + entry_points = [crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]] for x in entry_points] component_starts = [crossings[c].crossing_strands()[i] for (c, i) in component_starts] return crossings, component_starts, entry_points + + def PD_code(self, KnotTheory=False, min_strand_index = 0): + PD = [] + entry_info = [s + min_strand_index for s in self.strand_labels] + + for c in self.crossings: + if isinstance(c, Crossing): + PD.append([s + min_strand_index for s in c.strand_labels]) + + if KnotTheory: + PD = "PD" + repr(PD).replace('[', 'X[')[1:] + entry_info = "EP" + repr(entry_info) + else: + PD = [tuple(x) for x in PD] + return PD, entry_info + def _component_starts_from_PD(self, code, labels, gluings): """ A PD code determines an order and orientation on the tangle From 8a89b37d91685b5c826400c8ad87dca66362965b Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Tue, 12 May 2026 17:30:49 -0500 Subject: [PATCH 03/11] Reidemeister move I and II working Co-authored-by: Copilot --- spherogram_src/links/links_base.py | 30 ++++--- spherogram_src/links/simplify.py | 87 +++++++++++------- spherogram_src/links/tangles.py | 137 ++++++++++++++++++++++------- 3 files changed, 175 insertions(+), 79 deletions(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index a94cda6..1f52ab9 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -261,7 +261,10 @@ def previous_corner(self): return self.opposite().rotate(-1) def strand_label(self): - return self.crossing.strand_labels[self.strand_index] + if isinstance(self.crossing, Strand): + return self.crossing.strand_label + else: + return self.crossing.strand_labels[self.strand_index] def oriented(self): """ @@ -282,9 +285,13 @@ class CrossingEntryPoint(CrossingStrand): """ def next(self): - c, e = self.crossing, self.strand_index - s = c._adjacent_len // 2 - return CrossingEntryPoint(*c.adjacent[(e + s) % (2 * s)]) + if isinstance(self.crossing, (Crossing, Strand)): + c, e = self.crossing, self.strand_index + s = c._adjacent_len // 2 + return CrossingEntryPoint(*c.adjacent[(e + s) % (2 * s)]) + else: + raise RuntimeError('This should not be reached') + return CrossingEntryPoint(*self.crossing.adjacent[self.strand_index]) def previous(self): d, j = self.opposite() @@ -292,7 +299,7 @@ def previous(self): if isinstance(d, (Crossing, Strand)): s = d._adjacent_len // 2 return CrossingEntryPoint(*self.opposite().rotate(s)) - else: + else: return CrossingEntryPoint(d, j) def other(self): @@ -312,9 +319,9 @@ def is_over_crossing(self): def component(self): ans = [self] - reversed = False + is_reversed = False while True: - if reversed: + if is_reversed: d = ans[0].previous() else: d = ans[-1].next() @@ -322,16 +329,16 @@ def component(self): if d == self: break else: - if reversed: + if is_reversed: ans.insert(0, d) else: ans.append(d) if not isinstance(d.crossing, (Crossing, Strand)): - if reversed: + if is_reversed: break else: - reversed = True + is_reversed = True return ans @@ -443,7 +450,7 @@ def rotate(self, s): """ def rotate(v): return (v + s) % 2 - new_adjacent = [self.adjacent[rotate(i)] for i in range(4)] + new_adjacent = [self.adjacent[rotate(i)] for i in range(2)] for i, (o, j) in enumerate(new_adjacent): if o != self: o.adjacent[j] = (self, i) @@ -461,6 +468,7 @@ def orient(self): self.sign = 1 def entry_points(self): + assert self.sign == 1 return [CrossingEntryPoint(self, 0)] def enumerate_lists(lists, n=0, filter=lambda x: True): diff --git a/spherogram_src/links/simplify.py b/spherogram_src/links/simplify.py index e6f91c5..edee721 100644 --- a/spherogram_src/links/simplify.py +++ b/spherogram_src/links/simplify.py @@ -74,7 +74,7 @@ def remove_crossings(link, eliminate): for C in eliminate: link.crossings.remove(C) new_components = [] - for component in link.link_components: + for component in link.link_components if isinstance(link, Link) else link.components: for C in eliminate: for cep in C.entry_points(): try: @@ -83,9 +83,12 @@ def remove_crossings(link, eliminate): pass if len(component): new_components.append(component) - components_removed = len(link.link_components) - len(new_components) + components_removed = len(link.link_components if isinstance(link, Link) else link.components) - len(new_components) link.unlinked_unknot_components += components_removed - link.link_components = new_components + if isinstance(link, Link): + link.link_components = new_components + else: + link.components = new_components def reidemeister_I(link, C): @@ -95,15 +98,18 @@ def reidemeister_I(link, C): Returns the pair: {crossings eliminated}, {crossings changed} """ elim, changed = set(), set() - for i in range(4): - if C.adjacent[i] == (C, (i + 1) % 4): - (A, a), (B, b) = C.adjacent[i + 2], C.adjacent[i + 3] - elim = {C} - if C != A: - A[a] = B[b] - changed = {A, B} - - remove_crossings(link, elim) + + if isinstance(C, Crossing): + for i in range(4): + if C.adjacent[i] == (C, (i + 1) % 4): + (A, a), (B, b) = C.adjacent[i + 2], C.adjacent[i + 3] + elim = {C} + if C != A: + A[a] = B[b] + changed = {A, B} + + remove_crossings(link, elim) + return elim, changed @@ -118,25 +124,28 @@ def reidemeister_I_and_II(link, A): if not eliminated: for a in range(4): (B, b), (C, c) = A.adjacent[a], A.adjacent[a + 1] - if B == C and (b - 1) % 4 == c and (a + b) % 2 == 0: - eliminated, changed = reidemeister_I(link, B) - if eliminated: - break - else: - W, w = A.adjacent[a + 2] - X, x = A.adjacent[a + 3] - Y, y = B.adjacent[b + 1] - Z, z = B.adjacent[b + 2] - eliminated = {A, B} - if W != B: - W[w] = Z[z] - changed.update({W, Z}) - if X != B: - X[x] = Y[y] - changed.update({X, Y}) - remove_crossings(link, eliminated) - break - + if all(isinstance(x, Crossing) for x in (B,C)): + if B == C and (b - 1) % 4 == c and (a + b) % 2 == 0: + eliminated, changed = reidemeister_I(link, B) + if eliminated: + break + else: + W, w = A.adjacent[a + 2] + X, x = A.adjacent[a + 3] + Y, y = B.adjacent[b + 1] + Z, z = B.adjacent[b + 2] + eliminated = {A, B} + if W != B: + W[w] = Z[z] + changed.update({W, Z}) + if X != B: + X[x] = Y[y] + changed.update({X, Y}) + remove_crossings(link, eliminated) + break + + + changed &= {x for x in changed if isinstance(x, Crossing)} return eliminated, changed @@ -177,11 +186,18 @@ def basic_simplify(link, build_components=True, to_visit=None, # Redo the strand labels (used for DT codes) if (success and build_components) or force_build_components: component_starts = [] - for component in link.link_components: + is_link = isinstance(link, Link) + + for component in link.link_components if is_link else link.components: assert len(component) > 0 if len(component) > 1: - a, b = component[:2] + if is_link or isinstance(component[0].crossing, (Strand, Crossing)): + a, b = component[:2] + else: + assert len(component) > 3 + a, b = component[1:3] else: + assert is_link a = component[0] b = a.next() if a.strand_label() % 2 == 0: @@ -820,7 +836,10 @@ def clear_orientations(link): """ Resets the orientations on the crossings of a link to default values """ - link.link_components = None + if isinstance(link, Link): + link.link_components = None + else: + link.components = None for i in link.crossings: i.sign = 0 i.directions.clear() diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 975a898..710f463 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -23,7 +23,7 @@ from collections import OrderedDict from .ordered_set import OrderedSet -from .links_base import Crossing, Strand, Link +from .links_base import Crossing, Strand, Link, CrossingEntryPoint from . import planar_isotopy class CyclicList(list): @@ -96,7 +96,7 @@ def add(self, c, advance): if advance: self.counter += 1 else: - raise ValueError("Each CEP should only be labeled once") + raise ValueError(f"Each CEP should only be labeled once, but {c} is already labeled with {self[c]}") class TangleComponents(list): def add(self, c): @@ -131,8 +131,9 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, Usually tangles should not be created directly using this constructor since the tangle operations and various primitive tangles are sufficient to create any tangle. """ + self.label = label - m, n = decode_boundary(boundary) + m, n = decode_boundary(boundary) component_starts = None start_orientations = None self.strand_labels = CyclicList(m * [None] + n * [None]) @@ -150,11 +151,28 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, if not all(isinstance(c, (Crossing, Strand)) for c in crossings): raise ValueError("Every element of crossings must be a Crossing or a Strand") - # Note that crossings in Tangle can contain Strands - self.crossings = crossings + + self.unlinked_unknot_components = 0 + component_strands = [] + fused_strands = [] + for s in crossings: + if isinstance(s, Strand): + if s.component_idx is not None: + # defer fusing + # TODO: deal with this + component_strands.append(s) + elif s.is_loop(): + self.unlinked_unknot_components += 1 + else: + fused_strands.append(s) + s.fuse() + + for s in fused_strands: + crossings.remove(s) # the pair for the number of lower strands and the number of upper strands self.boundary = (m, n) + self.boundary_strands = CyclicList([]) # -1 if entering Tangle, 1 if exiting Tangle, 0 if not yet oriented. self.boundary_signs = CyclicList(m * [0] + n * [0]) @@ -162,21 +180,36 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, # contain (self, j) where j is the strand number. The fact this is called # 'adjacent' means that the Tangle can take part in the joining protocol # implemented in join_strands. - self.adjacent = (m + n) * [None] + self.adjacent = CyclicList((m + n) * [None]) entry_points = entry_points or [] if len(entry_points) != m + n: raise ValueError("The number of boundary strands is not equal to the length" " of entry_points") for i, e in enumerate(entry_points): - # TODO: make it so that the entry points are attached to Strands? - join_strands((self, i), e) + if isinstance(e.crossing, Strand): + self.boundary_strands.append(e.crossing) + join_strands(e, (self, i)) + else: + boundary_strand = Strand(label = f'TS({self}, {i})') + self.boundary_strands.append(boundary_strand) + join_strands(e, (boundary_strand, 0)) + join_strands((self, i), (boundary_strand, 1)) + + # Note that crossings in Tangle can contain Strands + self.crossings = crossings if build: self._build(start_orientations, component_starts, entry_points=entry_points) assert self.is_oriented() - self.label = label + def __getitem__(self, i): + return (self, i % (self.boundary[0] + self.boundary[1])) + + def __setitem__(self, i, other): + o, j = other + self.adjacent[i % (self.boundary[0] + self.boundary[1])] = other + o.adjacent[j] = (self, i) def is_upward(self): return self.boundary_signs == CyclicList([-1] * self.boundary[0] + [1] * self.boundary[1]) @@ -187,10 +220,26 @@ def is_downward(self): def is_oriented(self): return all(s != 0 for s in self.boundary_signs) + def entry_points(self): + assert self.is_oriented() + return [CrossingEntryPoint(self, i) for i in range(self.boundary[0] + self.boundary[1]) + if self.boundary_signs[i] == -1] + def _build(self, start_orientations=None, component_starts=None, entry_points = None): self._orient_crossings(start_orientations=start_orientations, entry_points=entry_points) self._build_components(component_starts=component_starts) + def _clear(self): + self.components = None + for c in self.crossings: + c._clear() + for s in self.boundary_strands: + s._clear() + + self.boundary_signs = CyclicList(self.boundary[0] * [0] + self.boundary[1] * [0]) + self.strand_labels = CyclicList(self.boundary[0] * [None] + self.boundary[1] * [None]) + self.strand_components = CyclicList(self.boundary[0] * [None] + self.boundary[1] * [None]) + def _rebuild(self, same_components_and_orientations = False): if same_components_and_orientations: # Hopefully we have enough of the original components left @@ -202,16 +251,13 @@ def _rebuild(self, same_components_and_orientations = False): if cs.crossing in self.crossings: start_css.append(cs) break - self.components = None - for c in self.crossings: - c._clear() + self._clear() if same_components_and_orientations: self._build(start_orientations=start_css, component_starts=start_css) else: self._build() - def all_crossings_oriented(self): return all(c.sign != 0 for c in self.crossings) @@ -220,13 +266,12 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): return if start_orientations is None: start_orientations = list() - remaining = OrderedSet( - sorted([(c, i) for c in self.crossings for i in range(c._adjacent_len) if c.sign == 0], - key = lambda x : any(x == (d, j) for d, j in entry_points[:self.boundary[0]]))) else: # copy as algorithm modifies this list start_orientations = list(start_orientations) - remaining = OrderedSet( - [(c, i) for c in self.crossings for i in range(c._adjacent_len) if c.sign == 0]) + + remaining = OrderedSet( + [(c, i) for c in self.crossings + list(reversed(self.boundary_strands)) + for i in range(c._adjacent_len) if c.sign == 0]) while len(remaining): if len(start_orientations) > 0: @@ -234,10 +279,10 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): else: c, i = start = remaining.pop() - reversed = False + is_reversed = False finished = False while not finished: - if reversed: + if is_reversed: c.make_tail(i) else: c.make_head(i) @@ -252,27 +297,35 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): finished = (c, i) == start else: - if reversed: + boundary_index = c.adjacent[i][1] + assert self.boundary_signs[boundary_index] == 0 + + if is_reversed: # Hit the boundary of the tangle from both sides, # done with this component + self.boundary_signs[boundary_index] = -1 finished = True else: # Hit the boundary of the tangle, # now go back and orient reversely - reversed = True - c, i = start + self.boundary_signs[boundary_index] = 1 + is_reversed = True + c, i = start s = c._adjacent_len // 2 c, i = c, (i + s) % (2 * s) for c in self.crossings: c.orient() + for s in self.boundary_strands: + s.orient() + def _build_components(self, component_starts=None): if component_starts is not None: # Take all CrossingStrand and CrossingEntryPoint objects # and turn them into CrossingEntryPoints - component_starts = [cs.crossing.entry_points()[cs.strand_index % 2] + component_starts = [cs.crossing.entry_points()[cs.strand_index % 2 if isinstance(cs.crossing, Crossing) else 0] for cs in component_starts] remaining, components = OrderedSet(self.crossing_entries()), TangleComponents() other_crossing_entries = [] @@ -306,16 +359,6 @@ def _build_components(self, component_starts=None): # Label arcs along the component for i, c in enumerate(component): - if isinstance(c.crossing, Tangle): - assert len(component) > 2 - assert self.boundary_signs[c.strand_index] == 0 - - if i == 0: - self.boundary_signs[c.strand_index] = -1 - else: - assert i == len(component) - 1 - self.boundary_signs[c.strand_index] = 1 - # if is a Crossing or at the end of the component, advance the label # otherwise, don't advance the label since we will still be on the same arc if isinstance(c.crossing, Crossing) or i == len(component) - 1: @@ -340,6 +383,10 @@ def crossing_entries(self): ans = [] for C in self.crossings: ans += C.entry_points() + + for s in self.boundary_strands: + ans += s.entry_points() + return ans def _crossings_from_PD_code(self, code, entry_points): @@ -479,6 +526,8 @@ def _component_starts_from_PD(self, code, labels, gluings): return starts + + # TODO: make the following operations interact with orientations... def __add__(self, other): """Put self to left of other and fuse the top-right strand of self to the top-left strand of other and the bottom-right strand of self to the bottom-left strand of other. @@ -674,6 +723,26 @@ def isosig(self, root=None, over_or_under=False): copy._fuse_strands() return planar_isotopy.min_isosig(copy, root, over_or_under) + def reverse_orientation(self, component_index): + # TODO + pass + + def is_planar(self): + # TODO + pass + + def simplify(self, mode = 'basic', type_III_limit = 100): + # TODO: double check if this works + from . import simplify + if mode == 'basic': + return simplify.basic_simplify(self) + elif mode == 'level': + return simplify.simplify_via_level_type_III(self, type_III_limit) + elif mode == 'pickup': + return simplify.pickup_simplify(self) + elif mode == 'global': + return simplify.pickup_simplify(self, type_III_limit) + def is_planar_isotopic(self, other, root=None, over_or_under=False) -> bool: return self.isosig() == other.isosig() From a2683a19ceba49cced34e2ff8f522fa783258bdb Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Tue, 12 May 2026 18:25:41 -0500 Subject: [PATCH 04/11] Creation from PD_code works for tangle without crossings Co-authored-by: Copilot --- spherogram_src/links/links_base.py | 4 +- spherogram_src/links/tangles.py | 75 +++++++++++++++++++----------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index 1f52ab9..92f81e1 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -430,7 +430,7 @@ def make_tail(self, a): "a+1". """ b = (a, (a + 1) % 2) - if self.direction: + if self.direction is not None and self.direction != b: raise ValueError("Can only orient a strand once.") self.direction = b @@ -440,7 +440,7 @@ def make_head(self, a): "a+1". """ b = ((a + 1) % 2, a) - if self.direction: + if self.direction is not None and self.direction != b: raise ValueError("Can only orient a strand once.") self.direction = b diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 710f463..263201b 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -21,9 +21,9 @@ """ import pickle -from collections import OrderedDict +from collections import OrderedDict, Counter from .ordered_set import OrderedSet -from .links_base import Crossing, Strand, Link, CrossingEntryPoint +from .links_base import Crossing, Strand, Link, CrossingStrand, CrossingEntryPoint from . import planar_isotopy class CyclicList(list): @@ -142,10 +142,11 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, if crossings is None: crossings = [] else: - if isinstance(crossings, str): + if isinstance(crossings, str) or isinstance(entry_points, str): raise NotImplementedError("Not Implemented. If you are trying to create a tangle from a PD code, input the PD code as a list instead.") - if len(crossings) > 0 and not isinstance(crossings[0], (Strand, Crossing)): + if (len(crossings) > 0 and not isinstance(crossings[0], (Strand, Crossing)))\ + or (entry_points is not None and len(entry_points) > 0 and not isinstance(entry_points[0], CrossingEntryPoint)): crossings, component_starts, entry_points = self._crossings_from_PD_code(crossings, entry_points) start_orientations = component_starts[:] @@ -154,8 +155,7 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, self.unlinked_unknot_components = 0 component_strands = [] - fused_strands = [] - for s in crossings: + for s in reversed(crossings): if isinstance(s, Strand): if s.component_idx is not None: # defer fusing @@ -164,11 +164,8 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, elif s.is_loop(): self.unlinked_unknot_components += 1 else: - fused_strands.append(s) s.fuse() - - for s in fused_strands: - crossings.remove(s) + crossings.remove(s) # the pair for the number of lower strands and the number of upper strands self.boundary = (m, n) @@ -187,14 +184,14 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, " of entry_points") for i, e in enumerate(entry_points): - if isinstance(e.crossing, Strand): - self.boundary_strands.append(e.crossing) + if isinstance(e[0], Strand): + self.boundary_strands.append(e[0]) join_strands(e, (self, i)) else: - boundary_strand = Strand(label = f'TS({self}, {i})') - self.boundary_strands.append(boundary_strand) - join_strands(e, (boundary_strand, 0)) - join_strands((self, i), (boundary_strand, 1)) + this_strand = Strand(label = f'TS({self}, {i})') + self.boundary_strands.append(this_strand) + join_strands(e, (this_strand, 0)) + join_strands((self, i), (this_strand, 1)) # Note that crossings in Tangle can contain Strands self.crossings = crossings @@ -248,7 +245,7 @@ def _rebuild(self, same_components_and_orientations = False): start_css = [] for comp in self.components: for cs in comp: - if cs.crossing in self.crossings: + if cs.crossing in self.crossings + self.boundary_strands: start_css.append(cs) break self._clear() @@ -259,7 +256,7 @@ def _rebuild(self, same_components_and_orientations = False): self._build() def all_crossings_oriented(self): - return all(c.sign != 0 for c in self.crossings) + return all(c.sign != 0 for c in self.crossings + self.boundary_strands) def _orient_crossings(self, start_orientations=None, entry_points = None): if self.all_crossings_oriented(): @@ -332,6 +329,8 @@ def _build_components(self, component_starts=None): self.labels = labels = ArcLabels() for c in self.crossings: c._clear_strand_info() + for s in self.boundary_strands: + s._clear_strand_info() while len(remaining): if component_starts: @@ -384,7 +383,7 @@ def crossing_entries(self): for C in self.crossings: ans += C.entry_points() - for s in self.boundary_strands: + for s in reversed(self.boundary_strands): ans += s.entry_points() return ans @@ -394,10 +393,14 @@ def _crossings_from_PD_code(self, code, entry_points): entry_points as INPUT: a list of labels of arcs left open in the tangle as OUTPUT: a list of CrossingStrands """ + assert Counter(entry_points).most_common(1)[0][1] <= 2, "Each entry point label should appear at most twice" + labels = set() for X in code: for i in X: labels.add(i) + for x in entry_points: + labels.add(x) gluings = OrderedDict() @@ -411,8 +414,7 @@ def _crossings_from_PD_code(self, code, entry_points): if any(len(v) > 2 for v in gluings.values()): raise ValueError("PD code isn't consistent") - component_starts = self._component_starts_from_PD( - code, labels, gluings) + crossings = [Crossing(i) for i, d in enumerate(code)] @@ -421,13 +423,29 @@ def _crossings_from_PD_code(self, code, entry_points): (c, i), (d, j) = item crossings[c][i] = crossings[d][j] - entry_points = [crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]] - for x in entry_points] + entry_strands = [] + entry_dict = dict() + + for i, x in enumerate(entry_points): + if x in gluings: + entry_strands.append(crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]]) + else: + this_strand = Strand(label = f'TS({self}, {i})') + if x not in entry_dict: + entry_strands.append((this_strand, 0)) + entry_dict[x] = (this_strand, 1) + else: + entry_strands.append((this_strand, 1)) + join_strands(entry_dict[x], (this_strand, 0)) + + component_starts = self._component_starts_from_PD( + code, labels, gluings, entry_dict) - component_starts = [crossings[c].crossing_strands()[i] + component_starts = [crossings[c].crossing_strands()[i] + if not isinstance(c, Strand) else CrossingStrand(c, i) for (c, i) in component_starts] - return crossings, component_starts, entry_points + return crossings, component_starts, entry_strands def PD_code(self, KnotTheory=False, min_strand_index = 0): PD = [] @@ -445,7 +463,7 @@ def PD_code(self, KnotTheory=False, min_strand_index = 0): return PD, entry_info - def _component_starts_from_PD(self, code, labels, gluings): + def _component_starts_from_PD(self, code, labels, gluings, entry_dict): """ A PD code determines an order and orientation on the tangle components as follows, where we view the code as labels on the @@ -466,7 +484,10 @@ def _component_starts_from_PD(self, code, labels, gluings): m = min(labels) labels.remove(m) - if len(gluings[m]) == 1: + if m not in gluings: + next_label = m + starts.append(entry_dict[m]) + elif len(gluings[m]) == 1: # entrance strand of the tangle [(c, index)] = gluings[m] From c19ee1c34f6c478d09bdcdd42308a79fb772b750 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Mon, 25 May 2026 18:47:34 -0500 Subject: [PATCH 05/11] Basic operations of Tangles working _rebuild() bug fixed simplify() bug fixed. All three Reidemeister moves work for Tangles but not the pickup moves TODO: 1. decide whether to fix describe() or replace it entirely with PD_code() 2. fix isosig() for Tangles 3. implement rot_num() --- spherogram_src/links/links_base.py | 17 +- spherogram_src/links/simplify.py | 22 +-- spherogram_src/links/tangles.py | 303 ++++++++++++++++++++++------- 3 files changed, 261 insertions(+), 81 deletions(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index 92f81e1..2f3d506 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -414,7 +414,7 @@ def format_adjacent(a): (self.label, [format_adjacent(a) for a in self.adjacent])) def is_loop(self): - return self == self.adjacent[0][0] + return self.adjacent[0] is not None and self == self.adjacent[0][0] def _clear(self): self.sign, self.direction = 0, None @@ -464,6 +464,7 @@ def rotate(v): def orient(self): if self.direction == (1, 0): self.rotate(1) + self.direction = (0,1) self.sign = 1 @@ -877,7 +878,8 @@ def _rebuild(self, same_components_and_orientations=False): for comp in self.link_components: for cs in comp: if cs.crossing in self.crossings: - start_css.append(cs) + s = cs.crossing._adjacent_len // 2 + start_css.append(cs.rotate(s)) break self.link_components = None for c in self.crossings: @@ -1039,6 +1041,17 @@ def _build_components(self, component_starts=None): self.link_components = components + @property + def components(self): + """ + Synonym for link_components + """ + return self.link_components + + @components.setter + def components(self, value): + self.link_components = value + def digraph(self): """ The underlying directed graph for the link diagram. diff --git a/spherogram_src/links/simplify.py b/spherogram_src/links/simplify.py index edee721..bb2c122 100644 --- a/spherogram_src/links/simplify.py +++ b/spherogram_src/links/simplify.py @@ -74,7 +74,7 @@ def remove_crossings(link, eliminate): for C in eliminate: link.crossings.remove(C) new_components = [] - for component in link.link_components if isinstance(link, Link) else link.components: + for component in link.components: for C in eliminate: for cep in C.entry_points(): try: @@ -83,12 +83,10 @@ def remove_crossings(link, eliminate): pass if len(component): new_components.append(component) - components_removed = len(link.link_components if isinstance(link, Link) else link.components) - len(new_components) + components_removed = len(link.components) - len(new_components) link.unlinked_unknot_components += components_removed - if isinstance(link, Link): - link.link_components = new_components - else: - link.components = new_components + + link.components = new_components def reidemeister_I(link, C): @@ -186,18 +184,16 @@ def basic_simplify(link, build_components=True, to_visit=None, # Redo the strand labels (used for DT codes) if (success and build_components) or force_build_components: component_starts = [] - is_link = isinstance(link, Link) - for component in link.link_components if is_link else link.components: + for component in link.components: assert len(component) > 0 if len(component) > 1: - if is_link or isinstance(component[0].crossing, (Strand, Crossing)): + if isinstance(component[0].crossing, (Strand, Crossing)): a, b = component[:2] else: assert len(component) > 3 a, b = component[1:3] else: - assert is_link a = component[0] b = a.next() if a.strand_label() % 2 == 0: @@ -836,10 +832,8 @@ def clear_orientations(link): """ Resets the orientations on the crossings of a link to default values """ - if isinstance(link, Link): - link.link_components = None - else: - link.components = None + link.components = None + for i in link.crossings: i.sign = 0 i.directions.clear() diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 263201b..c31c88c 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -31,6 +31,8 @@ def __init__(self, iterable): super().__init__(iterable) def __getitem__(self, i): + if isinstance(i, slice): + return super().__getitem__(i) return super().__getitem__(i % len(self)) def join_strands(x, y): @@ -131,7 +133,10 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, Usually tangles should not be created directly using this constructor since the tangle operations and various primitive tangles are sufficient to create any tangle. """ - self.label = label + if label is None: + self.label = id(self) + else: + self.label = label m, n = decode_boundary(boundary) component_starts = None @@ -146,27 +151,10 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, raise NotImplementedError("Not Implemented. If you are trying to create a tangle from a PD code, input the PD code as a list instead.") if (len(crossings) > 0 and not isinstance(crossings[0], (Strand, Crossing)))\ - or (entry_points is not None and len(entry_points) > 0 and not isinstance(entry_points[0], CrossingEntryPoint)): + or (entry_points is not None and len(entry_points) > 0 and not isinstance(entry_points[0], (CrossingStrand, list, tuple))): crossings, component_starts, entry_points = self._crossings_from_PD_code(crossings, entry_points) start_orientations = component_starts[:] - if not all(isinstance(c, (Crossing, Strand)) for c in crossings): - raise ValueError("Every element of crossings must be a Crossing or a Strand") - - self.unlinked_unknot_components = 0 - component_strands = [] - for s in reversed(crossings): - if isinstance(s, Strand): - if s.component_idx is not None: - # defer fusing - # TODO: deal with this - component_strands.append(s) - elif s.is_loop(): - self.unlinked_unknot_components += 1 - else: - s.fuse() - crossings.remove(s) - # the pair for the number of lower strands and the number of upper strands self.boundary = (m, n) self.boundary_strands = CyclicList([]) @@ -184,21 +172,54 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, " of entry_points") for i, e in enumerate(entry_points): - if isinstance(e[0], Strand): - self.boundary_strands.append(e[0]) - join_strands(e, (self, i)) - else: - this_strand = Strand(label = f'TS({self}, {i})') - self.boundary_strands.append(this_strand) - join_strands(e, (this_strand, 0)) - join_strands((self, i), (this_strand, 1)) + this_strand = Strand(label = f'TSE({self}, {i})') + self.boundary_strands.append(this_strand) + join_strands(e, (this_strand, 1)) + join_strands((self, i), (this_strand, 0)) + + if not all(isinstance(c, (Crossing, Strand)) for c in crossings): + raise ValueError("Every element of crossings must be a Crossing or a Strand") - # Note that crossings in Tangle can contain Strands + self.unlinked_unknot_components = 0 + component_strands = [] + for s in reversed(crossings): + if isinstance(s, Strand): + if s.component_idx is not None: + # defer fusing + component_strands.append(s) + elif s.is_loop(): + self.unlinked_unknot_components += 1 + else: + s.fuse() + crossings.remove(s) + + # Note that crossings in Tangle can contain Strands with comp_idx for now self.crossings = crossings if build: - self._build(start_orientations, component_starts, entry_points=entry_points) - assert self.is_oriented() + self._build(start_orientations, component_starts) + assert self.is_oriented(), 'Tangle is not oriented after build' + + for s in component_strands: + comp_id = s.component_idx + comp = self.components[s.strand_component] + + if isinstance(comp[0].crossing, Tangle): + for cep in reversed(comp): + if cep.crossing == s: + comp.remove(cep) + break + else: + raise RuntimeError(f"Component strand {s} not found in component {comp}") + + # Note that the components are always built following the orientation + # hence below always insists that the comp_id is labeled on the entrance strand + if comp[1].component_idx is not None: + assert comp[1].component_idx == comp_id + else: + comp[1].component_idx = comp_id + + self.crossings.remove(s) def __getitem__(self, i): return (self, i % (self.boundary[0] + self.boundary[1])) @@ -216,14 +237,32 @@ def is_downward(self): def is_oriented(self): return all(s != 0 for s in self.boundary_signs) + + def make_upward(self): + if self.is_upward(): + return + + assert self.is_oriented(), 'Tangle should be oriented to tell if it is upward' + + to_reverse = set() + for i in range(self.boundary[0]): + if self.boundary_signs[i] == 1: + to_reverse.add(self.boundary_strands[i].strand_component) + + self.reverse_orientation(to_reverse) def entry_points(self): - assert self.is_oriented() + assert self.is_oriented(), 'Tangle should be oriented to tell the entry points' return [CrossingEntryPoint(self, i) for i in range(self.boundary[0] + self.boundary[1]) if self.boundary_signs[i] == -1] + + def update_label(self, label): + self.label = label + for i, s in enumerate(self.boundary_strands): + s.label = f'TSE({self}, {i})' - def _build(self, start_orientations=None, component_starts=None, entry_points = None): - self._orient_crossings(start_orientations=start_orientations, entry_points=entry_points) + def _build(self, start_orientations=None, component_starts=None): + self._orient_crossings(start_orientations=start_orientations) self._build_components(component_starts=component_starts) def _clear(self): @@ -246,7 +285,8 @@ def _rebuild(self, same_components_and_orientations = False): for comp in self.components: for cs in comp: if cs.crossing in self.crossings + self.boundary_strands: - start_css.append(cs) + s = cs.crossing._adjacent_len // 2 + start_css.append(cs.rotate(s)) break self._clear() if same_components_and_orientations: @@ -258,7 +298,7 @@ def _rebuild(self, same_components_and_orientations = False): def all_crossings_oriented(self): return all(c.sign != 0 for c in self.crossings + self.boundary_strands) - def _orient_crossings(self, start_orientations=None, entry_points = None): + def _orient_crossings(self, start_orientations=None): if self.all_crossings_oriented(): return if start_orientations is None: @@ -275,7 +315,7 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): c, i = start = start_orientations.pop() else: c, i = start = remaining.pop() - + is_reversed = False finished = False while not finished: @@ -283,7 +323,6 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): c.make_tail(i) else: c.make_head(i) - remaining.discard((c, i)) if not c.adjacent[i][0] == self: @@ -295,7 +334,7 @@ def _orient_crossings(self, start_orientations=None, entry_points = None): finished = (c, i) == start else: boundary_index = c.adjacent[i][1] - assert self.boundary_signs[boundary_index] == 0 + assert self.boundary_signs[boundary_index] == 0, f'Boundary {boundary_index} is unexpectedly signed, something is wrong with the gluings' if is_reversed: # Hit the boundary of the tangle from both sides, @@ -358,6 +397,14 @@ def _build_components(self, component_starts=None): # Label arcs along the component for i, c in enumerate(component): + if isinstance(c.crossing, Tangle) and self.boundary_signs[c.strand_index] == 0: + assert len(component) > 2 + + if i == 0: + self.boundary_signs[c.strand_index] = -1 + else: + assert i == len(component) - 1 + self.boundary_signs[c.strand_index] = 1 # if is a Crossing or at the end of the component, advance the label # otherwise, don't advance the label since we will still be on the same arc if isinstance(c.crossing, Crossing) or i == len(component) - 1: @@ -430,7 +477,7 @@ def _crossings_from_PD_code(self, code, entry_points): if x in gluings: entry_strands.append(crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]]) else: - this_strand = Strand(label = f'TS({self}, {i})') + this_strand = Strand(label = f'PDSE({self}, {i})') if x not in entry_dict: entry_strands.append((this_strand, 0)) entry_dict[x] = (this_strand, 1) @@ -463,6 +510,10 @@ def PD_code(self, KnotTheory=False, min_strand_index = 0): return PD, entry_info + def rot_num(self): + #TODO + pass + def _component_starts_from_PD(self, code, labels, gluings, entry_dict): """ A PD code determines an order and orientation on the tangle @@ -548,7 +599,8 @@ def _component_starts_from_PD(self, code, labels, gluings, entry_dict): return starts - # TODO: make the following operations interact with orientations... + # The following operators always clear the current orientations on both tangles + # and recreate an orientation with default behaviour. def __add__(self, other): """Put self to left of other and fuse the top-right strand of self to the top-left strand of other and the bottom-right strand of self to the bottom-left strand of other. @@ -564,7 +616,15 @@ def __add__(self, other): join_strands(a[mA - 1], b[0]) join_strands(a[mA + nA - 1], b[mB]) entry_points = a[:mA - 1] + b[1:mB] + a[mA:mA + nA - 1] + b[mB + 1:] - return Tangle((mA + mB - 2, nA + nB - 2), A.crossings + B.crossings, entry_points) + + crossings = A.crossings + A.boundary_strands + B.crossings + B.boundary_strands + + for c in crossings: + c._clear() + + return Tangle((mA + mB - 2, nA + nB - 2), + crossings, + entry_points) def __mul__(self, other): """Join with self *above* other, as with braid multiplication. @@ -583,7 +643,15 @@ def __mul__(self, other): a, b = A.adjacent, B.adjacent for i in range(mA): join_strands(a[i], b[mB + i]) - return Tangle((mB, nA), A.crossings + B.crossings, b[:mB] + a[mA:]) + + crossings = A.crossings + A.boundary_strands + B.crossings + B.boundary_strands + + for c in crossings: + c._clear() + + return Tangle((mB, nA), + crossings, + b[:mB] + a[mA:]) def __neg__(self): """Mirror image of self. @@ -594,6 +662,7 @@ def __neg__(self): for c in T.crossings: if not isinstance(c, Strand): c.rotate_by_90() + c.orient() return T def __or__(self, other): @@ -606,7 +675,11 @@ def __or__(self, other): (mA, nA), (mB, nB) = A.boundary, B.boundary a, b = A.adjacent, B.adjacent entry_points = a[:mA] + b[:mB] + a[mA:] + b[mB:] - return Tangle((mA + mB, nA + nB), A.crossings + B.crossings, entry_points) + crossings = A.crossings + A.boundary_strands + B.crossings + B.boundary_strands + + return Tangle((mA + mB, nA + nB), + crossings, + entry_points) def copy(self): return pickle.loads(pickle.dumps(self)) @@ -620,9 +693,14 @@ def rotate(self, s): anticlockwise = [0, 1, 3, 2] rotate = dict(zip(anticlockwise, rotate_list(anticlockwise, s))) T = self.copy() + T.adjacent = [T.adjacent[rotate[i]] for i in range(4)] for i, (o, j) in enumerate(T.adjacent): o.adjacent[j] = (T, i) + + T.boundary_strands = [T.boundary_strands[rotate[i]] for i in range(4)] + T._rebuild(True) + return T def invert(self): @@ -651,7 +729,12 @@ def numerator_closure(self): join_strands(T.adjacent[i], T.adjacent[i + 1]) for i in range(0, n, 2): join_strands(T.adjacent[m + i], T.adjacent[m + i + 1]) - return Link(T.crossings, check_planarity=False) + + crossings = T.crossings + T.boundary_strands + for c in crossings: + c._clear() + + return Link(crossings, check_planarity=False) def denominator_closure(self): """The braid closure, where corresponding strands between the top and bottom @@ -673,14 +756,26 @@ def denominator_closure(self): T = self.copy() for i in range(n): join_strands(T.adjacent[i], T.adjacent[m + i]) - return Link(T.crossings, check_planarity=False) + + crossings = T.crossings + T.boundary_strands + for c in crossings: + c._clear() + + return Link(crossings, check_planarity=False) def link(self): """If its boundary is (0, 0), return this Tangle as a Link.""" if self.boundary != (0, 0): raise ValueError("The boundary must be (0, 0)") - return Link(self.copy().crossings, check_planarity=False) + + crossings = self.copy().crossings + for c in crossings: + c._clear() + return Link(crossings, check_planarity=False) + + + # TODO: test reshape def reshape(self, boundary, displace=0): """Renumber the boundary strands so that the tangle has the new boundary shape. This is performed by either repeatedly moving the last strands from the @@ -693,18 +788,31 @@ def reshape(self, boundary, displace=0): """ m, n = self.boundary Tm, Tn = decode_boundary(boundary) - if (m, n) == (Tm, Tn): + if (m, n) == (Tm, Tn) and displace == 0: return self if m + n != Tm + Tn: raise ValueError("Reshaping requires the tangle have the same number of boundary" " strands as in the new boundary.") + anticlockwise = [i for i in range(m)] + list(reversed([m + i for i in range(n)])) + rotate = dict(zip(anticlockwise, rotate_list(anticlockwise, displace))) + T = self.copy() + + displaced_adj = [T.adjacent[rotate[i]] for i in range(m + n)] # The 'adjacent' array but in total counterclockwise order - adj_ccw = T.adjacent[:m] + list(reversed(T.adjacent[m:])) - adj_ccw = rotate_list(adj_ccw, displace) + adj_ccw = displaced_adj[:m] + list(reversed(displaced_adj[m:])) + T.adjacent = adj_ccw[:Tm] + list(reversed(adj_ccw[Tm:])) + T.boundary = (Tm, Tn) + for i, (o, j) in enumerate(T.adjacent): + o.adjacent[j] = (T, i) - return Tangle((Tm, Tn), T.crossings, - adj_ccw[:Tm] + list(reversed(adj_ccw[Tm:]))) + displaced_bd_strands = [T.boundary_strands[rotate[i]] for i in range(m + n)] + bd_strands_ccw = displaced_bd_strands[:m] + list(reversed(displaced_bd_strands[m:])) + T.boundary_strands = bd_strands_ccw[:Tm] + list(reversed(bd_strands_ccw[Tm:])) + + T._rebuild(True) + + return T def circular_rotate(self, n): """ @@ -726,6 +834,7 @@ def circular_sum(self, other, n=0): raise ValueError("Tangles must have compatible boundary shapes") return (self * (other.circular_rotate(n))).denominator_closure() + # TODO: check if isosig still works def isosig(self, root=None, over_or_under=False): """ Return a bunch of data which encodes the planar isotopy class of the @@ -745,8 +854,68 @@ def isosig(self, root=None, over_or_under=False): return planar_isotopy.min_isosig(copy, root, over_or_under) def reverse_orientation(self, component_index): - # TODO - pass + """ + component_index: either a single index of component or a list of indices of components + """ + if not isinstance(component_index, (set, list)): + component_index = [component_index] + + org_entries = [] + for comp in self.components: + for cs in comp: + if cs.crossing in self.crossings + self.boundary_strands: + org_entries.append(cs) + break + + new_starts = [] + for i, cs in enumerate(org_entries): + if i not in component_index: + c, e = cs.crossing, cs.strand_index + s = c._adjacent_len // 2 + reversed_cs = CrossingStrand(c, (e + s) % (2 * s)) + new_starts.append(reversed_cs) + else: + new_starts.append(cs) + + self._clear() + self._build(start_orientations = new_starts, + component_starts = new_starts) + + def faces(self): + """ + + """ + corners = OrderedSet([CrossingStrand(c, i) + for c in self.crossings for i in range(4)]) + faces = [] + while len(corners): + cs0 = corners.pop() + face = [cs0] + next = cs0 + while True: + # Next two lines equiv to: next = next.next_corner() + c, e = next.crossing, next.strand_index + if isinstance(c, Tangle): + if e == 0: + next = CrossingStrand(*c.adjacent[c.boundary[0]]) + elif e < c.boundary[0]: + next = CrossingStrand(*c.adjacent[e-1]) + elif e < c.boundary[0] + c.boundary[1] - 1: + next = CrossingStrand(*c.adjacent[e+1]) + else: + assert e == c.boundary[0] + c.boundary[1] - 1 + next = CrossingStrand(*c.adjacent[c.boundary[0]-1]) + else: + next = next.next_corner() + + if next == cs0: + faces.append(face) + break + else: + corners.discard(next) + face.append(next) + + return faces def is_planar(self): # TODO @@ -759,10 +928,8 @@ def simplify(self, mode = 'basic', type_III_limit = 100): return simplify.basic_simplify(self) elif mode == 'level': return simplify.simplify_via_level_type_III(self, type_III_limit) - elif mode == 'pickup': - return simplify.pickup_simplify(self) - elif mode == 'global': - return simplify.pickup_simplify(self, type_III_limit) + else: + raise NotImplementedError() def is_planar_isotopic(self, other, root=None, over_or_under=False) -> bool: return self.isosig() == other.isosig() @@ -786,6 +953,7 @@ def _fuse_strands(self, preserve_boundary=False, preserve_components=False): def __repr__(self): return "" % self.label + # TODO: fix describe, or remove it? def describe(self, fuse_strands=True): """Give a PD-like description of the tangle in the form Tangle[{lower arcs}, {upper arcs}, P and X codes]. @@ -928,11 +1096,11 @@ def IntegerTangle(n): T = OneTangle() for i in range(n - 1): T += OneTangle() - T.label = f"IntegerTangle({n})" + T.update_label(f"IntegerTangle({n})") return T elif n < 0: T = -IntegerTangle(-n) - T.label = f"IntegerTangle({n})" + T.update_label(f"IntegerTangle({n})") return T else: raise ValueError("Expecting int") @@ -982,8 +1150,11 @@ def __init__(self, a, b=1): T = IntegerTangle(p) + T.invert() if a < 0: T = -T - Tangle.__init__(self, 2, T.crossings, T.adjacent, - f"RationalTangle({a}, {b})") + + Tangle.__init__(self, 2, + T.crossings + T.boundary_strands, + T.adjacent, + label = f"RationalTangle({a}, {b})") # --------------------------------------------------- # @@ -1009,10 +1180,9 @@ def IdentityBraid(n): """ if n < 0: raise ValueError("Expecting non-negative int") - strands = [Strand() for i in range(n)] - entry_points = [(s, 0) for s in strands] + [(s, 1) for s in strands] - return Tangle(n, strands, entry_points, - f"IdentityBraid({n})") + entry_points = 2* [i for i in range(n)] + return Tangle(n, [], entry_points, + label = f"IdentityBraid({n})") def BraidTangle(gens, n=None): @@ -1056,4 +1226,7 @@ def gen(i): if abs(i) >= n: raise ValueError("Generators must have magnitude less than n") b = b * gen(i) + + b.make_upward() + return b From 75c0c5d040dac47ddacbfb5c644e1f779c0f0e49 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Tue, 26 May 2026 02:45:38 -0500 Subject: [PATCH 06/11] First version passing all previously written doctests describe and isosig kept using old_tangles TODO: 1. rot_num 2. flip 3. computation of RT invariants 4. is_planar --- spherogram_src/links/old_tangles.py | 394 ++++++++++++++++++++++++++++ spherogram_src/links/tangles.py | 154 ++++------- 2 files changed, 448 insertions(+), 100 deletions(-) create mode 100644 spherogram_src/links/old_tangles.py diff --git a/spherogram_src/links/old_tangles.py b/spherogram_src/links/old_tangles.py new file mode 100644 index 0000000..6688579 --- /dev/null +++ b/spherogram_src/links/old_tangles.py @@ -0,0 +1,394 @@ +""" +A tangle is piece of a knot diagram in a disk where some of the +strands meet the boundary. Tangles can be composed by gluing them +along arcs in each boundary that have the same number of incident +strands. + +This module gives a version of tangles where there are four distinguished +boundary arcs used for gluing: the bottom and top, which can have incident +strands, and the left and right, which cannot. Tangles can be glued +vertically using ``*`` and horizontally using ``|``. There is also a second +kind of horizontal composition using ``+`` where the rightmost strands of the top +and bottom of the first tangle are glued to the leftmost strands of the top and +bottom of the second tangle. + +Rational tangles (created using ``RationalTangle``) are following the paper + +Classifying and Applying Rational Knots and Rational Tangles +http://homepages.math.uic.edu/~kauffman/VegasAMS.pdf + +See doc.pdf for conventions. +""" +import pickle + +from .links import Crossing, Strand, Link +from . import planar_isotopy + + +def join_strands(x, y): + """ + Input: two (c, i) pairs where c is a Crossing, Strand, or Tangle object and i is an index into + c.adjacent. Joins the objects by having them refer to each other at those positions. + + When c is a Tangle it is conceptually a special case since its c.adjacent is being + used to record the boundary strands. + + This function equivalent to creating a Strand s with s.adjacent = [x, y] and then + doing s.fuse() + """ + (a, i), (b, j) = x, y + a.adjacent[i] = (b, j) + b.adjacent[j] = (a, i) + + +def rotate_list(L, s): + """Rotate the list, putting L[s] into index 0.""" + n = len(L) + return [L[(i + s) % n] for i in range(n)] + + +def decode_boundary(boundary): + """The boundary is either a nonnegative integer or a pair of non-negative integers. + + * When the input is an integer n, this returns (n, n). + * When the input is a pair (m, n), then it returns (m, n). + + >>> decode_boundary(2) + (2, 2) + >>> decode_boundary((3,4)) + (3, 4) + >>> decode_boundary(-2) + Traceback (most recent call last): + ... + ValueError: Number of bottom boundary strands cannot be negative + >>> decode_boundary((3,-2)) + Traceback (most recent call last): + ... + ValueError: Number of top boundary strands cannot be negative + """ + if isinstance(boundary, tuple): + m, n = boundary + else: + m = n = boundary + if m < 0: + raise ValueError("Number of bottom boundary strands cannot be negative") + if n < 0: + raise ValueError("Number of top boundary strands cannot be negative") + return (m, n) + + +class Tangle: + def __init__(self, boundary=2, crossings=None, entry_points=None, label=None): + """ + A tangle is a fragment of a Link with some number of boundary + strands. Tangles can be composed in various ways along their boundary strands, + including the horizontal and vertical compositions of the tangle category. + + Inputs: + + * When boundary is an integer, then the tangle has n strands coming into both + the top and the bottom of the tangle. When boundary is a pair of integers + (m, n), then the tangle has m strands coming into the bottom and n coming + into the top. + + The strands are numbered 0 to m-1 on the bottom and m to m+n-1 on the + top, both from left to right. + + * crossings is a list of Crossing or Strand objects that comprise the tangle. + * entry_points is a list of pairs (c, i) where c is a Crossing or Strand + and i indexes into c.adjacent. These pairs describe the boundary strands + in order of the strand numbering. + * label is an arbitrary label for the tangle for informational purposes, which + appears in the ``repr`` form of the tangle. + + Usually tangles should not be created directly using this constructor since the + tangle operations and various primitive tangles are sufficient to create any tangle. + """ + + m, n = decode_boundary(boundary) + + if crossings is None: + crossings = [] + for c in crossings: + if not isinstance(c, (Crossing, Strand)): + raise ValueError("Every element of crossings must be a Crossing or a Strand") + self.crossings = crossings + + # the pair for the number of lower strands and the number of upper strands + self.boundary = (m, n) + + # a list of (c, i) pairs for the boundary strands. Each c will reciprocally + # contain (self, j) where j is the strand number. The fact this is called + # 'adjacent' means that the Tangle can take part in the joining protocol + # implemented in join_strands. + self.adjacent = (m + n) * [None] + entry_points = entry_points or [] + if len(entry_points) != m + n: + raise ValueError("The number of boundary strands is not equal to the length" + " of entry_points") + for i, e in enumerate(entry_points): + join_strands((self, i), e) + + self.label = label + + def __add__(self, other): + """Put self to left of other and fuse the top-right strand of self to the top-left + strand of other and the bottom-right strand of self to the bottom-left strand of other. + + >>> (IdentityBraid(2) + BraidTangle([1])).describe() + 'Tangle[{1,2}, {3,4}, P[1,3], X[2,4,5,5]]' + """ + A, B = self.copy(), other.copy() + (mA, nA), (mB, nB) = A.boundary, B.boundary + if mA == 0 or mB == 0 or nA == 0 or nB == 0: + raise ValueError("Tangles must have at least one top and bottom strand each.") + a, b = A.adjacent, B.adjacent + join_strands(a[mA - 1], b[0]) + join_strands(a[mA + nA - 1], b[mB]) + entry_points = a[:mA - 1] + b[1:mB] + a[mA:mA + nA - 1] + b[mB + 1:] + return Tangle((mA + mB - 2, nA + nB - 2), A.crossings + B.crossings, entry_points) + + def __mul__(self, other): + """Join with self *above* other, as with braid multiplication. + (See doc.pdf) + + >>> BraidTangle([1,1]).describe() + 'Tangle[{1,2}, {3,4}, X[5,4,3,6], X[2,5,6,1]]' + >>> (BraidTangle([1])*BraidTangle([1])).describe() + 'Tangle[{1,2}, {3,4}, X[5,4,3,6], X[2,5,6,1]]' + """ + A, B = self.copy(), other.copy() + (mA, nA), (mB, nB) = A.boundary, B.boundary + if mA != nB: + raise ValueError("Tangles must have a compatible number of strands to multiply them") + + a, b = A.adjacent, B.adjacent + for i in range(mA): + join_strands(a[i], b[mB + i]) + return Tangle((mB, nA), A.crossings + B.crossings, b[:mB] + a[mA:]) + + def __neg__(self): + """Mirror image of self. + + >>> (-BraidTangle([1])).describe() + 'Tangle[{1,2}, {3,4}, X[4,3,1,2]]'""" + T = self.copy() + for c in T.crossings: + if not isinstance(c, Strand): + c.rotate_by_90() + return T + + def __or__(self, other): + """Put self to left of other. This is like tangle addition but without the fusing of strands. + + >>> (IdentityBraid(1) | CupTangle()).describe() + 'Tangle[{1}, {2,3,4}, P[1,2], P[3,4]]' + """ + A, B = self.copy(), other.copy() + (mA, nA), (mB, nB) = A.boundary, B.boundary + a, b = A.adjacent, B.adjacent + entry_points = a[:mA] + b[:mB] + a[mA:] + b[mB:] + return Tangle((mA + mB, nA + nB), A.crossings + B.crossings, entry_points) + + def copy(self): + return pickle.loads(pickle.dumps(self)) + + def rotate(self, s): + """Rotate anticlockwise by s*90 degrees. This is only for (2,2) tangles. + + See ``Tangle.reshape()`` for a generalization to all tangle shapes.""" + if self.boundary != (2, 2): + raise ValueError("Only boundary=(2,2) tangles can be rotated") + anticlockwise = [0, 1, 3, 2] + rotate = dict(zip(anticlockwise, rotate_list(anticlockwise, s))) + T = self.copy() + T.adjacent = [T.adjacent[rotate[i]] for i in range(4)] + for i, (o, j) in enumerate(T.adjacent): + o.adjacent[j] = (T, i) + return T + + def invert(self): + """Rotate anticlockwise by 90 and take the mirror image. This is only for (2,2) tangles.""" + if self.boundary != (2, 2): + raise ValueError("Only boundary=(2,2) tangles can be inverted") + return -self.rotate(1) + + def numerator_closure(self): + """The bridge closure, where consecutive pairs of strands at both the top and + at the bottom are respectively joined by caps and cups. The numbers of + strands at both the top and the bottom must be even. Returns a Link. + + A synonym for this is ``Tangle.bridge_closure()``. + + sage: BraidTangle([2,-1,2],4).numerator_closure().alexander_polynomial() + t^2 - t + 1 + sage: BraidTangle([1,1,1]).rotate(1).numerator_closure().alexander_polynomial() + t^2 - t + 1 + """ + m, n = self.boundary + if m % 2 or n % 2: + raise ValueError("To do bridge closure, both the top and bottom must have an even number of strands") + T = self.copy() + for i in range(0, m, 2): + join_strands(T.adjacent[i], T.adjacent[i + 1]) + for i in range(0, n, 2): + join_strands(T.adjacent[m + i], T.adjacent[m + i + 1]) + return Link(T.crossings, check_planarity=False) + + def denominator_closure(self): + """The braid closure, where corresponding strands between the top and bottom + are joined. The number of strands at the top must equal the number of strands at + the bottom. Returns a Link. + + A synonym for this is ``Tangle.braid_closure()``. + + sage: BraidTangle([1,1,1]).braid_closure().alexander_polynomial() + t^2 - t + 1 + sage: BraidTangle([1,-2,1,-2]).braid_closure().alexander_polynomial() + t^2 - 3*t + 1 + >>> BraidTangle([1,-2,1,-2]).braid_closure().exterior().identify() # doctest: +SNAPPY + [m004(0,0), 4_1(0,0), K2_1(0,0), K4a1(0,0), otet02_00001(0,0)] + """ + m, n = self.boundary + if m != n: + raise ValueError("To do braid closure, both the top and bottom numbers of strands must be equal") + T = self.copy() + for i in range(n): + join_strands(T.adjacent[i], T.adjacent[m + i]) + return Link(T.crossings, check_planarity=False) + + def link(self): + """If its boundary is (0, 0), return this Tangle as a Link.""" + if self.boundary != (0, 0): + raise ValueError("The boundary must be (0, 0)") + return Link(self.copy().crossings, check_planarity=False) + + def reshape(self, boundary, displace=0): + """Renumber the boundary strands so that the tangle has the new boundary + shape. This is performed by either repeatedly moving the last strands from the + bottom right to the top right or vice versa. Simultaneously, displace controls + a rotation of the tangle where the tangle is rotated clockwise by ``displace`` steps + (so, for example, if 0 <= displace < m then the strand numbered ``displace`` + becomes the new lower-left strand). + + This is a generalization of ``Tangle.rotate()``. + """ + m, n = self.boundary + Tm, Tn = decode_boundary(boundary) + if m + n != Tm + Tn: + raise ValueError("Reshaping requires the tangle have the same number of boundary" + " strands as in the new boundary.") + T = self.copy() + # The 'adjacent' array but in total counterclockwise order + adj_ccw = T.adjacent[:m] + list(reversed(T.adjacent[m:])) + adj_ccw = rotate_list(adj_ccw, displace) + + return Tangle((Tm, Tn), T.crossings, + adj_ccw[:Tm] + list(reversed(adj_ccw[Tm:]))) + + def circular_rotate(self, n): + """ + Rotate a tangle in a circular fashion clockwise, keeping the same boundary. + + This generalizes ``Tangle.rotate()``, and it is a mild specialization of ``Tangle.reshape()``. + """ + return self.reshape(self.boundary, n) + + def circular_sum(self, other, n=0): + """ + Glue two tangles together to form a link by gluing them vertically and then taking + the braid closure (the ``Tangle.denominator_closure()``). + The second tangle is rotated clockwise by n strands using ``Tangle.circular_rotate()``. + """ + Am, An = self.boundary + Bm, Bn = self.boundary + if (Am, An) != (Bn, Bm): + raise ValueError("Tangles must have compatible boundary shapes") + return (self * (other.circular_rotate(n))).denominator_closure() + + def isosig(self, root=None, over_or_under=False): + """ + Return a bunch of data which encodes the planar isotopy class of the + tangle. Of course, this is just up to isotopy of the plane + (no Reidemeister moves). A root can be specified with a CrossingStrand + and ``over_or_under`` toggles whether only the underlying + shadow (4-valent planar map) is considered or the tangle with the + over/under data at each crossing. + + >>> BraidTangle([1]).isosig() == BraidTangle([1]).circular_rotate(1).isosig() + True + >>> BraidTangle([1]).isosig() == BraidTangle([-1]).isosig() + True + """ + copy = self.copy() + copy._fuse_strands() + return planar_isotopy.min_isosig(copy, root, over_or_under) + + def is_planar_isotopic(self, other, root=None, over_or_under=False) -> bool: + return self.isosig() == other.isosig() + + def _fuse_strands(self, preserve_boundary=False, preserve_components=False): + """Fuse all strands and delete them, even ones incident to only the boundary (unless + ``preserve_boundary`` is True). This will eliminate Strands that are loops as well. + + If ``preserve_components`` is True, then do not fuse strands that have the + ``component_idx`` attribute.""" + for s in reversed(self.crossings): + if isinstance(s, Strand): + # check that the strand is not only incident to the boundary + if preserve_boundary and all(a[0] == self for a in s.adjacent): + continue + if preserve_components and s.component_idx is not None: + continue + s.fuse() + self.crossings.remove(s) + + def __repr__(self): + return "" % self.label + + def describe(self, fuse_strands=True): + """Give a PD-like description of the tangle in the form + Tangle[{lower arcs}, {upper arcs}, P and X codes]. + + If fuse_strands is True, then fuse all internal Strand nodes first. + + >>> BraidTangle([1]).describe() + 'Tangle[{1,2}, {3,4}, X[2,4,3,1]]' + """ + T = self.copy() + if fuse_strands: + T._fuse_strands(preserve_boundary=True, preserve_components=True) + T.label = 0 + # give each crossing/strand a unique identifier, which + # is used for calculating ids for arcs + for i, c in enumerate(T.crossings): + c.label = i + 1 + arc_ids = {} + + def arc_key(c, i): + """For the given entity c and index into c.adjacent, + create a name for the incident arc. This gives something + that's suitable for use as a dictionary key.""" + d, j = c.adjacent[i] + return tuple(sorted([(c.label, i), (d.label, j)])) + + def arc_id(c, i): + """Get the unique integer id associated to the arc, generating + a fresh one if needed.""" + return arc_ids.setdefault(arc_key(c, i), len(arc_ids) + 1) + m, n = T.boundary + lower = "{" + ",".join(str(arc_id(T, i)) for i in range(m)) + "}" + upper = "{" + ",".join(str(arc_id(T, i)) for i in range(m, m + n)) + "}" + parts = [] + for c in T.crossings: + arcs = [arc_id(c, i) for i in range(len(c.adjacent))] + if isinstance(c, Crossing): + parts.append("X[%s,%s,%s,%s]" % tuple(arcs)) + elif isinstance(c, Strand): + if c.component_idx is not None: + parts.append(f"P[{arcs[0]},{arcs[1]}, component->{c.component_idx}]") + else: + parts.append(f"P[{arcs[0]},{arcs[1]}]") + else: + raise TypeError("Unexpected entity") + return f"Tangle[{lower}, {upper}{''.join(', ' + p for p in parts)}]" \ No newline at end of file diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index c31c88c..a0759ba 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -23,8 +23,7 @@ from collections import OrderedDict, Counter from .ordered_set import OrderedSet -from .links_base import Crossing, Strand, Link, CrossingStrand, CrossingEntryPoint -from . import planar_isotopy +from .links import Crossing, Strand, Link, CrossingStrand, CrossingEntryPoint class CyclicList(list): def __init__(self, iterable): @@ -181,44 +180,40 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, raise ValueError("Every element of crossings must be a Crossing or a Strand") self.unlinked_unknot_components = 0 - component_strands = [] - for s in reversed(crossings): - if isinstance(s, Strand): - if s.component_idx is not None: - # defer fusing - component_strands.append(s) - elif s.is_loop(): - self.unlinked_unknot_components += 1 - else: - s.fuse() - crossings.remove(s) - - # Note that crossings in Tangle can contain Strands with comp_idx for now + # Note that crossings in Tangle can contain Strands for now + # which will be removed after build self.crossings = crossings if build: self._build(start_orientations, component_starts) assert self.is_oriented(), 'Tangle is not oriented after build' - for s in component_strands: - comp_id = s.component_idx - comp = self.components[s.strand_component] + # Remove all Strands from crossings and components. + # Note that this will not affect strands in boundary_strands + for s in reversed(crossings): + if isinstance(s, Strand): + comp = self.components[s.strand_component] - if isinstance(comp[0].crossing, Tangle): - for cep in reversed(comp): - if cep.crossing == s: - comp.remove(cep) - break - else: - raise RuntimeError(f"Component strand {s} not found in component {comp}") - - # Note that the components are always built following the orientation - # hence below always insists that the comp_id is labeled on the entrance strand - if comp[1].component_idx is not None: - assert comp[1].component_idx == comp_id + if isinstance(comp[0].crossing, Tangle): + for cep in reversed(comp): + if cep.crossing == s: + comp.remove(cep) + break + else: + raise RuntimeError(f"Component strand {s} not found in component {comp}") + + # Note that the components are always built following the orientation + # hence below always insists that the comp_id is labeled on the entrance strand + if s.component_idx is not None: + comp_id = s.component_idx + if comp[1].crossing.component_idx is not None: + assert comp[1].crossing.component_idx == comp_id + else: + comp[1].crossing.component_idx = comp_id + if s.is_loop(): + self.unlinked_unknot_components += 1 else: - comp[1].component_idx = comp_id - + s.fuse() self.crossings.remove(s) def __getitem__(self, i): @@ -478,6 +473,7 @@ def _crossings_from_PD_code(self, code, entry_points): entry_strands.append(crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]]) else: this_strand = Strand(label = f'PDSE({self}, {i})') + crossings.append(this_strand) if x not in entry_dict: entry_strands.append((this_strand, 0)) entry_dict[x] = (this_strand, 1) @@ -606,7 +602,7 @@ def __add__(self, other): strand of other and the bottom-right strand of self to the bottom-left strand of other. >>> (IdentityBraid(2) + BraidTangle([1])).describe() - 'Tangle[{1,2}, {3,4}, P[1,3], X[2,4,5,5]]' + 'Tangle[{1,2}, {3,4}, X[2,4,5,5], P[1,3]]' """ A, B = self.copy(), other.copy() (mA, nA), (mB, nB) = A.boundary, B.boundary @@ -774,8 +770,6 @@ def link(self): return Link(crossings, check_planarity=False) - - # TODO: test reshape def reshape(self, boundary, displace=0): """Renumber the boundary strands so that the tangle has the new boundary shape. This is performed by either repeatedly moving the last strands from the @@ -834,7 +828,15 @@ def circular_sum(self, other, n=0): raise ValueError("Tangles must have compatible boundary shapes") return (self * (other.circular_rotate(n))).denominator_closure() - # TODO: check if isosig still works + def to_old_tangle(self): + from . import old_tangles + copy = self.copy() + + return old_tangles.Tangle(copy.boundary, + copy.crossings + copy.boundary_strands, + copy.adjacent, + copy.label) + def isosig(self, root=None, over_or_under=False): """ Return a bunch of data which encodes the planar isotopy class of the @@ -849,9 +851,9 @@ def isosig(self, root=None, over_or_under=False): >>> BraidTangle([1]).isosig() == BraidTangle([-1]).isosig() True """ - copy = self.copy() - copy._fuse_strands() - return planar_isotopy.min_isosig(copy, root, over_or_under) + + return self.to_old_tangle().isosig(root = root, + over_or_under=over_or_under) def reverse_orientation(self, component_index): """ @@ -922,7 +924,6 @@ def is_planar(self): pass def simplify(self, mode = 'basic', type_III_limit = 100): - # TODO: double check if this works from . import simplify if mode == 'basic': return simplify.basic_simplify(self) @@ -932,28 +933,11 @@ def simplify(self, mode = 'basic', type_III_limit = 100): raise NotImplementedError() def is_planar_isotopic(self, other, root=None, over_or_under=False) -> bool: - return self.isosig() == other.isosig() - - def _fuse_strands(self, preserve_boundary=False, preserve_components=False): - """Fuse all strands and delete them, even ones incident to only the boundary (unless - ``preserve_boundary`` is True). This will eliminate Strands that are loops as well. - - If ``preserve_components`` is True, then do not fuse strands that have the - ``component_idx`` attribute.""" - for s in reversed(self.crossings): - if isinstance(s, Strand): - # check that the strand is not only incident to the boundary - if preserve_boundary and all(a[0] == self for a in s.adjacent): - continue - if preserve_components and s.component_idx is not None: - continue - s.fuse() - self.crossings.remove(s) + return self.isosig(root = root, over_or_under=over_or_under) == other.isosig(root = root, over_or_under = over_or_under) def __repr__(self): return "" % self.label - # TODO: fix describe, or remove it? def describe(self, fuse_strands=True): """Give a PD-like description of the tangle in the form Tangle[{lower arcs}, {upper arcs}, P and X codes]. @@ -963,43 +947,8 @@ def describe(self, fuse_strands=True): >>> BraidTangle([1]).describe() 'Tangle[{1,2}, {3,4}, X[2,4,3,1]]' """ - T = self.copy() - if fuse_strands: - T._fuse_strands(preserve_boundary=True, preserve_components=True) - T.label = 0 - # give each crossing/strand a unique identifier, which - # is used for calculating ids for arcs - for i, c in enumerate(T.crossings): - c.label = i + 1 - arc_ids = {} - - def arc_key(c, i): - """For the given entity c and index into c.adjacent, - create a name for the incident arc. This gives something - that's suitable for use as a dictionary key.""" - d, j = c.adjacent[i] - return tuple(sorted([(c.label, i), (d.label, j)])) - - def arc_id(c, i): - """Get the unique integer id associated to the arc, generating - a fresh one if needed.""" - return arc_ids.setdefault(arc_key(c, i), len(arc_ids) + 1) - m, n = T.boundary - lower = "{" + ",".join(str(arc_id(T, i)) for i in range(m)) + "}" - upper = "{" + ",".join(str(arc_id(T, i)) for i in range(m, m + n)) + "}" - parts = [] - for c in T.crossings: - arcs = [arc_id(c, i) for i in range(len(c.adjacent))] - if isinstance(c, Crossing): - parts.append("X[%s,%s,%s,%s]" % tuple(arcs)) - elif isinstance(c, Strand): - if c.component_idx is not None: - parts.append(f"P[{arcs[0]},{arcs[1]}, component->{c.component_idx}]") - else: - parts.append(f"P[{arcs[0]},{arcs[1]}]") - else: - raise TypeError("Unexpected entity") - return f"Tangle[{lower}, {upper}{''.join(', ' + p for p in parts)}]" + + return self.to_old_tangle().describe(fuse_strands=fuse_strands) Tangle.bridge_closure = Tangle.numerator_closure @@ -1015,7 +964,7 @@ def ComponentTangle(component_idx): >>> T=(RationalTangle(2,3)+IdentityBraid(1))|(RationalTangle(2,5)+ComponentTangle(-1)) >>> T.describe() - 'Tangle[{1,2}, {3,4}, X[5,6,7,3], X[8,7,6,9], X[1,8,9,5], X[10,11,12,4], X[13,14,11,10], X[15,16,14,17], X[2,15,17,13], P[16,12, component->-1]]' + 'Tangle[{1,2}, {3,4}, X[5,3,6,7], X[8,5,7,9], X[1,8,9,6], X[10,4,11,12], X[13,14,12,11], X[14,15,16,10], X[17,16,15,13], P[2,17, component->-1]]' >>> M=T.braid_closure().exterior() # doctest: +SNAPPY >>> M.dehn_fill([(1,0),(0,0)]) # doctest: +SNAPPY @@ -1024,7 +973,7 @@ def ComponentTangle(component_idx): >>> T=(RationalTangle(2,3)+IdentityBraid(1))|(RationalTangle(2,5)+ComponentTangle(0)) >>> T.describe() - 'Tangle[{1,2}, {3,4}, X[5,6,7,3], X[8,7,6,9], X[1,8,9,5], X[10,11,12,4], X[13,14,11,10], X[15,16,14,17], X[2,15,17,13], P[16,12, component->0]]' + 'Tangle[{1,2}, {3,4}, X[5,3,6,7], X[8,5,7,9], X[1,8,9,6], X[10,4,11,12], X[13,14,12,11], X[14,15,16,10], X[17,16,15,13], P[2,17, component->0]]' >>> M=T.braid_closure().exterior() # doctest: +SNAPPY >>> M.dehn_fill([(0,0),(1,0)]) # doctest: +SNAPPY @@ -1151,8 +1100,13 @@ def __init__(self, a, b=1): if a < 0: T = -T + crossings = T.crossings + T.boundary_strands + + for c in crossings: + c._clear() + Tangle.__init__(self, 2, - T.crossings + T.boundary_strands, + crossings, T.adjacent, label = f"RationalTangle({a}, {b})") @@ -1203,9 +1157,9 @@ def BraidTangle(gens, n=None): >>> BraidTangle([-1]).describe() 'Tangle[{1,2}, {3,4}, X[1,2,4,3]]' >>> BraidTangle([1],3).describe() - 'Tangle[{1,2,3}, {4,5,6}, P[3,6], X[2,5,4,1]]' + 'Tangle[{1,2,3}, {4,5,6}, X[2,5,4,1], P[3,6]]' >>> BraidTangle([2],3).describe() - 'Tangle[{1,2,3}, {4,5,6}, P[1,4], X[3,6,5,2]]' + 'Tangle[{1,2,3}, {4,5,6}, X[3,6,5,2], P[1,4]]' >>> BraidTangle([1,2]).describe() 'Tangle[{1,2,3}, {4,5,6}, X[7,5,4,1], X[3,6,7,2]]' >>> BraidTangle([1,2,1]).describe() From 7b14645ec37342100a05d2e507477509731b35ca Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Thu, 28 May 2026 17:16:29 -0500 Subject: [PATCH 07/11] Add doctests and allow SnapPy to import BraidTangle and ComponentTangle --- spherogram_src/__init__.py | 2 +- spherogram_src/links/__init__.py | 4 +- spherogram_src/links/links_base.py | 33 ++++++ spherogram_src/links/tangles.py | 173 ++++++++++++++++++++++++++--- 4 files changed, 193 insertions(+), 19 deletions(-) diff --git a/spherogram_src/__init__.py b/spherogram_src/__init__.py index fd4644b..fc98bef 100644 --- a/spherogram_src/__init__.py +++ b/spherogram_src/__init__.py @@ -23,4 +23,4 @@ def version(): # from spherogram.links.tangles: 'Tangle', 'CapTangle', 'CupTangle', 'RationalTangle', 'ZeroTangle', 'InfinityTangle', 'MinusOneTangle', 'OneTangle', 'IntegerTangle', - 'IdentityBraid', 'ComponentTangle', 'join_strands'] + 'IdentityBraid', 'BraidTangle', 'ComponentTangle', 'join_strands'] diff --git a/spherogram_src/links/__init__.py b/spherogram_src/links/__init__.py index 91a5c36..5fa6bd3 100644 --- a/spherogram_src/links/__init__.py +++ b/spherogram_src/links/__init__.py @@ -2,7 +2,7 @@ import sys from .links import Crossing, Strand, Link, ClosedBraid -from .tangles import Tangle, CapTangle, CupTangle, RationalTangle, ZeroTangle, InfinityTangle, MinusOneTangle, OneTangle, IntegerTangle, IdentityBraid, ComponentTangle, join_strands +from .tangles import Tangle, CapTangle, CupTangle, RationalTangle, ZeroTangle, InfinityTangle, MinusOneTangle, OneTangle, IntegerTangle, IdentityBraid, BraidTangle, ComponentTangle, join_strands from . import orthogonal from .random_links import random_link from . import bands @@ -25,5 +25,5 @@ def pdf_docs(): __all__ = ['Crossing', 'Strand', 'Link', 'ClosedBraid', 'Tangle', 'CapTangle', 'CupTangle', 'RationalTangle', 'ZeroTangle', 'InfinityTangle', 'MinusOneTangle', 'OneTangle', 'IntegerTangle', - 'IdentityBraid', 'join_strands', + 'IdentityBraid', 'BraidTangle', 'ComponentTangle','join_strands', 'pdf_docs', 'random_link', 'bands'] diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index 2f3d506..ef65b34 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -890,6 +890,39 @@ def _rebuild(self, same_components_and_orientations=False): else: self._build() + def reverse_orientation(self, component_index): + """ + Reverse the orientation of components specified by component_index. + + component_index: either a single index of component or a list of indices of components + + """ + if not isinstance(component_index, (set, list, tuple)): + component_index = [component_index] + + org_entries = [] + for comp in self.components: + for cs in comp: + if cs.crossing in self.crossings: + org_entries.append(cs) + break + + new_starts = [] + for i, cs in enumerate(org_entries): + if i not in component_index: + c, e = cs.crossing, cs.strand_index + s = c._adjacent_len // 2 + reversed_cs = CrossingStrand(c, (e + s) % (2 * s)) + new_starts.append(reversed_cs) + else: + new_starts.append(cs) + + self.link_components = None + for c in self.crossings: + c._clear() + self._build(start_orientations = new_starts, + component_starts = new_starts) + def _check_crossing_orientations(self): for C in self.crossings: if C.sign == 1: diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index a0759ba..9e3c1d0 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -129,8 +129,12 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, * label is an arbitrary label for the tangle for informational purposes, which appears in the ``repr`` form of the tangle. - Usually tangles should not be created directly using this constructor since the - tangle operations and various primitive tangles are sufficient to create any tangle. + Tangles now support creation from PD_code, for example: + + >>> Tangle(3, [[0,4,1,5],[1,8,2,9],[2,7,3,6],[5,9,6,10]], [0,4,8,10,3,7], label = 'RIII') + + + see doc of ``PD_code`` for more details. """ if label is None: self.label = id(self) @@ -171,7 +175,7 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, " of entry_points") for i, e in enumerate(entry_points): - this_strand = Strand(label = f'TSE({self}, {i})') + this_strand = Strand(label = f'TSE({str(self)}, {i})') self.boundary_strands.append(this_strand) join_strands(e, (this_strand, 1)) join_strands((self, i), (this_strand, 0)) @@ -353,6 +357,23 @@ def _orient_crossings(self, start_orientations=None): s.orient() def _build_components(self, component_starts=None): + """ + Each component is stored as a list of *entry points* to crossings. + If the component starts and ends at the boundary of tangles, + the corresponding CrossingEntryPoint(self, boundary_index) + will be put at the tail and the head of the list. + + If provided, the component_starts must consist of one + CrossingEntryPoint per component. + + >>> len(RationalTangle(-2, 3).components) + 2 + >>> len(Tangle(3, [[0,4,1,5],[1,8,2,9],[2,7,3,6],[5,9,6,10]], + ... [0,4,8,10,3,7], label = 'RIII').components) + 3 + >>> len(((RationalTangle(2,3)+IdentityBraid(1))|(RationalTangle(2,5)+ComponentTangle(-1))).components) + 2 + """ if component_starts is not None: # Take all CrossingStrand and CrossingEntryPoint objects # and turn them into CrossingEntryPoints @@ -456,8 +477,6 @@ def _crossings_from_PD_code(self, code, entry_points): if any(len(v) > 2 for v in gluings.values()): raise ValueError("PD code isn't consistent") - - crossings = [Crossing(i) for i, d in enumerate(code)] for item in gluings.values(): @@ -472,7 +491,7 @@ def _crossings_from_PD_code(self, code, entry_points): if x in gluings: entry_strands.append(crossings[gluings[x][0][0]].crossing_strands()[gluings[x][0][1]]) else: - this_strand = Strand(label = f'PDSE({self}, {i})') + this_strand = Strand(label = f'PDSE({str(self)}, {i})') crossings.append(this_strand) if x not in entry_dict: entry_strands.append((this_strand, 0)) @@ -491,6 +510,22 @@ def _crossings_from_PD_code(self, code, entry_points): return crossings, component_starts, entry_strands def PD_code(self, KnotTheory=False, min_strand_index = 0): + """ + The planar diagram code for the tangle. Unlike for links, it returns two extra fields, + boundary and entry_info in addition to the PD code of crossings, in order to specify + how the boundary and entries of the tangle is arranged. The fields are ordered as follows: + + boundary, PD, entry_info + + so that they can be unpacked immediately for creating Tangles. + + >>> RationalTangle(-1,2).PD_code() + ((2, 2), [(1, 5, 2, 4), (3, 1, 4, 0)], [0, 3, 2, 5]) + >>> BraidTangle([1,2,1]).PD_code() + ((3, 3), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [0, 3, 6, 8, 5, 2]) + >>> Tangle(*RationalTangle(-1,2).PD_code()).PD_code() + ((2, 2), [(1, 5, 2, 4), (3, 1, 4, 0)], [0, 3, 2, 5]) + """ PD = [] entry_info = [s + min_strand_index for s in self.strand_labels] @@ -504,7 +539,7 @@ def PD_code(self, KnotTheory=False, min_strand_index = 0): else: PD = [tuple(x) for x in PD] - return PD, entry_info + return self.boundary, PD, entry_info def rot_num(self): #TODO @@ -779,6 +814,14 @@ def reshape(self, boundary, displace=0): becomes the new lower-left strand). This is a generalization of ``Tangle.rotate()``. + + >>> T = BraidTangle([1,2,1]) + >>> T.PD_code() + ((3, 3), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [0, 3, 6, 8, 5, 2]) + >>> T.reshape((4,2)).PD_code() + ((4, 2), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [0, 3, 6, 2, 8, 5]) + >>> T.reshape((4,2), displace = 1).PD_code() + ((4, 2), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [3, 6, 2, 5, 0, 8]) """ m, n = self.boundary Tm, Tn = decode_boundary(boundary) @@ -828,7 +871,7 @@ def circular_sum(self, other, n=0): raise ValueError("Tangles must have compatible boundary shapes") return (self * (other.circular_rotate(n))).denominator_closure() - def to_old_tangle(self): + def _to_old_tangle(self): from . import old_tangles copy = self.copy() @@ -850,16 +893,41 @@ def isosig(self, root=None, over_or_under=False): True >>> BraidTangle([1]).isosig() == BraidTangle([-1]).isosig() True + >>> BraidTangle([1,1]).isosig() == BraidTangle([-1,-1]).isosig() + True + >>> BraidTangle([1,1]).isosig(over_or_under=True) == BraidTangle([-1,-1]).isosig(over_or_under=True) + False """ - return self.to_old_tangle().isosig(root = root, + return self._to_old_tangle().isosig(root = root, over_or_under=over_or_under) def reverse_orientation(self, component_index): """ + Reverse the orientation of components specified by component_index, + changing the current tangle and the signs of crossings. + component_index: either a single index of component or a list of indices of components + + >>> T = BraidTangle([1,2,1]) + >>> T + + >>> T.PD_code() + ((3, 3), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [0, 3, 6, 8, 5, 2]) + >>> T.reverse_orientation(1) + >>> T.PD_code() + ((3, 3), [(7, 3, 8, 4), (6, 2, 7, 1), (4, 0, 5, 1)], [0, 5, 6, 8, 3, 2]) + >>> T.reverse_orientation([1,2]) + >>> T.PD_code() + ((3, 3), [(6, 4, 7, 5), (7, 1, 8, 2), (3, 1, 4, 0)], [0, 3, 8, 6, 5, 2]) + >>> T.reverse_orientation([0,2]) + >>> T.PD_code() + ((3, 3), [(7, 5, 8, 4), (6, 0, 7, 1), (3, 1, 4, 2)], [2, 3, 6, 8, 5, 0]) + >>> T.reverse_orientation([0]) + >>> T.PD_code() + ((3, 3), [(7, 5, 8, 4), (6, 2, 7, 1), (3, 1, 4, 0)], [0, 3, 6, 8, 5, 2]) """ - if not isinstance(component_index, (set, list)): + if not isinstance(component_index, (set, list, tuple)): component_index = [component_index] org_entries = [] @@ -885,10 +953,26 @@ def reverse_orientation(self, component_index): def faces(self): """ - + The faces are the complementary regions of the tangle diagram in the disk, + where the boundary of the disk is thought of as the cusp. + + Each face is given as a list of corners of crossings as one + goes around *clockwise*. These corners are recorded as + CrossingStrands, where CrossingStrand(c, j) denotes the corner + of the face abutting crossing c between strand j and j + 1; + similarly, if c is the tangle itself, it denots the corner + as one stands at the j-th boundary entry and look *counterclockwisely*. + + Alternatively, the sequence of CrossingStrands can be regarded + as the *heads* of the oriented edges of the face. + + >>> len(IdentityBraid(2).faces()) + 3 + >>> len(BraidTangle([1,2,1]).faces()) + 7 """ corners = OrderedSet([CrossingStrand(c, i) - for c in self.crossings for i in range(4)]) + for c in self.crossings + self.boundary_strands for i in range(c._adjacent_len)]) faces = [] while len(corners): cs0 = corners.pop() @@ -924,6 +1008,49 @@ def is_planar(self): pass def simplify(self, mode = 'basic', type_III_limit = 100): + """ + Tries to simplify the tangle diagram. Returns whether it succeeded + in reducing the number of crossings. Modifies the tangle in place, + and unknot components which are also unlinked may be silently discarded. + The ordering of ``components`` is not always preserved. + + The following strategies can be employed. + + 1. In the default ``basic`` mode, it does Reidemeister I and II moves + until none are possible. + + 2. In ``level`` mode, it does random Reidemeister III moves, reducing + the number of crossings via type I and II moves whenever possible. + The process stops when it has done ``type_III_limit`` *consecutive* + type III moves without any simplification. + + The ``pickup`` and ``global`` modes are currently not available for tangles. + + Some examples: + + >>> T = Tangle(2, [[0,3,1,4],[1,5,2,4]], [0,3,2,5], label = 'RII') + >>> T + + >>> T.simplify('basic') + True + >>> T + + >>> T.simplify('basic') # Already done all it can + False + + >>> T = Tangle(3, [[0,4,1,5],[1,8,2,9],[2,7,3,6],[5,9,6,10]], + ... [0,4,8,10,3,7], label = 'RIII') + >>> T + + >>> T.simplify('basic') + False + >>> T # No change happens + + >>> T.simplify('level') + True + >>> T + + """ from . import simplify if mode == 'basic': return simplify.basic_simplify(self) @@ -936,6 +1063,9 @@ def is_planar_isotopic(self, other, root=None, over_or_under=False) -> bool: return self.isosig(root = root, over_or_under=over_or_under) == other.isosig(root = root, over_or_under = over_or_under) def __repr__(self): + return "" % (self.label, len(self.components), len(self.crossings), self.boundary[0], self.boundary[1]) + + def __str__(self): return "" % self.label def describe(self, fuse_strands=True): @@ -948,10 +1078,11 @@ def describe(self, fuse_strands=True): 'Tangle[{1,2}, {3,4}, X[2,4,3,1]]' """ - return self.to_old_tangle().describe(fuse_strands=fuse_strands) + return self._to_old_tangle().describe(fuse_strands=fuse_strands) Tangle.bridge_closure = Tangle.numerator_closure + Tangle.braid_closure = Tangle.denominator_closure @@ -962,6 +1093,9 @@ def ComponentTangle(component_idx): this tangle should be the last component when it is turned into a Link. + >>> ComponentTangle(2) + + >>> T=(RationalTangle(2,3)+IdentityBraid(1))|(RationalTangle(2,5)+ComponentTangle(-1)) >>> T.describe() 'Tangle[{1,2}, {3,4}, X[5,3,6,7], X[8,5,7,9], X[1,8,9,6], X[10,4,11,12], X[13,14,12,11], X[14,15,16,10], X[17,16,15,13], P[2,17, component->-1]]' @@ -985,10 +1119,9 @@ def ComponentTangle(component_idx): Traceback (most recent call last): ... ValueError: Two Strand objects in different components have the same component_idx values - """ s = Strand(component_idx=component_idx) - return Tangle((1, 1), [s], [(s, 0), (s, 1)]) + return Tangle((1, 1), [s], [(s, 0), (s, 1)], label = f'ComponentTangle({component_idx})') def CapTangle(): @@ -1084,6 +1217,9 @@ class RationalTangle(Tangle): attributes: ``fraction`` gives (a, b) and ``partial_quotients`` gives the continued fraction expansion of ``abs(a)/b``. + >>> RationalTangle(-2,3) + + >>> RationalTangle(2,5).braid_closure().exterior().identify() # doctest: +SNAPPY [m004(0,0), 4_1(0,0), K2_1(0,0), K4a1(0,0), otet02_00001(0,0)] """ @@ -1127,6 +1263,8 @@ def IdentityBraid(n): 'Tangle[{1}, {2}, P[1,2]]' >>> IdentityBraid(2).describe() 'Tangle[{1,2}, {3,4}, P[1,3], P[2,4]]' + >>> IdentityBraid(5) + >>> IdentityBraid(-1) Traceback (most recent call last): ... @@ -1151,7 +1289,7 @@ def BraidTangle(gens, n=None): number of strands that works for the given list of generators >>> BraidTangle([], 1) - + >>> BraidTangle([1]).describe() 'Tangle[{1,2}, {3,4}, X[2,4,3,1]]' >>> BraidTangle([-1]).describe() @@ -1164,6 +1302,8 @@ def BraidTangle(gens, n=None): 'Tangle[{1,2,3}, {4,5,6}, X[7,5,4,1], X[3,6,7,2]]' >>> BraidTangle([1,2,1]).describe() 'Tangle[{1,2,3}, {4,5,6}, X[7,5,4,8], X[3,6,7,9], X[2,9,8,1]]' + >>> BraidTangle([1,2,1]) + """ if n is None: n = max(-min(gens), max(gens)) + 1 @@ -1182,5 +1322,6 @@ def gen(i): b = b * gen(i) b.make_upward() + b.update_label(f'BraidTangle({gens}, {n})') return b From 53e7cdf49f622a9a10d6774c1e8d95d7e9e7368f Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Thu, 28 May 2026 17:18:03 -0500 Subject: [PATCH 08/11] Remove is_planar and rot_num in TODO phase temporarly to prepare for creating pull request --- spherogram_src/links/tangles.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 9e3c1d0..7f602ba 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -540,10 +540,6 @@ def PD_code(self, KnotTheory=False, min_strand_index = 0): PD = [tuple(x) for x in PD] return self.boundary, PD, entry_info - - def rot_num(self): - #TODO - pass def _component_starts_from_PD(self, code, labels, gluings, entry_dict): """ @@ -1003,10 +999,6 @@ def faces(self): return faces - def is_planar(self): - # TODO - pass - def simplify(self, mode = 'basic', type_III_limit = 100): """ Tries to simplify the tangle diagram. Returns whether it succeeded From 76b157ebab8a6a1f865d5febbf405cfe5a43a841 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Thu, 28 May 2026 17:55:51 -0500 Subject: [PATCH 09/11] Update docstring for make_upward --- spherogram_src/links/links_base.py | 1 - spherogram_src/links/tangles.py | 20 ++++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/spherogram_src/links/links_base.py b/spherogram_src/links/links_base.py index ef65b34..c14acb7 100644 --- a/spherogram_src/links/links_base.py +++ b/spherogram_src/links/links_base.py @@ -291,7 +291,6 @@ def next(self): return CrossingEntryPoint(*c.adjacent[(e + s) % (2 * s)]) else: raise RuntimeError('This should not be reached') - return CrossingEntryPoint(*self.crossing.adjacent[self.strand_index]) def previous(self): d, j = self.opposite() diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 7f602ba..e21df77 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -238,6 +238,26 @@ def is_oriented(self): return all(s != 0 for s in self.boundary_signs) def make_upward(self): + """ + Change the orientation of the tangle, trying to make it upwardly oriented. + The order of components is preserved. + + >>> T = BraidTangle([1,2,1]) + >>> T.reverse_orientation([1,2]) + >>> T.is_upward() + False + >>> T.make_upward() + >>> T.is_upward() + True + + Like alternating() for Links, this may fail silently if there is no orientation + which makes the tangle upwardly oriented. + + >>> T = RationalTangle(-2,3) + >>> T.make_upward() + >>> T.is_upward() + False + """ if self.is_upward(): return From e3632027543eb84c8b4fb12608e6d0fd66f6bbf1 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Thu, 28 May 2026 18:23:42 -0500 Subject: [PATCH 10/11] Make __or__ preserve orientations on the original tangles --- spherogram_src/links/tangles.py | 54 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index e21df77..2ebd433 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -106,7 +106,7 @@ def add(self, c): return component class Tangle: - def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, label=None): + def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, label=None, start_orientations = None, component_starts = None): """ A tangle is a fragment of a Link with some number of boundary strands. Tangles can be composed in various ways along their boundary strands, @@ -142,8 +142,8 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, self.label = label m, n = decode_boundary(boundary) - component_starts = None - start_orientations = None + component_starts = component_starts + start_orientations = start_orientations self.strand_labels = CyclicList(m * [None] + n * [None]) self.strand_components = CyclicList(m * [None] + n * [None]) @@ -155,6 +155,8 @@ def __init__(self, boundary=2, crossings=None, entry_points=None, build = True, if (len(crossings) > 0 and not isinstance(crossings[0], (Strand, Crossing)))\ or (entry_points is not None and len(entry_points) > 0 and not isinstance(entry_points[0], (CrossingStrand, list, tuple))): + assert component_starts is None and start_orientations is None, "Specifying components_starts and start_orientations is not compatible with creating from PD codes" + crossings, component_starts, entry_points = self._crossings_from_PD_code(crossings, entry_points) start_orientations = component_starts[:] @@ -300,13 +302,8 @@ def _rebuild(self, same_components_and_orientations = False): # Hopefully we have enough of the original components left # to figure out what this is. Otherwise, new choices will # be made as in the default algorithm. - start_css = [] - for comp in self.components: - for cs in comp: - if cs.crossing in self.crossings + self.boundary_strands: - s = cs.crossing._adjacent_len // 2 - start_css.append(cs.rotate(s)) - break + start_css = self._start_orientations() + self._clear() if same_components_and_orientations: self._build(start_orientations=start_css, @@ -712,11 +709,40 @@ def __neg__(self): c.orient() return T + def _start_orientations(self): + """ + Obtain the start orientations according to the current orientation + and components (the latter may be outdated) + """ + start_css = [] + for comp in self.components: + for cs in comp: + if cs.crossing in self.crossings + self.boundary_strands: + s = cs.crossing._adjacent_len // 2 + start_css.append(cs.rotate(s)) + break + + return start_css + def __or__(self, other): - """Put self to left of other. This is like tangle addition but without the fusing of strands. + """ + Put self to left of other. This is like tangle addition but without the fusing of strands. + Preserves the orientations of both tangles, since no gluing happens. >>> (IdentityBraid(1) | CupTangle()).describe() 'Tangle[{1}, {2,3,4}, P[1,2], P[3,4]]' + + >>> T = BraidTangle([1,2,1]) + >>> T.reverse_orientation([1,2]) + >>> T.is_upward() + False + >>> T.boundary_signs + [-1, 1, 1, -1, -1, 1] + >>> TT = T | snappy.RationalTangle(1,2) + >>> TT.is_upward() + False + >>> TT.boundary_signs + [-1, 1, 1, -1, -1, -1, -1, 1, 1, 1] """ A, B = self.copy(), other.copy() (mA, nA), (mB, nB) = A.boundary, B.boundary @@ -724,9 +750,13 @@ def __or__(self, other): entry_points = a[:mA] + b[:mB] + a[mA:] + b[mB:] crossings = A.crossings + A.boundary_strands + B.crossings + B.boundary_strands + start_css = A._start_orientations() + B._start_orientations() + return Tangle((mA + mB, nA + nB), crossings, - entry_points) + entry_points, + start_orientations=start_css, + component_starts=start_css) def copy(self): return pickle.loads(pickle.dumps(self)) From a1aec955be18b23825d73978e15aea768111b7e1 Mon Sep 17 00:00:00 2001 From: Shana <903443276@qq.com> Date: Thu, 28 May 2026 18:24:17 -0500 Subject: [PATCH 11/11] Fix docstring --- spherogram_src/links/tangles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spherogram_src/links/tangles.py b/spherogram_src/links/tangles.py index 2ebd433..44c5dcf 100644 --- a/spherogram_src/links/tangles.py +++ b/spherogram_src/links/tangles.py @@ -738,7 +738,7 @@ def __or__(self, other): False >>> T.boundary_signs [-1, 1, 1, -1, -1, 1] - >>> TT = T | snappy.RationalTangle(1,2) + >>> TT = T | RationalTangle(1,2) >>> TT.is_upward() False >>> TT.boundary_signs