Source code for cubemarspycan.units
"""Pure unit conversions.
Nothing in this module performs I/O, imports ``can``, or holds mutable state. It is the
bottom of the dependency graph and is covered to 100%.
The float/uint pair is the single most important thing here. The manual's own
``float_to_uint`` uses ``(1 << bits) / span``, which **overflows the field at exactly
x_max** (12.5 rad in a 16-bit field maps to 65536, which does not fit; 5.0 N*m in a
12-bit field maps to 4096, which does not fit). We use ``((1 << bits) - 1) / span``,
which is the exact inverse of the firmware's documented ``uint_to_float`` and can never
overflow. The two formulas disagree by at most 1 LSB (0.00038 rad on AK40-10 position).
"""
from __future__ import annotations
import math
TAU = 2.0 * math.pi
"""One full turn in radians."""
_RADPS_PER_RPM = TAU / 60.0
[docs]
def max_uint(bits: int) -> int:
"""Largest value representable in an unsigned field of ``bits`` bits."""
return (1 << bits) - 1
[docs]
def float_to_uint(x: float, lo: float, hi: float, bits: int) -> int:
"""Quantise ``x`` in ``[lo, hi]`` onto an unsigned ``bits``-bit field.
``x`` is clamped into range first, so this never raises for out-of-range input and
the result is always in ``[0, max_uint(bits)]``. ``lo`` maps to 0 and ``hi`` maps to
``max_uint(bits)`` exactly.
"""
if hi <= lo:
raise ValueError(f"empty range: lo={lo!r} hi={hi!r}")
if not 1 <= bits <= 32:
raise ValueError(f"bits out of range: {bits!r}")
if math.isnan(x):
raise ValueError("cannot encode NaN")
x = min(max(x, lo), hi)
u = round((x - lo) * (max_uint(bits) / (hi - lo)))
# round() on a clamped value cannot escape the field, but belt and braces: a value
# that escapes here would corrupt neighbouring bit-fields in the packed frame.
return min(max(u, 0), max_uint(bits))
[docs]
def uint_to_float(u: int, lo: float, hi: float, bits: int) -> float:
"""Inverse of :func:`float_to_uint`. This matches the firmware's documented formula."""
if hi <= lo:
raise ValueError(f"empty range: lo={lo!r} hi={hi!r}")
if not 1 <= bits <= 32:
raise ValueError(f"bits out of range: {bits!r}")
return u * (hi - lo) / max_uint(bits) + lo
[docs]
def lsb(lo: float, hi: float, bits: int) -> float:
"""Size of one least-significant bit, in the field's own units."""
return (hi - lo) / max_uint(bits)
# --- rotational -------------------------------------------------------------------
[docs]
def rpm_to_radps(rpm: float) -> float:
"""Mechanical RPM to rad/s. Side-neutral: whatever shaft you put in, you get out.
Every other conversion in this module is side-specific; this one is a pure unit
change, so it cannot be wrong about the gearbox.
"""
return rpm * _RADPS_PER_RPM
[docs]
def radps_to_rpm(radps: float) -> float:
"""rad/s to mechanical RPM, the inverse of :func:`rpm_to_radps`. Side-neutral."""
return radps / _RADPS_PER_RPM
[docs]
def erpm_to_radps(erpm: float, pole_pairs: int, gear_ratio: float) -> float:
"""Electrical RPM -> mechanical rad/s **at the output shaft**.
ERPM counts electrical revolutions of the rotor, so both the pole-pair count and the
gearbox divide out. For the AK40-10 (14 pole pairs, 10:1) one ERPM is 7.480e-4 rad/s.
"""
if pole_pairs <= 0:
raise ValueError(f"pole_pairs must be positive, got {pole_pairs!r}")
if gear_ratio <= 0:
raise ValueError(f"gear_ratio must be positive, got {gear_ratio!r}")
return erpm * TAU / (60.0 * pole_pairs * gear_ratio)
[docs]
def radps_to_erpm(radps: float, pole_pairs: int, gear_ratio: float) -> float:
"""Mechanical rad/s at the output shaft -> electrical RPM."""
if pole_pairs <= 0:
raise ValueError(f"pole_pairs must be positive, got {pole_pairs!r}")
if gear_ratio <= 0:
raise ValueError(f"gear_ratio must be positive, got {gear_ratio!r}")
return radps * 60.0 * pole_pairs * gear_ratio / TAU
deg_to_rad = math.radians
rad_to_deg = math.degrees
# --- two's complement helpers (servo-mode wire fields) ------------------------------
[docs]
def to_signed(value: int, bits: int) -> int:
"""Reinterpret the low ``bits`` of ``value`` as a two's-complement signed integer."""
value &= max_uint(bits)
sign = 1 << (bits - 1)
return value - (1 << bits) if value & sign else value
[docs]
def to_unsigned(value: int, bits: int) -> int:
"""Two's-complement encode ``value`` into ``bits`` bits, wrapping like C would."""
return value & max_uint(bits)