Skip to content

diffdrive

Eric Busboom edited this page Sep 8, 2026 · 1 revision

DiffDrive — differential-drive wheel kernel

The differential-drive wheel kernel — the control law, what it demands of a caller, and how it is gated.

Status: design record for code being lifted, not invented. The control law already exists and is already gated; this document says what it is, what it demands of a caller, and what changes when it moves into this repo.

Source of truth for the extraction: src/archive/diffdrive/ (1657 lines of kernel, 1223 of gate). Provenance is in that directory's own README.md — extracted 2026-08-18 from src/firm/control/differential_drive.{h,cpp}, the law itself byte-identical, namespace and includes changed.


1. What it is

One class, DiffDrive::DifferentialDrive, owning two wheels end to end:

  • the staged duty write schedule and split-phase encoder sampling,
  • the (velocity, twist) control law — feedforward + Stage A wheel correction
    • fast PID + Stage C bias adaptation,
  • stall, deficit and wedge latches,
  • a lease watchdog on every motion command,
  • an estop latch.

Its whole dependency list is <cmath>, <cstdint>, <algorithm>. It has its own namespace and no inheritance relationship with any firmware header. That independence is the reason it can move here at all, and it is worth defending: the package gate compiles it with an include path of exactly its own directory, so a firmware include creeping in fails immediately.

1.1 It speaks counts, not millimetres

The kernel's commands and outputs are in native encoder counts — counts, counts/s. There are no millimetres in it, no track width, no wheel radius, no chassis geometry of any kind.

This is not an oversight to be corrected. It is the boundary that keeps the kernel portable: geometry is a property of a particular robot, and a wheel control law is not. Every mm ↔ counts conversion belongs above this class, in the adapter (see protocol §4).


2. The four ports

A caller implements four small interfaces, declared at the top of the header. They are the package's own types, on purpose — the parent firmware connects them to its HAL with one-line forwarding adapters; a MicroPython C module or a MakeCode/PXT package implements them directly.

port methods job
Motor 13 one wheel: stage duty, tick() to execute + collect, counts out, emergency stop, wedge reporting
Clock 1 nowMicros() — monotonic [us]
Sleeper 2 sleepMillis() settle/pace + yield() cooperative hand-off
FiberLauncher 1 start the kernel loop on its own thread — optional

FiberLauncher is the one you can decline. If you would rather drive the kernel from your own loop, call step() yourself and implement launch() to fail loudly. For a first test harness that is the simpler choice: it makes the whole thing single-threaded and deterministic.

2.1 Motor is the port with real obligations

Two of its 13 methods carry semantics that are easy to get wrong and expensive to debug:

  • sampleTime() must stamp on collect SUCCESS only. A failed collect holds the previous stamp. The kernel derives its i2cFaultCount purely from that stamp failing to advance — requestSample()/tick() return void, so stamp non-advance is the only observable "that collect did not land". A port that stamps unconditionally silently reports a healthy bus.
  • rebaseline() is a software re-anchor and must issue no bus traffic. Encoders are never device-reset; position is accumulated and re-origined in software, and each rebaseline bumps that wheel's positionEpoch so a host can tell "the robot moved backwards" from "the origin moved".

3. The command surface a protocol adapter needs

Only a small part of the class is reachable from the wire, and for testing the kernel it is smaller still:

Status drive(float velocity, float twist, uint32_t lease);   // [counts/s] [counts/s] [ms]
Status driveDuty(float dutyLeft, float dutyRight, uint32_t lease);  // [%] [%] [ms]
void   neutral();      // commanded stop, through the full stop path
void   estop();        // latch: zero NOW; holds until estopClear()
void   estopClear();
Output output() const; // seq-consistent snapshot

twist is the measured/commanded half-differential, CCW-positive. So for left/right wheel speeds:

velocity = (left + right) / 2
twist    = (right - left) / 2

3.1 The lease is the safety property, and it maps straight onto the wire

Every motion command carries a lease — a duration in [ms] from now, clamped to kLeaseMax. When it expires the kernel stops. A dead caller cannot mean a runaway.

This lines up exactly with the wire's WHEELS_V <left> <right> <duration> (renamed from WHEELS, 2026-08-22 — docs/design/motion-api.md §9.2), where duration is a required [ms] field with a 5000 ceiling for precisely the same reason. duration becomes lease with no reinterpretation — the one place where the wire and the kernel already agree on a concept without anyone having designed it that way.

That agreement is why WHEELS_V is the right verb to build the first end-to-end test around, rather than a body-frame verb like MOVE_X.

3.2 neutral() and estop() are not synonyms — but not for the reason a full robot's numbers suggest

neutral() writes a neutral command to the mailbox; controlStep() zeroes duty demand for it immediately, no ramp (stageStop() is a bare stageDuty(0, 0)). estop() sets a latch that forces that same immediate-zero path from the next cycle onward, regardless of the mailbox's state, and additionally refuses every new motion command until estopClear().

Both are immediate at the duty level. The distinction is persistence and guarantee, not speed of stopping: neutral()'s zero is an ordinary mailbox write the very next drive() call overrides; estop()'s zero is latched — effective even if the command handshake is wedged — and it blocks new motion rather than merely commanding a stop.

A robot with a motion planner on top of this kernel can measure a much bigger contrast between its own planned-stop verb and its own estop verb — a planned stop that queues behind an active move can let the robot travel the rest of that move before it even begins decelerating, while an estop interrupts immediately. That is a property of the queue, not of this kernel's neutral() vs estop(), and this kernel has no queue: don't cite a queued-stop measurement here, because it does not describe what this class does. See protocol §5.1.

Any halt path — a test harness's Ctrl-C included — calls estop(), because it is the one that latches and refuses new commands, not because it decelerates differently.


4. The Output snapshot is the telemetry source

output() returns a seq-consistent snapshot with everything a t telemetry frame needs, already measured:

  • timing — now [ms], nowFine [us], cycleCount, cyclePeriodMeasured [us], cycleBusy [us], cycleOverrunCount
  • per-wheel measurement — positionLeft/Right [counts], velocityLeft/Right [counts/s], sampleTimeLeft/Right [us], positionEpochLeft/Right
  • derived — velocity, twist (both [counts/s]), appliedDutyLeft/Right [%]
  • learned state — lambda, biasLeft/Right [counts/s]
  • health — ready, estopped, leaseExpired, stallHalted, and per-wheel sat/stall/wedge/wedgeSuspect/deficit/connected
  • sticky diagnostics — leaseExpiryCount, i2cFaultCount

cycleBusy is worth calling out: it is the number to measure before an ASCII telemetry-formatting cost can be trusted (the wire's own snprintf-per-column concern). The kernel already publishes it, so the measurement is a subtraction, not a new instrument.

4.1 Age math

sampleTime* are [us] stamps from the same base as nowFine. Age is (int32_t)(nowFine - sampleTime) — deliberately signed, deliberately wrapping. Right is deterministically about one settle window younger than left, because sampling is sequential split-phase. A telemetry consumer that treats the two wheels as simultaneous will see a phantom twist at high speed.


5. What changes in this repo

Nothing about the law. Specifically:

  1. The files move as-is from src/archive/diffdrive/ into src/diffdrive/ — same namespace, same includes, same behaviour.
  2. The fidelity gate comes with them. golden_ref_drive.{h,cpp} + fidelity_harness.cpp hold the kernel duty-for-duty against the frozen pre-kernel law it descends from (feedforward exact; closed loop equal at steady state). That gate is the reason we can move this code and still claim it is the same code, so it is not optional cargo.
  3. The standalone-build check comes with them — compile with an include path of exactly the package directory.

5.1 This copy is authoritative

Decision (stakeholder, 2026-08-20): radio-robot-lib is the authoritative source for the control law. The prior locations are deprecated.

radio-robot-elite currently holds two copies — the original at src/firm/control/differential_drive.{h,cpp} and the extracted package at src/firm/diffdrive/. Both are now downstream of this repo, and both are slated for removal once a consumer cuts over.

What that settles, and what it does not:

  • Fixes land here first. A change made in elite and not here is a regression waiting to be re-applied, not a fix.
  • The divergence window is still real until elite actually cuts over. Three copies exist today and the fidelity gate is what keeps them honest — but only when it is run. Shortening that window is worth doing early; it is not this library's blocker, but it is elite's.
  • Provenance stays documented, not assumed. The gate holds this code duty-for-duty against golden_ref_drive, which is the only reason we can claim this is the same law that drives the robot rather than a plausible descendant of it.

6. Configuration

DifferentialDrive::Config plus the fluent setters are this library's configuration, and they are all of it — gains, limits, latch thresholds, cycle period. There is no config store, no field table, and no wire schema here; per the 2026-08-20 decision, configuration storage is out of scope for both libraries in this repo. See protocol §6.

Note what is not in Config: any millimetre, any track width, any wheel radius. Geometry belongs to the caller (§1.1).