motion_scheduler.hpp¶
MotionScheduler — the thing that actually runs a routine.
This header declares 8 types (82 members) and 1 free function.
Extracted from include/shulib/motion/motion_scheduler.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¶
class ITickPacer~ITickPacerITickPacerITickPacer (overload 2)ITickPacer (overload 3)operator=operator= (overload 2)paceenum class WaitResultSatisfiedTimedOutfaultBit— free functionstruct MotionSchedulerConfigabortFaultMaskloopMonitorattributionClockplausibilityclass CommandIdStampSinkCommandIdStampSinklogwantsRecordemitsummarizesetActiveIdactiveIdsetTickPhasessetEstimatorAuditbeginTickclass MotionStatsSinkMotionStatsSinklogwantsRecordemitsummarizebeginMotionhasDatatargetPoseovershootdriftstruct CompletedMotionidnameexitabortFaultstartTimeendTimepreemptedfinalPosehasPathDatatargetPoseovershootdriftclass IMotionObserver~IMotionObserverIMotionObserverIMotionObserver (overload 2)IMotionObserver (overload 3)operator=operator= (overload 2)onMotionCompleteclass MotionSchedulerMotionSchedulerMotionScheduler (overload 2)MotionScheduler (overload 3)operator=operator= (overload 2)~MotionSchedulerdepsasynctickwaitUntilSettledwaitUntilcancelhasActiveMotionactiveCommandIdlastExitReasonlastCompletedmotionsStartedmotionsSettledmotionsTimedOutmotionsCancelledmotionsAbortedcompletedCountloopMonitorsetBoundaryObserverboundaryObserverrunHasHeadingDatarunMaxHeadingDriftrunFinalHeadingDriftattributionkMaxStalledPaces
class ITickPacer¶
The seam through which the WORLD advances between scheduler ticks (header: "who owns the loop"). Host sim: step the A2 plant by the tick dt. Robot: delay to the next tick boundary. pace() MUST eventually advance IClock::now() — every bounded wait depends on time actually passing; a pacer that never advances the clock trips the scheduler's stalled-pace precondition (loudly) rather than hanging.
class, declared at include/shulib/motion/motion_scheduler.hpp:192.
ITickPacer::~ITickPacer¶
Interface boilerplate: a public virtual destructor, with the copy/move set defaulted back in because declaring a destructor suppresses the implicit MOVE constructor and move assignment (the implicit copies survive, merely deprecated — spelling all five keeps the intent explicit rather than inherited). The scheduler holds a pacer by REFERENCE and never copies, moves or destroys one — the pacer is caller-owned and must outlive the scheduler.
function, declared at include/shulib/motion/motion_scheduler.hpp:200.
ITickPacer::ITickPacer¶
Covered by the comment on ~ITickPacer — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:201.
ITickPacer::ITickPacer (overload 2)¶
Covered by the comment on ~ITickPacer — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:202.
ITickPacer::ITickPacer (overload 3)¶
Covered by the comment on ~ITickPacer — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:203.
ITickPacer::operator=¶
Covered by the comment on ~ITickPacer — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:204.
ITickPacer::operator= (overload 2)¶
Covered by the comment on ~ITickPacer — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:205.
ITickPacer::pace¶
Advance the world to the next control-tick instant.
function, declared at include/shulib/motion/motion_scheduler.hpp:208.
enum class WaitResult¶
The outcome of waitUntil — a DISTINCT vocabulary from ExitReason on purpose: a predicate satisfying is not a motion settling, and conflating them would let "the wait timed out" read as "the motion timed out".
enum class, declared at include/shulib/motion/motion_scheduler.hpp:214.
WaitResult::Satisfied¶
the predicate became true (possibly true on entry)
enumerator, declared at include/shulib/motion/motion_scheduler.hpp:215.
WaitResult::TimedOut¶
the timeout elapsed first — the predicate never held
enumerator, declared at include/shulib/motion/motion_scheduler.hpp:216.
faultBit¶
One bit per FaultCode value, for MotionSchedulerConfig::abortFaultMask.
free function, declared at include/shulib/motion/motion_scheduler.hpp:220.
struct MotionSchedulerConfig¶
Scheduler policy, COPIED at construction — mutating the caller's struct afterwards changes nothing about a live scheduler. The defaults are the competition posture: abort a motion only when the estimate is lying (ODO_STUCK), tick-time attribution OFF (nullptr = zero clock calls, zero cost), and a generous advisory plausibility envelope that never rewrites a pose. Every pointer here must outlive the scheduler.
struct, declared at include/shulib/motion/motion_scheduler.hpp:229.
MotionSchedulerConfig::abortFaultMask¶
Faults that ABORT the active motion when raised during it (header: "the fault policy"). Default: ODO_STUCK only — the one code that means the estimate is lying. Policy, not physics: configurable by design.
field, declared at include/shulib/motion/motion_scheduler.hpp:233.
MotionSchedulerConfig::loopMonitor¶
Scheduler-owned loop timing watchdog (LOOP_OVERRUN). The budget must be strictly greater than the nominal tick period (loop_monitor.hpp).
field, declared at include/shulib/motion/motion_scheduler.hpp:237.
MotionSchedulerConfig::attributionClock¶
D-3 tick-time attribution clock (chunk C5). nullptr = attribution OFF — zero clock calls, zero cost (the A1 contract, structurally). When set, it must be a clock that advances DURING a tick (tick_attribution.hpp says which: real time on the robot — R1 wires it; a scripted fake in tests — the SIM clock only advances between ticks and would attribute all zeros). Must outlive the scheduler.
field, declared at include/shulib/motion/motion_scheduler.hpp:245.
MotionSchedulerConfig::plausibility¶
D-5 pose-delta plausibility envelope (chunk C5): per-tick estimate motion beyond maxSpeed/maxYawRate × margin × dt raises IMPLAUSIBLE (advisory, episode-gated — plausibility_guard.hpp). Defaults are generous physical upper bounds (PROVISIONAL, A4: HA-56).
field, declared at include/shulib/motion/motion_scheduler.hpp:251.
class CommandIdStampSink¶
ITelemetrySink decorator that stamps DebugRecord.activeCommandId with the scheduler's current id (0 between motions). Stamping at the SINK makes id assignment unforgettable for every record producer — no motion type has to remember to do it. The overwrite is unconditional: this scheduler is THE id assigner (debug_record.hpp), so an incoming nonzero id would be a bug, not information. wantsRecord() forwards to the inner sink — the A1 pair rule — so record population stays skipped when nothing consumes it; the one-record copy in emit() is paid only when a real sink is attached. Since C5 it also stamps the D-3 tickPhase slots: the scheduler sets the LAST COMPLETED tick's attribution after each tick (records are emitted mid-tick, before this tick's total is knowable — the one-tick lag documented on the schema field). With attribution off the stamp is the quiet all-zeros default. One decorator, one record copy, both stamps. ── Since E1 it also stamps the ESTIMATOR fields, and the tick's fault ────────── Two holes were found while wiring the blackbox, and both are fixed HERE because this is the layer that owns record population: * Only MoveToPose stamped correctionDx/Dy/clampedThisTick; TurnTo, StrafeTo, DriveBrake, HoldPose and the idle record left them at zero, so what the fusion gate did was invisible for most of a run. The §18.2 gating slots (gateResidual*, gateMahalanobis, gateReason, covarianceTrace) had no producer at all. * DebugRecord::fault — "the fault raised THIS tick" — had NO producer anywhere in the tree. TermSink has rendered flt=NAME since A1 and it could never appear on a real run; the SdSink flight recorder's whole trigger is that field. Both are now stamped from the ONE place every record already passes through, which is the same reasoning that put the command id here. The fault stamp is deliberately CONDITIONAL (unlike the id): a producer that already knows its own fault keeps it. Honest scope: the stamped fault is the most recent fault raised during this tick BEFORE this record was emitted — a fault raised later in the same tick lands on the next record. The FaultLatch remains the authority on the first-fault root cause.
class, declared at include/shulib/motion/motion_scheduler.hpp:286.
CommandIdStampSink::CommandIdStampSink¶
explicit CommandIdStampSink(hal::ITelemetrySink& inner, const diag::FaultLatch* faults = nullptr) noexcept
faults (optional) supplies the per-tick fault stamp; nullptr disables it.
function, declared at include/shulib/motion/motion_scheduler.hpp:289.
CommandIdStampSink::log¶
Pass-through, unstamped: every stamp this decorator applies rides the RECORD channel, so a log line never carries a command id.
function, declared at include/shulib/motion/motion_scheduler.hpp:295.
CommandIdStampSink::wantsRecord¶
Forwards the inner sink's answer — the A1 pair rule. A NullSink run therefore still skips record population entirely, and this decorator costs one bool query.
function, declared at include/shulib/motion/motion_scheduler.hpp:302.
CommandIdStampSink::emit¶
Stamp one record and forward it: the command id (UNCONDITIONALLY — this scheduler is the id assigner, so an incoming nonzero id is a bug, not information), the last completed tick's phase breakdown, the estimator's gate audit, and — only if the producer left it None — the fault raised so far this tick. Costs one DebugRecord copy, paid only when a sink downstream actually wants records.
function, declared at include/shulib/motion/motion_scheduler.hpp:309.
CommandIdStampSink::summarize¶
C5 decorator rule (telemetry_sink.hpp): forward, or the summary dies here.
function, declared at include/shulib/motion/motion_scheduler.hpp:331.
CommandIdStampSink::setActiveId¶
The id every subsequent record is stamped with; 0 means "between motions". The scheduler calls this when it arms a motion and again at its boundary — nothing else should, or records will be attributed to a motion that never emitted them.
function, declared at include/shulib/motion/motion_scheduler.hpp:336.
CommandIdStampSink::activeId¶
Whatever setActiveId() last received; 0 between motions.
function, declared at include/shulib/motion/motion_scheduler.hpp:338.
CommandIdStampSink::setTickPhases¶
void setTickPhases( const std::array<units::Time, static_cast<std::size_t>(diag::kTickPhaseSlots)>& phases) noexcept
Install the per-TickPhase time breakdown stamped onto subsequent records. The scheduler passes the LAST COMPLETED tick's numbers, because a record emitted mid-tick cannot know its own tick's total — that is the one-tick lag documented on DebugRecord::tickPhase. All zeros while attribution is off.
function, declared at include/shulib/motion/motion_scheduler.hpp:344.
CommandIdStampSink::setEstimatorAudit¶
The estimator's account of the tick just localized (E1). The scheduler calls this right after Localizer::update(), so every record emitted during the tick — motion or idle — carries the same, consistent gate audit.
function, declared at include/shulib/motion/motion_scheduler.hpp:353.
CommandIdStampSink::beginTick¶
Open a new tick for the fault stamp: everything raised from here on belongs to this tick. Cheap (one counter read) and a no-op without a latch.
function, declared at include/shulib/motion/motion_scheduler.hpp:359.
class MotionStatsSink¶
ITelemetrySink decorator that AGGREGATES the active motion's record stream into the C5 result-line quantities (motion_result.hpp carries their definitions): start pose, target, worst excursion past the target, final heading error. Sits AFTER the id stamp in the scheduler's chain (it discriminates on the stamped id) and forwards everything untouched — a pure observer. Why derive these from the RECORD STREAM rather than ask the motion: the boundary (CompletedMotion) must not re-derive what the motion already published per tick (brief rule 7), overshoot is inherently a per-tick MAX no boundary snapshot can recover, and the stream is the one place every motion type — including future Tier-3 ones — already reports target/measured/error uniformly. Consequence, stated honestly: with NullSink no records flow (wantsRecord false ⇒ never even built), so hasData() is false and the result line renders "n/a" for the derived fields — you cannot have free result numbers AND zero-cost ticks; the always-real fields (final pose, duration, outcome) come from the boundary itself. Aggregation rules (each load-bearing, pinned by test): * only records with a nonzero stamped id (idle/teleop records are not the motion's story); * only Running-state ticks and — once Running was seen — the exit-state record (waiting-for-estimate records carry deliberately-zero errors and, for capture-at-live motions, a not-yet-real target: aggregating them would fabricate numbers, the exact lie the brief bans); * target is re-sampled per record (capture-at-live motions publish it from the first live tick; TurnTo/DriveBrake publish a here-anchored target).
class, declared at include/shulib/motion/motion_scheduler.hpp:406.
MotionStatsSink::MotionStatsSink¶
inner is NON-OWNING and must outlive this sink; every call is forwarded to it. One of these serves a whole scheduler, not one motion — beginMotion() is what clears the aggregates between motions.
function, declared at include/shulib/motion/motion_scheduler.hpp:411.
MotionStatsSink::log¶
Pass-through: only the record channel carries the quantities this sink derives.
function, declared at include/shulib/motion/motion_scheduler.hpp:414.
MotionStatsSink::wantsRecord¶
Forwards the inner sink's answer, which is also the honest limit of this sink: behind a sink that wants no records, nothing is ever aggregated, hasData() stays false, and the derived result-line fields render "n/a" rather than a made-up 0.
function, declared at include/shulib/motion/motion_scheduler.hpp:422.
MotionStatsSink::emit¶
Aggregate, then forward the record UNMODIFIED — a pure observer that stamps nothing, so it may sit anywhere after the id stamp it discriminates on.
function, declared at include/shulib/motion/motion_scheduler.hpp:426.
MotionStatsSink::summarize¶
Pass-through, per the decorator rule (telemetry_sink.hpp): a decorator that keeps the default no-op body silently eats the run summary.
function, declared at include/shulib/motion/motion_scheduler.hpp:433.
MotionStatsSink::beginMotion¶
New motion armed: forget the previous motion's story.
function, declared at include/shulib/motion/motion_scheduler.hpp:436.
MotionStatsSink::hasData¶
True iff at least one live (Running) record was aggregated.
function, declared at include/shulib/motion/motion_scheduler.hpp:450.
MotionStatsSink::targetPose¶
The motion's published target, RE-SAMPLED from the most recent aggregated record: a capture-at-live motion has no real target until its first live tick, so this is the last target it published, not the one it was constructed with. A default Pose2d before the current motion's first live tick — beginMotion() clears it with the rest of the aggregates, so it can never serve the PREVIOUS motion's target. Still pair it with hasData(): a default Pose2d is also a legal target, so "origin" and "nothing yet" are indistinguishable from the value alone.
function, declared at include/shulib/motion/motion_scheduler.hpp:458.
MotionStatsSink::overshoot¶
Overshoot per motion_result.hpp: projection past the target along the start→target direction when the motion HAD a direction; worst wander from the point when it did not (|target − start| < kHoldEpsilonIn).
function, declared at include/shulib/motion/motion_scheduler.hpp:463.
MotionStatsSink::drift¶
|final heading error| — the last aggregated record's errorHeading.
function, declared at include/shulib/motion/motion_scheduler.hpp:473.
struct CompletedMotion¶
One finished motion, as the scheduler saw it — the raw material for the C5 per-motion result line (motion/run_reporter.hpp formats it; this type only records). The C5 fields were ADDED here rather than shadowed in a parallel struct (brief rule 7: CompletedMotion is the one motion-boundary record).
struct, declared at include/shulib/motion/motion_scheduler.hpp:530.
CompletedMotion::id¶
the activeCommandId it ran under
field, declared at include/shulib/motion/motion_scheduler.hpp:531.
CompletedMotion::name¶
IMotion::name() (stable literal)
field, declared at include/shulib/motion/motion_scheduler.hpp:532.
CompletedMotion::exit¶
Running ⇒ "none yet"
field, declared at include/shulib/motion/motion_scheduler.hpp:533.
CompletedMotion::abortFault¶
None for a settle/timeout/user-cancel; the causal FaultCode when the scheduler's fault policy (or the task-boundary catch) forced the abort.
field, declared at include/shulib/motion/motion_scheduler.hpp:536.
CompletedMotion::startTime¶
clock at async()
field, declared at include/shulib/motion/motion_scheduler.hpp:537.
CompletedMotion::endTime¶
clock at the exit/cancel boundary
field, declared at include/shulib/motion/motion_scheduler.hpp:538.
CompletedMotion::preempted¶
True iff this Cancelled boundary was a PRE-EMPTION (a newer motion took the slot) — §18.4's SUPERSEDED, distinct from a user cancel.
field, declared at include/shulib/motion/motion_scheduler.hpp:543.
CompletedMotion::finalPose¶
The estimate at the boundary — ALWAYS real (read from the Localizer at finalize, independent of the record stream).
field, declared at include/shulib/motion/motion_scheduler.hpp:546.
CompletedMotion::hasPathData¶
True iff the record stream flowed for a live tick of this motion; the three fields below are only meaningful when it did (MotionStatsSink's honest-scope note — with NullSink they render "n/a", never a lie).
field, declared at include/shulib/motion/motion_scheduler.hpp:550.
CompletedMotion::targetPose¶
the motion's published target (last sampled)
field, declared at include/shulib/motion/motion_scheduler.hpp:551.
CompletedMotion::overshoot¶
worst excursion past the target (see semantics)
field, declared at include/shulib/motion/motion_scheduler.hpp:552.
CompletedMotion::drift¶
|final heading error|
field, declared at include/shulib/motion/motion_scheduler.hpp:553.
class IMotionObserver¶
Boundary-observer seam (chunk C5): the scheduler calls this SYNCHRONOUSLY at every motion boundary — exit, fault abort, user cancel, pre-empt — right after CompletedMotion is fully recorded. This is what makes the per-motion result line STRUCTURAL (RunReporter implements it): a routine cannot forget to report a boundary, the A1 emitRecord lesson one layer up. Contract: the callback may log through the sinks; it must NOT call any scheduler verb (async/cancel/tick/waits — enforced by precondition: the boundary is not a place to re-plan a routine from). It must not throw.
class, declared at include/shulib/motion/motion_scheduler.hpp:564.
IMotionObserver::~IMotionObserver¶
Interface boilerplate: a public virtual destructor, with the copy/move set defaulted back in because declaring a destructor suppresses the implicit MOVE constructor and move assignment (the implicit copies survive, merely deprecated — spelling all five keeps the intent explicit rather than inherited). Observers attach by RAW POINTER through setBoundaryObserver(); the scheduler never owns one, so an observer must outlive it or be detached first.
function, declared at include/shulib/motion/motion_scheduler.hpp:572.
IMotionObserver::IMotionObserver¶
Covered by the comment on ~IMotionObserver — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:573.
IMotionObserver::IMotionObserver (overload 2)¶
Covered by the comment on ~IMotionObserver — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:574.
IMotionObserver::IMotionObserver (overload 3)¶
Covered by the comment on ~IMotionObserver — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:575.
IMotionObserver::operator=¶
Covered by the comment on ~IMotionObserver — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:576.
IMotionObserver::operator= (overload 2)¶
Covered by the comment on ~IMotionObserver — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:577.
IMotionObserver::onMotionComplete¶
One finished motion, observed at its boundary.
function, declared at include/shulib/motion/motion_scheduler.hpp:580.
class MotionScheduler¶
The loop that actually runs a routine. Exactly ONE active motion and no queue: starting another PRE-EMPTS the first into the cancel safe state (0 V + Brake, applied synchronously), so there is no tick on which two motions command. It never owns time — the injected ITickPacer advances the world, which is what lets the same scheduler be deterministic in host sim and real on the robot. The verbs are async() to arm, tick() or a blocking wait to make progress, cancel() to stop; cancel() with nothing active is still the panic stop, because a cancel that can be too late is one nobody can rely on. Nothing here can hang: waitUntilSettled() is bounded by the motion's own watchdog, waitUntil() by a required explicit timeout, and a pacer that stops advancing the clock fails loudly rather than spinning. Faults in abortFaultMask abort the MOTION, never the run. Single-task by contract, like everything it composes.
class, declared at include/shulib/motion/motion_scheduler.hpp:594.
MotionScheduler::MotionScheduler¶
MotionScheduler(const MotionDeps& deps, ITickPacer& pacer, const MotionSchedulerConfig& config = {})
deps is the same bundle every motion takes (validated non-null); all pointees — and pacer — must outlive the scheduler.
function, declared at include/shulib/motion/motion_scheduler.hpp:598.
MotionScheduler::MotionScheduler (overload 2)¶
Neither copyable nor movable, and not by taste: the context this scheduler hands to motions points at the scheduler's OWN telemetry decorator, so a copy or a move would leave that route aimed at the original object. Construct one where it will live and pass it by reference. DESTRUCTION WITH A MOTION ARMED FORCES THE DRIVE SAFE. F2 closed this hole for the blocking waits with WaitUnwindGuard — a throw through waitUntilSettled()/waitUntil() used to leave the motors at their last command — and the destructor was the remaining path with identical consequences: sched.async(m); followed by a return, or a throw out of a hand-rolled non-blocking loop, dropped the scheduler with active_ != nullptr and left the drive energized, silently. It commands applyCancelSafeState() DIRECTLY and deliberately does NOT call cancel(). The armed motion may already be destroyed by the time this runs: motions live on the caller's stack for exactly the scheduled window, and the idiom that creates this hole — construct the scheduler, then construct a motion, then leave the scope — destroys them in reverse, so active_ dangles here. cancel() would call active_->cancel() through that dangling pointer; the test for this case caught precisely that, as a SIGABRT. So the destructor does the half that needs no motion: the drivetrain is made safe, and the Cancelled boundary is NOT recorded, because recording it honestly requires reading an object that may no longer exist. A caller that wants the accounting calls cancel() itself, which is what the rest of this header tells it to do. With NO motion armed it does nothing at all — unlike cancel()'s panic stop, because destroying an idle scheduler is not a panic and must not reach out and brake a drivetrain the caller may still be driving through another object.
function, declared at include/shulib/motion/motion_scheduler.hpp:651.
MotionScheduler::MotionScheduler (overload 3)¶
Covered by the comment on MotionScheduler (overload 2) — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:652.
MotionScheduler::operator=¶
Covered by the comment on MotionScheduler (overload 2) — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:653.
MotionScheduler::operator= (overload 2)¶
Covered by the comment on MotionScheduler (overload 2) — one comment documents this run of special members.
function, declared at include/shulib/motion/motion_scheduler.hpp:654.
MotionScheduler::~MotionScheduler¶
Neither copyable nor movable, and not by taste: the context this scheduler hands to motions points at the scheduler's OWN telemetry decorator, so a copy or a move would leave that route aimed at the original object. Construct one where it will live and pass it by reference. DESTRUCTION WITH A MOTION ARMED FORCES THE DRIVE SAFE. F2 closed this hole for the blocking waits with WaitUnwindGuard — a throw through waitUntilSettled()/waitUntil() used to leave the motors at their last command — and the destructor was the remaining path with identical consequences: sched.async(m); followed by a return, or a throw out of a hand-rolled non-blocking loop, dropped the scheduler with active_ != nullptr and left the drive energized, silently. It commands applyCancelSafeState() DIRECTLY and deliberately does NOT call cancel(). The armed motion may already be destroyed by the time this runs: motions live on the caller's stack for exactly the scheduled window, and the idiom that creates this hole — construct the scheduler, then construct a motion, then leave the scope — destroys them in reverse, so active_ dangles here. cancel() would call active_->cancel() through that dangling pointer; the test for this case caught precisely that, as a SIGABRT. So the destructor does the half that needs no motion: the drivetrain is made safe, and the Cancelled boundary is NOT recorded, because recording it honestly requires reading an object that may no longer exist. A caller that wants the accounting calls cancel() itself, which is what the rest of this header tells it to do. With NO motion armed it does nothing at all — unlike cancel()'s panic stop, because destroying an idle scheduler is not a panic and must not reach out and brake a drivetrain the caller may still be driving through another object.
function, declared at include/shulib/motion/motion_scheduler.hpp:655.
MotionScheduler::deps¶
The MotionDeps to construct scheduled motions FROM: identical to the caller's deps except telemetry routes through the id stamp (header: observability). A motion built with raw deps still schedules correctly — its records merely carry id 0. Flagged for F6: the C4 facade must build motions from THIS so the stamping is structural, not remembered.
function, declared at include/shulib/motion/motion_scheduler.hpp:669.
MotionScheduler::async¶
Start motion without blocking: arm it and return — it progresses on subsequent ticks (tick() / the blocking waits). If a motion is active, PRE-EMPT per the pinned semantics (header): the old motion is cancelled into the safe state first; there is no tick on which both command. async(active motion) is a well-defined RESTART (cancel + re-arm). motion must outlive its scheduled run. Callable from a waitUntil predicate; NOT from inside a motion tick.
function, declared at include/shulib/motion/motion_scheduler.hpp:678.
MotionScheduler::tick¶
One scheduler tick (header: "who owns the loop") — for callers running their own paced loop (the facade's non-blocking mode; teleop polling). Does NOT pace: the caller owns cadence here. Returns whether a motion is still active after the tick. Not callable re-entrantly or from a blocking wait (the wait already owns the loop).
function, declared at include/shulib/motion/motion_scheduler.hpp:710.
MotionScheduler::waitUntilSettled¶
Block until the active motion exits; returns its ExitReason (Settled / TimedOut / Cancelled — never Running). Bounded WITHOUT a parameter: the motion's own watchdog guarantees exit (C1, mutation-proven), and the stalled-pace guard converts a broken pacer into a loud failure. With no active motion the wait is VACUOUSLY over and returns lastExitReason() immediately (Settled on a virgin scheduler — completedCount() tells a caller nothing actually ran).
function, declared at include/shulib/motion/motion_scheduler.hpp:727.
MotionScheduler::waitUntil¶
Block until pred() holds (checked BEFORE the first tick — true on entry returns immediately) or timeoutSeconds elapses, whichever is first; the return says which. The active motion (if any) keeps ticking throughout — this is the marker/callback primitive (G2's PathRunner). timeout is REQUIRED, finite and >= 0 (0 = an honest poll); a timeout logs one Warn line and raises NO fault (header: nothing may hang). pred may call async()/cancel() (pre-emption applies); it must not call a blocking verb (precondition).
function, declared at include/shulib/motion/motion_scheduler.hpp:758.
MotionScheduler::cancel¶
Stop the active motion into the defined safe state (0 V + Brake — motion.hpp), record the Cancelled boundary, and idle the scheduler. With NO active motion this is the PANIC STOP: the safe state is applied to the drive anyway (a cancel that can be "too late" to do anything is a cancel nobody can rely on). Idempotent; callable from a waitUntil predicate AND from a pacer's pace() (the F2 deadline cut — pinned in the re-entrancy banner); NOT from inside a motion tick.
function, declared at include/shulib/motion/motion_scheduler.hpp:797.
MotionScheduler::hasActiveMotion¶
True between async() and that motion's boundary — equivalently activeCommandId() != 0. False again the instant a motion settles, times out, is cancelled or is pre-empted, on the same tick, before any wait returns.
function, declared at include/shulib/motion/motion_scheduler.hpp:814.
MotionScheduler::activeCommandId¶
The active motion's command id; 0 when none. Ids are 1-based and monotonically increasing for the scheduler's lifetime.
function, declared at include/shulib/motion/motion_scheduler.hpp:817.
MotionScheduler::lastExitReason¶
Exit reason of the most recently finished motion. Settled before any motion has finished (the vacuous-wait default — see waitUntilSettled).
function, declared at include/shulib/motion/motion_scheduler.hpp:820.
MotionScheduler::lastCompleted¶
The most recent motion boundary in full, overwritten at each one. Default- constructed until a motion finishes, and IN THAT VIRGIN STATE ONLY it disagrees with lastExitReason(): this reads Running ("none yet") where that reads Settled (the vacuous-wait default). Once any motion has reached a boundary the two always agree — finalize() writes both from the same exit reason. completedCount() is what actually says whether anything ran.
function, declared at include/shulib/motion/motion_scheduler.hpp:827.
MotionScheduler::motionsStarted¶
async() calls over the scheduler's lifetime — restarts and pre-empting starts included, so this counts STARTS, not distinct motion objects. It equals completedCount() plus one while a motion is active, and equals it exactly when idle.
function, declared at include/shulib/motion/motion_scheduler.hpp:831.
MotionScheduler::motionsSettled¶
Motions that reached their exit group and stopped there — the only success verdict of the four; the counters around it are all the ways a motion did not finish the job it was given.
function, declared at include/shulib/motion/motion_scheduler.hpp:835.
MotionScheduler::motionsTimedOut¶
Motions the MOTION's own watchdog ended. A waitUntil() timeout is not counted here and raises no fault — that is a wait giving up, not a motion failing.
function, declared at include/shulib/motion/motion_scheduler.hpp:838.
MotionScheduler::motionsCancelled¶
User/pre-empt cancellations (abortFault == None).
function, declared at include/shulib/motion/motion_scheduler.hpp:840.
MotionScheduler::motionsAborted¶
Fault-policy + task-boundary aborts (abortFault != None).
function, declared at include/shulib/motion/motion_scheduler.hpp:842.
MotionScheduler::completedCount¶
Every motion that reached a boundary: settled + timed out + cancelled + aborted, a partition with no double counting. This is the number that tells a caller whether anything actually ran, which lastExitReason() cannot — it reads Settled on a scheduler that has never been given a motion.
function, declared at include/shulib/motion/motion_scheduler.hpp:847.
MotionScheduler::loopMonitor¶
The scheduler's own tick-timing watchdog, for worstDt() / overrunCount() after a run. The scheduler ticks it once per tick and RE-BASELINES it at every async() and at the top of each blocking wait — that drops only the previous tick's timestamp, so a deliberate gap in which the caller's own code ran between motions is not reported as an overrun. Nothing here ever clears the statistics: worstDt() and overrunCount() are WHOLE-RUN totals, not per-motion ones. A gap between two of the caller's own tick() calls is NOT re-baselined and does count as an overrun.
function, declared at include/shulib/motion/motion_scheduler.hpp:857.
MotionScheduler::setBoundaryObserver¶
Attach/replace the boundary observer (nullptr detaches). One observer: the C5 reporter is the intended consumer; fan-out belongs to a composite the caller writes if ever needed. Contract in IMotionObserver.
function, declared at include/shulib/motion/motion_scheduler.hpp:864.
MotionScheduler::boundaryObserver¶
The attached observer, or nullptr. NON-OWNING: the scheduler neither deletes it nor extends its lifetime, so detach before the observer dies.
function, declared at include/shulib/motion/motion_scheduler.hpp:867.
MotionScheduler::runHasHeadingData¶
The run's heading story for the §18.3 summary: max / final of the PER-MOTION BOUNDARY drifts (|final heading error| of each motion that produced path data). Deliberately not mid-tick transients: a 90° turn passes through 90° of "error" by design, and a summary that reported it would bury the real story — how headings LANDED.
function, declared at include/shulib/motion/motion_scheduler.hpp:874.
MotionScheduler::runMaxHeadingDrift¶
The largest |final heading error|, in RADIANS, over every motion boundary that produced path data; 0 while runHasHeadingData() is false. BOUNDARY values only — a 90° turn passes through 90° of error by design, and counting that would bury the story this reports. Never reset: one scheduler is one run.
function, declared at include/shulib/motion/motion_scheduler.hpp:879.
MotionScheduler::runFinalHeadingDrift¶
|final heading error|, in RADIANS, at the LAST boundary that produced path data — where the run's heading actually LANDED, as opposed to its worst moment. 0 while runHasHeadingData() is false, which is not the same as a run that landed square.
function, declared at include/shulib/motion/motion_scheduler.hpp:885.
MotionScheduler::attribution¶
The D-3 attribution instrument, when enabled (nullptr when off).
function, declared at include/shulib/motion/motion_scheduler.hpp:890.
MotionScheduler::kMaxStalledPaces¶
Consecutive pace() calls that may fail to advance the clock before the scheduler declares the pacer broken (header: nothing may hang). Pure logic constant — no hardware claim, hence no register entry.
field, declared at include/shulib/motion/motion_scheduler.hpp:897.
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 — 163 lines, click to expand
MotionScheduler — the thing that actually runs a routine (chunk C2, WS6/M2).
C1 made the library able to execute ONE motion; every routine test hand-rolled
the loop. This class is that loop, formalized: exactly one active motion at a
time, started without blocking (async), waited on (waitUntilSettled /
waitUntil), stopped into a defined safe state (cancel), with the fault policy
C1 explicitly deferred ("a motion raises but does not self-abort — the
scheduler owns cancellation"). C4's Chassis facade wraps these verbs; F6
freezes their shape at D2 — everything here is what that facade will inherit.
── Who owns the loop (the A2/C1 controller-first shape, formalized) ────────────────
One scheduler tick is EXACTLY the loop C1's rig documented:
localizer.update(); // the estimate advances FIRST (sees time t)
loopMonitor.tick(); // timing pathology → LOOP_OVERRUN, visibly
active ? active->tick() // the motion reads the world and commands
: idle work; // no motion: HealthMonitor + an idle record
<the world advances to t+dt> // via the injected ITickPacer (below)
The scheduler NEVER owns time. Advancing the world is the pacer's job — in
host sim that is stepping the A2 plant; on the robot it is delaying to the
next tick boundary. This is the same inversion as IClock: the scheduler is
deterministic because it can only observe time, never make it.
── One active motion — structural, in two layers ───────────────────────────────────
(1) The scheduler has ONE active slot and no queue. Starting a motion while
one is active PRE-EMPTS: the old motion is cancel()led — which puts the
drivetrain in the cancel safe state AND renders the old object inert
(post-cancel ticks are contractual no-ops that touch no motor) — and only
then is the new motion armed. There is no tick on which both command.
* Rejected REJECT-while-active: a routine that forgot one
waitUntilSettled() would silently SKIP a move — the robot does the
wrong thing quietly, the worst failure class.
* Rejected QUEUE: a queue is F2's Sequence combinator in disguise
(explicitly out of scope), and a latent queued motion firing seconds
later is scarier than last-command-wins. Pre-empt matches how a robot
is commanded: a NEW order supersedes the old one, safely.
(2) The pre-empted object itself cannot re-command even if a stale caller
still ticks it — enforcement lives in the MOTION (motion.hpp cancel
contract), not in scheduler bookkeeping. Direct IMotion use that never
touches a scheduler remains possible at Tier 3 by design; C4's facade is
what closes that door for library users.
── cancel(): the defined safe state ────────────────────────────────────────────────
Every cancel path lands in applyCancelSafeState() (motion.hpp): zero volts +
BrakeMode::Brake, commanded SYNCHRONOUSLY inside the call — no further tick
is required for the drivetrain to be safe (a cancel that depends on someone
continuing to tick is a cancel that can leave motors energized). Scheduler
cancel() with NO active motion still applies the safe state: the panic stop
must always work. Rationale for brake-not-coast/hold: motion.hpp. Real-world
braking efficacy is a registered hardware claim (A4: HA-53).
── The fault policy (C1's named deferral, decided here) ────────────────────────────
After each Running tick the scheduler checks whether any fault in
`abortFaultMask` was raised SINCE THIS MOTION STARTED (per-code raiseCount
snapshots — a since-clear bitmask cannot see a re-raise, and a dead encoder
that faulted in motion 1 must still abort motion 2). On a hit: the motion is
cancelled into the safe state, one Warn line names the causal code, and the
boundary records it (CompletedMotion.abortFault). The RUN continues — faults
log and recover, they never crash (fault.hpp); what the next motion does
about a fault-aborted predecessor is the caller's strategy.
Default mask = ODO_STUCK only. The reasoning, per code:
* ODO_STUCK → ABORT. The spin-vs-motion cross-check says the estimate is
LYING (dead encoder) or the robot is physically stalled. Continuing to
servo against a lying estimate is worse than stopping: the controller
integrates toward a phantom target at full authority. Brake and hand the
decision back to the routine.
* IMU_LOST → CONTINUE. C1 pinned that Degraded does NOT gate: encoders
are still good, and freezing mid-run strands the robot mid-field (the
Localizer's own D8 choice). The watchdog + tolerances bound the damage.
* BROWNOUT → CONTINUE. A power collapse zeroes effective volts at the
firmware; the ESTIMATE is still honest. If the pack bounces back,
finishing the motion is the right outcome; if not, the motion's watchdog
times it out. Aborting would convert a transient sag into a dead run.
* GPS_GATE_REJECT → CONTINUE. The gate already did its job — the lying fix
was rejected and the estimate is protected. Aborting on a defended attack
would let a flaky sensor end routines.
* MOTOR_OVER_TEMP / LOOP_OVERRUN → CONTINUE. Degraded authority / timing,
not a lying estimate; both are visible, bounded, and watchdog-contained.
* MOTION_TIMEOUT never needs the mask: the motion already exited TimedOut.
The mask is configurable because this is policy, not physics — a team may
legitimately choose abort-on-brownout for a strategy that prefers parking.
── The task-boundary catch (check.hpp's contract, discharged here) ─────────────────
core/check.hpp promises: on-robot, a PreconditionError thrown mid-motion is
"caught by the motion scheduler at the task boundary and converted to a
FAULT_ABORT exit + a safe drivetrain state". This tick loop is that boundary.
The catch is TIGHT — PreconditionError only, wrapped around active->tick()
only (swallowing arbitrary exceptions would hide real bugs; a Localizer
breach has no motion boundary to unwind to and propagates). If the installed
handler already raised (the on-robot policy), no second raise happens — the
faultCount snapshot disambiguates. One bad reading degrades one motion to a
fault code; it never aborts the auton.
── Nothing may hang ────────────────────────────────────────────────────────────────
* waitUntilSettled() is bounded by the MOTION's watchdog — C1 proved (and
mutation-guarded) that no motion can fail to exit. No second timeout.
* waitUntil(pred, timeout) REQUIRES an explicit finite timeout — every
bound is caller-visible, and there is no invented default constant. The
return distinguishes Satisfied from TimedOut; a timeout logs one Warn
line but raises NO fault (a timed-out wait is a legitimate strategy
branch — "wait for the ring, else move on" — not a pathology).
* The predicate is checked BEFORE the first tick: true-on-entry returns
Satisfied having ticked zero times; timeout 0 is an honest poll.
* A broken pacer that never advances the clock would hang EVERY bound —
watchdogs read the same frozen clock — so the scheduler counts
consecutive non-advancing paces and fails the kMaxStalledPaces
precondition loudly instead of spinning forever.
── Re-entrancy (decided and pinned) ────────────────────────────────────────────────
* async()/cancel() from inside a waitUntil predicate: ALLOWED. Pre-emption
applies normally; the swap happens between ticks, so the single-command
invariant holds, and the wait simply continues over the new state.
* cancel() from inside the pacer's pace(): ALLOWED, and pinned since F2 —
pace() runs between ticks (inTick_/inBoundary_ both false), so the
precondition set always admitted it; it was undocumented and untested
until F2's end-of-run guard came to RELY on it (the deadline cut: a
cancel from pace() unwinds waitUntilSettled on the same iteration, with
zero latency — measured). Pinned by test so a later chunk cannot break
the guard by tightening this precondition. async() from pace() is
equally precondition-legal and measured to WORK — and is REJECTED as a
pattern: the hijacked wait keeps looping over the new motion and returns
ITS verdict as the original caller's (a moveTo that was cut reports
Settled describing a motion the caller never issued, with zero log
lines). The guard never uses it; nothing should.
* A BLOCKING verb (waitUntil / waitUntilSettled / tick) from inside a
predicate — or from pace(): REJECTED by precondition — disguised
recursion whose depth is user-data-dependent. Nothing in the G2 marker
use case needs it; relaxing later is additive, un-forbidding is not.
* async()/cancel() from inside a motion's tick(): REJECTED by precondition
— mutating the active slot while active->tick() is on the stack.
── Unwind safety of the blocking waits (F2, closing C4's known gap at its root) ───
A throw through a blocking wait (stalled-pace precondition, a Localizer
breach, a throwing predicate) used to leave the active motion ARMED and the
motors at their last command: C4 documented the consequence for
waitUntilSettled (a verb's stack-owned motion dangling in the slot) and
patched it in the FACADE with runBlocking's DetachGuard — but the same hole
was open for direct Tier-3 waits and for waitUntil, where F2 measured the
worst state in the campaign: 11.4 V under Coast with the slot pointing at a
dying stack object (Chassis::waitUntil is a bare pass-through). The fix now
lives HERE, in the loop owner: both waits cancel on unwind (safe state +
boundary recorded + slot cleared) before the exception propagates. The
facade's DetachGuard stays — redundant cancels are idempotent, and belt
plus braces is the right dress code for the panic path.
── Observability (what C5 will need; A1's cost contract respected) ─────────────────
The scheduler assigns activeCommandId (debug_record.hpp: "ids are assigned by
the motion scheduler") by interposing CommandIdStampSink between the motions
and the real sink: every record emitted while a motion is active is stamped
with its id — unforgettably, for every motion type, including future ones.
Construct motions from scheduler.deps() so their records route through the
stamp (the C4 facade will make this plumbing automatic; flagged for F6). The
stamper forwards wantsRecord() — the pair rule — so a NullSink run still
skips record population entirely. Idle ticks emit a quiet record (no
invented target) so the stream stays continuous between motions; motion
boundaries surface as CompletedMotion + per-exit counters, NOT as formatted
result lines — that formatting is C5's, deliberately not built here.
Single-task by contract, like everything it composes. Not copyable/movable:
it holds a self-referential context (the stamped telemetry route).
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.