Source code for cubemarspycan.unwrap

"""Multi-turn position tracking.

A CAN position field is finite. The AK40-10's MIT field covers +/-12.5 rad, so a little
under two output turns; servo mode covers +/-3200 degrees. Travel past the end and the
reported value either wraps or saturates, and **the manual does not say which**. So this
is opt-in: you tell it which behaviour you observed on the bench, and until you have, the
motor layer does not unwrap at all.

Sampling requirement: wrap detection assumes that between two samples the motor moved less
than half a field span. For the AK40-10 in MIT mode that is 12.5 rad at up to 45.5 rad/s,
so any loop faster than 3.6 Hz is safe - a wide margin, but it is a real precondition and
it is why this class refuses to guess when a sample is missed.
"""

from __future__ import annotations

from .errors import SpecIncompleteError
from .spec import FieldRange, WrapMode

__all__ = ["TurnCounter", "WrapMode"]


[docs] class TurnCounter: """Turns a wrapping field reading into a continuous value. Not thread-safe by design: it belongs to one motor and is driven from ``update()`` on the control thread, never from the receive thread. Keeping the derivation off the receive path means it runs at a known rate rather than at whatever rate frames happen to arrive. """ __slots__ = ("_field", "_last_raw", "_mode", "_saturation_seen", "_turns") def __init__(self, field: FieldRange, mode: WrapMode = WrapMode.UNKNOWN) -> None: self._field = field self._mode = mode self._turns = 0 self._last_raw: float | None = None self._saturation_seen = False @property def turns(self) -> int: """Net field spans traversed since the last reset.""" return self._turns @property def mode(self) -> WrapMode: """The wrap behaviour this counter was built for. :attr:`~cubemarspycan.spec.WrapMode.UNKNOWN` means unwrapping is disabled and multi-turn reads refuse: guessing whether a field wraps or saturates produces a position that is wrong by a whole field span. """ return self._mode @property def saturation_seen(self) -> bool: """True once a reading has sat at a field limit, where position is unrecoverable.""" return self._saturation_seen
[docs] def reset(self, raw: float | None = None) -> None: """Forget the accumulated turns, optionally re-anchoring on ``raw``. Called by :meth:`~cubemarspycan.motor.mit.MitMotor.zero_here`, since the origin has moved and the old turn count no longer means anything. Without ``raw`` the next reading establishes the new anchor. """ self._turns = 0 self._last_raw = raw self._saturation_seen = False
[docs] def update(self, raw: float) -> float: """Feed a raw field reading, get a continuous position back.""" if self._mode is WrapMode.UNKNOWN: raise SpecIncompleteError( "multi-turn unwrapping needs to know whether this firmware wraps or " "saturates at the position field limit; the manual does not say. Settle " "it on the bench (drive past the limit and watch the reported value), " "then pass wrap_mode=WrapMode.WRAP or WrapMode.SATURATE. Until then the " "raw field reading is available unmodified." ) if self._mode is WrapMode.SATURATE: # Nothing to recover: once the reading sticks at the rail, the true position # is simply not on the wire. Flag it so the caller can distinguish "at the # limit" from "moving normally near the limit". if raw >= self._field.hi - self._field.lsb or raw <= self._field.lo + self._field.lsb: self._saturation_seen = True self._last_raw = raw return raw if self._last_raw is not None: delta = raw - self._last_raw half = self._field.span / 2.0 if delta > half: self._turns -= 1 elif delta < -half: self._turns += 1 self._last_raw = raw return raw + self._turns * self._field.span