Skip to content

Ford: opt-in pinion curvature measurement (Use Pinion Yaw Sensor)#145

Merged
alan-polk merged 7 commits into
BluePilotDev:bp-devfrom
jrneulight:bp-7.0-pinion-rework
Jul 20, 2026
Merged

Ford: opt-in pinion curvature measurement (Use Pinion Yaw Sensor)#145
alan-polk merged 7 commits into
BluePilotDev:bp-devfrom
jrneulight:bp-7.0-pinion-rework

Conversation

@jrneulight

@jrneulight jrneulight commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Ford: opt-in pinion curvature measurement — "Use Pinion Yaw Sensor"

Opt-in toggle (default OFF; zero behavior change unless enabled) that measures curvature
from the steering pinion angle instead of the RCM yaw rate, in both the control
software and the panda safety firmware, for vehicles whose yaw sensor is faulty.

Depends on #144 (this branch includes it): the shadow published to the angle-mode
deviation check must come from the same measurement family the check consumes.

Scope: this PR is measurement only. The low-speed stall-rescue extension that
previously rode along here has been split out to a follow-up so this can be reviewed and
merged on its own; nothing in this branch changes stall-detector behaviour.

The evidence: what a faulty Ford yaw sensor actually looks like

Measured with the shipped diagnostic (tools/ford_yaw_health_check.py) across five
months of one 2021 Explorer's drives, with the sign convention anchored by the
on-road-validated pinion measurement:

Drive corr(yaw, steering geometry) median gain (healthy ≈ +1.0)
Feb 27 positive (shape tracks) +0.42
Jun 29 +0.89 +1.80
~Jul 15 (two drives) +0.99 +0.71 / +0.72
Jul 17 +0.995 +0.62
Jul 18 morning +0.981 +2.11
Jul 18 midday +0.993 +0.45
Jul 18 evening +0.99 +0.92 ← a broken sensor can look healthy for a drive

The failure mode is drive-to-drive unstable gain with high positive correlation
never sign inversion, and invisible to the CAN quality flag: the message transport is
perfect throughout (100.0 Hz, QF=3 on 100% of frames, zero gaps >50 ms). Only the value
is wrong. A 2× gain error at 0.005 curvature is 2.5× the safety check's entire error
band, so the system-level damage on this car was: replayed phantom
"steering limit exceeded" fault rate 55% in the 20–35 mph band, hands-off tracking
ratio 0.88, and a year of "Service AdvanceTrac"-style complaints with a healthy EPS.

Fleet precedent: Tesla, Toyota, Nissan and PSA safety code all measure from the steering
angle sensor. Ford was the outlier in trusting yaw rate.

Why the changes are shaped this way

  • Both layers switch together, decided once at car start. The toggle is read at car
    init and lands on CP_SP.safetyParamcurrent_safety_param_sp (the sunnypilot SP
    safety param, USB 0xdf — the Subaru STOP_AND_GO pattern), which ford_init reads. The
    control side mirrors the same value. A live flip is deliberately impossible: control
    and firmware measuring from different families would make the safety layer fight the
    control layer.
  • Per-platform geometry in a firmware table, not hardcoded. 12 rows
    (slip factor / steer ratio / wheelbase) selected by a 4-bit index in the SP param.
    A zero or out-of-range index disables the feature outright — a half-configured param
    can never select the wrong geometry. The table cannot rot: a test reads it back
    through ALLOW_DEBUG libsafety getters and checks every row against CarSpecs +
    calc_slip_factor(VehicleModel(CP)).
  • Edge excluded by design: FORD_EDGE_MK2 (ALT_STEER_ANGLE) only broadcasts a
    relative pinion angle, which openpilot centers with a runtime-learned offset from a
    park-assist signal. Learned calibration inside safety firmware — where a few degrees
    of unlearned offset equals the whole error band — is not acceptable; the toggle no-ops
    there and every description says so.
  • RX enforcement only when consumed: the SteeringPinion_Data check
    (100 Hz, counter, quality flag) is added via SET_RX_CHECKS only when the feature is
    on, so a pinion hiccup can never disable controls for non-users. The OEM checksum
    algorithm is unknown (Ford sum-invert patterns don't match real frames) → checksum
    ignored, integrity via counter + QF + frequency. Bit extraction was verified against
    CANParser on 3000/3000 real frames.
  • Band 0.002 → 0.003 on the pinion path only (second FORD_LIMITS instantiation):
    the raw pinion angle carries no roll/alignment-offset compensation in firmware; the
    stock path keeps 100 untouched.

The evidence afterwards

  • Firmware check replay (tools/ford_pinion_replay.py, diagnosis routes
    00000001--25********, 000000d9--89********): phantom fault rate at 20–35 mph
    55% → 0.018% (curvature mode), 0.28% overall on the angle-mode shadow check;
    Python deviation-clip bite 14.9–17.7% → 4.3–6.0%.
  • Road test (predecessor single-car build of this same measurement): hands-off
    tracking ratio 0.82 → 0.90 (measured against the live learned steerRatio
    17.8–18.0; the factory 16.8 reads ~7% higher), under-tracking 26.6% → 15.4%; firmware
    shadow-vs-measured wire correlation +0.989 over 11,733 angle-mode frames; a
    counterfactual sign flip yields 52% violations — the sign convention is proven, not
    assumed.
  • Measurement independence: across drives whose yaw gain wandered 0.33–1.29×,
    pinion-measured tracking delivery stayed in a ±2% band (~0.93–0.97 at live steer
    ratio) — steering no longer follows the broken sensor.
  • Zero-delta with the flag off: the stock safety classes never set the SP param and
    their per-test outcomes are bit-identical to bp-dev (verified per-method, including
    the pre-existing failures); firmware stock path expands to the upstream arrays and
    limits exactly.
  • On-car acceptance drive (toggle enabled via the shipped settings path, tip of this
    branch): ~27 min on demanding roads (curvy sections at 100–200 m radii, driver
    corrections included). Zero in-drive lateral safety blocks — all 25,003 steering frames
    and 41,672 LKA frames reached the bus (echo-verified; safetyTxBlocked moved only at
    ignition init) — zero latent shadow-check signatures, and hands-off tracking 0.85 at live
    steer ratio. That build also carried the low-speed stall-rescue extension that has
    since been split out of this PR (see the scope note above); it acts only below 9 m/s,
    so the measurement and tracking figures quoted here are unaffected by it.

What each commit is

  1. control sideget_current_curvature() gains the pinion/vehicle-model source,
    selected by FordSafetyFlagsSP.STEER_ANGLE_CURVATURE on CP_SP (plain int constants,
    sibling convention); FORD_PINION_GEOMETRY_INDEX platform map.
  2. safety firmware — pinion angle_meas block, geometry table + index handling in
    ford_init, conditional RX checks, 100/150 limit variants; pinion safety-test mixin
    re-running the entire stock matrix across three geometries (Explorer, Bronco Sport,
    F-150) plus pinion-specific tests (the sign-flipped command that a broken sensor
    would demand must block; QF gating; inertness below the gate speed) and the
    geometry-consistency test.
  3. toggle + plumbingFordPrefSteerAngleCurvature (params_keys.h + params.json),
    _initialize_ford, BluePilot menu Vehicle-section toggle + mici variant + sunnylink
    entry (needs_onroad_cycle, offroad-only).
  4. toolsford_yaw_health_check.py (the "do I need this?" diagnostic) and
    ford_pinion_replay.py (the firmware-check replay used for the numbers above).

Test it yourself

# is a given car's yaw sensor healthy? works from qlogs (append /a), any Ford route
FORD_REPLAY_DONGLE_ID=<dongle> tools/ford_yaw_health_check.py '<dongle>|<route>/a'
# run it on 2-3 different drives: a gain that moves between drives is itself the fault

# safety: pinion matrix + geometry-table consistency + zero-delta
pytest opendbc_repo/opendbc/safety/tests/test_ford.py -k "Pinion"
pytest opendbc_repo/opendbc/safety/tests/test_ford.py -k "not Pinion"   # == bp-dev baseline

# control-side unit tests (measurement selection, shadow truthfulness,
# capnp serialization regression)
pytest opendbc_repo/opendbc/sunnypilot/car/ford/tests/

# replay the firmware check semantics against your own routes (rlogs required)
FORD_REPLAY_DONGLE_ID=<dongle> tools/ford_pinion_replay.py <route>

On a car: BluePilot menu → Vehicle → "Use Pinion Yaw Sensor" → start the car (the
param is read once at car start; the descriptions and the sunnylink needs_onroad_cycle
flag say so). Expect: no phantom steering-limit alerts at 20–35 mph and visibly better curve tracking
on an affected car. Post-drive, the two replay tools reproduce every number in this PR
from your own rlogs.

Risks / limits

  • Edge unsupported (relative-angle sensor; toggle no-ops, greyed with explanation).
  • Pinion checksum unverifiable (unknown OEM algorithm) — counter + QF + 100 Hz enforced,
    and only when the feature is on.
  • The yaw message stays RX-checked even when unused (unchanged from stock; measured
    transport on the faulty car is clean, so no practical exposure).
  • Requires a car restart to apply (init-time by design, see above).

Frame-exact port of ford.h's Lane_Assist_Data1/LateralMotionControl TX-hook semantics
(value limits, steer_angle_cmd_checks, shadow-curvature check, ROC checks, and -- the
piece an earlier analysis omitted -- the reset-bypass latch) replayed against real
rlogs. Ground truth is independent of the sim: a sendcan frame with no TX loopback echo
in the can stream was actually blocked, and pandaStates.safetyTxBlocked counter deltas
cross-check the totals, so mis-attribution of block causes can't recur.

Includes a --truthful-shadow counterfactual that latches the shadow from measured
curvature on the frames the truthful-shadow control fix republishes, at real LKA
cadence, to validate that fix against recorded routes.

Claude-Session: https://claude.ai/code/session_01VaqLvbbgSL67xLkFUp9sRU
…ing overrides

The shadow curvature (bp_kappa_cmd, carried in Lane_Assist_Data1 bytes 5-6 and judged
against angle_meas by ford.h's angle-mode deviation check) was zeroed during human-turn
overrides, stall blips, and inactive frames, and kept publishing the clipped planner
kappa while the driver pressed. Two problems:

1. Driver pressing mid-curve with the mode still enabled: the clipped planner kappa
   cannot follow the wheel, so the shadow exits the deviation band. Frame-exact replay
   of ~3h of road-test routes (tools/ford_lmc_safety_replay.py) attributed the ONLY
   in-drive lateral safety block to exactly this scenario (sustained curve, latch
   drained, shadow -630 vs measured [-795,-782] CAN units).

2. Zeroed shadow during mode-0/inactive frames: the panda latches the shadow from LKA
   (33 Hz) and enable from LMC (20 Hz), so the first re-engaged frame can compare a
   stale zero against real measured curvature. Today this race is masked by ford.h's
   reset-bypass latch (replay: 14 such frames across three routes, every one bypassed,
   zero actually blocked) -- but the published value was still dishonest, and any future
   tightening of that latch would surface the race.

Fix: route every measurement read through LateralCurvExt.get_current_curvature() and
publish the shadow from it whenever the planner kappa cannot honestly describe the
car's steering -- while pressed, during human-turn/blip mode-0 pulses, and while
inactive. The latched shadow then tracks reality continuously and the check compares
measured-vs-measured at every re-engage edge.

Replay validation (same tool, truthful-shadow counterfactual): shadow-check violations
2/2/14 -> 0/0/0 across the three routes; the one real block is eliminated; no new
violations introduced (the value is inside the band by construction at latch time).

Claude-Session: https://claude.ai/code/session_01VaqLvbbgSL67xLkFUp9sRU
Some Ford RCMs broadcast implausible yaw rate (sign-inverted vs the IMU and steering
geometry, corr -0.98 on a 2021 Explorer across three datasets over five months) while
the signal's quality flag still reads OK. Since current_curvature is measured from that
yaw signal, an affected car sees phantom "steering limit exceeded" blocks at 20-35 mph
and weak curve tracking, with a healthy EPS.

This adds an opt-in alternative measurement: the steering pinion angle
(SteeringPinion_Data, PSCM) converted through the vehicle model -- the same measurement
family Tesla/Toyota/Nissan/PSA safety code uses, independently validated against the
comma IMU (corr +0.99). Selection is init-time state on CP_SP.safetyParam
(FordSafetyFlagsSP.STEER_ANGLE_CURVATURE, set from the FordPrefSteerAngleCurvature
toggle in a later commit), never a live Params read: the safety firmware mirrors the
same flag from current_safety_param_sp, and the two layers must always agree.

Default OFF is a zero-delta: get_current_curvature() returns the yaw-rate measurement
exactly as before. FORD_PINION_GEOMETRY_INDEX maps each supported platform to the
firmware geometry-table row (Edge excluded: ALT_STEER_ANGLE reads a relative pinion
angle and lacks the absolute measurement this needs; the toggle no-ops there).
…RVATURE SP flag

Adds an opt-in alternative angle_meas source for vehicles whose RCM broadcasts
implausible yaw (sign-inverted vs IMU/steering geometry) while its quality flag reads
OK: the steering pinion angle (SteeringPinion_Data, PSCM) converted through the vehicle
model, replay-validated on real routes (bit extraction verified against CANParser on
3000/3000 frames; historical phantom "steering limit exceeded" fault rate 55% -> 0.018%
with this source).

Configuration comes from the sunnypilot SP safety param (current_safety_param_sp,
delivered via USB 0xdf -- the subaru_common.h pattern): bit 0 enables, bits 1-4 select a
row in a per-platform geometry table (slip factor / steer ratio / wheelbase for all 12
supported platforms; FORD_EDGE_MK2 is unsupported -- ALT_STEER_ANGLE reads a relative
pinion angle). A zero or out-of-range index disables the feature outright, so a
half-configured param can never select the wrong geometry.

Default off is a zero-delta by construction:
  - the yaw rx block, the 0.002 error band, and the rx-check list are selected exactly
    as stock when the flag is clear (the pinion RxCheck is only enforced when the
    message is actually consumed, via SET_RX_CHECKS -- a pinion hiccup can never
    disable controls for non-users);
  - the widened 0.003 band (raw pinion angle has no roll/offset compensation in
    firmware) applies only on the pinion path, via a second FORD_LIMITS instantiation.
  - StePinAn_No_Cs checksum algorithm is unknown (Ford sum-invert patterns don't match
    real frames) -> ignore_checksum; integrity via counter + quality flag + 100Hz check.

Tests: the pinion classes rerun the entire stock matrix with the SP param set
(SAFETY_PARAM_SP, Toyota convention) across three geometries (Explorer primary,
Bronco Sport smallest wheelbase, F-150 largest), plus pinion-specific tests (sign
convention -- the broken-yaw failure mode must block; QF gating; inertness below the
gate speed). The stock classes never set the SP param and pass unchanged (zero-delta
proof). A geometry-consistency test reads the firmware table through new ALLOW_DEBUG
libsafety getters and checks every row against CarSpecs + calc_slip_factor, so the
table cannot rot as platforms change.
…surement

FordPrefSteerAngleCurvature (bool, default off, PERSISTENT+BACKUP; UI title "Use Pinion
Yaw Sensor"): read once at car init via the sunnypilot params path (initialize_params ->
_initialize_ford), which sets FordSafetyFlagsSP.STEER_ANGLE_CURVATURE plus the platform's
geometry-table index on CP_SP.safetyParam -- the same value the safety firmware receives
as current_safety_param_sp and the control side mirrors, so the two layers can never
disagree. Init-time by design (no live Params read, no ccProp): a live flip against stale
firmware would fight the panda. card re-reads the param at every car start, so a flip
simply applies on the next drive; the sunnylink entry is offroad-only and flagged
needs_onroad_cycle, and the BluePilot-menu descriptions say so in plain words.

The toggle lives in the BluePilot menu's Vehicle section (plus the mici variant and the
sunnylink remote settings). The param is declared in BOTH common/params_keys.h and
bluepilot/params/params.json per fork convention -- the params_keys.h registry entry is
what card validates keys against. CP_SP.safetyParam stays a plain int
(FordSafetyFlagsSP uses plain int constants like its SP siblings): an IntFlag-typed
value crashes card's capnp serialization, observed on-device and regression-tested.

Platforms without a geometry row (FORD_EDGE_MK2: ALT_STEER_ANGLE reads a relative
pinion angle) silently keep stock yaw behavior -- the toggle no-ops rather than
half-configuring, matching the firmware's invalid-index handling.

The settings descriptions lead with the symptoms of the sensor fault this works around
and point at tools/ford_yaw_health_check.py as the "do I need this?" diagnostic.
ghbarker added a commit to ghbarker/openpilot that referenced this pull request Jul 20, 2026
…luePilotDev#144 (truthful shadow)

Folds the two open upstream PRs that bear on the Mach-E angle-mode
lateral failures into this branch, adapted to compose with the drift
branch and low-speed boost already here:

- PR BluePilotDev#148: the classic stall branch now arms on a fractional delivery
  deficit (measured < 0.65x desired) instead of desired-leads-measured
  plus the absolute gap alone. Upstream's on-road evidence: the absolute
  condition misfired twice mid-curve on honest deep-curve entry
  transients (0.7-0.85x delivered), releasing steering exactly when the
  car was behind; true stalls measure 0.28-0.59x. The drift branch keeps
  its own near-straight guard and is unaffected.

- PR BluePilotDev#144: bp_kappa_cmd (the shadow curvature ford.h's angle-mode
  deviation check judges against angle_meas) is now published from
  measured curvature whenever the planner kappa cannot honestly describe
  the car's steering: inactive frames, human-turn override, stall-blip
  pulses, and while the driver presses. Kills the stale-zero re-engage
  race at the source -- which matters more on this branch, since the
  drift branch fires mode-0 pulses in situations the old code never did
  -- and fixes the driver-press shadow excursions behind the only two
  real lateral safety blocks in upstream's 4.5h replay. All measurement
  reads route through the new LateralCurvExt.get_current_curvature().

Tests: merged both PRs' unit suites into one file (shared harness),
plus two new drift-branch cases (hands-off drift fires; curve exit with
a genuine command never fires). 9/9 pass locally. Replay of the three
incident segments (4/6/8) is unchanged except one classic fire shifting
0.2s later under the stricter gate -- every fire on this route is a true
stall (0.2-0.3x delivered) or a guarded drift event.

Not folded: PR BluePilotDev#145 (pinion yaw sensor) -- changes panda safety firmware
(ford.h) and is opt-in; deferred until after this branch's road test.
PR BluePilotDev#146 (Expedition FW), BluePilotDev#147 (audio), BluePilotDev#132 (comma-four UI refactor) are
unrelated to this issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ford_yaw_health_check.py answers "do I need the steering-angle curvature measurement
toggle?" from any drive's logs by comparing the RCM yaw rate against the yaw implied by
the steering angle through the vehicle model (with the comma IMU as a mount sanity
check). The sign convention is anchored by the on-road-validated pinion measurement
(wire-shadow corr +0.989), so both fault classes are caught: sign inversion (negative
correlation) and the gain fault actually measured on a faulty 2021 Explorer RCM --
high correlation but a median gain of +2.11x on one drive and +0.45x on another the
same day, either of which corrupts the curvature measurement far beyond the safety
check's whole error band.

ford_pinion_replay.py replays the pinion-sourced firmware check semantics and the
Python-layer clip against real rlogs (validation: historical phantom fault rate 55% ->
0.018% with the pinion source on the diagnosis routes). Reads the dongle ID from
FORD_REPLAY_DONGLE_ID.
@jrneulight
jrneulight force-pushed the bp-7.0-pinion-rework branch from c8214ef to e0bb63e Compare July 20, 2026 14:26
@alan-polk
alan-polk merged commit fe5b019 into BluePilotDev:bp-dev Jul 20, 2026
alan-polk pushed a commit that referenced this pull request Jul 20, 2026
The bp-dev merge in #145 resolved the settings_ui.json conflict by hand and fused
two adjacent toggle objects into one: FordPrefSteerAngleCurvature kept its opening
brace, then BPUseCustomSounds' keys were appended into the same object instead of
starting a new one.

JSON allows duplicate keys and the last wins, so the file still parsed and still
grepped as containing both toggle names -- but once parsed, the pinion toggle was
gone from the sunnylink settings UI along with its offroad_only enablement. The
feature itself merged fine; only its remote settings entry disappeared.

settings_ui.json is generated from settings_ui_src/ by
tools/compile_settings_ui.py. The YAML source is correct and was never affected,
so this simply regenerates the committed artifact from it.

Verified: tools/compile_settings_ui.py --check now matches (it did not before), and
sunnypilot/sunnylink/tests/ goes from 2 failed / 75 passed to 77 passed / 1 skipped.
alan-polk added a commit that referenced this pull request Jul 21, 2026
…'s shown

PR #145 correctly no-ops this toggle in the safety/control layers for Edge
MK2 (its pinion sensor only reports a relative angle -- see
FORD_PINION_GEOMETRY_INDEX in values_ext.py), but never actually disabled
the toggle itself: TICI settings, MICI settings, and the SunnyLink remote
config all left it live-clickable with only a description-text mention
("Not available on the Edge") as the safeguard. An Edge owner could flip
it, have it silently do nothing, and reasonably conclude some unrelated
fix (in one report, a car restart clearing a stale dashcam-mode
fingerprint -- CarParamsCache is CLEAR_ON_MANAGER_START, so any reboot
alone forces a fresh re-fingerprint) was actually caused by the toggle.

- TICI/MICI: grey the toggle via ui_state.CP.carFingerprint.
- SunnyLink: added a "platform" capability (mirrors the existing "brand"
  bundle-first/CP-fallback pattern exactly) since no per-model capability
  existed to gate on, then a not_ford_edge_mk2 macro referenced from
  vehicle.yaml. Regenerated settings_ui.json via compile_settings_ui.py
  (never hand-edited) -- diff is exactly the new enablement rule.

Verified: capabilities/settings-schema/ford test suites all pass (same
pre-existing, unrelated failures as before this change), ruff clean
(same pre-existing issues as before, nothing new), and an end-to-end
check that generate_capabilities() resolves platform="FORD_EDGE_MK2"
from a synthetic CarParams.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants