Skip to content

lateral: stop intentional carcontroller shaping from starving the integrator; make the Clarity L/R splits tunable - #8

Open
anasufovic wants to merge 4 commits into
JamesL787:vfnfrom
anasufovic:armin/lateral-fixes
Open

lateral: stop intentional carcontroller shaping from starving the integrator; make the Clarity L/R splits tunable#8
anasufovic wants to merge 4 commits into
JamesL787:vfnfrom
anasufovic:armin/lateral-fixes

Conversation

@anasufovic

@anasufovic anasufovic commented Sep 1, 2026

Copy link
Copy Markdown

Two lateral fixes found on a 2021 Civic Hatchback EX (Bosch), EPS 39990-TGG,A120 with the TGG-4250 linear-max mod (not the 2.5x), comma 3, Pop V2, LatControlPID. Both are default-off and bit-identical to current behaviour when their gate file is absent, on every car.

Base is vfn (merge-base e61af6208). The four control files on this branch are hash-identical to the build that produced every number below — this is what was driven, not a reconstruction.

1. Intentional carcontroller shaping starves the lateral integrator

steer_limited_by_safety is set on any applied-vs-requested mismatch over a flat 1e-2. But the modified-EPS Honda carcontroller reshapes the command every frame on purpose — override fade, the torque LPF (tau 0.08-0.10), low-speed zeroing under NrdrMinSteerSpeed. So the flag is true on 99.4-99.9% of frames, the integrator is frozen 81-87% of a drive, and the loop runs close to P-only. Intentional shaping isn't a safety limit; the binary threshold can't tell them apart.

Replaces the freeze decision only — the flag itself is untouched and still reported — with a relative rule, one shared implementation in latcontrol.py so the online path and any offline reconstruction can't drift:

blocked = |requested - applied| > max(1e-2, rel * |requested|)   or sign flip

rel comes from /data/HondaIntegratorFix as a percent; absent or 0 keeps the legacy binary behaviour exactly. 0.30 was chosen by replaying the rule over 67k logged frames: steady turns 18% frozen vs 80% under the old rule, early override fade still ~100% at every threshold, and the two regimes separate cleanly (p50 0.12 vs 0.92).

Anti-windup now also clips against the command that's actually sent. This controller reshapes p/i/f with per-term scales, a scheduled output multiplier and an additive learned trim, so clipping against a bare p+i+d+f was protecting the integrator against a number that was never on the wire. PIDController.update takes an optional compose callback; callers that pass nothing keep the original arithmetic byte-for-byte.

Below 2 mph the integrator leaks (tau 0.5 s) instead of hard-freezing, so it can't hold a stale value across a low-speed cutoff.

2. The Clarity left/right splits aren't right for this rack

The output scale is asymmetric by design, but its constants were fitted on the Clarity. On the TGG-4250 Civic the left branch floors at 0.6863 while right sits ~1.11-1.15 — same corner, ~1.6x less authority going left. It reads as lazy left turns.

Two file-backed knobs blend the constants toward the opposite side, turn-in and unwind separately:

  • /data/HondaTurnInAsymmetry — blends only the two turn-in constants
  • /data/HondaUnwindAsymmetry — blends only the two unwind constants

Scale is 0-100: 100 = the current Clarity split exactly (so absent/unset is a no-op), 0 = fully symmetric. I drove both at 50, i.e. halfway — that's where all the numbers below come from. I didn't tune past 50 because it was already at the noise floor; there may be a better value.

Why not let TuneLearner handle it? It was on for every drive here, it keeps separate left/right maps, and its docstring says it exists to cancel direction-dependent bias — so this is the first thing it should have caught. It can't, for two structural reasons: the trim is additive (output_torque += learner_trim) while the defect is a multiplicative output scale, so no single per-cell offset cancels it (whatever fixes a gentle corner is wrong for a hard one); and it's clamped to 10% of the rail against a 1.62x authority difference. So the mechanism designed for this class of problem was running and structurally couldn't fix it. That's the argument for the fix living at the scale, not in the trim.

On the knobs — I wouldn't ship it in this shape. They exist so I could measure this on the road across a range of values, not because a runtime knob is the right end state. The correct value looks like a property of the rack rather than a user preference, so a per-fingerprint constant is probably right, and the whole thing wants to be neater and more compact than it is. Leaving it as-is for now rather than guessing at the shape you'd want — happy to fold it into a constant for the modified-EPS Civic Bosch, make it fingerprint-keyed, or restructure it however you'd rather review. The measurements are the part I'd stand behind; the plumbing is scaffolding.

The separation matters. Blending all four together buys turn-in strength by weakening the corner exit — that was tried first and produced a left-exit over-rotation (+5.08/+7.34° vs -2.65/-5.36° right) that put the car over a centre divider. Learned that one the expensive way.

3. Stale PID integral across a disengagement

LatControl.reset() cleared only the saturation timer, so pid.i survived a disengagement and was re-injected whole on the first frame after re-engaging. Measured on the road: +0.19115 carried across a 0.20 s gap, and -0.13616 across a 0.35 s gap where it landed against a positive proportional term. One case carried across 57.8 s of inactive time.

Latent while the shaping mismatch kept the integrator frozen 81-87% of the time — there was rarely anything meaningful stored. Fix 1 above arms it, which is why it's in this PR.

Fixed by overriding reset() rather than patching the inactive branch: controlsd calls LaC.reset() directly on inactive frames without going through update(), so putting the state in reset() and having the inactive branch call it makes the two paths the same code by construction. LatControlCurvature.reset() already does this.

Offline replay over the logged frames with controlsd's reset call reproduced: 14,734 continuously-active frames bit-identical, no frame differs before a re-engagement. 9 new tests, 7 of which fail against the unfixed code.

No driving improvement is claimed for this one. Adversarial review of both recorded occurrences found the effect invisible in one (peak Δ 0.0022 of steer_max) and confined to a low-speed driver-initiated re-engagement in the other. It's a correctness fix for stale state, and it has not been road-tested yet.

(An earlier version of this description claimed the asymmetry commit already did this. It didn't — that claim was wrong, and the commit message on 922f9b298 carries the same error. Now actually fixed, in b440094c9.)

Numbers

Full lateral config the numbers were taken at, since some of it is non-default and would change what you'd reproduce:

gates (this PR) HondaIntegratorFix=30, HondaTurnInAsymmetry=50, HondaUnwindAsymmetry=50
LatPScale low/std/hwy 130 / 130 / 130 (non-default)
LatIScale, LatFScale 100 / 100 / 100 (neutral)
LatSmoothSeconds 0.105
LaneCenteringE2EAuthority 0.6
NrdrTuneLearner / rate on / 30
NrdrLatStiction on
NrdrLearnSteerRatio, NrdrLearnStiffness on, on
NrdrMinSteerSpeed 2 mph
NrdrDriverOverrideThreshold 1800
HondaCenterScale / boost thr / min speed 0.5 / 3.0 / 50
HondaUnwindFreeze / ff mult / boost s on / 2.0 / 1.0
HondaLateralPidKpScale, KiScale 1.0, 1.0
model Pop Model V2 (pop223)

LatPScale at 130 is the one most likely to matter — at stock 100 the left/right numbers below will probably look different. NrdrTuneLearner and NrdrLatStiction are both on and supply a large share of the torque at small commands, so results with either off aren't comparable.

before after
L/R turn-in gap 12.44° 0.09-0.28° (every speed band)
left turn-in error −16.33° −1.50°
left exit error +5.08/+7.34° +0.70/+0.03°
integrator frozen 81-87% 29%
driver torque >1500 8.1% of frames (lowest recorded)
corrections >50 mph 2.2-2.45/min 1 driver manoeuvre in 4.9 min

On that last row, so it isn't oversold: there were 13 wheel touches above 50 mph, 9 of them signalled lane changes. The other 4 are one 3-second manoeuvre where the car's own error stayed under 2° and |cmd| peaked at 0.60, not at the rail.

Caveats

  • Straight-line wobble is up slightly — 39/min vs 33, amplitude unchanged at 1.21° std. Minor, but real.
  • One car, one rack, ~33 min of analysed driving over two routes. Not a fleet result.
  • The knobs are plain files under /data/, not registered params — deliberate, to avoid a params_pyx.so rebuild during testing. Happy to convert for merge if you'd rather.
  • 57 tests pass on the branch; the freeze rule is pinned against a 4200-frame logged fixture. selfdrive/controls/tests/test_controlsd.py can't be collected on a mac host (native msgq), so controlsd's runtime path is covered by a source-order test only.

Not in this PR

A variable-ratio fix (solve the desired angle on the rack rather than selecting the ratio at the measured angle) is on a separate branch and deliberately not proposed here — it's worth about 0.2° and it's still in review.

Also unknown: whether the integrator-starvation bug exists on the nrdr-* dialect. It isn't dialect-specific in principle — it's carcontroller shaping vs steer_limited_by_safety — but that file differs by ~586 lines and I haven't checked. Happy to look if it's useful.

Have rlogs for all of the above if you want them.

anasufovic and others added 4 commits September 1, 2026 01:01
The modified-EPS lateral output scale is asymmetric by design, but its
constants were fitted on the Clarity. On a Civic Bosch with the TGG-4250
linear-max rack the left-turn branch floors at 0.6863 while the right sits
around 1.11-1.15, so the same corner taken left gets ~1.6x less authority
than taken right. On the road that reads as lazy left turns: measured left
turn-in error -16.33 deg vs -1.50 right, and a 12.44 deg left/right gap.

Two file-backed knobs blend the Clarity constants toward the opposite side:
  /data/HondaTurnInAsymmetry  blends ONLY the two turn-in constants
  /data/HondaUnwindAsymmetry  blends ONLY the two unwind constants
Both default to the existing Clarity values, so an absent or unparseable
file is bit-identical to before on every car.

Turn-in and unwind are deliberately separate. Blending all four constants
together buys turn-in strength by weakening the corner exit -- that was
tried first and produced a left-exit over-rotation (+5.08/+7.34 deg exit
error vs -2.65/-5.36 right) that put the car over a centre divider.

Also clears PID state when lateral goes inactive, so a stored integrator
cannot carry across a disengagement.

Measured on a 2021 Civic Hatchback EX (Bosch), EPS 39990-TGG,A120 with the
TGG-4250 mod, comma 3, both knobs at 50:
  left/right turn-in gap   12.44 deg -> 0.56-0.75 deg
  left exit error   +5.08/+7.34 deg -> +0.70/+0.03 deg

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy
…teral integrator

controlsd sets steer_limited_by_safety whenever the applied torque differs
from the requested one by more than a flat 1e-2. On a modified-EPS Honda the
carcontroller deliberately reshapes the command every frame -- override fade,
the torque LPF (tau 0.08-0.10), low-speed zeroing below NrdrMinSteerSpeed --
so that flag is true on 99.4-99.9% of frames. The lateral integrator is frozen
on 81-87% of a drive and the loop runs close to P-only. Intentional shaping is
not a safety limit, but the binary threshold cannot tell the two apart.

Replaces the freeze decision (only the freeze -- the flag itself is untouched
and still reported) with a relative rule, shared by one implementation in
latcontrol.py so the online path and any offline reconstruction cannot drift:

  blocked = |requested - applied| > max(1e-2, rel * |requested|)   or sign flip

with rel from /data/HondaIntegratorFix as a percent; absent or 0 keeps the
legacy binary behaviour exactly. Threshold settled at 0.30 by replaying the
rule over 67k logged frames: steady turns 18% frozen vs 80% under the old
rule, early override fade still ~100% at every threshold, and the two regimes
separate cleanly (p50 0.12 vs 0.92).

Anti-windup now also clips against the command that is actually sent. This
controller reshapes p/i/f with per-term scales, a scheduled output multiplier
and an additive learned trim, so clipping against a bare p+i+d+f protected the
integrator against a number that was never on the wire. PIDController.update
takes an optional compose callback; callers that pass nothing keep the
original arithmetic byte-for-byte.

Below 2 mph the integrator now leaks (tau 0.5 s) instead of hard-freezing, so
it cannot hold a stale value across a low-speed cutoff.

Tests: the freeze rule is pinned against a 4200-frame logged fixture, and the
controlsd dispatch is pinned by source order after an earlier version of this
change used self.LaC before it was assigned.

Measured on a 2021 Civic Hatchback EX (Bosch), EPS 39990-TGG,A120 with the
TGG-4250 mod, comma 3:
  integrator frozen   81-87% -> 29%
  driver torque >1500 on 8.1% of frames, lowest recorded
  4.9 min above 50 mph, zero controller-initiated corrections
  (9 of 13 wheel touches were signalled lane changes; the other 4 were one
   driver-initiated manoeuvre where |cmd| peaked at 0.60, not at the rail)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy
… duplicated block

Behaviour unchanged -- the code is byte-identical to the driven build once
comments and blank lines are stripped. Removes a comment describing a
hard-clear that the code no longer does (it leaks), a duplicated raw-press
explanation, and a params comment mangled by an earlier squash.
LatControl.reset() cleared only the saturation timer, so pid.i survived a
disengagement and was re-injected whole on the first frame after the next
engagement. Measured on the road: an integral of +0.19115 carried across a
0.20 s gap, and -0.13616 across a 0.35 s gap where it landed against a
POSITIVE proportional term. It also survives long gaps -- one case carried
across 57.8 s of inactive time, arriving ~90x larger than a fresh integrator
would have been.

This was latent while the shaping mismatch kept the integrator frozen 81-87%
of the time: there was rarely anything meaningful stored to re-inject. The
previous commit removes that starvation, which arms it.

Fixed by overriding reset() rather than patching update()'s inactive branch.
controlsd calls LaC.reset() directly on inactive frames without going through
update(), so putting the state in reset() and having the inactive branch call
it makes the two paths the same code by construction. LatControlCurvature.reset()
already does this; this removes an inconsistency between the two controllers.

The full PIDController.reset() is used rather than clearing i alone: p, d and f
are recomputed unconditionally at the top of PIDController.update() before
anything reads them, so zeroing them cannot change the first active frame, and
using the controller's own API keeps this from depending on which fields happen
to be stale today.

Offline replay over the logged frames, real controller and VehicleModel with
controlsd's reset() call reproduced: 14,734 continuously-active frames are
bit-identical, and no frame differs before a re-engagement.

No driving improvement is claimed. Adversarial review of the two recorded
occurrences found the effect invisible in one (peak delta 0.0022 of steer_max)
and confined to a low-speed driver-initiated re-engagement in the other. This
is a correctness fix for stale state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy
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