health_monitor.hpp¶
HealthMonitor — sensor/power pathology → FaultCode, edge-triggered.
This header declares 3 types (14 members).
Extracted from include/shulib/diag/health_monitor.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 HealthMonitorConfigbrownoutVoltsbrownoutRecoverVoltsmaxMotorTempCclass HealthMonitorHealthMonitortickbrownedOutimuLostresetstruct HealthMonitor::Observations
struct HealthMonitorConfig¶
The trip points HealthMonitor compares each tick's observables against. Every number here is PROVISIONAL hardware guesswork rather than measurement — the V5's real cutoff under load and the real thermal droop onset on our motors are both unmeasured until the on-robot phase (register HA-42, HA-44) — so treat the defaults as a starting point to tune, not calibration.
struct, declared at include/shulib/diag/health_monitor.hpp:69.
HealthMonitorConfig::brownoutVolts¶
Battery voltage at/below which a BROWNOUT episode trips. PROVISIONAL (A4: HA-42).
field, declared at include/shulib/diag/health_monitor.hpp:71.
HealthMonitorConfig::brownoutRecoverVolts¶
Voltage the pack must RECOVER above before a new brownout episode can trip (hysteresis; must be >= brownoutVolts). PROVISIONAL (A4: HA-42).
field, declared at include/shulib/diag/health_monitor.hpp:74.
HealthMonitorConfig::maxMotorTempC¶
Motor temperature (°C) at/above which MOTOR_OVER_TEMP trips. PROVISIONAL (A4: HA-44).
field, declared at include/shulib/diag/health_monitor.hpp:76.
class HealthMonitor¶
Turns per-tick sensor and power observables into FaultCode raises. EDGE-TRIGGERED per EPISODE: a pathology that persists for 500 ticks is ONE fault, not 500, and each condition re-arms only once it clears — brownout with hysteresis on top, so a pack sagging around the threshold under a pulsing load cannot chatter episodes. It takes plain VALUES rather than component references, because diag/ is a dependency leaf and may not name estimator types; the caller reads them from the components it already owns. Timing is deliberately not here — LoopMonitor owns overruns. Single-task by contract, like the rest of diag/.
class, declared at include/shulib/diag/health_monitor.hpp:86.
HealthMonitor::HealthMonitor¶
EXPLICIT: the defaulted second parameter makes this a one-argument converting constructor, so without it HealthMonitor m = someLatch; compiled and a FaultLatch& silently converted at any call site taking a HealthMonitor by value or const&. Its diag/ siblings mark their single-argument constructors explicit (tick_attribution.hpp), which is what made this read as an oversight rather than a decision. faults is borrowed, not owned, and must outlive the monitor — which only ever raises into it and never clears it. config is COPIED and checked here rather than at the first trip: all three thresholds must be finite, brownoutVolts and maxMotorTempC must be > 0, and brownoutRecoverVolts must be >= brownoutVolts so hysteresis cannot run backwards. The finiteness of the recover level is load-bearing rather than tidiness. It used to be ORDERED but not checked for finiteness, so +Inf constructed — and +Inf passes the ordering. The re-arm test in tick() (v >= brownoutRecoverVolts) could then never be true, brownoutActive_ never cleared, and the whole run reported at most ONE brownout episode however many times the pack collapsed: the E1 anti-spam edge trigger silently became a permanent mute on the one signal it exists to report.
function, declared at include/shulib/diag/health_monitor.hpp:124.
HealthMonitor::tick¶
Evaluate one tick's observables; raise one fault per NEW episode (header).
function, declared at include/shulib/diag/health_monitor.hpp:138.
HealthMonitor::brownedOut¶
True once ANY brownout episode has occurred this run (latched; header note).
function, declared at include/shulib/diag/health_monitor.hpp:195.
HealthMonitor::imuLost¶
True while the IMU is in a lost episode (seen ready, currently not).
function, declared at include/shulib/diag/health_monitor.hpp:197.
HealthMonitor::reset¶
New-run boundary (mirrors FaultLatch::clear()): forget episodes AND the brownout marker; the boot-window rule starts over (imuSeenReady resets).
function, declared at include/shulib/diag/health_monitor.hpp:201.
struct HealthMonitor::Observations¶
The per-tick observables. The caller reads these from the components it already owns; every default is the HEALTHY value, so a caller without some source (e.g. no motor temps wired yet) simply leaves the field alone.
struct, declared at include/shulib/diag/health_monitor.hpp:91.
HealthMonitor::Observations::imuReady¶
IImu::isReady()
field, declared at include/shulib/diag/health_monitor.hpp:92.
HealthMonitor::Observations::odomImplausible¶
PilonsOdometry::lastDeltaImplausible()
field, declared at include/shulib/diag/health_monitor.hpp:93.
HealthMonitor::Observations::odomStalled¶
caller-computed wheels-spin-but-no-motion cross-check (see note below)
field, declared at include/shulib/diag/health_monitor.hpp:94.
HealthMonitor::Observations::fixGated¶
Localizer::lastCorrection().gated
field, declared at include/shulib/diag/health_monitor.hpp:96.
HealthMonitor::Observations::batteryVolts¶
IBattery::voltage()
field, declared at include/shulib/diag/health_monitor.hpp:97.
HealthMonitor::Observations::maxMotorTempC¶
max IMotor::temperature() over the drive
field, declared at include/shulib/diag/health_monitor.hpp:98.
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 — 53 lines, click to expand
HealthMonitor — sensor/power pathology → FaultCode, edge-triggered (WS13, chunk A3).
── Why this exists ─────────────────────────────────────────────────────────────────
A1 built the fault vocabulary (FaultCode) and the latch (FaultLatch); A3 is the
chunk that must prove "every sensor pathology raises a fault code with a safe
fallback" is REAL. The estimators deliberately do not raise faults themselves —
PilonsOdometry exposes lastDeltaImplausible(), the Localizer exposes quality
state, the HAL exposes isReady()/voltage()/temperature() — because raising is
POLICY, and fault.hpp assigns that policy to the loop layer ("OdoStuck …
raised by the C/E layers"). This class is that policy, factored so the A3 test
loops and C1's real tick loop share ONE implementation instead of each
hand-rolling edge detection.
── Why it takes raw OBSERVABLES, not component references ──────────────────────────
tick(Observations) receives plain bools/values the caller already reads each tick
(imu.isReady(), odom.lastDeltaImplausible(), localizer.lastCorrection().gated,
battery.voltage(), max motor temperature). Rejected: holding IImu&/Localizer&
references — diag/ is a dependency LEAF (debug_record.hpp: localization may
include diag, NEVER the reverse), so the monitor cannot name estimator types; and
raw values also keep it trivially testable and reusable for any future source of
the same observables. LoopOverrun is deliberately NOT here — LoopMonitor (A1)
already owns timing and its dt bookkeeping; two monitors, two concerns.
── Edge-triggered, per EPISODE (the anti-spam contract) ────────────────────────────
A pathology that persists for 500 ticks is ONE episode, not 500 faults: each
condition raises on its false→true transition and re-arms when the condition
clears. FaultLatch already counts cascades; flooding it with one code per tick
would bury the first-fault story that latch exists to tell (a 2am log with 500
IMU_LOST lines is the legacy anti-pattern §18 bans). Brownout adds HYSTERESIS
(recoverVolts above the trip point) so a pack sagging around the threshold under
a pulsing load cannot chatter episodes.
── Semantics that are contracts, each pinned by test ───────────────────────────────
* IMU_LOST fires only on a loss AFTER the IMU has been seen ready — the boot
calibration window (isReady() false from t=0) is NORMAL, not a fault. Waiting
out calibration is the loop's job (C1); reporting it as a loss would make every
clean boot start with a spurious fault.
* BROWNOUT is additionally LATCHED here (brownedOut() stays true for the run) —
the E1 "latched brownout marker" semantics: a pack that collapsed and bounced
back is still a collapsed pack, and the end-of-run summary must say so.
* GPS no-fix is NOT a fault, by design: Driving Skills has no strip at all, so
"no fix" is a normal operating state the Localizer already reports as quality
decay. What IS raised is GPS_GATE_REJECT — a fix that arrived and was rejected
by the fusion gate (the fixGated observable), i.e. a sensor actively lying.
* MOTOR_OVER_TEMP threshold defaults to 55 °C — the V5's documented first
throttle step. PROVISIONAL (A4 register HA-44): the exact droop onset on our
motors is unmeasured until R4.
* brownoutVolts default 10.5 V — PROVISIONAL (A4 register HA-42): the true V5
cutoff behaviour under load is unmeasured until R3/R4.
(Register: docs/hardware-assumptions.md.)
Single-task by contract, like the rest of diag/ (see fault.hpp).
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.