From 922f9b298200ac56c4a34a26d6f5b28cccc148c5 Mon Sep 17 00:00:00 2001 From: Armin Nasufovic <12374077+anasufovic@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:01:59 -0700 Subject: [PATCH 1/4] controls: make the Clarity left/right turn-in and unwind splits tunable 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) Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy --- selfdrive/controls/lib/latcontrol_pid.py | 85 ++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index 8c57c1b729c..898a3ad0d70 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -109,6 +109,35 @@ }) + +_TURN_IN_ASYMMETRY_PATH = "/data/HondaTurnInAsymmetry" +_UNWIND_ASYMMETRY_PATH = "/data/HondaUnwindAsymmetry" + + +def _read_turn_in_asymmetry(default: float = 1.0) -> float: + """Percent of the Clarity-fitted left/right turn-in split, 100 = unchanged, 0 = symmetric.""" + try: + with open(_TURN_IN_ASYMMETRY_PATH, "rb") as f: + value = float(f.read().strip()) / 100.0 + except (OSError, ValueError, TypeError): + return default + if not math.isfinite(value): + return default + return min(max(value, 0.0), 1.0) + + +def _read_unwind_asymmetry(default: float = 1.0) -> float: + """Percent of the Clarity-fitted left/right UNWIND split, 100 = unchanged, 0 = symmetric.""" + try: + with open(_UNWIND_ASYMMETRY_PATH, "rb") as f: + value = float(f.read().strip()) / 100.0 + except (OSError, ValueError, TypeError): + return default + if not math.isfinite(value): + return default + return min(max(value, 0.0), 1.0) + + def get_nrdr_modified_eps_kf(v_ego: float) -> float: return float(np.interp(v_ego, NRDR_MODIFIED_EPS_KF_SPEED_BP, NRDR_MODIFIED_EPS_KF_V)) @@ -256,6 +285,8 @@ def _clarity_eps_pid_output_scale( center_taper_high: float, center_boost_threshold_deg: float, center_boost_min_speed_ms: float, + turn_in_asymmetry: float = 1.0, + unwind_asymmetry: float = 1.0, ) -> float: abs_angle = abs(desired_angle_deg) speed_weight = min(max((v_ego - 4.0) / 10.0, 0.0), 1.0) @@ -275,12 +306,43 @@ def _clarity_eps_pid_output_scale( center_speed_weight = 1.0 center_taper = center_taper_high * center_taper_scale * center_speed_weight - mid_turn_scale = 0.1200 if is_left else 0.0150 - mid_turn_turn_in_scale = -0.5500 if is_left else -0.0524 - mid_turn_unwind_scale = -0.0743 if is_left else -0.0842 - base_scale = 0.0722 if is_left else 0.0972 - turn_in_scale = -0.0799 if is_left else 0.0888 - unwind_scale = 0.1600 if is_left else 0.2000 + # These constants were fitted on a Clarity and are strongly direction-asymmetric on TURN-IN: + # at a large angle above ~26 mph they floor a left turn-in at 0.6863 while giving a right + # turn-in 1.113-1.149, a 1.62x gap. `turn_in_asymmetry` blends the left turn-in constants + # toward the right ones so the split can be measured on a car that is not a Clarity: + # 1.0 keeps the Clarity behaviour exactly, 0.0 makes turn-in direction-symmetric. + # + # Hold/unwind is bit-identical at every knob setting: nothing on that path is blended. + a = min(max(float(turn_in_asymmetry), 0.0), 1.0) + if is_left: + # Blend ONLY the two turn-in-phase constants. mid_turn_scale and base_scale are shared with + # the hold/unwind phase: blending them too (as this did originally) bought turn-in strength by + # weakening left corner EXIT, which showed up on the road as the car holding ~8-15 deg too much + # left on the way out of a curve. Leaving them alone gives MORE turn-in for zero exit penalty + # -- at knob 65, 0.796 instead of 0.768, with exit unchanged rather than -2.9%. + mid_turn_scale = 0.1200 + base_scale = 0.0722 + mid_turn_turn_in_scale = -0.5500 * a + -0.0524 * (1.0 - a) + turn_in_scale = -0.0799 * a + 0.0888 * (1.0 - a) + else: + mid_turn_scale = 0.0150 + mid_turn_turn_in_scale = -0.0524 + base_scale = 0.0972 + turn_in_scale = 0.0888 + # The UNWIND constants are Clarity-asymmetric too, in the direction that makes a left corner + # exit badly: left keeps more holding torque on the way out (0.1600 vs 0.2000 reduction), so a + # left exit unwinds slower and over-rotates while a right exit under-rotates. Measured left + # exits +5.08/+7.34 deg, right exits -2.65/-5.36 -- both signs match. Same disease as turn-in. + # 1.0 keeps the Clarity behaviour exactly, 0.0 gives a left exit the right-hand constants. + # Note the low-speed-unwind branch below bypasses these entirely, so very slow exits are + # unaffected at any setting. + b = min(max(float(unwind_asymmetry), 0.0), 1.0) + if is_left: + mid_turn_unwind_scale = -0.0743 * b + -0.0842 * (1.0 - b) + unwind_scale = 0.1600 * b + 0.2000 * (1.0 - b) + else: + mid_turn_unwind_scale = -0.0842 + unwind_scale = 0.2000 scale = 1.0 + (center_weight * center_taper) scale += speed_weight * mid_turn_weight * mid_turn_scale @@ -357,6 +419,8 @@ def __init__(self, CP, CI, dt): self.unwind_boost_elapsed = 0.0 self.lat_stiction = LatStiction(dt, self.steer_max) self.lat_stiction_enabled = False + self.turn_in_asymmetry = 1.0 + self.unwind_asymmetry = 1.0 self.prev_saturated = False def update_honda_lateral_pid_gain_scale(self, starpilot_toggles): @@ -504,6 +568,13 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat self.unwind_ff_multiplier = _get_param_float(self.params, "HondaUnwindFfMultiplier", 2.0, 1.0, 4.0) self.unwind_boost_cap_s = _get_param_float(self.params, "HondaUnwindBoostSeconds", 1.0, 0.0, 3.0) self.lat_stiction_enabled = _get_param_bool(self.params, "NrdrLatStiction") + # 100 = Clarity-fitted left/right turn-in split (previous behaviour), 0 = symmetric. + # Read as a plain file rather than through Params: the key is deliberately NOT in + # params_keys.h, so this needs no params_pyx.so rebuild to carry on a device. Same + # NOT under /data/params/d: the params system deletes unregistered keys on boot. So `echo 65 > /data/HondaTurnInAsymmetry` + # takes effect within ~3 s, and a missing/garbage file keeps the previous behaviour. + self.turn_in_asymmetry = _read_turn_in_asymmetry() + self.unwind_asymmetry = _read_unwind_asymmetry() p_scale = _lat_pid_scale_banded(CS.vEgo, self.lat_p_scale_low, self.lat_p_scale_standard, self.lat_p_scale_highway) i_scale = _lat_pid_scale_banded(CS.vEgo, self.lat_i_scale_low, self.lat_i_scale_standard, self.lat_i_scale_highway) @@ -526,6 +597,8 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat self.center_taper_high, self.center_boost_threshold, self.center_boost_min_speed * _MPH_TO_MS, + self.turn_in_asymmetry, + self.unwind_asymmetry, ) if self.is_subaru_impreza: From ec9ffebfcd225b85b6ec26ff0970a7b528637782 Mon Sep 17 00:00:00 2001 From: Armin Nasufovic <12374077+anasufovic@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:02:22 -0700 Subject: [PATCH 2/4] controls: stop intentional carcontroller shaping from starving the lateral 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) Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy --- common/pid.py | 16 ++- selfdrive/controls/controlsd.py | 59 ++++++++- selfdrive/controls/lib/latcontrol.py | 25 ++++ selfdrive/controls/lib/latcontrol_pid.py | 125 ++++++++++++++++-- .../lib/tests/test_integrator_wind_blocked.py | 50 +++++++ .../lib/tests/wind_blocked_fixture.npy | Bin 0 -> 67328 bytes selfdrive/controls/tests/test_lac_dispatch.py | 56 ++++++++ 7 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 selfdrive/controls/lib/tests/test_integrator_wind_blocked.py create mode 100644 selfdrive/controls/lib/tests/wind_blocked_fixture.npy create mode 100644 selfdrive/controls/tests/test_lac_dispatch.py diff --git a/common/pid.py b/common/pid.py index e3fa8afdf40..f95e6433691 100644 --- a/common/pid.py +++ b/common/pid.py @@ -43,7 +43,8 @@ def set_limits(self, pos_limit, neg_limit): self.pos_limit = pos_limit self.neg_limit = neg_limit - def update(self, error, error_rate=0.0, speed=0.0, feedforward=0., freeze_integrator=False): + def update(self, error, error_rate=0.0, speed=0.0, feedforward=0., freeze_integrator=False, + compose=None): self.speed = speed self.p = self.k_p * float(error) self.d = self.k_d * error_rate @@ -52,8 +53,17 @@ def update(self, error, error_rate=0.0, speed=0.0, feedforward=0., freeze_integr if not freeze_integrator: i = self.i + self.k_i * self.i_dt * error - # Don't allow windup if already clipping - test_control = self.p + i + self.d + self.f + # Don't allow windup if already clipping. + # + # `compose` lets a caller that reshapes p/i/d/f before sending them (per-term scales, a + # scheduled output multiplier, an additive trim) tell us what the candidate integral would + # actually become on the wire. Without it we clip against p+i+d+f, which is not the command + # for such a caller, so the integrator is protected against the wrong number in both + # directions. Callers that send p+i+d+f unchanged pass nothing and keep the original path. + if compose is None: + test_control = self.p + i + self.d + self.f + else: + test_control = compose(self.p, i, self.d, self.f) i_upperbound = self.i if test_control > self.pos_limit else self.pos_limit i_lowerbound = self.i if test_control < self.neg_limit else self.neg_limit self.i = np.clip(i, i_lowerbound, i_upperbound) diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 72cb474052d..323ee3d7bae 100644 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -1,3 +1,4 @@ +import inspect #!/usr/bin/env python3 import math from numbers import Number @@ -23,7 +24,7 @@ get_lateral_active, ) from openpilot.selfdrive.controls.lib.lane_centering import LaneCenteringController -from openpilot.selfdrive.controls.lib.latcontrol import LatControl +from openpilot.selfdrive.controls.lib.latcontrol import LatControl, integrator_wind_blocked from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, STEER_ANGLE_SATURATION_THRESHOLD from openpilot.selfdrive.controls.lib.latcontrol_curvature import LatControlCurvature @@ -373,6 +374,37 @@ def get_torque_control_params(CP, torque_params, starpilot_toggles, use_live_par return lat_accel_factor, lat_accel_offset, friction +_INTEGRATOR_FIX_PATH = "/data/HondaIntegratorFix" + + +_INTEGRATOR_FIX_CACHE = [0.0, -1] + + +def _read_integrator_fix_threshold_cached(frame: int, default: float = 0.0) -> float: + """Cached wrapper: this sits in the 100 Hz control loop, and the value is a hand-edited knob. + Re-read once a second; 1 s of latency on a tuning file is irrelevant, a syscall per frame is not.""" + if frame - _INTEGRATOR_FIX_CACHE[1] >= 100 or _INTEGRATOR_FIX_CACHE[1] < 0: + _INTEGRATOR_FIX_CACHE[0] = _read_integrator_fix_threshold(default) + _INTEGRATOR_FIX_CACHE[1] = frame + return _INTEGRATOR_FIX_CACHE[0] + + +def _read_integrator_fix_threshold(default: float = 0.0) -> float: + """Relative freeze threshold as a percent. Absent/0/garbage -> 0.0 = legacy absolute behaviour. + + File-gated rather than a Params key so it needs no params_pyx.so rebuild and can be flipped + live (the value is re-read every frame; the controller picks it up within a frame). + """ + try: + with open(_INTEGRATOR_FIX_PATH, "rb") as f: + value = float(f.read().strip()) / 100.0 + except (OSError, ValueError, TypeError): + return default + if value != value or value in (float("inf"), float("-inf")): + return default + return min(max(value, 0.0), 1.0) + + class Controls: def __init__(self) -> None: self.params = Params() @@ -389,6 +421,7 @@ def __init__(self) -> None: self.pm = messaging.PubMaster(['carControl', 'controlsState', 'starpilotLateralState']) self.steer_limited_by_safety = False + self.integrator_wind_blocked = None self.curvature = 0.0 self.desired_curvature = 0.0 self.lc_smooth_release = 0.0 @@ -432,6 +465,11 @@ def __init__(self) -> None: if self.CP.lateralTuning.which() == "torque" and (self.starpilot_toggles.nnff or self.starpilot_toggles.nnff_lite): self.LaC = LatControlNNFF(self.CP, self.CI, DT_CTRL) + # Resolved once, and only after EVERY possible self.LaC assignment above -- including the NNFF + # replacement. Only LatControlPID accepts the integrator-freeze classification; angle, + # curvature, torque and NNFF keep their existing signatures. + self._lac_takes_wind_blocked = "integrator_wind_blocked" in inspect.signature(self.LaC.update).parameters + def update_nrdr_autotune_params(self): self.learn_steer_ratio = self.params.get_bool("NrdrLearnSteerRatio", default=True) self.learn_stiffness = self.params.get_bool("NrdrLearnStiffness", default=True) @@ -783,12 +821,17 @@ def state_control(self): lat_delay = self.sm["liveDelay"].lateralDelay + lat_smooth_seconds actuators.curvature = self.desired_curvature + # Only LatControlPID takes the integrator-freeze classification. The angle, curvature, torque + # and NNFF controllers keep their existing signatures, so passing it unconditionally would be a + # TypeError on every one of those cars -- gate state or not. + lac_extra = (self.integrator_wind_blocked,) if self._lac_takes_wind_blocked else () steer, lateral_output, lac_log = self.LaC.update(CC.latActive, CS, self.VM, lp, self.steer_limited_by_safety, self.desired_curvature, curvature_limited, lat_delay, self.calibrated_pose, self.sm['modelV2'], - self.starpilot_toggles) + self.starpilot_toggles, + *lac_extra) actuators.torque = float(steer) if self.CP.steerControlType == car.CarParams.SteerControlType.curvatureDEPRECATED: actuators.curvature = float(lateral_output) @@ -880,9 +923,21 @@ def publish(self, CC, lac_log): self.steer_limited_by_safety = abs(CC.actuators.steeringAngleDeg - CO.actuatorsOutput.steeringAngleDeg) > \ STEER_ANGLE_SATURATION_THRESHOLD else: + # steer_limited_by_safety keeps its original absolute meaning -- it also feeds saturation + # and "take control" detection, which we are not changing. The integrator gets a separate, + # relative judgement so intentional carcontroller shaping (LPF, override ramp) stops being + # mistaken for the actuator refusing to follow. Threshold comes from + # /data/HondaIntegratorFix: absent or 0 restores the legacy behaviour exactly. self.steer_limited_by_safety = abs(CC.actuators.torque - CO.actuatorsOutput.torque) > 1e-2 + # None means "fix disabled" -- the lateral controller then takes its entirely legacy path, + # anti-windup composition included. One gate for the whole change, so it is on or off, never + # half-applied. + _thr = _read_integrator_fix_threshold_cached(self.sm.frame) + self.integrator_wind_blocked = integrator_wind_blocked( + float(CC.actuators.torque), float(CO.actuatorsOutput.torque), _thr) if _thr > 0.0 else None else: self.steer_limited_by_safety = False + self.integrator_wind_blocked = None # TODO: both controlsState and carControl valids should be set by # sm.all_checks(), but this creates a circular dependency diff --git a/selfdrive/controls/lib/latcontrol.py b/selfdrive/controls/lib/latcontrol.py index ed6bafe04f1..875614b7fa4 100644 --- a/selfdrive/controls/lib/latcontrol.py +++ b/selfdrive/controls/lib/latcontrol.py @@ -6,6 +6,31 @@ from openpilot.selfdrive.locationd.helpers import Pose +def integrator_wind_blocked(requested: float, applied: float, rel_threshold: float) -> bool: + """Is the actuator genuinely prevented from following us, or is this just intentional shaping? + + controlsd's `abs(requested - applied) > 1e-2` cannot tell the two apart. On a Honda whose + carcontroller low-pass filters and ramps the command on purpose, that absolute test is true on + ~99% of frames and starves the integrator (measured 80% of steady-turn frames frozen). + + A RELATIVE test separates them cleanly. Measured over 67k logged frames on a modified-EPS + Civic Bosch, |requested-applied|/|requested| is: + steady turns (LPF phase lag) : p50 0.12 p75 0.23 p90 0.44 + first 0.3 s of override fade : p50 0.92 p75 0.98 p90 1.00 + At a 0.30 threshold that is 18% of steady-turn frames against 100% of early-fade frames. + + The sign term catches a reversal, where the actuator is moving opposite to the request. + + rel_threshold <= 0 disables the relative test and restores the legacy absolute behaviour. + """ + gap = abs(requested - applied) + if rel_threshold <= 0.0: + return gap > 1e-2 + if requested * applied < 0.0: + return True + return gap > max(1e-2, rel_threshold * abs(requested)) + + class LatControl(ABC): def __init__(self, CP, CI, dt): self.dt = dt diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index 898a3ad0d70..e3de545bec8 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -113,6 +113,17 @@ _TURN_IN_ASYMMETRY_PATH = "/data/HondaTurnInAsymmetry" _UNWIND_ASYMMETRY_PATH = "/data/HondaUnwindAsymmetry" +# The Honda carcontroller zeroes the LKAS request below NrdrMinSteerSpeed (1 mph default; 2 on the +# car this was developed against). A fixed threshold here rather than reading that param keeps the +# controller independent of a carcontroller tuning knob. +# +# This sits well inside the modified-EPS freeze band (freeze_threshold 2.0 m/s = 4.5 mph), so the +# integrator cannot grow below it in the first place -- the change is purely hold-vs-clear. Even if +# NrdrMinSteerSpeed were raised above this, the worst case is that we clear a frozen integrator +# slightly earlier than the wire goes quiet, never that we clear a live one. +_MIN_STEER_SPEED_CLEAR_MS = 2.0 * 0.44704 +_INTEGRATOR_LEAK_TAU_S = 0.5 + def _read_turn_in_asymmetry(default: float = 1.0) -> float: """Percent of the Clarity-fitted left/right turn-in split, 100 = unchanged, 0 = symmetric.""" @@ -420,9 +431,27 @@ def __init__(self, CP, CI, dt): self.lat_stiction = LatStiction(dt, self.steer_max) self.lat_stiction_enabled = False self.turn_in_asymmetry = 1.0 + self._compose_state = (1.0, 1.0, 1.0, 1.0, 0.0) + self._stiction_delta = 0.0 self.unwind_asymmetry = 1.0 self.prev_saturated = False + def _compose_scaled(self, p, i, d, f): + """The scaled PID command: per-term scales then the scheduled output scale. Single source of + truth for that arithmetic -- the real output and the anti-windup candidate both start here.""" + p_scale, i_scale, f_scale, out_scale, _ = self._compose_state + return (p * p_scale + i * i_scale + d + f * f_scale) * out_scale + + def _compose_candidate(self, p, i, d, f): + """What the candidate integral would actually put on the wire: the scaled command plus the + additive stages that follow it. The learner trim is a pure map lookup so it is exact; the + stiction delta is last frame's, because stiction transforms the output and is stateful -- exact + to within one 10 ms frame against its 0.30 s capture tau, and zero when saturated, which is the + regime anti-windup exists for. Including these is deliberate: omitting an additive + same-direction term makes the candidate an UNDERestimate, firing the clamp late and permitting + MORE windup, not less.""" + return self._compose_scaled(p, i, d, f) + self._compose_state[4] + self._stiction_delta + def update_honda_lateral_pid_gain_scale(self, starpilot_toggles): if not self.is_honda_pid_lateral: return @@ -438,7 +467,7 @@ def update_honda_lateral_pid_gain_scale(self, starpilot_toggles): self.pid._k_i = [self.base_ki_bp, scale_lateral_pid_gain_values(self.base_ki_v, ki_scale)] def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, curvature_limited, - lat_delay, calibrated_pose, model_data, starpilot_toggles): + lat_delay, calibrated_pose, model_data, starpilot_toggles, integrator_wind_blocked=None): self.update_honda_lateral_pid_gain_scale(starpilot_toggles) pid_log = log.ControlsState.LateralPIDState.new_message() @@ -518,6 +547,8 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat ff_unwind_weight *= time_gate ff *= 1.0 + ff_unwind_weight * max(unwind_ff_boost - 1.0, 0.0) + learner_trim = 0.0 + raw_steering_pressed = bool(CS.steeringPressed) steering_pressed = CS.steeringPressed # Civic Bosch used to take a graded detector of its own here. It now shares the generic # modified-EPS one with the Clarity, so override feel is identical across the cars. @@ -531,17 +562,51 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat ) self.eps_modified_steering_pressed_prev = steering_pressed + # On a modified-EPS Honda the carcontroller reshapes torque every frame on purpose (low-speed + # zeroing, override ramp, LPF, optional delta limiter), so controlsd's + # `abs(actuators.torque - actuatorsOutput.torque) > 1e-2` is true on ~99% of frames and starves + # the integrator (measured 53-89% frozen). That mismatch is intentional shaping, not an + # actuator constraint the integrator has to respect: Honda's safety hook applies no + # active-state torque clamp, the output is already clipped to +-steer_max below, and `compose` + # now stops the integrator pushing the *composed* command past that rail. So the mismatch is + # only a real limit signal for cars that don't reshape. + # On a reshaping car use the relative judgement from controlsd when it is available; the + # absolute flag is true on ~99% of frames there and is what starved the integrator. + # integrator_wind_blocked is None on the legacy path, and 0 in the gate file makes controlsd + # compute it with the old absolute rule, so both fall back to previous behaviour exactly. + if integrator_wind_blocked is None: + mismatch_freezes_i = steer_limited_by_safety + else: + mismatch_freezes_i = bool(integrator_wind_blocked) freeze_threshold = 2.0 if self.is_eps_modified else 5.0 - freeze_integrator = steer_limited_by_safety or steering_pressed or CS.vEgo < freeze_threshold + # `steering_pressed` above may be the modified-EPS detector, which deliberately delays a + # same-direction press by up to 0.28 s. The carcontroller meanwhile acts on the RAW press + # immediately, so for that window it is fading torque out while we would still be integrating. + # The mismatch boolean used to mask this by accident; freeze on the raw press explicitly. + # Below the carcontroller's own cutoff (NrdrMinSteerSpeed, 1 mph by default) the wire carries + # zero regardless of what we ask for, so any integral built there is fiction. Freezing it + # merely stores that fiction until the car rolls back above the cutoff, where it is applied + # in full. Clear it instead: nothing downstream can act on it while we are below, and the + # override fade brings torque back in gently on the way out. + # Below the carcontroller's cutoff the wire carries zero whatever we ask for, so any integral + # stored there is fiction that gets applied in full when the car rolls back above it. Leak it + # rather than freezing (which preserves the fiction) or hard-clearing (bumpy, and contrary to + # the no-hard-clears conclusion from the earlier driver-hand work). tau 0.5 s empties it in + # about a second and a half of standstill while staying continuous. + if self.is_eps_modified and integrator_wind_blocked is not None and CS.vEgo < _MIN_STEER_SPEED_CLEAR_MS: + self.pid.i *= math.exp(-self.dt / _INTEGRATOR_LEAK_TAU_S) + + # The raw-press term closes a window the filtered detector leaves open (it delays a + # same-direction press up to 0.28 s while the carcontroller is already fading torque out). + # It only matters once the integrator is actually free, so it rides the same gate -- keeping + # the disabled path bit-identical to stock. + raw_press_freezes_i = raw_steering_pressed and integrator_wind_blocked is not None + freeze_integrator = (mismatch_freezes_i or raw_press_freezes_i or steering_pressed + or CS.vEgo < freeze_threshold) unwind_detected = phase < UNWIND_FREEZE_PHASE_THRESHOLD and abs_angle_des < UNWIND_FREEZE_ANGLE_NEAR_CENTER if self.is_eps_modified and self.unwind_freeze_enabled and unwind_detected: freeze_integrator = True - output_torque = self.pid.update(error, - feedforward=ff, - speed=CS.vEgo, - freeze_integrator=freeze_integrator) - # The Civic Bosch testing ground applies its own hardcoded center taper below; let it own the # output scale so the two tapers can never compound. civic_bosch_testing_ground = self.is_civic_bosch_modified and civic_bosch_modified_lateral_testing_ground_active() @@ -579,7 +644,6 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat p_scale = _lat_pid_scale_banded(CS.vEgo, self.lat_p_scale_low, self.lat_p_scale_standard, self.lat_p_scale_highway) i_scale = _lat_pid_scale_banded(CS.vEgo, self.lat_i_scale_low, self.lat_i_scale_standard, self.lat_i_scale_highway) f_scale = _lat_pid_scale_banded(CS.vEgo, self.lat_f_scale_low, self.lat_f_scale_standard, self.lat_f_scale_highway) - output_torque = self.pid.p * p_scale + self.pid.i * i_scale + self.pid.d + self.pid.f * f_scale lane_change = bool(getattr(CS, "leftBlinker", False) or getattr(CS, "rightBlinker", False)) if lane_change: @@ -587,8 +651,10 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat center_taper_scale = 0.0 else: center_taper_scale = float(self.center_taper_scale.update(1.0)) - if not civic_bosch_testing_ground: - output_torque *= _clarity_eps_pid_output_scale( + if civic_bosch_testing_ground: + eps_output_scale = 1.0 + else: + eps_output_scale = _clarity_eps_pid_output_scale( angle_steers_des_no_offset, phase, float(CS.steeringRateDeg), @@ -601,6 +667,35 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat self.unwind_asymmetry, ) + # Anti-windup has to judge the candidate integral against the torque we are about to SEND. + # The command is rebuilt with independent per-term scales, multiplied by a scheduled output + # scale, then given an additive learner trim and a stiction stage -- so it differs from + # p+i+d+f by up to ~30% and the integrator was protected against the wrong number in both + # directions. _compose_output is the single source of truth: the real output below and the + # anti-windup candidate both go through it, so the two cannot drift apart. + # + # The learner trim is a pure map lookup, so it is exact. The stiction delta is last frame's, + # because stiction TRANSFORMS the output and is stateful -- the candidate is therefore exact + # to within one 10 ms frame against stiction's 0.30 s capture tau, and stiction bypasses + # itself when saturated, so in the regime anti-windup exists for the delta is zero and the + # candidate is exact. Including it rather than omitting it as "conservative" is deliberate: + # omitting an additive same-direction term makes the candidate an UNDERestimate, firing the + # clamp late and permitting MORE windup, not less. + learner_trim = float(self.tune_learner.apply(CS.vEgo, angle_steers_des)) + self._compose_state = (p_scale, i_scale, f_scale, eps_output_scale, learner_trim) + + output_torque = self.pid.update(error, + feedforward=ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator, + compose=self._compose_candidate if integrator_wind_blocked is not None else None) + output_torque = self._compose_scaled(self.pid.p, self.pid.i, self.pid.d, self.pid.f) + else: + output_torque = self.pid.update(error, + feedforward=ff, + speed=CS.vEgo, + freeze_integrator=freeze_integrator) + if self.is_subaru_impreza: raw_output_torque = self.pid.p + self.pid.i + self.pid.d + self.pid.f output_torque = raw_output_torque * get_subaru_impreza_pid_output_scale(error) @@ -625,7 +720,7 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat output_torque = self.prev_output_torque + (output_alpha * (output_torque - self.prev_output_torque)) output_torque = float(max(min(output_torque, self.steer_max), -self.steer_max)) - output_torque += self.tune_learner.apply(CS.vEgo, angle_steers_des) + output_torque += learner_trim if self.is_eps_modified else self.tune_learner.apply(CS.vEgo, angle_steers_des) output_torque = float(max(min(output_torque, self.steer_max), -self.steer_max)) paramsd_ok = bool( @@ -640,18 +735,24 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat if self.lat_stiction_enabled: des_rate_degs = desired_angle_delta / self.dt lane_change_stiction = bool(getattr(CS, "leftBlinker", False) or getattr(CS, "rightBlinker", False)) + _pre_stiction = output_torque output_torque = float(self.lat_stiction.update( active, CS.vEgo, error, des_rate_degs, float(CS.steeringRateDeg), output_torque, steering_pressed, lane_change_stiction, self.prev_saturated)) + self._stiction_delta = output_torque - _pre_stiction else: self.lat_stiction.reset() + self._stiction_delta = 0.0 pid_log.active = True pid_log.p = float(self.pid.p) pid_log.i = float(self.pid.i) pid_log.f = float(self.pid.f) pid_log.output = float(output_torque) - pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, steer_limited_by_safety, curvature_limited)) + # Same classification as the integrator: _check_saturation() refuses to accumulate while the + # limiter flag is set, so leaving the ~99%-true mismatch here would keep saturation and + # "take control" detection suppressed on modified-EPS cars even after it stops freezing I. + pid_log.saturated = bool(self._check_saturation(self.steer_max - abs(output_torque) < 1e-3, CS, mismatch_freezes_i, curvature_limited)) self.prev_angle_steers_des_no_offset = angle_steers_des_no_offset self.prev_output_torque = float(output_torque) self.prev_saturated = bool(pid_log.saturated) diff --git a/selfdrive/controls/lib/tests/test_integrator_wind_blocked.py b/selfdrive/controls/lib/tests/test_integrator_wind_blocked.py new file mode 100644 index 00000000000..5e981872cbe --- /dev/null +++ b/selfdrive/controls/lib/tests/test_integrator_wind_blocked.py @@ -0,0 +1,50 @@ +"""The offline analysis tool and the shipped rule must be the same function, forever. + +The integrator-freeze decision is reconstructed offline from logged carControl.actuators.torque and +carOutput.actuatorsOutput.torque, because there is no spare capnp field to log it in. That is only +sound while the reconstruction and `integrator_wind_blocked()` agree exactly. This test pins that +against a fixture of real logged frames from six drives, at every threshold we have used. +""" +import numpy as np + +from openpilot.selfdrive.controls.lib.latcontrol import integrator_wind_blocked + +FIXTURE = "selfdrive/controls/lib/tests/wind_blocked_fixture.npy" +THRESHOLDS = (0.0, 0.15, 0.30, 0.40) + + +def _offline(requested, applied, threshold): + """The vectorised form used by the offline tool. Must match the scalar rule exactly.""" + gap = np.abs(requested - applied) + if threshold <= 0.0: + return gap > 1e-2 + return (requested * applied < 0.0) | (gap > np.maximum(1e-2, threshold * np.abs(requested))) + + +class TestIntegratorWindBlocked: + def test_matches_offline_reconstruction(self): + data = np.load(FIXTURE) + requested, applied = data[:, 0], data[:, 1] + for threshold in THRESHOLDS: + expected = _offline(requested, applied, threshold) + for k in range(len(requested)): + assert bool(expected[k]) == integrator_wind_blocked(float(requested[k]), float(applied[k]), threshold), \ + f"mismatch at frame {k}, threshold {threshold}: cmd={requested[k]} out={applied[k]}" + + def test_zero_threshold_is_legacy_absolute_rule(self): + assert integrator_wind_blocked(0.30, 0.28, 0.0) # 0.02 gap > 1e-2 + assert not integrator_wind_blocked(0.30, 0.295, 0.0) # 0.005 gap + + def test_relative_rule_admits_shaping_and_blocks_fade(self): + # steady turn: LPF phase lag, ~13% of command -> integrator may wind + assert not integrator_wind_blocked(0.30, 0.26, 0.30) + # early override fade: applied is still ramping from zero -> must not wind + assert integrator_wind_blocked(0.30, 0.02, 0.30) + + def test_sign_reversal_always_blocks(self): + assert integrator_wind_blocked(0.30, -0.05, 0.30) + assert integrator_wind_blocked(-0.30, 0.05, 0.30) + + def test_tiny_commands_use_the_absolute_floor(self): + # relative test alone would be hypersensitive near zero; the 1e-2 floor prevents that + assert not integrator_wind_blocked(0.01, 0.005, 0.30) diff --git a/selfdrive/controls/lib/tests/wind_blocked_fixture.npy b/selfdrive/controls/lib/tests/wind_blocked_fixture.npy new file mode 100644 index 0000000000000000000000000000000000000000..168eb08809f6d080f1366da7b4f93d0c341cd5ec GIT binary patch literal 67328 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

;kp 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 Z%z(P4nm)C8*vxO=e>GpW$^Xs&{~MY5$O`}f literal 0 HcmV?d00001 diff --git a/selfdrive/controls/tests/test_lac_dispatch.py b/selfdrive/controls/tests/test_lac_dispatch.py new file mode 100644 index 00000000000..8145c6cb9d0 --- /dev/null +++ b/selfdrive/controls/tests/test_lac_dispatch.py @@ -0,0 +1,56 @@ +"""Controls.__init__ must resolve the lateral-controller dispatch AFTER self.LaC exists, and the +dispatch must call every controller with an argument count it accepts. + +Both defects this guards against were shipped and caught in review, not by testing: + 1. controlsd passed the integrator-freeze flag positionally to every controller, but only + LatControlPID accepts it -> TypeError on the first control frame for angle/curvature/torque/ + NNFF cars, regardless of gate state. + 2. the fix for (1) resolved the dispatch before self.LaC was assigned -> AttributeError in + __init__ on EVERY car, including the one it was meant to help. +A syntax check catches neither. This does. +""" +import inspect +from pathlib import Path + +from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID +from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle +from openpilot.selfdrive.controls.lib.latcontrol_curvature import LatControlCurvature +from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque + +FLAG = "integrator_wind_blocked" +BASE_ARGS = 11 # active, CS, VM, params, steer_limited_by_safety, desired_curvature, + # curvature_limited, lat_delay, calibrated_pose, model_data, starpilot_toggles + + +class TestLateralDispatch: + def test_only_pid_takes_the_flag(self): + assert FLAG in inspect.signature(LatControlPID.update).parameters + for cls in (LatControlAngle, LatControlCurvature, LatControlTorque): + assert FLAG not in inspect.signature(cls.update).parameters, \ + f"{cls.__name__} unexpectedly accepts {FLAG}; the dispatch assumes it does not" + + def test_every_controller_accepts_what_the_dispatch_sends(self): + """Mirrors controlsd: BASE_ARGS always, plus one iff the controller declares the flag.""" + for cls in (LatControlPID, LatControlAngle, LatControlCurvature, LatControlTorque): + params = list(inspect.signature(cls.update).parameters)[1:] # drop self + takes = FLAG in params + sent = BASE_ARGS + (1 if takes else 0) + required = [p for p, v in list(inspect.signature(cls.update).parameters.items())[1:] + if v.default is inspect.Parameter.empty] + assert len(required) <= sent <= len(params), \ + f"{cls.__name__}: dispatch sends {sent} args, signature takes {len(required)}..{len(params)}" + + def test_resolution_happens_after_every_lac_assignment(self): + """The guard must not read self.LaC before the controller-selection block has run. + + Read as text rather than importing controlsd: that module pulls compiled device-only + extensions, and this defect is an ordering property of the source, so a textual check is both + sufficient and runnable anywhere. + """ + src = Path(__file__).resolve().parents[2] / "controls" / "controlsd.py" + lines = src.read_text().splitlines() + guard = next(i for i, l in enumerate(lines) if "_lac_takes_wind_blocked =" in l) + last_assignment = max(i for i, l in enumerate(lines) if "self.LaC = " in l) + assert guard > last_assignment, ( + f"dispatch resolved at line {guard + 1}, before the last self.LaC assignment at " + f"line {last_assignment + 1} -> AttributeError in Controls.__init__") From 17cc5e37f02c3a5ce5a9be66a563ff4c0c67192d Mon Sep 17 00:00:00 2001 From: Armin Nasufovic <12374077+anasufovic@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:52:45 -0700 Subject: [PATCH 3/4] controls: trim comments to the file's own density; drop a stale and a 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. --- selfdrive/controls/lib/latcontrol_pid.py | 112 ++++++++--------------- 1 file changed, 36 insertions(+), 76 deletions(-) diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index e3de545bec8..833f302c819 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -113,14 +113,9 @@ _TURN_IN_ASYMMETRY_PATH = "/data/HondaTurnInAsymmetry" _UNWIND_ASYMMETRY_PATH = "/data/HondaUnwindAsymmetry" -# The Honda carcontroller zeroes the LKAS request below NrdrMinSteerSpeed (1 mph default; 2 on the -# car this was developed against). A fixed threshold here rather than reading that param keeps the -# controller independent of a carcontroller tuning knob. -# -# This sits well inside the modified-EPS freeze band (freeze_threshold 2.0 m/s = 4.5 mph), so the -# integrator cannot grow below it in the first place -- the change is purely hold-vs-clear. Even if -# NrdrMinSteerSpeed were raised above this, the worst case is that we clear a frozen integrator -# slightly earlier than the wire goes quiet, never that we clear a live one. +# The carcontroller zeroes the LKAS request below NrdrMinSteerSpeed. Fixed here rather than reading +# that param so the controller stays independent of a carcontroller knob; it sits inside the +# modified-EPS freeze band, so the worst case is leaking an already-frozen integrator early. _MIN_STEER_SPEED_CLEAR_MS = 2.0 * 0.44704 _INTEGRATOR_LEAK_TAU_S = 0.5 @@ -317,20 +312,15 @@ def _clarity_eps_pid_output_scale( center_speed_weight = 1.0 center_taper = center_taper_high * center_taper_scale * center_speed_weight - # These constants were fitted on a Clarity and are strongly direction-asymmetric on TURN-IN: - # at a large angle above ~26 mph they floor a left turn-in at 0.6863 while giving a right - # turn-in 1.113-1.149, a 1.62x gap. `turn_in_asymmetry` blends the left turn-in constants - # toward the right ones so the split can be measured on a car that is not a Clarity: - # 1.0 keeps the Clarity behaviour exactly, 0.0 makes turn-in direction-symmetric. - # - # Hold/unwind is bit-identical at every knob setting: nothing on that path is blended. + # Fitted on a Clarity and strongly asymmetric on TURN-IN: above ~26 mph at a large angle they + # floor a left turn-in at 0.6863 against 1.113-1.149 right, a 1.62x gap. `turn_in_asymmetry` + # blends left toward right; 1.0 is the Clarity behaviour, 0.0 is symmetric. Hold/unwind is + # bit-identical at every setting. a = min(max(float(turn_in_asymmetry), 0.0), 1.0) if is_left: - # Blend ONLY the two turn-in-phase constants. mid_turn_scale and base_scale are shared with - # the hold/unwind phase: blending them too (as this did originally) bought turn-in strength by - # weakening left corner EXIT, which showed up on the road as the car holding ~8-15 deg too much - # left on the way out of a curve. Leaving them alone gives MORE turn-in for zero exit penalty - # -- at knob 65, 0.796 instead of 0.768, with exit unchanged rather than -2.9%. + # Blend ONLY the turn-in constants. mid_turn_scale and base_scale are shared with hold/unwind; + # blending those too bought turn-in strength by weakening the left corner exit (~8-15 deg of + # extra held angle on the way out). mid_turn_scale = 0.1200 base_scale = 0.0722 mid_turn_turn_in_scale = -0.5500 * a + -0.0524 * (1.0 - a) @@ -340,13 +330,10 @@ def _clarity_eps_pid_output_scale( mid_turn_turn_in_scale = -0.0524 base_scale = 0.0972 turn_in_scale = 0.0888 - # The UNWIND constants are Clarity-asymmetric too, in the direction that makes a left corner - # exit badly: left keeps more holding torque on the way out (0.1600 vs 0.2000 reduction), so a - # left exit unwinds slower and over-rotates while a right exit under-rotates. Measured left - # exits +5.08/+7.34 deg, right exits -2.65/-5.36 -- both signs match. Same disease as turn-in. - # 1.0 keeps the Clarity behaviour exactly, 0.0 gives a left exit the right-hand constants. - # Note the low-speed-unwind branch below bypasses these entirely, so very slow exits are - # unaffected at any setting. + # The UNWIND constants are asymmetric the same way: left keeps more holding torque on the way out + # (0.1600 vs 0.2000), so left exits over-rotate and right exits under-rotate (measured +5.08/+7.34 + # vs -2.65/-5.36). 1.0 is the Clarity behaviour, 0.0 gives left the right-hand constants. The + # low-speed-unwind branch below bypasses these entirely. b = min(max(float(unwind_asymmetry), 0.0), 1.0) if is_left: mid_turn_unwind_scale = -0.0743 * b + -0.0842 * (1.0 - b) @@ -562,44 +549,26 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat ) self.eps_modified_steering_pressed_prev = steering_pressed - # On a modified-EPS Honda the carcontroller reshapes torque every frame on purpose (low-speed - # zeroing, override ramp, LPF, optional delta limiter), so controlsd's - # `abs(actuators.torque - actuatorsOutput.torque) > 1e-2` is true on ~99% of frames and starves - # the integrator (measured 53-89% frozen). That mismatch is intentional shaping, not an - # actuator constraint the integrator has to respect: Honda's safety hook applies no - # active-state torque clamp, the output is already clipped to +-steer_max below, and `compose` - # now stops the integrator pushing the *composed* command past that rail. So the mismatch is - # only a real limit signal for cars that don't reshape. - # On a reshaping car use the relative judgement from controlsd when it is available; the - # absolute flag is true on ~99% of frames there and is what starved the integrator. - # integrator_wind_blocked is None on the legacy path, and 0 in the gate file makes controlsd - # compute it with the old absolute rule, so both fall back to previous behaviour exactly. + # This carcontroller reshapes torque every frame on purpose (low-speed zeroing, override ramp, + # LPF), so controlsd's absolute mismatch flag is true on ~99% of frames and starves the + # integrator. That is intentional shaping, not an actuator limit: no active-state torque clamp + # is applied, the output is clipped to +-steer_max below, and `compose` stops the integrator + # pushing the composed command past that rail. Use the relative judgement when it is available; + # None (legacy path) or 0 in the gate file keeps the old absolute rule exactly. if integrator_wind_blocked is None: mismatch_freezes_i = steer_limited_by_safety else: mismatch_freezes_i = bool(integrator_wind_blocked) freeze_threshold = 2.0 if self.is_eps_modified else 5.0 - # `steering_pressed` above may be the modified-EPS detector, which deliberately delays a - # same-direction press by up to 0.28 s. The carcontroller meanwhile acts on the RAW press - # immediately, so for that window it is fading torque out while we would still be integrating. - # The mismatch boolean used to mask this by accident; freeze on the raw press explicitly. - # Below the carcontroller's own cutoff (NrdrMinSteerSpeed, 1 mph by default) the wire carries - # zero regardless of what we ask for, so any integral built there is fiction. Freezing it - # merely stores that fiction until the car rolls back above the cutoff, where it is applied - # in full. Clear it instead: nothing downstream can act on it while we are below, and the - # override fade brings torque back in gently on the way out. - # Below the carcontroller's cutoff the wire carries zero whatever we ask for, so any integral - # stored there is fiction that gets applied in full when the car rolls back above it. Leak it - # rather than freezing (which preserves the fiction) or hard-clearing (bumpy, and contrary to - # the no-hard-clears conclusion from the earlier driver-hand work). tau 0.5 s empties it in - # about a second and a half of standstill while staying continuous. + # Below the carcontroller's cutoff the wire carries zero whatever we ask for, so an integral + # stored there gets applied in full when the car rolls back above it. Leak rather than freeze + # (preserves it) or hard-clear (bumpy). if self.is_eps_modified and integrator_wind_blocked is not None and CS.vEgo < _MIN_STEER_SPEED_CLEAR_MS: self.pid.i *= math.exp(-self.dt / _INTEGRATOR_LEAK_TAU_S) - # The raw-press term closes a window the filtered detector leaves open (it delays a - # same-direction press up to 0.28 s while the carcontroller is already fading torque out). - # It only matters once the integrator is actually free, so it rides the same gate -- keeping - # the disabled path bit-identical to stock. + # The filtered detector delays a same-direction press up to 0.28 s while the carcontroller is + # already fading torque out; freeze on the raw press to close that window. Rides the same gate + # so the disabled path stays bit-identical. raw_press_freezes_i = raw_steering_pressed and integrator_wind_blocked is not None freeze_integrator = (mismatch_freezes_i or raw_press_freezes_i or steering_pressed or CS.vEgo < freeze_threshold) @@ -633,11 +602,10 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat self.unwind_ff_multiplier = _get_param_float(self.params, "HondaUnwindFfMultiplier", 2.0, 1.0, 4.0) self.unwind_boost_cap_s = _get_param_float(self.params, "HondaUnwindBoostSeconds", 1.0, 0.0, 3.0) self.lat_stiction_enabled = _get_param_bool(self.params, "NrdrLatStiction") - # 100 = Clarity-fitted left/right turn-in split (previous behaviour), 0 = symmetric. - # Read as a plain file rather than through Params: the key is deliberately NOT in - # params_keys.h, so this needs no params_pyx.so rebuild to carry on a device. Same - # NOT under /data/params/d: the params system deletes unregistered keys on boot. So `echo 65 > /data/HondaTurnInAsymmetry` - # takes effect within ~3 s, and a missing/garbage file keeps the previous behaviour. + # 100 = Clarity split (previous behaviour), 0 = symmetric. A plain file, not a param: the + # key is not in params_keys.h so no params_pyx.so rebuild is needed, and it must live + # outside /data/params/d, which is pruned of unregistered keys on boot. Missing or garbage + # keeps the previous behaviour. self.turn_in_asymmetry = _read_turn_in_asymmetry() self.unwind_asymmetry = _read_unwind_asymmetry() @@ -667,20 +635,12 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat self.unwind_asymmetry, ) - # Anti-windup has to judge the candidate integral against the torque we are about to SEND. - # The command is rebuilt with independent per-term scales, multiplied by a scheduled output - # scale, then given an additive learner trim and a stiction stage -- so it differs from - # p+i+d+f by up to ~30% and the integrator was protected against the wrong number in both - # directions. _compose_output is the single source of truth: the real output below and the - # anti-windup candidate both go through it, so the two cannot drift apart. - # - # The learner trim is a pure map lookup, so it is exact. The stiction delta is last frame's, - # because stiction TRANSFORMS the output and is stateful -- the candidate is therefore exact - # to within one 10 ms frame against stiction's 0.30 s capture tau, and stiction bypasses - # itself when saturated, so in the regime anti-windup exists for the delta is zero and the - # candidate is exact. Including it rather than omitting it as "conservative" is deliberate: - # omitting an additive same-direction term makes the candidate an UNDERestimate, firing the - # clamp late and permitting MORE windup, not less. + # Judge the candidate integral against the torque actually sent: the command is rebuilt with + # per-term scales, a scheduled output scale, a learner trim and a stiction stage, so it + # differs from p+i+d+f by up to ~30%. _compose_output is the single source of truth for both + # the real output and the candidate. The stiction delta is last frame's (it is stateful and + # transforms the output); it is zero when saturated, which is the regime anti-windup exists + # for. Omitting it would make the candidate an underestimate and permit more windup, not less. learner_trim = float(self.tune_learner.apply(CS.vEgo, angle_steers_des)) self._compose_state = (p_scale, i_scale, f_scale, eps_output_scale, learner_trim) From b440094c9320be68f0ad48c9623526a76ac0f4df Mon Sep 17 00:00:00 2001 From: Armin Nasufovic <12374077+anasufovic@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:15:29 -0700 Subject: [PATCH 4/4] controls: clear the PID state when lateral control is inactive 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) Claude-Session: https://claude.ai/code/session_01DMd7c2D7J2y5srtSMasSvy --- selfdrive/controls/lib/latcontrol_pid.py | 39 +++- .../lib/tests/test_latcontrol_pid_reset.py | 179 ++++++++++++++++++ 2 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 selfdrive/controls/lib/tests/test_latcontrol_pid_reset.py diff --git a/selfdrive/controls/lib/latcontrol_pid.py b/selfdrive/controls/lib/latcontrol_pid.py index 833f302c819..3fb133eb40d 100644 --- a/selfdrive/controls/lib/latcontrol_pid.py +++ b/selfdrive/controls/lib/latcontrol_pid.py @@ -439,6 +439,35 @@ def _compose_candidate(self, p, i, d, f): MORE windup, not less.""" return self._compose_scaled(p, i, d, f) + self._compose_state[4] + self._stiction_delta + def reset(self): + """Drop every piece of carried-over state when lateral control is not running. + + controlsd calls this on each frame lateral is inactive (selfdrive/controls/controlsd.py:590) + and update()'s own inactive branch calls it too, so the two paths cannot drift apart. + + The inherited LatControl.reset() only cleared the saturation timer, so self.pid.i survived a + disengagement and was re-injected whole on the first frame after re-engagement -- measured at + +0.19115 across a 0.2 s gap and -0.13616 across a 0.35 s gap, the latter against a positive + proportional term. LatControlCurvature.reset() already did the right thing; this matches it. + + 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. + """ + super().reset() + self.pid.reset() + self.eps_modified_steering_pressed_filter_s = 0.0 + self.eps_modified_steering_pressed_prev = False + self.center_taper_scale.x = 1.0 + self.unwind_boost_elapsed = 0.0 + self.prev_output_torque = 0.0 + self.prev_saturated = False + self.lat_stiction.reset() + # Stiction is stateful and _compose_candidate consumes last frame's delta; with the stiction + # filter itself reset, the delta it goes with is zero. + self._stiction_delta = 0.0 + def update_honda_lateral_pid_gain_scale(self, starpilot_toggles): if not self.is_honda_pid_lateral: return @@ -492,14 +521,10 @@ def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvat if not active: output_torque = 0.0 pid_log.active = False + # The only piece of inactive-frame state that is frame dependent: everything else is + # cleared by reset(), which is also what controlsd calls on every inactive frame. self.prev_angle_steers_des_no_offset = angle_steers_des_no_offset - self.eps_modified_steering_pressed_filter_s = 0.0 - self.eps_modified_steering_pressed_prev = False - self.center_taper_scale.x = 1.0 - self.unwind_boost_elapsed = 0.0 - self.prev_output_torque = 0.0 - self.prev_saturated = False - self.lat_stiction.reset() + self.reset() else: self.frame += 1 diff --git a/selfdrive/controls/lib/tests/test_latcontrol_pid_reset.py b/selfdrive/controls/lib/tests/test_latcontrol_pid_reset.py new file mode 100644 index 00000000000..62e96cb819e --- /dev/null +++ b/selfdrive/controls/lib/tests/test_latcontrol_pid_reset.py @@ -0,0 +1,179 @@ +"""LatControlPID must not carry integrator state across a disengagement. + +controlsd calls LaC.reset() on every frame lateral is inactive +(selfdrive/controls/controlsd.py:589-591), but the inherited LatControl.reset() +(selfdrive/controls/lib/latcontrol.py:47-48) clears only the saturation timer. LatControlPID's +own inactive branch cleared stiction, the pressed filter, the centre taper, the unwind state and +the previous output, but never the PID controller itself, so self.pid.i survived a disengagement +and was re-injected whole on the first frame after re-engagement. + +The fixtures below are the measured values from two logged drives on the modified-EPS Civic +Bosch; see REENGAGEMENTS. +""" +# LatControlPID pulls in common.transformations.transformations, a compiled extension that only +# exists for the device architecture. Try the real one first -- on a device or in CI this block is a +# no-op -- and only if it will not load, stand in a module of the same shape so this file can be +# collected on a development host. The stubs raise if anything ever calls them: this suite exercises +# the lateral controller, never the geometry, and a silently wrong rotation would be worse than an +# ImportError. Nothing else is stubbed, and this is deliberately NOT a conftest so it cannot reach +# any other suite. +try: + import openpilot.common.transformations.transformations # noqa: F401 +except (ImportError, OSError): + import sys + import types + + def _needs_native_extension(name): + def unavailable(*args, **kwargs): + raise RuntimeError(f"{name} needs the compiled transformations extension; this suite never calls it") + return unavailable + + _stub = types.ModuleType("openpilot.common.transformations.transformations") + for _fn in ("ecef_euler_from_ned_single", "euler2quat_single", "euler2rot_single", + "ned_euler_from_ecef_single", "quat2euler_single", "quat2rot_single", + "rot2euler_single", "rot2quat_single"): + setattr(_stub, _fn, _needs_native_extension(_fn)) + sys.modules["openpilot.common.transformations.transformations"] = _stub + +import math +from types import SimpleNamespace + +import pytest + +from opendbc.car import structs +from opendbc.car.honda.interface import CarInterface +from opendbc.car.honda.values import CAR +from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID + +CarParams = structs.CarParams + +TOGGLES = SimpleNamespace(force_torque_controller=False, nnff=False, nnff_lite=False) + +# a comma in the eps fw version is what marks a modified EPS -- this is the car that was driven +MODIFIED_FW = b'39990-TGG,A120\x00\x00' + +# LatControlPID only reaches into CI for the feedforward function +STUB_CI = SimpleNamespace(get_steer_feedforward_function=lambda: (lambda angle, v_ego: angle)) + +DT = 0.01 + +# Real transitions, read out of the rlogs of two drives on 2026-09-01. Each row is +# (route, t_last_active, i_at_last_active, t_reengage, p_at_reengage). +# The integral logged on the first re-engaged frame equalled i_at_last_active to five decimals +# in both cases; drive 1's is the interesting one, because the carried-over integral is NEGATIVE +# while the proportional term on that first frame is strongly POSITIVE. +REENGAGEMENTS = [ + ("98a5b56c6d", 475.437, +0.19115, 475.638, -1.14554), + ("5900224619", 147.988, -0.13616, 148.347, +0.44807), +] + +# drive 3 98a5b56c6d: lateral went inactive at t=206.090 and did not come back until t=263.890. +LONGEST_LOGGED_INACTIVE_GAP_S = 57.811 + + +class _CS: + steeringAngleDeg = 0.0 + steeringRateDeg = 0.0 + vEgo = 20.0 + steeringPressed = False + steeringTorque = 0.0 + leftBlinker = False + rightBlinker = False + + +class _VM: + sR = 15.38 + + def get_steer_from_curvature(self, curv, v_ego, roll): + return math.radians(curv * 1000.0 * self.sR) + + +_PARAMS = SimpleNamespace(roll=0.0, angleOffsetDeg=0.0) + + +@pytest.fixture +def lat(): + car_fw = [CarParams.CarFw(ecu=CarParams.Ecu.eps, fwVersion=MODIFIED_FW, address=0x18DA30F1, subAddress=0)] + CP = CarInterface.get_params(CAR.HONDA_CIVIC_BOSCH, {0: {}, 1: {}, 2: {}}, car_fw, False, False, False, TOGGLES) + return LatControlPID(CP, STUB_CI, DT) + + +def _inactive_update(controller): + """One frame through update() with lateral inactive, the way controlsd would call it.""" + return controller.update(False, _CS(), _VM(), _PARAMS, False, 0.0, False, 0.0, None, None, TOGGLES) + + +@pytest.mark.parametrize("route, t_last, i_last, t_reengage, p_reengage", REENGAGEMENTS) +def test_reset_clears_a_logged_integral(lat, route, t_last, i_last, t_reengage, p_reengage): + """The controlsd path. This is the one that was broken: controlsd calls reset(), not update().""" + lat.pid.i = i_last + lat.reset() + assert lat.pid.i == 0.0, ( + f"{route}: integral {i_last:+.5f} logged at t={t_last:.3f} survived reset() and would be " + f"re-injected at t={t_reengage:.3f}, where the proportional term was {p_reengage:+.5f}" + ) + + +@pytest.mark.parametrize("route, t_last, i_last, t_reengage, p_reengage", REENGAGEMENTS) +def test_inactive_update_clears_a_logged_integral(lat, route, t_last, i_last, t_reengage, p_reengage): + """The update() path, so the two inactive paths cannot drift apart.""" + lat.pid.i = i_last + _inactive_update(lat) + assert lat.pid.i == 0.0, f"{route}: integral {i_last:+.5f} survived an inactive update() frame" + + +def test_integral_cannot_survive_a_long_inactive_gap(lat): + """Drive 3's 57.8 s gap, run frame by frame through the path controlsd actually takes.""" + lat.pid.i = -0.01897 + for _ in range(int(LONGEST_LOGGED_INACTIVE_GAP_S / DT)): + _inactive_update(lat) + lat.reset() + assert lat.pid.i == 0.0 + + +def test_reset_clears_the_whole_pid_controller(lat): + """p, d and f are recomputed at the top of PIDController.update() before anything reads them, + so clearing them cannot change the first active frame -- but leaving them set would make + pid_log and self.pid.control report last engagement's numbers while lateral is off.""" + lat.pid.p, lat.pid.i, lat.pid.d, lat.pid.f, lat.pid.control = 0.1, 0.2, 0.3, 0.4, 0.5 + lat.reset() + assert (lat.pid.p, lat.pid.i, lat.pid.d, lat.pid.f, lat.pid.control) == (0.0, 0.0, 0.0, 0.0, 0) + + +def test_reset_still_clears_the_saturation_timer(lat): + """The override must not lose what the base class did.""" + lat.sat_time = 1.5 + lat.reset() + assert lat.sat_time == 0.0 + + +def test_reset_clears_the_rest_of_the_inactive_state(lat): + """Everything the inactive branch used to clear inline now has to come out of reset(), or + moving it there would have been a regression.""" + lat.eps_modified_steering_pressed_filter_s = 0.4 + lat.eps_modified_steering_pressed_prev = True + lat.center_taper_scale.x = 0.25 + lat.unwind_boost_elapsed = 0.7 + lat.prev_output_torque = 0.3 + lat.prev_saturated = True + lat._stiction_delta = 0.05 + lat.reset() + assert lat.eps_modified_steering_pressed_filter_s == 0.0 + assert lat.eps_modified_steering_pressed_prev is False + assert lat.center_taper_scale.x == 1.0 + assert lat.unwind_boost_elapsed == 0.0 + assert lat.prev_output_torque == 0.0 + assert lat.prev_saturated is False + assert lat._stiction_delta == 0.0 + + +def test_active_frames_are_untouched(lat): + """The normal path must not be affected: an active frame never calls reset(), and the + integral it builds has to persist from one active frame to the next.""" + torque, _, pid_log = lat.update(True, _CS(), _VM(), _PARAMS, False, 5e-5, False, 0.0, None, None, TOGGLES) + assert pid_log.active + first_i = lat.pid.i + assert first_i != 0.0, "an active frame with a standing error must integrate" + lat.update(True, _CS(), _VM(), _PARAMS, False, 5e-5, False, 0.0, None, None, TOGGLES) + assert lat.pid.i != first_i, "the integral must keep accumulating across active frames" + assert torque != 0.0