Skip to content

debug_record.hpp

DebugRecord — the per-tick snapshot schema.

This header declares 3 types (55 members) and 1 constant.

Extracted from include/shulib/diag/debug_record.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

enum class GateReason

enum class GateReason : std::uint8_t

Why the fusion gate accepted/rejected this tick's correction (§18.2 "gating reason"). WIRE-STABLE: explicit values, append-only, pinned by test (these go on the F9 wire). A1 defines the vocabulary; E2/E3 (correctors) and E4 (EKF) populate it.

enum class, declared at include/shulib/diag/debug_record.hpp:48.

GateReason::None

None = 0

no correction proposal this tick (pure dead-reckoning)

enumerator, declared at include/shulib/diag/debug_record.hpp:49.

GateReason::Accepted

Accepted = 1

a proposal passed the gate and was (nudge-)applied

enumerator, declared at include/shulib/diag/debug_record.hpp:50.

GateReason::RejectedInnovation

RejectedInnovation = 2

outside the innovation bound (complementary tier)

enumerator, declared at include/shulib/diag/debug_record.hpp:51.

GateReason::RejectedMahalanobis

RejectedMahalanobis = 3

failed the Mahalanobis gate (EKF tier, E4)

enumerator, declared at include/shulib/diag/debug_record.hpp:52.

GateReason::RejectedNoFix

RejectedNoFix = 4

source had no usable fix (off-strip GPS / no tag, E2/E3)

enumerator, declared at include/shulib/diag/debug_record.hpp:53.

GateReason::RejectedHighYawRate

RejectedHighYawRate = 5

spinning too fast to trust the fix (E2)

enumerator, declared at include/shulib/diag/debug_record.hpp:54.

GateReason::RejectedNormalizedInnovation

RejectedNormalizedInnovation = 6

Failed the complementary tier's NORMALIZED-INNOVATION gate: |residual| exceeded gateSigma times the fix's own 1σ (measurement σ from the device's reported error, widened by how far the estimate has dead-reckoned since its last fix). Appended at E2 rather than reusing RejectedMahalanobis, which needs a filter-estimated covariance the complementary tier does not have — see gps_corrector.hpp. (E2)

enumerator, declared at include/shulib/diag/debug_record.hpp:60.

GateReason::RejectedStaleFix

RejectedStaleFix = 7

The source re-reported a sample it has already folded, so there is no new information this tick. The V5 GPS camera produces a fix every ~50 ms while the control loop runs at ~100 Hz, so a corrector that folds every read counts one measurement five times. (E2)

enumerator, declared at include/shulib/diag/debug_record.hpp:65.

GateReason::RejectedSensorQuality

RejectedSensorQuality = 8

The source claims a fix but reports a self-error too large to be worth folding — a sensor saying "I can see, badly" rather than "I cannot see". (E2) — reused at E3 for a tag detection below the confidence floor, which is the same statement.

enumerator, declared at include/shulib/diag/debug_record.hpp:69.

GateReason::RejectedNoTagMapEntry

RejectedNoTagMapEntry = 9

A tag was SEEN but the tag map does not know where it is, so no absolute pose can be derived from it. Distinct from RejectedNoFix on purpose: this is a CONFIGURATION error the team can fix (an id missing from the map, or an empty map), not the field being the field, and it is the one worth shouting about. (E3)

enumerator, declared at include/shulib/diag/debug_record.hpp:74.

GateReason::RejectedTagRange

RejectedTagRange = 10

Every visible tag was outside the corrector's trusted range band — too close to fit in the frame, or far enough that planar-PnP's heading ambiguity makes the orientation untrustworthy (localization/apriltag_corrector.hpp). (E3)

enumerator, declared at include/shulib/diag/debug_record.hpp:78.

GateReason::RejectedObservationAge

RejectedObservationAge = 11

The newest vision frame is older than the corrector's freshness horizon: the vision task has stalled, died, or was never started. Distinct from RejectedStaleFix (a frame already folded — the normal steady state) and from RejectedNoFix (looked, saw nothing), because "the camera stopped talking" calls for a different response than either. (E3)

enumerator, declared at include/shulib/diag/debug_record.hpp:83.

GateReason::CovarianceReinit

CovarianceReinit = 12

The EKF tier gave up on its own confidence and re-initialised its covariance: N consecutive Mahalanobis rejections with a persistently large innovation means the filter's belief about how wrong it might be is itself wrong. The estimate is NOT moved — only the uncertainty is reset — so §13 #4's never-snap bound still holds on this tick and every tick after it (E4's T2 ruling, localization/ekf_fusion.hpp). It is a WORD in the record rather than an inference because an estimator that quietly changes its mind about how much to trust the world is the hardest kind of run to debug; covarianceTrace jumping on the same tick is the independent numeric witness. (E4)

enumerator, declared at include/shulib/diag/debug_record.hpp:92.

enum class TickPhase

enum class TickPhase : std::uint8_t

Index vocabulary for DebugRecord::tickPhase — WHO consumed the loop budget this tick (diagnostics-plan D-3: LoopMonitor detects an overrun but cannot attribute it; these slots turn "the loop is slow" into a name). WIRE-STABLE: explicit values, append-only, pinned by test (F9 serializes the array these index). Defined at C5 — BEFORE the H1 freeze — precisely so the slots exist even where the producer does not yet (the one genuinely time-sensitive act in diagnostics-plan.md).

enum class, declared at include/shulib/diag/debug_record.hpp:102.

TickPhase::Localization

Localization = 0

Localizer::update() — producer: C5 (MotionScheduler)

enumerator, declared at include/shulib/diag/debug_record.hpp:103.

TickPhase::Motion

Motion = 1

the active motion's tick / the idle work — producer: C5

enumerator, declared at include/shulib/diag/debug_record.hpp:104.

TickPhase::Health

Health = 2

health observables, where separable — RESERVED (E1+)

enumerator, declared at include/shulib/diag/debug_record.hpp:105.

TickPhase::Telemetry

Telemetry = 3

sink formatting/IO, where separable — RESERVED (E1+)

enumerator, declared at include/shulib/diag/debug_record.hpp:106.

TickPhase::Scheduler

Scheduler = 4

scheduler bookkeeping, where separable — RESERVED (E1+)

enumerator, declared at include/shulib/diag/debug_record.hpp:107.

TickPhase::User

User = 5

caller-owned work (G2 markers, mechanisms) — RESERVED, still no producer. F1 RULED why it stayed empty rather than filling it: the only place caller work is visible today is a waitUntil predicate, which runs OUTSIDE the attribution bracket, and crediting it would break the pinned sum contract (attributed phases never exceed the tick total — tick_attribution.hpp). The named producer is F2's sequencer loop / G2's marker dispatch, which own a loop and can bracket user work properly.

enumerator, declared at include/shulib/diag/debug_record.hpp:108.

kTickPhaseSlots

inline constexpr int kTickPhaseSlots = 8

Capacity of DebugRecord::tickPhase. STRICTLY GREATER than the defined phases on purpose: slots 6..7 are spare, reserved before the F9 freeze so a new phase is a vocabulary append, not a wire reshape. Pinned by test.

constant, declared at include/shulib/diag/debug_record.hpp:121.

struct DebugRecord

struct DebugRecord

The per-tick snapshot (§18.2), captured each control tick and rate-budgeted by the producer. Plain struct on purpose: it is a snapshot, not an invariant-bearing type — the invariants live in the systems that populate it.

struct, declared at include/shulib/diag/debug_record.hpp:126.

DebugRecord::kMaxWheels

static constexpr int kMaxWheels = kinematics::WheelSpeeds::kMaxWheels

Per-wheel capacity, tied to the kinematics contract so they can never diverge.

field, declared at include/shulib/diag/debug_record.hpp:128.

DebugRecord::t

units::Time t{}

seconds since the run epoch (the [t=…] stamp) — producer: C1

field, declared at include/shulib/diag/debug_record.hpp:131.

DebugRecord::dt

units::Time dt{}

this tick's measured dt — producer: C1 (via LoopMonitor)

field, declared at include/shulib/diag/debug_record.hpp:132.

DebugRecord::targetPose

math::Pose2d targetPose{}

where the active motion wants the robot — C1

field, declared at include/shulib/diag/debug_record.hpp:135.

DebugRecord::measuredPose

math::Pose2d measuredPose{}

the fused estimate (Localizer::pose()) — C1

field, declared at include/shulib/diag/debug_record.hpp:136.

DebugRecord::errorX

units::Length errorX{}

target − measured, field x — C1

field, declared at include/shulib/diag/debug_record.hpp:137.

DebugRecord::errorY

units::Length errorY{}

target − measured, field y — C1

field, declared at include/shulib/diag/debug_record.hpp:138.

DebugRecord::errorHeading

units::AngleDim errorHeading{}

shortest signed heading error (radians) — C1

field, declared at include/shulib/diag/debug_record.hpp:139.

DebugRecord::commanded

math::ChassisSpeeds commanded{}

commanded (vx, vy, ω) this tick — C1

field, declared at include/shulib/diag/debug_record.hpp:140.

DebugRecord::wheelCount

int wheelCount = 0

valid entries in the arrays below, [0, kMaxWheels] — C1

field, declared at include/shulib/diag/debug_record.hpp:143.

DebugRecord::wheelVoltage

std::array<units::Voltage, static_cast<std::size_t>(kMaxWheels)> wheelVoltage{}

— C1

field, declared at include/shulib/diag/debug_record.hpp:144.

DebugRecord::wheelCurrent

std::array<units::Current, static_cast<std::size_t>(kMaxWheels)> wheelCurrent{}

— C1

field, declared at include/shulib/diag/debug_record.hpp:145.

DebugRecord::imuYaw

math::Angle imuYaw{}

canonical IMU heading — C1

field, declared at include/shulib/diag/debug_record.hpp:148.

DebugRecord::imuYawRate

units::AngularVelocity imuYawRate{}

canonical yaw rate — C1

field, declared at include/shulib/diag/debug_record.hpp:149.

DebugRecord::activeCommandId

std::uint32_t activeCommandId = 0

0 = no active command. Ids are assigned by the motion scheduler (C2); the value is wire-stable as a plain integer regardless of what the ids come to mean.

field, declared at include/shulib/diag/debug_record.hpp:154.

DebugRecord::activeCommandState

std::uint8_t activeCommandState = 0

Motion-layer state (run/settling/…). 0 = idle. The VOCABULARY is owned by the motion layer (C1/C2); once assigned, values are wire-stable like FaultCode's.

field, declared at include/shulib/diag/debug_record.hpp:157.

DebugRecord::deadReckoning

bool deadReckoning = false

Localizer::isDeadReckoning() — C1

field, declared at include/shulib/diag/debug_record.hpp:160.

DebugRecord::qualityClass

std::uint8_t qualityClass = 0

Categorical quality, mirroring localization::Localizer::Quality numerically (0=Uninitialized 1=DeadReckon 2=Corrected 3=Degraded — the mapping is pinned by test so a reorder of either enum is caught). Kept as a raw byte so diag/ stays a leaf that localization/ may depend on, never the reverse. — C1

field, declared at include/shulib/diag/debug_record.hpp:165.

DebugRecord::quality

double quality = 0.0

the [0,1] scalar (Localizer::quality()) — C1

field, declared at include/shulib/diag/debug_record.hpp:166.

DebugRecord::covarianceTrace

double covarianceTrace = 0.0

EKF covariance trace once E4 lands; the complementary tier may surface its scalar trust weight here until then. One slot, semantics per active fusion policy (§18.2 "covariance trace / filter trust weights"). — E4

field, declared at include/shulib/diag/debug_record.hpp:170.

DebugRecord::gateResidualX

units::Length gateResidualX{}

innovation, field x — E2/E3

field, declared at include/shulib/diag/debug_record.hpp:173.

DebugRecord::gateResidualY

units::Length gateResidualY{}

innovation, field y — E2/E3

field, declared at include/shulib/diag/debug_record.hpp:174.

DebugRecord::gateResidualHeading

units::AngleDim gateResidualHeading{}

innovation, heading (radians) — E3

field, declared at include/shulib/diag/debug_record.hpp:175.

DebugRecord::gateMahalanobis

double gateMahalanobis = 0.0

Mahalanobis distance of the fix — E4

field, declared at include/shulib/diag/debug_record.hpp:176.

DebugRecord::gateReason

GateReason gateReason = GateReason::None

why accepted/rejected — E2/E3/E4

field, declared at include/shulib/diag/debug_record.hpp:177.

DebugRecord::correctionDx

units::Length correctionDx{}

net position nudge applied this tick — C1

field, declared at include/shulib/diag/debug_record.hpp:180.

DebugRecord::correctionDy

units::Length correctionDy{}

— C1

field, declared at include/shulib/diag/debug_record.hpp:181.

DebugRecord::correctionDTheta

units::AngleDim correctionDTheta{}

heading nudge (0 at M2: heading is IMU-owned) — E3

field, declared at include/shulib/diag/debug_record.hpp:182.

DebugRecord::clampedThisTick

bool clampedThisTick = false

the per-tick nudge budget bound the correction — C1

field, declared at include/shulib/diag/debug_record.hpp:183.

DebugRecord::strafeFallbackActive

bool strafeFallbackActive = false

H-drive turn-then-drive fallback engaged (§13 #5) — C3

field, declared at include/shulib/diag/debug_record.hpp:184.

DebugRecord::fault

FaultCode fault = FaultCode::None

fault raised THIS tick (the latch keeps the first)

field, declared at include/shulib/diag/debug_record.hpp:187.

DebugRecord::batteryVoltage

units::Voltage batteryVoltage{}

— C1

field, declared at include/shulib/diag/debug_record.hpp:188.

DebugRecord::batteryCurrent

units::Current batteryCurrent{}

— C1

field, declared at include/shulib/diag/debug_record.hpp:189.

DebugRecord::droppedRecords

std::uint32_t droppedRecords = 0

emit()-channel records dropped so far — C5

field, declared at include/shulib/diag/debug_record.hpp:196.

DebugRecord::droppedLines

std::uint32_t droppedLines = 0

log()-channel lines dropped so far — C5

field, declared at include/shulib/diag/debug_record.hpp:197.

DebugRecord::tickPhase

std::array<units::Time, static_cast<std::size_t>(kTickPhaseSlots)> tickPhase{}

D-3: per-subsystem tick-time attribution in canonical seconds, indexed by TickPhase (top of file). Slots for phases marked RESERVED hold 0 until their producer exists; slots 6..7 are spare capacity (kTickPhaseSlots note). The values describe the most recently COMPLETED tick (the stamping sink cannot know the current tick's total mid-tick; one-tick lag, documented at the producer). All-zero when attribution is off, which is indistinguishable here from a tick that spent no time anywhere — read it with that in mind. Do NOT audit the sum against dt: tick_attribution.hpp's "attributed never exceeds the total" contract is qualified ON THE SAME CLOCK, and the attribution clock is injected separately from the loop clock dt is measured on — this record carries no attribution total of its own to compare against. Even on that one clock the relation is soft enough that TickAttribution floors its own remainder at 0, because a clock that jumps mid-phase can push the phases past the total. So read a shortfall as un-instrumented work rather than a missing phase, and read a sum above dt as a statement about two clocks rather than a broken record. — C5 (scheduler)

field, declared at include/shulib/diag/debug_record.hpp:212.

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 — 30 lines
 DebugRecord — the per-tick snapshot schema (master plan §18.2; WS13, chunk A1).

 ONE record, MANY sinks (§18.1): this struct is the single source of truth every sink
 merely FORMATS — TermSink pretty-prints it (A1), SdSink writes it to the blackbox (E1),
 Shul2Sink serializes it onto the wire (H1). Because every sink shares this one schema,
 bench / terminal / field / sim traces are directly comparable.

 ── FREEZE REGISTER NOTE (read before touching a field) ─────────────────────────────
 F9 (frozen at chunk H1) is the wire serialization of EXACTLY this record. From A1
 onward, every field change carries the cost of reshaping that wire, every sink, the
 SdSink blackbox layout, and the VexBuilder overlay AT ONCE. That is why the COMPLETE
 §18.2 field set is defined NOW — including fields for systems that do not exist yet
 (gating residuals at E2/E3, covariance trace at E4, strafe fallback at C3). Later
 chunks POPULATE fields; they must never RESHAPE them. Additions are append-only.
 ────────────────────────────────────────────────────────────────────────────────────

 Populated-when: each field notes the chunk that first writes it. Until then it holds
 its documented default — a default-constructed record is a valid "quiet" record (no
 command, no fault, zero quality), pinned by test/debug_record_test.cpp.

 Typed units by design (F3): every field with a dimension is a units::Quantity /
 math value type, so a milliseconds-vs-seconds or degrees-vs-radians confusion cannot
 enter the record. Angular ERRORS/deltas are units::AngleDim (radians, non-wrapping —
 a difference is not a heading); absolute headings are math::Angle (wrapping).

 Fixed capacity, no heap: the per-wheel arrays reuse kinematics::WheelSpeeds::kMaxWheels
 so the two capacities can never diverge; the record is a flat value type produced every
 ~10ms tick, so it must never allocate (see hal::emitRecord for how a NullSink build
 skips even POPULATING it).

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.