Skip to content

Ford: continuous closed-loop auto-calibration of the angle-mode speed factors#161

Open
ghbarker wants to merge 15 commits into
BluePilotDev:bp-devfrom
ghbarker:ford-angle-autocal
Open

Ford: continuous closed-loop auto-calibration of the angle-mode speed factors#161
ghbarker wants to merge 15 commits into
BluePilotDev:bp-devfrom
ghbarker:ford-angle-autocal

Conversation

@ghbarker

@ghbarker ghbarker commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

The evidence that motivated this

  • The angle-mode speed factors (FordLowSpeedFactor_ang / FordHighSpeedFactor_ang) correct the PSCM's imperfect path-angle-to-turn conversion, and today they are hand-tuned: watch the requested-vs-actual curvature traces, compare the tops and bottoms of the graphs, tap +/- until the peaks line up. That is per-car (tires and alignment shift it), easy to get subtly wrong (no error bars on an eyeball), and leaves no record of why a value was chosen.
  • Mining the reference drive (00000006--31********) showed the raw material is there to automate it: steady engaged curves and matched command/measured curvature apexes carry direct gain information — but only after aggressive filtering. The same logs are full of counterfeits: bump flicks (measurement-rate spikes while the command is quiet), rough-surface stretches, tire-limit corners where cmd != meas is physics, longitudinal load transfer, sub-threshold driver grip, and a ~0.10 left/right gain split at the high anchor from road crown that a naive estimator happily averages in.
  • Found while wiring telemetry, standalone-worthy: ControllerStateBP dataclass fields must be declared in opendbc/car/structs.py to survive convert_to_capnp — undeclared setattr is silently dropped and the capnp default gets logged. Three fields were being written by the publisher and never reached the wire, which made the device look disarmed in every log while the controller ran fine.
  • A first on-road span (0000000b--d5********, ~1 h) surfaced one more: the low anchor accumulates ~5x less evidence than the high one (city curve frames are mostly grip/accel-rejected), so it wandered 0.98 -> 1.06 -> 1.00 inside one drive — felt from the seat as curve turn-in overshoot through the 1.30x curve branch.

Why the fix is shaped this way

  • Continuous, not one-shot. An earlier converge-and-verify design kept evidence in memory and lost it at ignition-off ("drove, rebooted, nothing changed"). Here the estimator's sufficient statistics serialize into FordAngleAutoCalState (JSON, saved every 30 s of dirty state) and restore on arming — evidence spans drives and ignition cycles by construction.
  • Apex evidence exists because winding roads never go steady. A dominant, prominent command extremum is matched to its same-sign measured twin inside the actuator-lag horizon, median-of-3 committed. Steady-state evidence alone would starve exactly where the factors matter most.
  • Reject first, fit second. Every counterfeit above has a dedicated gate, each with a persisted per-cause counter so the offline analyzer can show what was rejected and why. Left/right divergence inflates effective stderr and blocks action until evidence balances — crown bias stalls the calibrator instead of poisoning it.
  • Actuation delay is designed around, then gated anyway. Steady windows and amplitude-ratio apexes are delay-insensitive by construction; on top, evidence collects only while liveDelay.status == estimated (the measurement chain shares locationd's warmup), and a window-total drift bound caps the lag bias of slow ramps at any delay.
  • Nudges are bounded and visible. At most 0.02 per step, 20 s apart, per-drive caps (0.04 low / 0.10 high — the low cap is the wander guard from the evidence above), only with sufficient anchor weight and stderr. It writes the same params the +/- buttons use, so the menu shows it working; a manual tap always wins (two-tick debounce against async-put lag, soft evidence reset, never a wipe).
  • Lifecycle is its own class. AutoCalPipeline is pure math with no I/O, shared byte-for-byte with the offline analyzer; AutoCalController owns params, arm/disarm, persistence, edit debounce, nudge writes, and errors; the strategy is down to poll_params() / feed() / idle(). Errors report to their own FordAngleAutoCalError param — a transient write failure can never overwrite accumulated evidence.
  • Locks when there is nothing left to do: both anchors with 60 s+ of weight, stderr in bounds, applied within 0.03 of target for 300 s of driving. Toggling off clears everything for a fresh start.

The evidence afterwards

  • On-road (Mach-E GT, comma four): factors walked 1.00/1.00 to convergence over four drives with evidence persisting across ignition cycles, and on 0000000b--d5******** (~1 h) the calibrator locked at 1.00/1.14 (stderr 0.023/0.025, targets inside the deadband for 300 s). Independent check of the converged values: median actual/requested curvature ratio 1.000 (city band) / 0.993 (highway band).
  • The wander guard, replayed over the same drive: low walks 0.96 -> 0.98 -> 1.00 monotonically to the identical lock — the 0.06 round trip is gone.
  • 47 tests: estimator/peaks/quality/nudger/lock units, synthetic closed-loop truth recovery with injected bumps and grips, and onboard-glue tests driving the real strategy with type-checked mock params (the fork's params are typed FLOAT; an untyped mock once hid a string-write bug that silently killed every nudge on-device).
  • Full card process replayed clean over 2.7 h of Mach-E rlogs with the calibrator armed; the analyzer replays the exact onboard pipeline (same imports) with virtual nudging for offline threshold work. The reference-route replay is byte-identical through all of the review-driven refactors below.
  • One review finding reversed itself: the reported off-by-one in the safety.c debug accessors is a false positive — the pinion geometry table is sized COUNT+1 with slot 0 as the sentinel (the index rides bits 1-4 of the safety param). Verified by tightening the check and watching the geometry test fail on the last row; now documented at the accessors so it doesn't get "fixed" again.

What each change is

  • opendbc/sunnypilot/car/ford/angle_autocal.py: the pure estimator — Frame dataclass, quality monitor, steady gate (with the drift bound), peak matcher, weighted LSQ with forgetting + L/R divergence, nudge policy, lock.
  • opendbc/sunnypilot/car/ford/angle_autocal_controller.py: AutoCalController — params handle, arm/disarm, JSON persistence + save cadence, user-edit debounce, nudge writes, liveDelay warmup gate, error reporting, telemetry status.
  • opendbc/sunnypilot/car/ford/lateral_angle_ext.py: three controller calls + Frame construction; the stall-blip branch now idles the pipeline and clears the saturation flag like the other early returns.
  • opendbc/sunnypilot/car/ford/values_ext.py: the gain model (platform gain pairs, CAR sets, platform_gains(), V_LOW/V_HIGH/LOW_ANCHOR_BASE) lives with the strategy; calibrator and analyzer consume it.
  • opendbc/car/structs.py: declare bmsAngleAutoCalibrate, bmsAngleAutoCalState, angleSaturated (the capnp-bridge fix).
  • cereal/custom.capnp: the three telemetry fields.
  • common/params_keys.h: register FordAngleAutoCal (BOOL), FordAngleAutoCalState (STRING), FordAngleAutoCalError (STRING, diagnostics-only channel).
  • bluepilot/selfdrive/car/bp_card_publisher.py: publish armed/status from the live controller only (the param-snapshot copies were dead and a second source of truth).
  • selfdrive/ui/bp/layouts/settings/bluepilot.py + mici/.../lateral_mici.py: the toggle on both settings surfaces, angle-mode gated.
  • opendbc/safety/tests/libsafety/safety.c: documentation comment at the geometry accessors (see false positive above).
  • opendbc/sunnypilot/car/ford/tests/test_angle_autocal.py: the 47-test suite.

Test it yourself

  • Driver-facing docs ship with the branch: docs/ford-angle-autocal.md — what to expect, timelines, troubleshooting.

  • Toggle Auto-Calibrate Adjustment Factors in lateral tuning (angle mode) and drive: the +/- menu values move on their own in 0.02 steps; the telemetry status in logs (bmsAngleAutoCalState) reads armed n=... w=.../... applied=... nudges=..., then locked.

  • Tap +/- mid-collection: your value sticks and the logged status reflects the soft reset.

  • Offline, on any logged drive: python bp/angle_autocal_analyze.py <dir> <route> (bp-tools) replays the exact pipeline and writes an HTML report of every accepted/rejected sample plus the virtual nudge timeline.

  • Toggle off and on: state clears, collection restarts from scratch.

Risks / limits

  • Angle mode only; other brands and curvature mode never construct the pipeline — the toggle is greyed outside angle mode.
  • Default OFF — zero behavior change until opted in; a locked calibration stops adjusting until the user toggles off/on.
  • The low anchor learns slowly by design (city curves are mostly rejected as contaminated); the per-drive cap bounds any single drive's effect on curve feel to two steps.
  • Evidence pauses (pipeline idles) until locationd's delay estimator reports estimated — costs the warmup minutes of each drive.
  • MIN_KAPPA (0.001) must stay at or above the curvature-gain interp knee in the strategy — they are the same number by design and must move together (noted at both sites).

🤖 Generated with Claude Code

ghbarker and others added 12 commits July 22, 2026 16:48
FordAngleAutoCal / FordAngleAutoCalState params (persistent),
ControllerStateBP fields @54-56, publisher wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…glue + tests)

Two-anchor gain estimator with steady-state gating, driven from the angle
lateral strategy in the card process; factors applied live, state persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the one-shot converge/verify-rounds design (whose evidence died at
ignition-off — a normal drive rarely satisfied both anchors, so nothing ever
visibly happened) with the automated version of the manual tuning method:

- apex evidence: command/measured curvature extrema matched across the
  actuator lag, amplitude ratio = gain (the 'tops and bottoms of the graphs'),
  median-of-3 committed; steady-state evidence kept alongside
- quality layer with per-cause rejection counters: bump-flick blanking (with
  wheel-speed corroboration), rough-road RMS on the high-passed residual,
  lat-accel margin down-weighting, longitudinal-accel gate, left/right
  divergence guard for crowned/banked roads
- live nudging: factors step toward the fit (0.02 max, 20s apart, 0.10/drive
  cap) and are written to the params — the lateral tuning menu shows them move;
  per-sample applied-gain bookkeeping keeps the estimate invariant to the
  nudge trajectory, so the loop backs off on overshoot
- evidence persists across drives: estimator sufficient statistics serialize
  into FordAngleAutoCalState (JSON) every 30s, restored on arming; legacy
  'done' states stay honored as locked
- lock when there is nothing left to adjust for 5 min of driving; user edits
  win instantly (soft evidence reset); toggle-off still clears everything

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tlier gate

Replayed against route 00000006 (2.7h Mach-E, known-good result 1.02/1.15):
- outlier-vs-fit gate removed: path-dependent (early evidence anchors the fit,
  later contradicting evidence gets rejected) — it alone distorted the fit to
  0.95/1.22. Robustness stays with the quality layer, ratio bounds, staging
  cancellation and apex median-of-3.
- accel 1.0->2.0 m/s^2 (was rejecting 8587 frames of ordinary braking into
  corners), spike 0.008->0.02 (pinion noise floor), rough RMS 0.0006->0.0015,
  ws jump 0.3->0.6
- decay TAU 30min->2h and lock weight 120->60s so a normally-driven car's
  evidence equilibrium (~90-165s/anchor) can actually reach lock
- peak weight 3.0->1.5s: apex evidence supplements steady evidence instead of
  dominating (transient undershoot pulled the low anchor ~0.02 high)

Virtual closed-loop replay: drive 1 ends applied 1.04/1.08; drive 2 (state
persisted) converges to 1.02/1.14, target 1.02/1.15 = the reference, and locks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elf-reporting errors

Field forensics from the first bp7 install (routes 00000002/00000003 on 2026-07-21):
params showed FordAngleAutoCal=1 + saved evidence at boot while every logged drive
published disarmed — and the logs couldn't say why, because telemetry re-read the
params instead of reporting the controller, and every failure path was a silent except.

- nudge writes sent f'{x:.2f}' STRINGS to FLOAT-typed params: TypeError, swallowed,
  nudges silently never landed. Now writes python floats; the mock Params in tests
  type-checks like the real params_pyx so this class of bug fails in CI.
- bmsAngleAutoCalibrate/bmsAngleAutoCalState now publish the controller's live state
  (armed/evidence/nudges, off, locked, or an error string), never a param re-read.
- every previously-silent failure (param tick, nudge write, state save) now parks a
  short 'error: ...' breadcrumb in FordAngleAutoCalState, visible in qlogs/initData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the muted auto-cal telemetry on EVERY build to date: the publisher
set bmsAngleAutoCalibrate/bmsAngleAutoCalState/angleSaturated on the python
struct, but convert_to_capnp only carries DECLARED dataclass fields — undeclared
setattr is silently dropped and the capnp defaults (False/'') got logged. That is
why the device always appeared disarmed in logs while params (and, tonight,
tracking physics at 1.000/0.993 median ratio with the nudged 1.04/1.16 factors)
proved the calibrator was running the whole time.

angleSaturated was equally affected: logged always-False since the field's
introduction, so offline analysis never actually excluded saturated frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
P1: human_turn/stall_blip parameters removed from SteadyStateGate.update and
AutoCalPipeline.update — provably dead False at the onboard call site (both
branches early-return after idling the pipeline). Gate gains reset() replacing
the seven-positional-False idle call. Offline analyzer now idles on those
frames, matching onboard semantics exactly.

P2: the stall-blip branch now clears bp_angle_saturated and idles the pipeline
before returning, like the disengage and human-turn branches — a blip can no
longer leak a stale gate baseline, straddled apex windows, or surviving staged
samples into the evidence stream.

P3: errors write to their own FordAngleAutoCalError param; a transient write
failure before ignition-off can no longer replace (and erase) the serialized
evidence in FordAngleAutoCalState.

Guard from route 0000000b (2026-07-22): the low anchor holds ~5x less evidence
than the high one and round-tripped 0.98->1.06->1.00 in one drive — felt as
turn-in overshoot via the 1.30x curve branch. MAX_DRIVE_DELTA_LOW = 0.04 bounds
one drive's low-side movement to two steps; replaying the same drive now walks
0.96->0.98->1.00 monotonically to the identical lock (1.00/1.14).

36 tests updated and passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate + steady-window drift bound

The evidence design was delay-insensitive by construction (steady windows and
apex amplitude ratios), but two exposures remained:

1) No measurement-chain warmup gate: kappa_meas flows through the vehicle model
   with liveParameters offsets from the same locationd stack that estimates the
   actuation delay. Evidence now collects only while liveDelay.status is
   'estimated'; before that the pipeline idles (staged samples and peak windows
   must not straddle the unestimated period). Offline analyzer mirrors the gate
   from the logged liveDelay stream.

2) Slow ramps inside the per-frame rate bound carry a lag bias: meas(t) answers
   cmd(t - tau), so a drifting command biases the same-frame ratio by up to
   tau*rate/kappa — tens of percent at the MIN_KAPPA floor at the ~0.42s delays
   liveDelay has measured. New STEADY_DRIFT_FRAC=0.10 bounds total command
   drift across the steady window, capping the error at a few percent worst
   case regardless of the actual delay, sign-symmetric over entries/exits.

Route 0000000b replay with both gates: same qualitative outcome (low walks
0.96->0.98 monotonically, high untouched at 1.14, locks), with ~10-15% less
evidence — the warmup minutes and ramp samples it was designed to drop.
37 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alibrator consumes (review 5.2)

GAIN_CAN / GAIN_CANFD_BOF / GAIN_CANFD_SUV, the per-platform CAR sets,
platform_gains(), and the V_LOW / V_HIGH / LOW_ANCHOR_BASE anchors now live in
values_ext.py. Kills the inverted dependency (strategy re-importing its own
gain model out of the calibrator through a noqa'd second import block) and the
inline duplicate of platform_gains() in update_angle_params. The strategy's
gain interp now uses the named anchors instead of hardcoded 13.5/26.82/1.30 —
one source for the numbers the calibrator's fit is expressed against.

Pure refactor: 37 tests pass, route 0000000b analyzer replay byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… strategy (review P4)

New angle_autocal_controller.py owns everything between the pure estimator and
the car: params handle, arm/disarm from the toggle, JSON evidence persistence +
save cadence, user-edit debounce, nudge writes, the liveDelay warmup gate, error
reporting, and the ground-truth telemetry status. FordLateralAngleExt shrinks to
three calls — poll_params() at its 1 Hz cadence, feed() per lateral frame,
idle() on inactive branches — plus two read-only properties (autocal_enabled,
bp_autocal_status) preserving the bp_card_publisher surface. feed() returns a
nudged factor pair and the strategy adopts it, so the write path to the live
steering values stays in exactly one place.

Removes from the strategy: 9 instance attributes, 3 _autocal_* methods, 2
module-level helpers, the module json dependency, and both inline lifecycle
blocks. The estimator (angle_autocal.py) stays pure math, untouched.

Glue tests target the controller surface. 37 tests pass on Windows; the
TestOnboardGlue suite runs in the WSL gate. Route 0000000b analyzer replay
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Frame dataclass (frozen, no defaults): AutoCalPipeline.update() and
  AutoCalController.feed() take one Frame instead of a 12-parameter clump the
  onboard hook and offline analyzer had to keep in positional agreement;
  defaults on physical signals (a_ego, saturated) were a silent-wrong-answer
  risk and are gone from the production API.
- Admission predicate computed once: SteadyStateGate exposes frame_clear (and
  grip_this_frame); the pipeline's apex-path frame_ok reads it back instead of
  maintaining a hand-synced copy of the grip/cooldown/flag logic.
- PeakMatcher ring buffer entries are a NamedTuple (_PeakFrame) — no more
  magic f[0]/f[1]/f[4] indices or unpack-repack in poison_recent().
- User-edit detection compares floats with EDIT_TOL (half the 0.01 menu
  granularity) instead of round-tripping through %.2f strings; the two-tick
  debounce stays (independently justified by async put lag).
- bp_card_publisher: the param-snapshot copies of bmsAngleAutoCalibrate/State
  are gone — they were dead (overwritten from the live controller every
  publish) and a second source of truth by construction.
- safety.c debug accessors: the reported idx <= COUNT off-by-one is a FALSE
  POSITIVE — the table is sized COUNT+1 with slot 0 as the sentinel and real
  rows 1..COUNT (index rides bits 1-4 of the safety param; ford.h bounds-checks
  index == 0 || index > COUNT the same way). Verified by tightening the check
  and watching test_ford's geometry test fail on the last row. Documented at
  the accessors so it doesn't get 'fixed' again.

(gate.reset() from this list already shipped with the P1 fix.)
37 tests pass; route 0000000b analyzer replay byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ghbarker
ghbarker force-pushed the ford-angle-autocal branch from f2b69d1 to f7acbda Compare July 22, 2026 20:53
@ghbarker

ghbarker commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest bp-dev (picks up the FORD_EDGE_MK2 pinion-toggle commit that touches the same settings files) and addressed the full design-review findings with five new commits:

  • Review blockers 1–3: dead human_turn/stall_blip parameters removed from the gate/pipeline signatures (those frames idle the pipeline before the call site); the stall-blip branch now clears the saturation flag and idles the pipeline like the other early returns; errors write to their own FordAngleAutoCalError param so a transient write failure can never overwrite accumulated evidence.
  • Actuation-delay protections: evidence collects only while liveDelay.status == estimated (the measurement chain shares locationd's warmup), and a steady-window drift bound caps the lag bias of slow ramps regardless of the actual delay.
  • 5.2: the gain model (platform gains, CAR sets, platform_gains(), V_LOW/V_HIGH/LOW_ANCHOR_BASE) moved to values_ext.py — strategy owns it, calibrator consumes it; the noqa'd back-import and an inline duplicate are gone.
  • P4: new AutoCalController owns the whole lifecycle (params, arm/disarm, persistence, edit debounce, nudges, errors, telemetry); the strategy is down to poll_params()/feed()/idle() plus two read-only telemetry properties.
  • 5.3 sweep: frozen Frame dataclass replaces the 12-parameter update clump; admission predicate computed once in the gate; NamedTuple peak buffer; float-tolerance user-edit detection; dead publisher param-cache entries dropped. The reported safety.c off-by-one turned out to be a false positive (the geometry table is 1-based with a sentinel row 0 — verified by tightening the check and watching the geometry test fail) and is now documented at the accessors.

On-road result: on a ~1 h drive (route 0000000b--d5********, Mach-E) the calibrator converged and locked at 1.00/1.14 (stderr 0.023/0.025, targets within deadband for 300 s). Also guarded here: the low anchor accumulates ~5× less evidence than the high one and wandered 0.98→1.06→1.00 within that drive (felt as curve turn-in overshoot through the 1.30× branch); MAX_DRIVE_DELTA_LOW = 0.04 bounds it, and the same drive now replays 0.96→0.98→1.00 monotonically to the identical lock.

47 tests (incl. the onboard-glue suite) pass; the reference-route replay is byte-identical through the refactors.

🤖 Generated with Claude Code

@ghbarker ghbarker changed the title Ford angle mode: continuous auto-calibration of the speed adjustment factors Ford: continuous closed-loop auto-calibration of the angle-mode speed factors Jul 22, 2026
ghbarker and others added 3 commits July 22, 2026 17:06
Plain-language guide for drivers: what the calibrator does, enabling it on both
device layouts + sunnylink, what to expect and how long it takes (honest numbers
from the on-road lock drive), manual +/- interaction, locking and recalibration,
hard behavior bounds, troubleshooting, and the offline analyzer for the curious.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The P4 port left Frame construction (and its wheel-speed/torque/accel reads)
running unconditionally every lateral frame — including for the majority of
drivers who never enable the calibrator, and controller.feed() then discarded
it. Caught by the closed-loop smoothing rig downstream, whose minimal CarState
mock had no reason to carry wheelSpeeds for a disabled feature. Gated on
autocal_ctl.enabled: disabled cost is one attribute check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same title/description/angle-mode gating as the on-device toggles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ghbarker

Copy link
Copy Markdown
Contributor Author

Two small additions since the reformat: the evidence Frame is now only constructed while the calibrator is armed (it was built and discarded every frame for drivers with the toggle off — caught by downstream sim tooling whose minimal mock had no wheelSpeeds), and the toggle is now exposed in sunnylink with the same angle-mode gating as the on-device surfaces.

The anti-weave smoothing feature is now up as #163, stacked on this branch — merging this first shrinks that one to its own two commits.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant