odo_stall_check.hpp¶
OdoStallCheck — the spin-vs-motion cross-check.
This header declares 2 types (10 members).
Extracted from include/shulib/motion/odo_stall_check.hpp — this page is that header's documentation, reformatted, so it cannot disagree with the code. Prose about how to think about the API lives in the user guide; worked recipes live in the cookbook; this page is the complete, mechanical list of what exists.
Contents¶
struct OdoStallCheckConfigwindowminSpinTravelmotionRatiowheelRadiusrotationRadiusclass OdoStallCheckkMaxWheelsOdoStallCheckupdatestalledreset
struct OdoStallCheckConfig¶
The five knobs of the spin-vs-motion cross-check, taken BY VALUE at construction (editing the struct afterwards does nothing to a live check) and every one of them validated by that constructor. Every default is PROVISIONAL: the two radii are stand-in geometry and the three thresholds are invented numbers — none has yet been measured against a real drivetrain or a real noise floor, so treat a default as a placeholder that compiles, not as a tuning.
struct, declared at include/shulib/motion/odo_stall_check.hpp:74.
OdoStallCheckConfig::window¶
Evaluation window (seconds). PROVISIONAL (A4: HA-52).
field, declared at include/shulib/motion/odo_stall_check.hpp:76.
OdoStallCheckConfig::minSpinTravel¶
Mean wheel-implied travel that counts as "the wheels are spinning" (inches per window). PROVISIONAL (A4: HA-52).
field, declared at include/shulib/motion/odo_stall_check.hpp:79.
OdoStallCheckConfig::motionRatio¶
observedMotion / spinTravel below this ⇒ stalled. PROVISIONAL (A4: HA-52).
field, declared at include/shulib/motion/odo_stall_check.hpp:81.
OdoStallCheckConfig::wheelRadius¶
Drive wheel RADIUS (inches) — converts shaft radians to surface travel. Stand-in geometry (3.25″ wheel, 1:1 gearing — A4: HA-14).
field, declared at include/shulib/motion/odo_stall_check.hpp:84.
OdoStallCheckConfig::rotationRadius¶
Converts |Δheading| to equivalent wheel travel (≈ center-to-wheel distance). Stand-in geometry (A4: HA-17/HA-52).
field, declared at include/shulib/motion/odo_stall_check.hpp:87.
class OdoStallCheck¶
The windowed spin-vs-motion cross-check: the drive encoders say the wheels rolled, the fused estimate says the robot did not move, and sustained disagreement means the odometry is stuck. It is the only defence against a FROZEN tracking encoder, which the estimator itself cannot see — zero travel is a perfectly plausible reading, so no plausibility guard fires while the fused pose walks away from truth at exactly truth's speed. Owned per-motion and reset() at start(), because a window straddling a motion boundary would read a setPose as motion. The verdict HOLDS between window closes, so a consumer sees one sustained episode, not chatter.
class, declared at include/shulib/motion/odo_stall_check.hpp:97.
OdoStallCheck::kMaxWheels¶
Fixed capacity of the per-wheel shaft baseline, mirroring kinematics::WheelSpeeds so the hot path never allocates. update() rejects a larger span outright rather than truncating.
field, declared at include/shulib/motion/odo_stall_check.hpp:101.
OdoStallCheck::OdoStallCheck¶
Copies config and validates every field: window finite and > 0, minSpinTravel > 0, both radii > 0, and motionRatio strictly inside (0, 1) — at 0 nothing could ever trip, at 1 any slip at all would read as a stall. A violation trips the precondition handler; nothing is clamped. The check starts with no baseline, so the first update() only baselines and no verdict can be true until a full window has elapsed.
function, declared at include/shulib/motion/odo_stall_check.hpp:108.
OdoStallCheck::update¶
[[nodiscard]] bool update(units::Time now, std::span<hal::IMotor* const> motors, const math::Pose2d& fusedPose)
Feed one tick's observables; returns the current (window-held) verdict. motors are the drive motors in kinematic order (size constant per run), NON-EMPTY and all non-null — the same discipline every other span-taking fan-out in the tree keeps (MotorMechanism, PneumaticMechanism, RobotContext, Localizer). Both checks were missing, and the empty case was the dangerous one: with no motors the mean shaft delta is 0, so spinTravel is 0, so spinTravel >= minSpinTravel is never true and the check reports "healthy" forever. A misconfiguration that silently disables a safety cross-check is exactly what this library's precondition discipline exists to turn into a loud failure. The in-tree path (ctx.driveMotors()) was already safe; a direct caller — which the generated reference invites — was not.
function, declared at include/shulib/motion/odo_stall_check.hpp:131.
OdoStallCheck::stalled¶
The latest window verdict (held between window closes).
function, declared at include/shulib/motion/odo_stall_check.hpp:167.
OdoStallCheck::reset¶
Forget the window baseline AND the verdict (motion start / after setPose).
function, declared at include/shulib/motion/odo_stall_check.hpp:170.
Design commentary, from the header¶
The header opens with the reasoning behind these shapes. It is reproduced here in full because a reference that only lists signatures teaches nobody why.
The header’s own reasoning — 54 lines, click to expand
OdoStallCheck — the spin-vs-motion cross-check (chunk C1; A3 handoff #2).
── Why this exists (the A3 finding, verbatim consequence) ──────────────────────────
A frozen tracking encoder is INVISIBLE to the M2 estimator: zero travel is a
perfectly plausible reading, so PilonsOdometry::lastDeltaImplausible() never
fires and the fused estimate walks away from truth at exactly the truth's
speed (measured during the hostile-fakes campaign, and asserted by test).
fault.hpp assigns OdoStuck to "the C/E layers"; the estimator-side detector
is E-phase work. Until then,
THIS windowed cross-check — owned by every C1 motion's tick — is the only
defence against a dead encoder: the drive encoders say the wheels are rolling,
the fused estimate says the robot is not moving. Sustained disagreement ⇒ the
odometry is stuck ⇒ HealthMonitor::Observations::odomStalled ⇒ ODO_STUCK.
── The verdict (per evaluation window) ─────────────────────────────────────────────
spinTravel = mean_i |Δ driveShaft_i| · wheelRadius (inches)
observedMotion = hypot(Δx, Δy) + rotationRadius · |Δheading| (inches)
stalled = spinTravel ≥ minSpinTravel
AND observedMotion < motionRatio · spinTravel
Design points, each load-bearing:
* The ROTATION TERM (rotationRadius·|Δheading|, shortest-path Δ so the ±180°
seam cannot inflate it) is what makes a pure TurnTo immune to false
positives: spinning in place the wheels travel ≈ R·Δθ each while the
position stands still — without the term every turn would fault. It also
means a frozen tracking encoder does NOT false-fault a pure turn: heading
is IMU-owned, so a turn genuinely progresses and reports its motion even
with dead position odometry. That is correct, not a miss — dead position
odometry cannot hurt a pure turn, and any translation attempt still trips.
* The RATIO (not an absolute floor) keeps the check speed-independent, with
margin: A3's slip model still propels ≈70% of spin (HA-40), far above the
25% default — slip degrades, a stuck estimate STOPS. A physically blocked
robot with spinning wheels also trips; that is the same fault family
(fault.hpp: "odometry implausible / WHEEL STUCK"), and on purpose.
* MEAN |Δshaft| over all drive wheels: a single dead DRIVE encoder leaves
(n-1)/n of the mean rather than zeroing it (still trips) — 1/2 on a
2-wheel tank, 3/4 on the X-drive named below, 2/3 on the C3 H-bot. This
bullet used to say "halves", which is the n=2 answer stated as if it were
general, in the header of a check whose flagship consumers have 3 and 4
wheels. The conclusion ("still trips") holds and is in fact STRONGER than
the wrong figure implied. An X-drive strafe (all four wheels spinning)
reads full spin travel.
* WINDOWED (default 0.3 s), not per-tick: per-tick deltas are quantization-
noise-dominated; a window integrates real travel. The verdict HOLDS until
the next window closes, so HealthMonitor's edge-per-episode logic sees one
sustained episode, not chatter.
Thresholds are PROVISIONAL (A4: HA-52) — window, minSpinTravel, motionRatio
and the radii are invented/stand-in numbers until R3 measures geometry and R4
measures noise floors. The register carries the falsifiable claims.
Owned per-motion, reset() at start(): the window baseline must not straddle a
motion boundary (a teleport/setPose between motions would look like motion).
Last updated 2026-08-15 — published from the main release branch, commit fa545ff. This site tracks releases, so between them it can lag the repository — that lag is deliberate, and it is what keeps the development log off the public site. For the current state of the code, see the repository.