Adding a motor

The registry is deliberately two-tier, and which tier you touch depends on what you are adding:

Tier

What it holds

Provenance

Scope

Per model

the five MIT field ranges

manual v1.0.18 p.63 — authoritative

shared by every variant

Per variant

drivetrain, physical limits, capabilities

CubeMars product datasheets

one KV and hardware revision

The split exists because KV and hardware revision change Kt, pole pairs and even encoder count. "AK80-9" alone does not identify a set of constants — the V2.0 KV135 and the V3.0 KV100 are different motors that share a wire protocol.

There are three routes. Pick by what you actually have.

1. A new variant of a model already in the manual

The common case, and the least work: the wire scaling already exists, so you add only the variant. In src/cubemarspycan/registry.py, beside the existing four:

_DS_AK80_9_V2 = "cubemars.com AK80-9 V2.0 KV135 spec table"

AK80_9_V2_KV135 = MotorSpec(
    name="AK80-9-V2.0-KV135",
    model="AK80-9",
    mit=MODEL_MIT_FIELDS["AK80-9"],  # already correct; never restate it
    drivetrain=Drivetrain(
        gear_ratio=Sourced(9.0, Source.DATASHEET, _DS_AK80_9_V2),
        pole_pairs=Sourced(21, Source.DATASHEET, _DS_AK80_9_V2),
        kt_nm_per_a=Sourced(0.070, Source.DATASHEET, _DS_AK80_9_V2, "rotor-side"),
    ),
    limits=PhysicalLimits(
        peak_torque_nm=Sourced(22.0, Source.DATASHEET, _DS_AK80_9_V2),
        peak_current_a=Sourced(28.0, Source.DATASHEET, _DS_AK80_9_V2),
        rated_voltage_v=Sourced(24.0, Source.DATASHEET, _DS_AK80_9_V2),
    ),
    capabilities=Capabilities(encoders=1),
)

Then add it to _VARIANTS. SPECS and the model-name alias are both derived from that tuple, so nothing else needs editing.

Two things you will hit:

  • tests/test_spec.py::test_variants_listed asserts an exact set, so add the new key there. It is a guard against a variant silently disappearing, so extend it rather than loosening it.

  • unknown() is not exported at the top level. Import it from the spec module: from cubemarspycan.spec import unknown.

2. A model that is not in the manual’s table

Then you need both tiers. Add a MODEL_MIT_FIELDS entry through the _mit(v_max, t_max) helper — position, Kp and Kd are identical for every AK model, so the helper takes only the two that differ — and a MODEL_GEAR_RATIO entry:

MODEL_MIT_FIELDS = {
    ...
    "AK70-20": _mit(30.0, 40.0),   # velocity rad/s, torque N*m
}

MODEL_GEAR_RATIO = {
    ...
    "AK70-20": 20.0,
}

tests/test_spec.py::test_every_manual_model_is_registered compares models() against the manual’s table, so add the model there too.

With no variant, the model resolves to a _skeleton(): wire scaling authoritative, every physical constant refusing. That is a usable state, not a broken one — you can command position, velocity and torque in wire units immediately, and only the conversions that need a datasheet constant will refuse.

3. Without modifying the library

MotorSpec is a plain frozen dataclass, so build one in your own code:

from cubemarspycan import Capabilities, Drivetrain, MitMotor, MotorSpec, Source, Sourced
from cubemarspycan.registry import MODEL_MIT_FIELDS

my_spec = MotorSpec(
    name="AK80-9-mine",
    model="AK80-9",
    mit=MODEL_MIT_FIELDS["AK80-9"],
    drivetrain=Drivetrain(
        gear_ratio=Sourced(9.0, Source.DATASHEET, "my datasheet"),
        pole_pairs=Sourced(21, Source.DATASHEET, "my datasheet"),
        kt_nm_per_a=Sourced(0.095, Source.DATASHEET, "my datasheet"),
    ),
    capabilities=Capabilities(encoders=1),
)

m = MitMotor(bus, motor_id=1, spec=my_spec, supply_voltage=24.0)

Right choice when the motor is yours alone, or the datasheet is not public. You lose cubemars dump-spec and the shared test coverage.

Leave what you do not know unknown

This is the part that matters more than the mechanics.

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 instead of guessing:

from cubemarspycan.spec import unknown

kt_nm_per_a = unknown("no datasheet consulted yet")

Recording a bench measurement

Do not hand-edit a constant after measuring it. Use evolve(), which keeps the provenance attached:

from cubemarspycan import Side, Source, Sourced, get_spec

spec = get_spec("AK80-9").evolve(
    mit_position_side=Sourced(
        Side.OUTPUT,
        Source.MEASURED,
        "bench 2026-09-17, socketcan can0",
        "one hand-turn of the output shaft read 6.2870 rad against 6.2832 expected",
    ),
)

Every bench step in bench.md prints a paste-able block in exactly that shape, so measurements flow back into the registry with their run date intact.

AK40_10_KV170 is the worked example to copy. It carries two Source.MEASURED fields from the 2026-09-16 bench, and its no_load_speed_radps note records that the datasheet’s own 435 rpm contradicts the Kv and Ke on the same page, which both give 408 rpm at 24 V. Recording the contradiction is the point — silently picking one of the two numbers is how a spec becomes untrustworthy.

Verify

cubemars dump-spec AK80-9-V2.0-KV135

prints the fields, the derived values and the full provenance audit — the fastest way to see that a spec is incomplete before a conversion refuses mid-run. Then:

pytest -q tests/test_spec.py tests/test_roundtrip.py

test_roundtrip.py is parametrised over MODEL_MIT_FIELDS, so a new model is automatically checked for quantisation round-trips within one LSB and for never exceeding its field’s max_uint.