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: Enum

Where 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(
value: T | None,
source: Source,
ref: str = '',
note: str = '',
)[source]

Bases: Generic[T]

A constant together with its provenance.

ref: str

Manual page, datasheet URL, or bench-run identifier.

property known: bool

Whether there is a value at all. Says nothing about how good it is.

known is the gate require() uses, so a guessed constant still passes it. Use trusted when 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.DATASHEET and Source.TOOL. Notably false for NAMEPLATE (inferred from the model name), ESTIMATED (derived from another constant) and ASSUMED - each of which is known, and any of which may be wrong.

The difference between this and known is the whole point of the type: a conversion may proceed on a known constant, but a warning belongs on one that is not trusted.

require(what: str) T[source]

Return the value, or explain what needs measuring and how to record it.

unknown(note: str = '') Sourced[T][source]

A constant that has not been established. Conversions needing it will refuse.

class WrapMode(*values)[source]

Bases: Enum

What 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 Side(*values)[source]

Bases: Enum

Which side of the gearbox a mechanical quantity refers to.

class FieldRange(lo: float, hi: float, bits: int)[source]

Bases: object

The scaling of one CAN bit-field. Handed to the quantiser; never a safety limit.

property span: float

hi - lo, in the field’s own units. Always positive.

property lsb: float

Resolution of this field in its own units.

property max_uint: int

The largest value the field can hold: (1 << bits) - 1.

The - 1 is load-bearing. The manual’s own formula divides the span by 1 << bits, which returns exactly 1 << bits at x == hi - one too large to fit, so a fully saturated command wraps to zero. Scaling against this value instead makes hi land on the largest representable code.

clamp(x: float) float[source]

x limited 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 PhysicalLimits is for.

contains(x: float) bool[source]

Whether x fits without clamping. Inclusive at both ends.

to_uint(x: float) int[source]

Quantise x to 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.

from_uint(u: int) float[source]

Decode a wire code back to the field’s own units.

Matches the firmware’s documented uint_to_float, so a value that round-trips through the driver comes back where it started.

class MitFields(
position: FieldRange,
velocity: FieldRange,
torque: FieldRange,
kp: FieldRange,
kd: FieldRange,
)[source]

Bases: object

The 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>,
)[source]

Bases: object

Servo-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 * 1e4 going out, 0.1 deg per 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: object

Constants that relate the rotor to the output shaft and current to torque.

kt_nm_per_a: Sourced[float]

Torque constant, rotor-side, N*m per amp of q-axis current.

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>,
)[source]

Bases: object

What the motor can actually do. Never handed to a codec: there are no bits here.

class Capabilities(
encoders: int = 1,
inner_encoder_bits: ~cubemarspycan.spec.Sourced[int] = <factory>,
outer_encoder_bits: ~cubemarspycan.spec.Sourced[int] = <factory>,
)[source]

Bases: object

What this variant’s hardware supports.

property permanent_zero: bool

Whether origin mode 1 is legal.

The manual restricts permanent zero to dual-encoder models. It writes flash, so sending it to a single-encoder motor is both meaningless and wearing. The AK40-10 has one encoder; the AK10-9 and AK80-8 have two.

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 = '',
)[source]

Bases: object

Everything 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.

name: str

Variant key, e.g. “AK40-10-KV170”.

model: str

Model as the manual’s field-range table names it, e.g. “AK40-10”.

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 SpecIncompleteError naming 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 SpecIncompleteError if 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 of output_torque_from_current(). Ignores gearbox losses, so the real current needed is somewhat higher - the AK40-10’s rated figures imply about 86% efficiency. Raises SpecIncompleteError if 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.

evolve(**changes: object) MotorSpec[source]

Return a new spec with fields replaced, e.g. after a bench measurement.

provenance_report() str[source]

Human-readable audit of where every non-wire constant came from.

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.

MODEL_GEAR_RATIO: dict[str, float]

Gear ratio read off the model name. TMotorCANControl has AK80-64 as 80:1; it is 64:1.

SPECS: dict[str, MotorSpec]

Every known spec, by variant key and by model alias.

models() list[str][source]

Model names as the manual’s field-range table spells them.

variants() list[str][source]

Variant keys that carry datasheet-verified physical constants.

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(
code: int,
text: str,
fault: CanFault | None,
source: str,
rx_monotonic: float,
seq: int,
)[source]

Bases: object

A latched driver fault. Data, never an exception, until the control thread acts.

source: str

“mit”, “servo” or “link”.

classmethod from_code(
code: int,
source: str,
rx_monotonic: float,
seq: int,
) 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,
)[source]

Bases: object

One 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.

spec: MotorSpec

The spec this frame was decoded against, and the source of every conversion.

position_rad: float

Angle in radians, output-side, as decoded from the 16-bit position field.

velocity_radps: float

Speed in rad/s, output-side, from the 12-bit velocity field.

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.

temperature_c: int

Driver temperature in degrees Celsius, decoded as D6 - 40.

fault_code: int

Raw driver fault code; 0 is healthy. See fault_text for 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 None for code 0 or an unrecognised code.

None is therefore not the same as healthy - check is_faulted for that, and fault_text for 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_rad on an AK40-10. Derived, not measured: the wire carries the output-side value, bench-confirmed at 6.3867 rad for one hand-turn. Raises SpecIncompleteError if the gear ratio is unknown.

property velocity_rotor_radps: float

Rotor-side speed in rad/s: the output speed times the gear ratio.

Raises SpecIncompleteError if 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_reply names it so. This inverts an idealised lossless model, so it is an estimate and says so. It warns once per spec while Kt is not Source.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,
)[source]

Bases: object

One 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_rad refuses until the spec says.

spec: MotorSpec

The spec this frame was decoded against, and the source of every conversion.

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_rad refuses until the spec records a measurement.

velocity_erpm: float

Speed in electrical RPM, not mechanical. See velocity_radps.

current_a: float

q-axis current in amps, as reported by the driver.

temperature_c: int

Driver temperature in degrees Celsius.

fault_code: int

Raw driver fault code; 0 is healthy. See fault_text for a description.

rx_monotonic: float

Arrival time from time.monotonic(), stamped on the receive thread.

seq: int

Monotonic counter, incremented once per published frame. 0 means none yet.

property fault: CanFault | None

The decoded fault, or None for code 0 or an unrecognised code.

None is therefore not the same as healthy - check is_faulted for that, and fault_text for 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_side has 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_deg is always available meanwhile.

class ServoEvent(kind: str, function_id: int, payload: bytes, rx_monotonic: float)[source]

Bases: object

A servo frame that is not state.

Function id 0x2C is the “entered servo mode” handshake, payload FA FB FC FD; 0x09 signals a jump to the bootloader. TMotorCANControl decodes both as position, turning the handshake into a bogus -128.5 degree reading.

kind: str

“servo_mode_ack” or “bootloader_jump”.

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: Enum

What 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: Enum

What 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(
field_name: str,
requested: float,
applied: float,
limit: float,
reason: str,
)[source]

Bases: object

What a command had to be changed to before it could be sent.

property clamped: bool

Whether the value was actually altered.

requested != applied. Reported rather than raised, so a saturating controller is visible in the log instead of being silently trimmed.

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>,
)[source]

Bases: object

Limits and reactions chosen by the caller, not read off the motor.

max_temp_c: float

Board temperature above which update() stops. The manual allows 100 C.

stale_warn_s: float

No feedback for this long while commanding -> warn.

stale_fatal_s: float

No feedback for this long -> safe-stop and raise StaleFeedbackError.

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_a is still unknown; otherwise it tightens that value.

supply_voltage_v: float | None

Declared supply. Used only to warn when the motor can outrun the velocity field.

forbidden: frozenset[str]

Named operations to refuse outright, e.g. {“permanent_zero”}.

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 longer mc_fault_code enum returned by the serial GET_VALUES reply (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: IntEnum

Fault 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 with KeyError: 7 on the motor-stall code that v1.0.18 added.

class SerialFault(*values)[source]

Bases: IntEnum

mc_fault_code from the serial GET_VALUES reply. Manual v1.0.18 p.52.

NOT interchangeable with CanFault. Reserved for v1.1.

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 CubemarsError[source]

Bases: Exception

Base class for every error this library raises.

exception SpecError[source]

Bases: CubemarsError

The motor specification cannot support what was asked of it.

exception SpecIncompleteError[source]

Bases: SpecError

A 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: SpecError

This 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: SpecError

The value exists on the wire but which side of the gearbox it refers to is unknown.

exception ProtocolError[source]

Bases: CubemarsError

Something on the wire did not match the protocol.

exception MalformedFrame[source]

Bases: ProtocolError

A frame could not be decoded. The only exception a codec may raise.

exception TransportError[source]

Bases: CubemarsError

The CAN link failed.

exception UnsupportedPlatform[source]

Bases: TransportError

The requested backend does not exist on this platform (e.g. socketcan on macOS).

exception SendFailed[source]

Bases: TransportError

A frame could not be put on the bus.

exception MotorError[source]

Bases: CubemarsError

Base for runtime problems with a specific motor.

exception MotorFault[source]

Bases: MotorError

The 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: MotorError

No fresh feedback within the configured window; the motor may be gone.

exception NotInControlMode[source]

Bases: MotorError

Commanded a motor that is not inside its control() block.

exception ServoModeNotConfirmed[source]

Bases: MotorError

The 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: object

One classic-CAN data frame: an arbitration id, up to 8 payload bytes, and a flag.

property dlc: int

Data length code: the payload length in bytes, 0-8.

AK frames are DLC 8 in both modes; anything else is a foreign frame.

hex() str[source]

The payload as space-separated uppercase hex, e.g. "FF FF FF FF FF FF FF FC".

For logs and for comparing against the manual’s byte tables.

frame(
arbitration_id: int,
data: Iterable[int],
*,
extended: bool = False,
) Frame[source]

Convenience constructor that accepts any iterable of byte values.

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.

max_uint(bits: int) int[source]

Largest value representable in an unsigned field of bits bits.

float_to_uint(x: float, lo: float, hi: float, bits: int) int[source]

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.

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.

to_signed(value: int, bits: int) int[source]

Reinterpret the low bits of value as a two’s-complement signed integer.

to_unsigned(value: int, bits: int) int[source]

Two’s-complement encode value into bits bits, wrapping like C would.

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,
)[source]

Bases: object

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.

property turns: int

Net field spans traversed since the last reset.

property mode: WrapMode

The wrap behaviour this counter was built for.

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.

property saturation_seen: bool

True once a reading has sat at a field limit, where position is unrecoverable.

reset(raw: float | None = None) None[source]

Forget the accumulated turns, optionally re-anchoring on raw.

Called by 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.

update(raw: float) float[source]

Feed a raw field reading, get a continuous position back.

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.

read() tuple[S | None, float, int][source]

Called from the control thread. Returns (value, rx_monotonic, seq).

The three move together, so a caller can always tell whether the value it is holding is the one whose timestamp it just checked.

property seq: int

Number of values published so far. Monotonic.

reset() None[source]

Drop the published value, returning the latch to its never-written state.

For the control thread between runs. Does not reset the sequence number, so a reader cannot mistake a reset for a fresh frame.

class FaultLatch[source]

Bases: object

Sticky 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.

clear() None[source]

Forget the fault. Explicit, and only from the control thread.

property count: int

How many faulted frames have arrived since the last clear.

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”.