Core data model¶
Specs and provenance¶
The motor data model.
Two ideas carry this module, and between them they retire most of the defect classes found in the prior art.
1. A wire field and a physical limit are different types.
FieldRange carries bits and exists to be handed to the quantiser.
PhysicalLimits has no bits and can never reach a codec. Keeping them apart
matters because across the AK line they differ in both directions: the AK40-10’s MIT
torque field is +/-5.0 N*m against a 4.1 N*m peak (the field over-promises), while the
AK80-9’s is +/-18 N*m against a 22 N*m peak (the field is what binds). Code that treats
“the limit” as one number is wrong for one of those motors whichever it picks.
2. Unknown is representable, and it refuses.
Every constant that is not on a wire is wrapped in Sourced, which records where
the number came from. A conversion that needs an unknown constant raises
SpecIncompleteError rather than guessing. TMotorCANControl
guessed - it hard-codes radps_per_ERPM = 5.82e-4 for every motor, which is 22% wrong
for the AK40-10 - and shipped a table of constants literally commented
UNTESTED CONSTANT!. Refusing is more useful than a plausible wrong answer.
- class Source(*values)[source]¶
Bases:
EnumWhere a number came from, loosely ordered by how much it should be trusted.
- MEASURED¶
Fitted on a bench by the user of this library.
- MANUAL¶
AK Series Module Driver Manual v1.0.18.
- DATASHEET¶
CubeMars published product specification.
- TOOL¶
Read out of CubeMarsTool from this particular driver.
- NAMEPLATE¶
Inferred from the model name, e.g. the “-10” in AK40-10 meaning 10:1.
- ESTIMATED¶
Derived from another constant, e.g. Kt from Kv.
- ASSUMED¶
Set by the user via evolve() without verification.
- UNKNOWN¶
No value. Conversions that need it must refuse.
- class Sourced( )[source]¶
Bases:
Generic[T]A constant together with its provenance.
- property known: bool¶
Whether there is a value at all. Says nothing about how good it is.
knownis the gaterequire()uses, so a guessed constant still passes it. Usetrustedwhen the quality matters.
- property trusted: bool¶
Whether the value came from a source worth acting on without checking.
True only for
Source.MEASURED,Source.MANUAL,Source.DATASHEETandSource.TOOL. Notably false forNAMEPLATE(inferred from the model name),ESTIMATED(derived from another constant) andASSUMED- each of which is known, and any of which may be wrong.The difference between this and
knownis the whole point of the type: a conversion may proceed on a known constant, but a warning belongs on one that is not trusted.
- unknown(note: str = '') Sourced[T][source]¶
A constant that has not been established. Conversions needing it will refuse.
- class WrapMode(*values)[source]¶
Bases:
EnumWhat the driver does when position leaves the field’s range.
- WRAP¶
Reported value rolls over to the far end. Unwrapping can recover true position.
- SATURATE¶
Reported value sticks at the limit. True position is unrecoverable past it.
- UNKNOWN¶
Not yet established on this firmware. Refuses rather than guessing.
- class FieldRange(lo: float, hi: float, bits: int)[source]¶
Bases:
objectThe scaling of one CAN bit-field. Handed to the quantiser; never a safety limit.
- property max_uint: int¶
The largest value the field can hold:
(1 << bits) - 1.The
- 1is load-bearing. The manual’s own formula divides the span by1 << bits, which returns exactly1 << bitsatx == hi- one too large to fit, so a fully saturated command wraps to zero. Scaling against this value instead makeshiland on the largest representable code.
- clamp(x: float) float[source]¶
xlimited to what the field can express.Wire-side only. This is not a safety limit - the field may be wider than the motor can survive, which is what
PhysicalLimitsis for.
- to_uint(x: float) int[source]¶
Quantise
xto the unsigned code the wire carries, clamping first.Exact inverse of
from_uint()to within one LSB, which is the best any quantiser can do. No field can encode an exact zero: the ranges are symmetric over an even-sized field, so the midpoint sits half an LSB above zero.
- class MitFields(
- position: FieldRange,
- velocity: FieldRange,
- torque: FieldRange,
- kp: FieldRange,
- kd: FieldRange,
Bases:
objectThe five MIT command fields. Manual v1.0.18 p.63.
Position and velocity are output-side. For the AK40-10 this is confirmed rather than assumed: the velocity field’s 45.5 rad/s is 434.5 rpm, which is the datasheet’s 435 rpm no-load speed.
- class ServoScaling(
- feedback_deg_per_lsb: float = 0.1,
- feedback_erpm_per_lsb: float = 10.0,
- feedback_amps_per_lsb: float = 0.01,
- duty_scale: float = 100000.0,
- current_scale: float = 1000.0,
- rpm_scale: float = 1.0,
- position_scale: float = 10000.0,
- pos_spd_position_scale: float = 10000.0,
- pos_spd_speed_divisor: float = 10.0,
- pos_spd_accel_divisor: float = 10.0,
- current_field: ~cubemarspycan.spec.FieldRange = <factory>,
- erpm_field: ~cubemarspycan.spec.FieldRange = <factory>,
- position_side: ~cubemarspycan.spec.Sourced[~cubemarspycan.spec.Side] = <factory>,
Bases:
objectServo-mode wire scaling. Identical across AK models; manual v1.0.18 pp.38-45.
Note the command and feedback scalings for position are different numbers (
deg * 1e4going out,0.1 degper LSB coming back) and the position-velocity packet divides speed and acceleration by 10 while the plain velocity packet does not. TMotorCANControl gets both of these wrong.
- SERVO_CAN_COMMON¶
Shared servo scaling. Every AK model uses these numbers.
- class Drivetrain(gear_ratio: ~cubemarspycan.spec.Sourced[float], pole_pairs: ~cubemarspycan.spec.Sourced[int], kt_nm_per_a: ~cubemarspycan.spec.Sourced[float], kv_rpm_per_v: ~cubemarspycan.spec.Sourced[float] = <factory>, ke_v_per_krpm: ~cubemarspycan.spec.Sourced[float] = <factory>)[source]¶
Bases:
objectConstants that relate the rotor to the output shaft and current to torque.
- class PhysicalLimits(
- peak_torque_nm: ~cubemarspycan.spec.Sourced[float] = <factory>,
- rated_torque_nm: ~cubemarspycan.spec.Sourced[float] = <factory>,
- peak_current_a: ~cubemarspycan.spec.Sourced[float] = <factory>,
- rated_current_a: ~cubemarspycan.spec.Sourced[float] = <factory>,
- no_load_speed_radps: ~cubemarspycan.spec.Sourced[float] = <factory>,
- rated_speed_radps: ~cubemarspycan.spec.Sourced[float] = <factory>,
- rated_voltage_v: ~cubemarspycan.spec.Sourced[float] = <factory>,
- max_board_temp_c: ~cubemarspycan.spec.Sourced[float] = <factory>,
Bases:
objectWhat the motor can actually do. Never handed to a codec: there are no
bitshere.
- class Capabilities(
- encoders: int = 1,
- inner_encoder_bits: ~cubemarspycan.spec.Sourced[int] = <factory>,
- outer_encoder_bits: ~cubemarspycan.spec.Sourced[int] = <factory>,
Bases:
objectWhat this variant’s hardware supports.
- class MotorSpec(
- name: str,
- model: str,
- mit: ~cubemarspycan.spec.MitFields,
- drivetrain: ~cubemarspycan.spec.Drivetrain,
- limits: ~cubemarspycan.spec.PhysicalLimits = <factory>,
- capabilities: ~cubemarspycan.spec.Capabilities = <factory>,
- servo: ~cubemarspycan.spec.ServoScaling = ServoScaling(feedback_deg_per_lsb=0.1,
- feedback_erpm_per_lsb=10.0,
- feedback_amps_per_lsb=0.01,
- duty_scale=100000.0,
- current_scale=1000.0,
- rpm_scale=1.0,
- position_scale=10000.0,
- pos_spd_position_scale=10000.0,
- pos_spd_speed_divisor=10.0,
- pos_spd_accel_divisor=10.0,
- current_field=FieldRange(lo=-60.0,
- hi=60.0,
- bits=32),
- erpm_field=FieldRange(lo=-100000.0,
- hi=100000.0,
- bits=32),
- position_side=Sourced(value=None,
- source=<Source.UNKNOWN: 'unknown'>,
- ref='',
- note='the manual states degrees and a +/-3200 deg range but never says whether servo position is rotor- or output-side; settle it with bench step B7')),
- mit_wrap_mode: ~cubemarspycan.spec.Sourced[~cubemarspycan.spec.WrapMode] = <factory>,
- mit_position_side: ~cubemarspycan.spec.Sourced[~cubemarspycan.spec.Side] = <factory>,
- manual_version: str = '1.0.18',
- notes: str = '',
Bases:
objectEverything known about one motor variant.
Keyed by variant, not model: KV and hardware revision change Kt, pole pairs and even encoder count, while the MIT field ranges are shared by every variant of a model.
- erpm_to_radps_output(erpm: float) float[source]¶
Electrical RPM to output-shaft rad/s.
Needs both the pole-pair count and the gear ratio, and raises
SpecIncompleteErrornaming whichever is unknown rather than substituting a plausible number. TMotorCANControl hard-codes one conversion factor for every motor, which is 22% wrong for the AK40-10.
- radps_output_to_erpm(radps: float) float[source]¶
Output-shaft rad/s to electrical RPM, the inverse of
erpm_to_radps_output().Raises
SpecIncompleteErrorif the pole-pair count or the gear ratio is unknown.
- output_torque_from_current(amps: float) float[source]¶
Output-shaft torque for a q-axis current, ignoring gearbox losses.
For the AK40-10 this reproduces the datasheet: 7.3 A * 0.056 * 10 = 4.09 N*m against a published 4.1 N*m peak.
- current_for_output_torque(torque_nm: float) float[source]¶
q-axis current, in amps, for a torque demanded at the output shaft.
torque_nm / (Kt * gear_ratio), the inverse ofoutput_torque_from_current(). Ignores gearbox losses, so the real current needed is somewhat higher - the AK40-10’s rated figures imply about 86% efficiency. RaisesSpecIncompleteErrorif Kt or the gear ratio is unknown.
- effective_torque_limit_nm() float[source]¶
The smaller of what the wire can express and what the motor can produce.
- effective_velocity_limit_radps() float[source]¶
The smaller of what the velocity field can express and the no-load speed.
Output-side, rad/s. Falls back to the field’s upper bound when the no-load speed is unknown - the honest answer there is the wire limit, not a guess at the mechanism. Note this binds in either direction across the AK line: the AK40-10’s field is wider than the motor, the AK80-9’s is narrower.
- no_load_speed_radps_at(supply_v: float) float[source]¶
Predicted no-load output speed at a given supply voltage.
- velocity_field_saturation_voltage() float[source]¶
Supply voltage above which the motor can outrun the MIT velocity field.
For the AK40-10 this is 25.6 V. At a fixed 24 V supply there is 6% headroom and the field is adequate under power; only back-driving can exceed it.
- require_permanent_zero() None[source]¶
Raise unless this variant may be sent origin mode 1. Emits no frame.
The registry¶
The motor registry.
Two tiers, because they have different provenance and change for different reasons:
Per model - the MIT field ranges, straight from manual v1.0.18 p.63. Authoritative for all ten models and shared by every variant of a model.
Per variant - drivetrain, physical limits and capabilities, from the CubeMars product datasheets. KV and hardware revision change Kt, pole pairs and even encoder count, so “AK80-9” alone does not identify a set of constants.
Where a datasheet has not been consulted, the constant is unknown() and the library
refuses the conversion that needs it. Do not fill these in from TMotorCANControl: its
table is measurably wrong (it lists AK80-9 Kt as 0.091/0.115 against a datasheet 0.095,
and AK10-9 as 0.16/0.206 against 0.198) and several entries are commented
UNTESTED CONSTANT!.
- MODEL_MIT_FIELDS: dict[str, MitFields]¶
MIT command/feedback field scaling for every model in manual v1.0.18 p.63.
State snapshots¶
Immutable state snapshots.
Every class here is frozen=True, slots=True, and that is load-bearing rather than
decorative. The receive thread builds a new state object per frame and never mutates
one it has published, so handing a reference back to the control thread is semantically a
copy. Tearing is therefore impossible by construction.
TMotorCANControl gets this wrong in two different ways: mit_can.py:774 and
servo_can.py:705 copy field-by-field out of an object the receive thread is mutating
concurrently (so you can read position from frame N and velocity from frame N+1), and
servo_serial.py:783 rebinds the name instead of copying, collapsing the double buffer
entirely.
- class FaultEvent( )[source]¶
Bases:
objectA latched driver fault. Data, never an exception, until the control thread acts.
- classmethod from_code( ) FaultEvent[source]¶
Build an event from a raw wire fault code, resolving its text.
An unrecognised code is preserved verbatim with a generic description rather than raising - a driver reporting something this library has not seen is exactly when the caller most needs the number.
- class MitState(
- spec: MotorSpec,
- position_rad: float,
- velocity_radps: float,
- torque_nm: float,
- temperature_c: int,
- fault_code: int,
- rx_monotonic: float,
- seq: int,
Bases:
objectOne decoded MIT feedback frame.
Position, velocity and torque are output-side. For the AK40-10 that is confirmed by the velocity field matching the datasheet no-load speed, not assumed.
- torque_nm: float¶
Torque in N*m, output-side.
The wire quantity is torque, not current: the manual’s own reply decoder names it so and scales it by the torque field. TMotorCANControl converts it to a “q-axis current” through Kt, the gear ratio and an undocumented 0.59 factor and presents that as the primary reading.
- fault_code: int¶
Raw driver fault code; 0 is healthy. See
fault_textfor a description.
- rx_monotonic: float¶
Arrival time from
time.monotonic(), stamped on the receive thread.Never the bus timestamp, which is epoch-based and on some backends comes from the driver with an unrelated origin. Staleness is measured against this.
- seq: int¶
Monotonic counter, incremented once per published frame. 0 means none yet.
A jump of more than 1 between two reads means several frames arrived while the loop was busy; the latch keeps the newest and nothing is lost.
- property fault: CanFault | None¶
The decoded fault, or
Nonefor code 0 or an unrecognised code.Noneis therefore not the same as healthy - checkis_faultedfor that, andfault_textfor something printable either way.
- property fault_text: str¶
A printable description of
fault_code, always non-empty.Falls back to naming the raw code when the driver reports something this library does not recognise.
- property is_faulted: bool¶
Whether the driver reported any non-zero fault code.
The authoritative check: unlike
fault, this is true for codes the library cannot name.
- property position_rotor_rad: float¶
Rotor-side angle in radians: the output angle times the gear ratio.
Ten times
position_radon an AK40-10. Derived, not measured: the wire carries the output-side value, bench-confirmed at 6.3867 rad for one hand-turn. RaisesSpecIncompleteErrorif the gear ratio is unknown.
- property velocity_rotor_radps: float¶
Rotor-side speed in rad/s: the output speed times the gear ratio.
Raises
SpecIncompleteErrorif the gear ratio is unknown.
- property position_deg: float¶
Output-shaft angle in degrees. A pure unit change on
position_rad.Needs no spec constant, so unlike the rotor-side properties it can never refuse.
- property estimated_current_a: float¶
q-axis current implied by the reported torque.
The MIT reply field is torque; the manual’s own
unpack_replynames it so. This inverts an idealised lossless model, so it is an estimate and says so. It warns once per spec while Kt is notSource.MEASURED.TMotorCANControl presents this quantity as the primary reading, having passed it through Kt, the gear ratio and an undocumented 0.59 fudge factor copied to every motor - three modelling assumptions wearing the costume of a raw measurement.
- class ServoStatus(
- spec: MotorSpec,
- position_deg: float,
- velocity_erpm: float,
- current_a: float,
- temperature_c: int,
- fault_code: int,
- rx_monotonic: float,
- seq: int,
Bases:
objectOne decoded servo feedback frame (function id 0x29).
Wire units are degrees, ERPM and amps. Which side of the gearbox the position refers to is not documented, so
output_radrefuses until the spec says.- position_deg: float¶
Angle in degrees, straight off the wire.
Always available, because it assumes nothing. Which side of the gearbox it refers to is undocumented, which is why
output_radrefuses until the spec records a measurement.
- velocity_erpm: float¶
Speed in electrical RPM, not mechanical. See
velocity_radps.
- fault_code: int¶
Raw driver fault code; 0 is healthy. See
fault_textfor a description.
- rx_monotonic: float¶
Arrival time from
time.monotonic(), stamped on the receive thread.
- property fault: CanFault | None¶
The decoded fault, or
Nonefor code 0 or an unrecognised code.Noneis therefore not the same as healthy - checkis_faultedfor that, andfault_textfor something printable either way.
- property fault_text: str¶
A printable description of
fault_code, always non-empty.Falls back to naming the raw code when the driver reports something this library does not recognise.
- property is_faulted: bool¶
Whether the driver reported any non-zero fault code.
The authoritative check: unlike
fault, this is true for codes the library cannot name.
- property velocity_radps: float¶
Output-shaft rad/s. Needs pole pairs and gear ratio; refuses without them.
- property output_rad: float¶
Output-shaft angle in radians.
Raises until
spec.servo.position_sidehas been established on a bench (step B7: rotate the output one turn; 360 deg means output-side, 3600 deg means rotor-side on a 10:1).position_degis always available meanwhile.
- class ServoEvent(kind: str, function_id: int, payload: bytes, rx_monotonic: float)[source]¶
Bases:
objectA servo frame that is not state.
Function id
0x2Cis the “entered servo mode” handshake, payloadFA FB FC FD;0x09signals a jump to the bootloader. TMotorCANControl decodes both as position, turning the handshake into a bogus -128.5 degree reading.
Safety policy¶
Caller-chosen safety behaviour.
Policy is deliberately not part of MotorSpec. A spec holds
sourced facts about a motor; a policy holds decisions about how your application wants to
treat them. Mixing the two is how TMotorCANControl ended up with an empirical 0.59
current fudge factor - measured once for one AK80-9 - baked into the constants of every
motor it supports.
- class ClampMode(*values)[source]¶
Bases:
EnumWhat to do with a command outside the usable range.
- EFFECTIVE¶
Clamp to
min(wire field, physical limit)per field. The default.
- FIELD¶
Clamp only to what the wire can express. Lets you exceed the motor’s rating.
- RAISE¶
Refuse out-of-range commands instead of clamping.
- class FaultAction(*values)[source]¶
Bases:
EnumWhat
update()does when the driver reports a non-zero fault code.- RAISE¶
Send a safe-stop frame, then raise MotorFault on the control thread. Default.
- WARN¶
Emit a warning and keep going. For diagnostics only.
- IGNORE¶
Latch it for inspection and say nothing.
- class ClampReport( )[source]¶
Bases:
objectWhat a command had to be changed to before it could be sent.
- class SafetyPolicy(
- max_temp_c: float = 75.0,
- clamp: ~cubemarspycan.policy.ClampMode = ClampMode.EFFECTIVE,
- on_fault: ~cubemarspycan.policy.FaultAction = FaultAction.RAISE,
- stale_warn_s: float = 0.1,
- stale_fatal_s: float = 0.5,
- current_ceiling_a: float | None = None,
- supply_voltage_v: float | None = None,
- warn_on_estimates: bool = True,
- forbidden: frozenset[str] = <factory>,
Bases:
objectLimits and reactions chosen by the caller, not read off the motor.
- current_ceiling_a: float | None¶
Hard ceiling for servo current commands.
The servo current field spans +/-60 A while an AK40-10 peaks at 7.3 A, so a typo can ask for eight times the motor’s rating. Mandatory for any variant whose
limits.peak_current_ais still unknown; otherwise it tightens that value.
Faults¶
Driver fault codes.
The manual defines two incompatible fault tables and they must never be mixed:
CanFault- the 0-7 code in byte 7 of a CAN feedback frame (manual v1.0.18 p.45), used by both MIT and servo-over-CAN.SerialFault- the much longermc_fault_codeenum returned by the serialGET_VALUESreply (p.52), where 1 means over-voltage rather than over-temperature.
Serial is out of scope for v1; SerialFault is defined anyway so that nobody
later reaches for CanFault on the serial path, which is the mistake the table
layout invites.
- class CanFault(*values)[source]¶
Bases:
IntEnumFault code from a CAN feedback frame (MIT and servo). Manual v1.0.18 p.45.
- describe_can_fault(code: int) tuple[CanFault | None, str][source]¶
Map a raw fault byte to
(enum_or_None, human_text).Never raises, never throws
KeyError. Codes outside 0-7 are reported verbatim rather than crashing the receive path. TMotorCANControl indexes a dict directly here and dies withKeyError: 7on the motor-stall code that v1.0.18 added.
Errors¶
Exception hierarchy.
Every exception in this module is raised on the caller’s thread. Nothing in the
receive path ever raises: a driver fault becomes a latched
FaultEvent, and only update() turns it into control
flow. That is the fix for the single most safety-relevant defect in TMotorCANControl,
where a fault raised inside the python-can notifier thread is swallowed, never reaches the
control loop, and the motor keeps being commanded while faulted.
- exception SpecError[source]¶
Bases:
CubemarsErrorThe motor specification cannot support what was asked of it.
- exception SpecIncompleteError[source]¶
Bases:
SpecErrorA conversion needs a constant this spec does not know.
Raised instead of guessing. Guessing is what produced the 8.6x position error and the 78x torque error in the reference library.
- exception CapabilityError[source]¶
Bases:
SpecErrorThis motor variant does not support the requested operation.
For example, permanent-zero (origin mode 1) on a single-encoder model such as the AK40-10. No frame is emitted when this is raised.
- exception UnresolvedFrameError[source]¶
Bases:
SpecErrorThe value exists on the wire but which side of the gearbox it refers to is unknown.
- exception ProtocolError[source]¶
Bases:
CubemarsErrorSomething on the wire did not match the protocol.
- exception MalformedFrame[source]¶
Bases:
ProtocolErrorA frame could not be decoded. The only exception a codec may raise.
- exception TransportError[source]¶
Bases:
CubemarsErrorThe CAN link failed.
- exception UnsupportedPlatform[source]¶
Bases:
TransportErrorThe requested backend does not exist on this platform (e.g. socketcan on macOS).
- exception SendFailed[source]¶
Bases:
TransportErrorA frame could not be put on the bus.
- exception MotorError[source]¶
Bases:
CubemarsErrorBase for runtime problems with a specific motor.
- exception MotorFault[source]¶
Bases:
MotorErrorThe driver reported a non-zero fault code.
Raised on the control thread by
update(), after a safe-stop frame has been sent.
- exception StaleFeedbackError[source]¶
Bases:
MotorErrorNo fresh feedback within the configured window; the motor may be gone.
- exception NotInControlMode[source]¶
Bases:
MotorErrorCommanded a motor that is not inside its
control()block.
- exception ServoModeNotConfirmed[source]¶
Bases:
MotorErrorThe driver never confirmed it is in servo mode.
Carries triage, because the usual cause is not wiring: CubeMarsTool defaults the CAN status-message rate to 0 on some drivers, in which case no 0x29 frames are ever sent and a naive library reports zeros forever.
Frames¶
A transport-neutral CAN frame.
This module deliberately does not import can. It is the shared vocabulary between
the pure codec layer and the transport layer; if it depended on python-can, the codec
would too, and the codec would stop being testable in isolation.
- class Frame(arbitration_id: int, data: bytes, is_extended_id: bool = False)[source]¶
Bases:
objectOne classic-CAN data frame: an arbitration id, up to 8 payload bytes, and a flag.
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).
- TAU¶
One full turn in radians.
- float_to_uint(x: float, lo: float, hi: float, bits: int) int[source]¶
Quantise
xin[lo, hi]onto an unsignedbits-bit field.xis clamped into range first, so this never raises for out-of-range input and the result is always in[0, max_uint(bits)].lomaps to 0 andhimaps tomax_uint(bits)exactly.
- uint_to_float(u: int, lo: float, hi: float, bits: int) float[source]¶
Inverse of
float_to_uint(). This matches the firmware’s documented formula.
- lsb(lo: float, hi: float, bits: int) float[source]¶
Size of one least-significant bit, in the field’s own units.
- rpm_to_radps(rpm: float) float[source]¶
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.
- radps_to_rpm(radps: float) float[source]¶
rad/s to mechanical RPM, the inverse of
rpm_to_radps(). Side-neutral.
- erpm_to_radps(erpm: float, pole_pairs: int, gear_ratio: float) float[source]¶
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.
- radps_to_erpm(radps: float, pole_pairs: int, gear_ratio: float) float[source]¶
Mechanical rad/s at the output shaft -> electrical RPM.
Multi-turn tracking¶
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.
- class TurnCounter(
- field: FieldRange,
- mode: WrapMode = WrapMode.UNKNOWN,
Bases:
objectTurns 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.- property mode: WrapMode¶
The wrap behaviour this counter was built for.
UNKNOWNmeans 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.
- property saturation_seen: bool¶
True once a reading has sat at a field limit, where position is unrecoverable.
Latches¶
Publication boxes between the receive thread and the control thread.
This is the only concurrency-critical module in the library, which is why it is a module rather than a few lines inside the motor class. It is about sixty lines and carries a thread-stress test.
The invariant that makes it correct is a type property enforced elsewhere: every state
class is frozen=True, slots=True, so the writer must construct a new value per frame
and can never mutate one it has already published. Handing a reference back to the reader
is therefore semantically a copy, and tearing is impossible rather than merely avoided.
For contrast, TMotorCANControl’s mit_can.py:774 copies field-by-field out of an object
the receive thread is concurrently mutating, so a caller can observe position from frame N
beside velocity from frame N+1; and servo_serial.py:783 rebinds the name instead of
copying, collapsing its double buffer entirely after the first update.
- class StateLatch[source]¶
Bases:
Generic[S]Single-writer, single-reader publication of an immutable value.
The lock is held only for three attribute assignments: no allocation, no logging and no I/O happen inside it, so the receive thread never blocks the control thread for longer than a few hundred nanoseconds.
- publish(value: S, rx_monotonic: float) None[source]¶
Called from the receive thread. Must never raise.
- class FaultLatch[source]¶
Bases:
objectSticky fault storage. Set from the receive thread, consumed from the control thread.
Nothing here raises. A fault stays data until
update()decides, on the caller’s thread, whether it becomes control flow. That is the fix for the most safety-relevant defect in the reference library, where a fault raised inside the python-can notifier thread never reached the control loop and the motor kept being commanded.- set(event: FaultEvent) None[source]¶
Latch a fault. The first one wins; later ones only bump the counter.
Keeping the first is deliberate: a fault often cascades (an over-current trips, which stalls the motor, which trips over-temperature), and the first code is the one that tells you what actually happened.
- peek() FaultEvent | None[source]¶
The latched fault, without consuming it.
- take_new() FaultEvent | None[source]¶
The latched fault if it has not been reported yet, else
None.Lets
update()raise once per fault instead of on every call.
- property faulted: bool¶
Whether a fault is currently latched.
Safe from either thread, and unlike
take_new()it does not consume the event: this answers “is it faulted”, not “is there news”.