Source code for QPolargraph.patterns.TarzanScan

'''TarzanScan — geometry-native polargraph scan pattern.

Each move in a Tarzan scan involves exactly one motor.  The payload
swings on a circular arc (constant belt length on one side) while the
other motor is held fixed, tracing the natural coordinate lines of the
polargraph.  Because no interpolation is required, each arc is smooth
and free of the curvature artefacts seen in Cartesian raster scans.

One scan cycle visits the four sides of the scan rectangle in order:

1. **Top → right edge** : right belt fixed, left belt unwinds.
   Payload arcs around the right pulley.
2. **Right edge → bottom** : left belt fixed, right belt unwinds.
   Payload arcs around the left pulley.
3. **Bottom → left edge** : right belt fixed, left belt winds.
   Payload arcs around the right pulley.
4. **Left edge → top** : left belt fixed, right belt winds.
   Payload arcs around the left pulley.

After one cycle the payload returns to the top edge at a new
x-position x\\ :sub:`1`.  The sequence x\\ :sub:`0`, x\\ :sub:`1`,
x\\ :sub:`2`, … is determined entirely by the scan geometry and the
choice of x\\ :sub:`0`; cycles are repeated until the starting
x-position leaves the scan rectangle.
'''

from QPolargraph.patterns.QScanPattern import QScanPattern
import numpy as np
import logging

logger = logging.getLogger(__name__)


[docs] class TarzanScan(QScanPattern): '''Geometry-native scan pattern using alternating single-motor arcs. Overrides :meth:`~QPolargraph.QScanPattern.QScanPattern.vertices` and :meth:`~QPolargraph.QScanPattern.QScanPattern.trajectory` to produce a sequence of circular arcs, each driven by a single motor. Scan data are collected on all four arc segments of every cycle. Parameters ---------- x0 : float, optional Starting x-coordinate on the top edge of the scan area [m]. Default: ``0.0``. Adjust until the trajectory covers the scan area satisfactorily. Notes ----- All parameters inherited from :class:`~QPolargraph.QScanPattern.QScanPattern` (``width``, ``height``, ``dx``, ``dy``, ``step``) are also accepted. ''' _TRAJECTORY_PTS = 50 def __init__(self, *args, x0: float = 0., **kwargs): super().__init__(*args, **kwargs) self._x0 = float(x0) @property def x0(self) -> float: '''Starting x-coordinate on the top edge of the scan area [m].''' return self._x0 @x0.setter def x0(self, value: float) -> None: self._x0 = float(value) @property def tarzan_B(self) -> float: '''Key parameter of the Tarzan map [m²]. Defined as ``B = 4h·x_right + y_top² − y_bottom²`` where ``h = ell/2``. The Tarzan map ``T(x₀)`` has a closed form involving only ``B`` and its partner ``E = −B + 8h·dx``. When ``B = 0`` the map is the identity and every orbit is period-1 regardless of ``x0``; adjust ``dy`` or ``height`` until ``B ≠ 0``. ''' x_left, y_top, x_right, y_bottom = self.rect h = self.polargraph.ell / 2. return 4. * h * x_right + y_top**2 - y_bottom**2 @property def is_degenerate(self) -> bool: '''``True`` when the scan geometry produces a periodic Tarzan map. Degeneracy (``B ≈ 0``) means every orbit is period-1: the scan repeats the same path on every cycle regardless of ``x0``. Increase or decrease ``dy`` (or change ``height``) to break the degeneracy. ''' scale = self.polargraph.ell * self.width return abs(self.tarzan_B) < 1e-9 * scale @property def fixed_point(self) -> float | None: '''Unique fixed point of the Tarzan map [m], or ``None``. Returns ``x0* = h + dx − B / (4·dx)`` when ``B ≠ 0`` and ``dx ≠ 0``. Passing ``x0 = fixed_point`` produces a period-1 orbit (identical repeated scans); avoid it. Returns ``None`` in two cases: * ``dx = 0`` and ``B ≠ 0``: no fixed points exist — any ``x0`` yields an aperiodic scan. * ``B = 0``: all ``x0`` are fixed points (degenerate geometry). ''' if self.is_degenerate: return None if abs(self.dx) < 1e-12: return None h = self.polargraph.ell / 2. return h + self.dx - self.tarzan_B / (4. * self.dx) # ------------------------------------------------------------------ # Internal geometry helpers # ------------------------------------------------------------------ def _pulley_positions(self) -> tuple[np.ndarray, np.ndarray]: '''Return the (x, y) positions of the left and right pulleys [m].''' h = self.polargraph.ell / 2. return np.array([-h, 0.]), np.array([h, 0.]) def _cycle(self, p_start: np.ndarray) -> list[np.ndarray] | None: '''Compute the four arc-corner points for one Tarzan cycle. Parameters ---------- p_start : np.ndarray Starting point ``(x, y)`` on the top edge of the scan area [m]. Returns ------- list of np.ndarray or None ``[P1, P2, P3, P4]`` — arc endpoints at the right edge, bottom edge, left edge, and top edge respectively [m]. Returns ``None`` if any arc fails to reach its target boundary (scan geometry is incompatible with a full cycle from this point). ''' x_left, y_top, x_right, y_bottom = self.rect L, R = self._pulley_positions() # Segment 1: arc around R (right pulley), top edge → right edge s2 = np.linalg.norm(p_start - R) d1 = s2**2 - (x_right - R[0])**2 if d1 < 0: return None p1 = np.array([x_right, np.sqrt(d1)]) # Segment 2: arc around L (left pulley), right edge → bottom edge s1 = np.linalg.norm(p1 - L) d2 = s1**2 - y_bottom**2 if d2 < 0: return None p2 = np.array([L[0] + np.sqrt(d2), y_bottom]) # Segment 3: arc around R (right pulley), bottom edge → left edge s2 = np.linalg.norm(p2 - R) d3 = s2**2 - (x_left - R[0])**2 if d3 < 0: return None p3 = np.array([x_left, np.sqrt(d3)]) # Segment 4: arc around L (left pulley), left edge → top edge s1 = np.linalg.norm(p3 - L) d4 = s1**2 - y_top**2 if d4 < 0: return None p4 = np.array([L[0] + np.sqrt(d4), y_top]) return [p1, p2, p3, p4] def _arc_points(self, p_start: np.ndarray, p_end: np.ndarray, center: np.ndarray) -> np.ndarray: '''Sample :attr:`_TRAJECTORY_PTS` points along a circular arc. Parameters ---------- p_start : np.ndarray Arc start point ``(x, y)`` [m]. p_end : np.ndarray Arc end point ``(x, y)`` [m]. center : np.ndarray Arc center ``(x, y)`` [m] (pulley position). Returns ------- numpy.ndarray ``(n, 2)`` array of ``(x, y)`` points along the arc [m]. ''' r = np.linalg.norm(p_start - center) theta_start = np.arctan2(p_start[1] - center[1], p_start[0] - center[0]) theta_end = np.arctan2(p_end[1] - center[1], p_end[0] - center[0]) theta = np.linspace(theta_start, theta_end, self._TRAJECTORY_PTS) x = center[0] + r * np.cos(theta) y = center[1] + r * np.sin(theta) return np.column_stack([x, y]) # ------------------------------------------------------------------ # QScanPattern interface # ------------------------------------------------------------------
[docs] def vertices(self) -> np.ndarray: '''Return arc-corner waypoints for all Tarzan scan cycles. Iterates cycles starting from ``(x0, y_top)`` until the next starting x-position leaves ``[x_left, x_right]``. Returns ------- numpy.ndarray ``(nvertices, 2)`` array of ``(x, y)`` waypoints [m]. ''' if self.is_degenerate: logger.warning( 'TarzanScan: degenerate geometry (B ≈ 0) — every cycle ' 'repeats the same path. Adjust dy or height until ' 'ell·width ≠ height·(y_top + y_bottom).') x_left, y_top, x_right, _ = self.rect p = np.array([self._x0, y_top]) pts = [p.copy()] while x_left <= p[0] <= x_right: result = self._cycle(p) if result is None: break p1, p2, p3, p4 = result pts.extend([p1, p2, p3, p4]) if p4[0] <= p[0]: # Fixed point or backward scan: record the cycle and stop. break p = p4 return np.array(pts)
[docs] def trajectory(self) -> np.ndarray: '''Return the Tarzan scan path sampled along each arc. Each segment between consecutive vertices is a true circular arc; this method samples each arc at :attr:`_TRAJECTORY_PTS` points for accurate display. Returns ------- numpy.ndarray ``(2, npts)`` array of ``(x, y)`` coordinates [m]. ''' L, R = self._pulley_positions() # Segments within each cycle alternate: R, L, R, L centers = [R, L, R, L] v = self.vertices() if len(v) < 2: return super().trajectory() arcs = [] for i in range(len(v) - 1): center = centers[i % 4] arc = self._arc_points(v[i], v[i + 1], center) arcs.append(arc) pts = np.vstack(arcs) return pts.T