feat(sunnypilot): add the stopping controller, the eco lead boost and per-feature Params toggles

This commit is contained in:
rav4kumar
2026-10-10 11:36:32 -07:00
parent 37d69fd5b4
commit d9dea37c76
13 changed files with 797 additions and 13 deletions
+2
View File
@@ -192,10 +192,12 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
{"SunnypilotStoppingController", {PERSISTENT | BACKUP, BOOL, "0"}},
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
// toyota specific params
{"ToyotaAutoHold", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaBrakeOnset", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaEnhancedBsm", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaTSS2Long", {PERSISTENT | BACKUP, BOOL, "0"}},
{"ToyotaDriveMode", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -136,6 +136,7 @@ class Controls(ControlsExt):
# accel PID loop
pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, self.CP_SP, CS.vEgo, CS.vCruise * CV.KPH_TO_MS)
actuators.accel = float(self.LoC.update(CC.longActive, CS, long_plan.aTarget, long_plan.shouldStop, pid_accel_limits))
self.long_accel_sp(actuators, CS, long_plan, pid_accel_limits) # sunnypilot hook
# Steering PID loop and lateral MPC
# Reset desired curvature to current to avoid violating the limits on engage
@@ -130,6 +130,7 @@ def initialize_params(params) -> list[dict[str, Any]]:
"ToyotaTSS2Long",
"ToyotaEnhancedBsm",
"ToyotaAutoHold",
"ToyotaBrakeOnset",
])
return [{k: params.get(k, return_default=True)} for k in keys]
@@ -12,11 +12,13 @@ from openpilot.cereal import log, custom
from opendbc.car import structs
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral
from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_v0 import LatControlTorque as LatControlTorqueV0
from openpilot.sunnypilot.selfdrive.controls.lib.stopping_controller import StoppingController
class ControlsExt(ModelStateBase):
@@ -31,6 +33,9 @@ class ControlsExt(ModelStateBase):
self.CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP)
cloudlog.info("controlsd_ext got CarParamsSP")
self.stopping_controller = StoppingController(self.CP.stopAccel) if params.get_bool("SunnypilotStoppingController") else None
self._stopping_prev = (LongCtrlState.off, 0.0)
self.sm_services_ext = ['radarState', 'selfdriveStateSP']
self.pm_services_ext = ['carControlSP']
@@ -47,6 +52,27 @@ class ControlsExt(ModelStateBase):
else:
return lac
def long_accel_sp(self, actuators, CS, long_plan, accel_limits: tuple[float, float]) -> None:
"""sunnypilot: terminal-stop policy applied after the stock LongControl.update() (see stopping_controller.py)."""
stock_state = self.LoC.long_control_state
stock_accel = actuators.accel
prev_state, prev_accel = self._stopping_prev
self._stopping_prev = (stock_state, stock_accel)
if self.stopping_controller is None:
return
state, accel = self.stopping_controller.update(
prev_state, stock_state, CS, long_plan.aTarget, prev_accel, stock_accel, accel_limits, long_plan.hasLead,
pitch=self.calibrated_pose.orientation.pitch if self.calibrated_pose is not None else None,
a_long=self.calibrated_pose.acceleration.x if self.calibrated_pose is not None else None)
if state != stock_state:
self.LoC.reset()
self.LoC.long_control_state = state
self.LoC.last_output_accel = accel
self._stopping_prev = (state, accel)
actuators.accel = float(accel)
def get_params_sp(self, sm: messaging.SubMaster) -> None:
if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD:
self.blinker_pause_lateral.get_params()
@@ -15,12 +15,12 @@ AccelProfile = custom.LongitudinalPlanSP.AccelController.Profile
MAX_ACCEL_BREAKPOINTS = [0., 3., 12, 24., 36.] # m/s
MAX_ACCEL_PROFILES = {
AccelProfile.eco: [1.85, 1.55, 0.35, 0.13, 0.10],
AccelProfile.eco: [1.75, 1.50, 0.40, 0.15, 0.12],
AccelProfile.normal: [1.95, 1.80, 0.70, 0.36, 0.25],
AccelProfile.sport: [2.00, 2.00, 1.20, 0.70, 0.50],
}
ECO_ENGINE_OFF_BP = [0., 3., 12., 16.67, 19.44, 22.22, 24., 36.] # m/s
ECO_ENGINE_OFF_MAX_ACCEL = [1.85, 1.55, 0.35, 0.25, 0.16, 0.12, 0.08, 0.06]
ECO_ENGINE_OFF_MAX_ACCEL = [1.75, 1.50, 0.35, 0.25, 0.16, 0.12, 0.08, 0.06]
CRUISE_DECEL_RESPONSE_TIME = {
AccelProfile.normal: 3.5,
@@ -30,11 +30,19 @@ CRUISE_DECEL_ACCEL = {
AccelProfile.normal: -0.50,
AccelProfile.sport: -0.65,
}
ECO_CRUISE_DECEL_BP = [0., 16.67, 19.44, 25.0, 33.3] # m/s
ECO_CRUISE_DECEL_BP = [0., 16.67, 19.44, 25.0, 33.3] # m/s
ECO_CRUISE_DECEL_V = [-0.40, -0.40, -0.22, -0.30, -0.40] # m/s^2
ECO_COAST_BLEND_BP = [16.67, 19.44] # m/s
ECO_COAST_MIN, ECO_COAST_MAX = -1.2, -0.05 # m/s^2
CRUISE_DECEL_TAPER_TIME = 1.0 # s
ECO_BOOST_DV_BP = [0.3, 2.5] # m/s, lead speed minus ego speed
ECO_BOOST_V = [0.0, 0.35] # m/s^2 added to the eco limit
ECO_BOOST_FADE_BP = [19.44, 22.22] # m/s (70 -> 80 km/h): full -> none
ECO_BOOST_D_MAX = 80.0 # m, only a lead this close counts
ECO_BOOST_PROB_MIN = 0.5
ECO_BOOST_RISE = 0.25 # m/s^3, how fast the boost builds
ECO_BOOST_FALL = 0.5 # m/s^3, how fast it fades
ECO_BOOST_DT = 0.05 # s, planner step
CRUISE_DECEL_TAPER_TIME = 1.0 # s; inside |decel| * this of the target the decel eases off proportionally (no overshoot)
class AccelController:
@@ -42,6 +50,7 @@ class AccelController:
self.params = Params()
self._cruise_decel: float | None = None
self._cruise_decel_target: float | None = None
self._boost = 0.0
self.update()
def update(self) -> None:
@@ -55,10 +64,26 @@ class AccelController:
def is_enabled(self) -> bool:
return self._enabled
def get_max_accel(self, v_ego: float, engine_off: bool = False) -> float:
def get_max_accel(self, v_ego: float, engine_off: bool = False, lead=None) -> float:
if engine_off and self._profile == AccelProfile.eco:
return float(np.interp(max(0.0, v_ego), ECO_ENGINE_OFF_BP, ECO_ENGINE_OFF_MAX_ACCEL))
return float(np.interp(max(0.0, v_ego), MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
base = float(np.interp(max(0.0, v_ego), ECO_ENGINE_OFF_BP, ECO_ENGINE_OFF_MAX_ACCEL))
else:
base = float(np.interp(max(0.0, v_ego), MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
if self._profile != AccelProfile.eco:
self._boost = 0.0
return base
return base + self._eco_lead_boost(v_ego, base, lead)
def _eco_lead_boost(self, v_ego: float, base: float, lead) -> float:
target = 0.0
if lead is not None and lead.present and lead.modelProb > ECO_BOOST_PROB_MIN and lead.dRel < ECO_BOOST_D_MAX:
target = float(np.interp(lead.vLead - v_ego, ECO_BOOST_DV_BP, ECO_BOOST_V))
target *= float(np.interp(v_ego, ECO_BOOST_FADE_BP, [1.0, 0.0]))
normal = float(np.interp(max(0.0, v_ego), MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.normal]))
target = float(np.clip(target, 0.0, max(normal - base, 0.0)))
step = (ECO_BOOST_RISE if target > self._boost else ECO_BOOST_FALL) * ECO_BOOST_DT
self._boost = float(np.clip(target, self._boost - step, self._boost + step))
return self._boost
def get_cruise_target(self, v_ego: float, v_target: float, accel_coast: float | None = None) -> float:
if not np.isfinite(v_target) or v_target <= 0.0 or v_target >= v_ego:
@@ -5,6 +5,8 @@ This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from types import SimpleNamespace
import numpy as np
from opendbc.car.interfaces import ACCEL_MAX
@@ -67,13 +69,15 @@ class TestAccelController(OpenpilotTestCase):
wheel_power = 1500 * controller.get_max_accel(speed, engine_off=True) * speed + road_load
assert wheel_power <= budget_kw * 1e3, kph
def test_eco_without_lead_reduces_throttle_after_launch(self):
def test_lead_boost_is_eco_only_and_absent_without_a_lead(self):
# the eco lead pull-away boost: no lead is the plain table lookup, and normal/sport ignore the lead entirely
eco = self.set_profile(AccelProfile.eco)
assert eco.get_max_accel(1.0, has_lead=False) == eco.get_max_accel(1.0, has_lead=True)
assert np.isclose(eco.get_max_accel(20.0, has_lead=False), 0.85 * eco.get_max_accel(20.0, has_lead=True))
assert eco.get_max_accel(1.0, lead=None) == eco.get_max_accel(1.0)
assert eco.get_max_accel(20.0, lead=None) == eco.get_max_accel(20.0)
normal = self.set_profile(AccelProfile.normal)
assert normal.get_max_accel(20.0, has_lead=False) == normal.get_max_accel(20.0, has_lead=True)
lead = SimpleNamespace(present=True, modelProb=0.9, dRel=30.0, vLead=23.0)
assert normal.get_max_accel(20.0, lead=lead) == normal.get_max_accel(20.0)
def test_sport_uses_openpilot_accel_max_at_launch(self):
controller = self.set_profile(AccelProfile.sport)
@@ -0,0 +1,129 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import numpy as np
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
AccelController, AccelProfile, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
)
class TestEcoLeadBoost(OpenpilotTestCase):
"""Eco lead pull-away boost (driver 2026-10-01 night)."""
@staticmethod
def _ac(profile):
from types import SimpleNamespace
ac = AccelController.__new__(AccelController)
ac._profile, ac._enabled, ac._boost = profile, True, 0.0
ac._cruise_decel = ac._cruise_decel_target = None
return ac, SimpleNamespace
def test_no_lead_unchanged(self):
ac, _ = self._ac(AccelProfile.eco)
v = 15.0
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
for _ in range(100):
assert ac.get_max_accel(v, False, None) == base
def test_pulling_away_lead_boosts_smoothly_within_normal(self):
ac, NS = self._ac(AccelProfile.eco)
v = 15.0
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v + 3.0)
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
normal = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.normal]))
out = [ac.get_max_accel(v, False, lead) for _ in range(60)]
steps = np.diff([base] + out)
assert max(steps) <= 0.25 * 0.05 + 1e-9 # eased in
assert out[-1] > base + 0.2 and out[-1] <= normal + 1e-9
lead.present = False
out2 = [ac.get_max_accel(v, False, lead) for _ in range(60)]
assert min(np.diff([out[-1]] + out2)) >= -0.5 * 0.05 - 1e-9 # eased out
assert abs(out2[-1] - base) < 1e-9
def test_no_boost_at_80_kph_or_for_a_closing_lead(self):
ac, NS = self._ac(AccelProfile.eco)
v = 80 / 3.6
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v + 3.0)
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
assert abs([ac.get_max_accel(v, False, lead) for _ in range(40)][-1] - base) < 1e-9
ac, NS = self._ac(AccelProfile.eco)
v = 15.0
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v - 1.0)
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
assert abs([ac.get_max_accel(v, False, lead) for _ in range(40)][-1] - base) < 1e-9
class _Lead:
def __init__(self, present=True, modelProb=0.9, dRel=30.0, vLead=18.0):
self.present, self.modelProb, self.dRel, self.vLead = present, modelProb, dRel, vLead
class _Radar:
def __init__(self, lead):
self.leadOne = lead
class _Sink:
def clear(self):
pass
def update(self, *args, **kwargs):
pass
class TestPlannerLeadWiring(OpenpilotTestCase):
"""The boost needs a lead, so the planner has to carry radarState.leadOne into get_max_accel_override."""
def setUp(self):
self.params = Params()
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
self.params.put("AccelPersonality", AccelProfile.eco, block=True)
@staticmethod
def _planner():
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.accel_controller = AccelController()
planner.accel_controller_active = False
planner.lead_one = None
return planner
@staticmethod
def _settle(planner, v_ego, lead=None, n=60):
return [planner.get_max_accel_override(v_ego, lead=lead) for _ in range(n)][-1]
def test_update_stores_the_radar_lead(self):
planner = self._planner()
planner.events_sp = _Sink()
planner.e2e_alerts_helper = _Sink()
lead = _Lead()
planner.update({"radarState": _Radar(lead)})
assert planner.lead_one is lead
def test_stored_lead_feeds_the_boost_and_an_explicit_lead_wins(self):
v = 15.0
lead = _Lead(vLead=v + 3.0)
planner = self._planner()
planner.lead_one = lead
boosted = self._settle(planner, v)
planner.accel_controller._boost = 0.0
planner.lead_one = _Lead(present=False)
base = self._settle(planner, v)
assert boosted > base + 0.2
planner.accel_controller._boost = 0.0
explicit = self._settle(planner, v, lead=lead)
assert abs(explicit - boosted) < 1e-9
def test_disabled_accel_controller_returns_no_override(self):
self.params.put_bool("AccelPersonalityEnabled", False, block=True)
assert self._planner().get_max_accel_override(15.0, lead=_Lead()) is None
@@ -31,6 +31,7 @@ class LongitudinalPlannerSP:
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
self.accel_controller = AccelController()
self.accel_controller_active = False
self.lead_one = None
self.events_sp = EventsSP()
self.dec = DynamicExperimentalController(CP, mpc)
self.scc = SmartCruiseControl()
@@ -56,11 +57,11 @@ class LongitudinalPlannerSP:
return False
def get_max_accel_override(self, v_ego: float, engine_off: bool = False) -> float | None:
def get_max_accel_override(self, v_ego: float, engine_off: bool = False, lead=None) -> float | None:
if not self.accel_controller.is_enabled():
return None
return self.accel_controller.get_max_accel(v_ego, engine_off)
return self.accel_controller.get_max_accel(v_ego, engine_off, lead if lead is not None else self.lead_one)
def get_cruise_target_override(self, v_ego: float, v_target: float, force_decel: bool, accel_coast: float | None = None) -> float:
if not self.accel_controller.is_enabled() or force_decel or self.source != LongitudinalPlanSource.cruise:
@@ -120,6 +121,7 @@ class LongitudinalPlannerSP:
def update(self, sm: messaging.SubMaster) -> None:
self.accel_controller.update()
self.lead_one = sm['radarState'].leadOne
self.events_sp.clear()
self.e2e_alerts_helper.update(sm, self.events_sp)
@@ -0,0 +1,243 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
import numpy as np
from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
STOP_LEVEL_V = 0.4 # m/s
STOP_LEVEL_TAU = 0.2 # s
STOP_CONFIRM_RISE = 0.2 # m/s^2
STOP_CONFIRM_DELAY = 0.2 # s
class StopConfirm:
def __init__(self):
self.reset()
def reset(self):
self.level: float | None = None
self.collapsed = False
self.confirmed_t = -1.0
def update(self, standstill: bool, v_ego: float, a_long: float | None) -> bool:
"""returns True once the stop is confirmed and STOP_CONFIRM_DELAY has passed"""
if a_long is None or not math.isfinite(a_long):
self.reset()
return False
if v_ego > STOP_LEVEL_V and not standstill:
alpha = DT_CTRL / (STOP_LEVEL_TAU + DT_CTRL)
self.level = a_long if self.level is None else self.level + alpha * (a_long - self.level)
self.collapsed = False
self.confirmed_t = -1.0
return False
if self.level is not None and a_long >= self.level + STOP_CONFIRM_RISE:
self.collapsed = True
if standstill and self.collapsed:
self.confirmed_t = 0.0 if self.confirmed_t < 0.0 else self.confirmed_t + DT_CTRL
elif not standstill:
self.confirmed_t = -1.0
return self.confirmed_t >= STOP_CONFIRM_DELAY - 1e-9
class StoppingController:
"""Optional terminal-stop policy applied after stock LongControl.update()."""
STOPPING_DECEL_RATE = 0.3 # m/s^2/s
STANDSTILL_HOLD_RATE = 1.0 # m/s^2/s
STANDSTILL_HOLD_DELAY_LEAD = 1.0 # s
STANDSTILL_HOLD_DELAY_NO_LEAD = 1.0 # s
STOPPING_FREEZE_MAX = 2.0 # s
STOPPING_EXIT_DEBOUNCE = 0.2 # s
ENGAGE_STANDSTILL_GRACE = 0.6 # s
STOPPING_FOLLOW_MIN = -0.10 # m/s^2
STOPPING_FOLLOW_RATE = 2.0 # m/s^3
CREEP_V_MIN = 0.03 # m/s
CREEP_RATE_GROWTH = 1.5 # m/s^2/s
CREEP_RATE_MAX = 1.0 # m/s^2/s
BLEND_V = 10.0 / 3.6 # m/s
END_V_HI = 5.0 / 3.6 # m/s
END_HI = -0.65 # m/s^2
END_LO = -0.35 # m/s^2
END_CURVE_V = [0.0, 1.0 / 3.6, 2.0 / 3.6, 3.0 / 3.6, 4.0 / 3.6, 5.0 / 3.6] # m/s
END_CURVE_A = [END_LO, END_LO, -0.56, -0.60, -0.65, END_HI] # m/s^2
END_PLAN_MIN = -0.25 # m/s^2
END_RATE = 2.0 # m/s^3
RELEASE_FAST = 4.0 # m/s^3
RELEASE_SLOW = 1.5 # m/s^3
RELEASE_BAND_LO = -0.6 # m/s^2
RELEASE_BAND_HI = 0.4 # m/s^2
REROLL_ACCEL = -0.6 # m/s^2
REROLL_JERK = 6.0 # m/s^3
FLAT_HOLD_ACCEL = -1.2 # m/s^2
FLAT_PITCH_DEG = 1.0 # deg
SLOPE_PITCH_DEG = 2.0 # deg
CF_WINDOW = 10.0 # s
CF_V_MAX = 8.0 / 3.6 # m/s
CF_TRIGGER = -0.15 # m/s^2
CF_END = -0.24 # m/s^2
CF_PLAN_SOFT = -0.45 # m/s^2
CF_PLAN_FIRM = -0.70 # m/s^2
CF_RATE = 0.9 # m/s^3
CF_SETTLE_ACCEL = -0.6 # m/s^2
CF_SETTLE_JERK = 0.72 # m/s^3
CF_HOLD_RATE = 0.3 # m/s^2/s
def __init__(self, stop_accel):
self.stop_accel = stop_accel
self.stop_confirm = StopConfirm()
self.cf_t: float | None = None
self.cf_vmax = 0.0
self.cf_stopping = False
self.cf_stopped = False
self.standstill_t = 0.0
self.stopping_t = 0.0
self.go_t = 0.0
self.engaged_t = 0.0
self.stopped_once = False
self.creep_t = 0.0
self.end_active = False
self.a_entry = self.END_HI
self.releasing = False
def _blend(self, v_ego):
if v_ego < self.END_V_HI:
return float(np.interp(max(v_ego, 0.0), self.END_CURVE_V, self.END_CURVE_A))
return float(np.interp(v_ego, [self.END_V_HI, self.BLEND_V], [self.END_HI, self.a_entry]))
def _end_request(self, a_target, prev_accel, v_ego):
target = min(a_target, self._blend(v_ego)) # the firmer of the plan and the line
step = self.END_RATE * DT_CTRL
return float(np.clip(target, prev_accel - step, prev_accel + step))
def _hold_floor(self, pitch, creeping):
if creeping or pitch is None or not math.isfinite(pitch):
return self.stop_accel
if abs(math.degrees(pitch) - self.FLAT_PITCH_DEG) >= self.SLOPE_PITCH_DEG:
return self.stop_accel
return max(self.stop_accel, self.FLAT_HOLD_ACCEL)
def _cf_request(self, a_target, prev_accel):
target = float(np.interp(a_target, [self.CF_PLAN_FIRM, self.CF_PLAN_SOFT], [self.CF_PLAN_FIRM, self.CF_END]))
target = min(target, a_target) if a_target < self.CF_PLAN_FIRM else target
if target < prev_accel:
return max(target, min(prev_accel, 0.0) - self.CF_RATE * DT_CTRL)
return min(target, prev_accel + self.END_RATE * DT_CTRL)
def _update_creep_follow(self, state, CS, a_target):
if state == LongCtrlState.off:
self.cf_t, self.cf_stopping, self.cf_stopped = None, False, False
return False
if CS.standstill:
if self.cf_stopping:
self.cf_stopped = True
self.cf_t = 0.0 if self.standstill_t > 0.5 else self.cf_t
self.cf_vmax, self.cf_stopping = 0.0, False
return False
self.cf_stopped = False
if self.cf_t is None:
return False
self.cf_t += DT_CTRL
self.cf_vmax = max(self.cf_vmax, CS.vEgo)
if self.cf_t > self.CF_WINDOW or self.cf_vmax >= self.CF_V_MAX or a_target > 0.0 and self.cf_stopping:
self.cf_t, self.cf_stopping = None, False
return False
if a_target <= self.CF_TRIGGER:
self.cf_stopping = True
return self.cf_stopping
def update(self, prev_state, state, CS, a_target, prev_accel, stock_accel, accel_limits, has_lead=False, pitch=None,
a_long=None):
if prev_state == LongCtrlState.stopping and state == LongCtrlState.pid and CS.standstill:
self.go_t += DT_CTRL
if self.go_t < self.STOPPING_EXIT_DEBOUNCE:
state = LongCtrlState.stopping
else:
self.go_t = 0.0
self.standstill_t = self.standstill_t + DT_CTRL if CS.standstill else 0.0
self.engaged_t = 0.0 if state == LongCtrlState.off else self.engaged_t + DT_CTRL
self.stopping_t = self.stopping_t + DT_CTRL if state == LongCtrlState.stopping else 0.0
if state != LongCtrlState.stopping:
self.stopped_once = False
elif CS.standstill:
self.stopped_once = True
creeping = self.stopped_once and not CS.standstill and CS.vEgo > self.CREEP_V_MIN
self.creep_t = self.creep_t + DT_CTRL if creeping else 0.0
cf_stop = self._update_creep_follow(state, CS, a_target)
stop_confirmed = self.stop_confirm.update(CS.standstill, CS.vEgo, a_long)
rolling = not CS.standstill and not self.stopped_once
if state == LongCtrlState.off or not rolling or CS.vEgo >= self.BLEND_V or a_target > 0.0:
self.end_active = False
elif not self.end_active and a_target <= self.END_PLAN_MIN:
self.end_active = True
self.a_entry = min(prev_accel, self.END_HI)
if state != LongCtrlState.stopping:
if cf_stop:
return state, float(np.clip(self._cf_request(a_target, prev_accel), accel_limits[0], accel_limits[1]))
if self.end_active:
return state, float(np.clip(self._end_request(a_target, prev_accel, CS.vEgo), accel_limits[0], accel_limits[1]))
if prev_state == LongCtrlState.stopping and state == LongCtrlState.pid and prev_accel < self.RELEASE_BAND_HI:
self.releasing = True
if self.releasing:
if stock_accel <= prev_accel or prev_accel >= self.RELEASE_BAND_HI or state == LongCtrlState.off:
self.releasing = False
return state, stock_accel
rate = self.RELEASE_FAST if prev_accel < self.RELEASE_BAND_LO else self.RELEASE_SLOW
return state, float(min(stock_accel, prev_accel + rate * DT_CTRL))
return state, stock_accel
output_accel = prev_accel
hold_floor = self._hold_floor(pitch, creeping)
if output_accel > hold_floor:
output_accel = min(output_accel, 0.0)
if not CS.standstill and not self.stopped_once and a_target < self.STOPPING_FOLLOW_MIN and a_target > output_accel:
output_accel = min(a_target, output_accel + self.STOPPING_FOLLOW_RATE * DT_CTRL)
if cf_stop:
output_accel = self._cf_request(a_target, prev_accel)
elif self.end_active:
output_accel = self._end_request(a_target, prev_accel, CS.vEgo)
hold_delay = self.STANDSTILL_HOLD_DELAY_LEAD if has_lead else self.STANDSTILL_HOLD_DELAY_NO_LEAD
if CS.standstill and not has_lead and self.engaged_t < self.ENGAGE_STANDSTILL_GRACE:
rate = 0.0
elif self.cf_stopped and CS.standstill and output_accel > self.CF_SETTLE_ACCEL:
output_accel = max(self.CF_SETTLE_ACCEL, output_accel - self.CF_SETTLE_JERK * DT_CTRL)
rate = 0.0
elif self.cf_stopped and CS.standstill:
rate = self.CF_HOLD_RATE # H: then the rest of the hold, slowly
elif self.standstill_t >= hold_delay or stop_confirmed:
rate = self.STANDSTILL_HOLD_RATE
elif creeping and output_accel > self.REROLL_ACCEL:
output_accel = max(self.REROLL_ACCEL, output_accel - self.REROLL_JERK * DT_CTRL)
rate = 0.0
elif creeping:
rate = min(self.STOPPING_DECEL_RATE + self.CREEP_RATE_GROWTH * self.creep_t, self.CREEP_RATE_MAX)
elif self.stopping_t >= self.STOPPING_FREEZE_MAX:
rate = self.STOPPING_DECEL_RATE
else:
rate = 0.0
if hold_floor != self.stop_accel:
output_accel = max(output_accel - rate * DT_CTRL, min(hold_floor, output_accel))
else:
output_accel -= rate * DT_CTRL
return state, float(np.clip(output_accel, accel_limits[0], accel_limits[1]))
@@ -0,0 +1,314 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
import math
from types import SimpleNamespace
from openpilot.common.realtime import DT_CTRL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.sunnypilot.selfdrive.controls.lib.stopping_controller import StoppingController
LIMITS = (-3.5, 2.0)
PID, STOPPING, OFF = LongCtrlState.pid, LongCtrlState.stopping, LongCtrlState.off
END_LO, END_HI = StoppingController.END_LO, StoppingController.END_HI
def cs(v_kph, standstill=False):
return SimpleNamespace(vEgo=v_kph / 3.6, standstill=standstill)
def run(sc, state, car, a_target, prev, secs, stock=None):
"""step the controller for secs with a fixed plan; returns the last output"""
out = prev
for _ in range(int(secs / DT_CTRL)):
_, out = sc.update(state, state, car, a_target, out, a_target if stock is None else stock, LIMITS, has_lead=True)
return out
def blend(sc, v_kph):
return sc._blend(v_kph / 3.6)
class TestEndOfStop(OpenpilotTestCase):
def test_the_line_runs_from_the_entry_request_through_5kph_to_the_rest_value(self):
sc = StoppingController(-2.0)
out = run(sc, PID, cs(9.9), -1.20, -1.20, 0.2)
assert sc.end_active and abs(sc.a_entry - (-1.20)) < 1e-6
# 7.5 km/h: halfway between the entry request and END_HI; the planner has eased to -0.30, the line is sent
out = run(sc, PID, cs(7.5), -0.30, out, 0.6)
assert abs(blend(sc, 7.5) - (-1.20 + (END_HI - -1.20) * 0.5)) < 1e-6
assert abs(out - blend(sc, 7.5)) < 1e-6
# 5 km/h: END_HI; below it a parabola: steep first, flat toward the rest value; the wheel stop: END_LO
out = run(sc, PID, cs(5.0), -0.20, out, 0.4)
assert abs(out - END_HI) < 1e-6
out = run(sc, PID, cs(2.5), -0.20, out, 0.4)
assert abs(out - (-0.48 + -0.60) / 2) < 1e-6
assert abs(blend(sc, 4.0) - (-0.65)) < 1e-6 and abs(blend(sc, 1.0) - (-0.30)) < 1e-6
assert abs(blend(sc, 0.5) - (-0.30)) < 1e-6 # 1-0 km/h unchanged
out = run(sc, PID, cs(0.0), -0.20, out, 0.4)
assert abs(out - END_LO) < 1e-6
def test_the_line_is_a_floor_a_firmer_plan_still_passes(self):
sc = StoppingController(-2.0)
out = run(sc, PID, cs(9.9), -1.20, -1.20, 0.2)
out = run(sc, PID, cs(5.0), -1.10, out, 0.5)
assert abs(out - (-1.10)) < 1e-6
out = run(sc, PID, cs(2.5), -1.8, out, 0.6)
assert abs(out - (-1.8)) < 1e-6
def test_a_light_plan_at_entry_starts_the_line_at_end_hi(self):
sc = StoppingController(-2.0)
light = END_HI + 0.1
out = run(sc, PID, cs(7.0), light, light, 0.5)
assert abs(sc.a_entry - END_HI) < 1e-6
assert abs(out - END_HI) < 1e-6
# the planner's own curve eases off below 5 km/h: the request follows the line, never the plan
out = run(sc, PID, cs(2.5), -0.10, out, 0.5)
assert abs(out - blend(sc, 2.5)) < 1e-6
def test_entry_is_rate_limited(self):
sc = StoppingController(-2.0)
light = END_HI + 0.2
_, out = sc.update(PID, PID, cs(7.0), light, light, light, LIMITS, has_lead=True)
assert out > END_HI
assert abs(out - (light - StoppingController.END_RATE * DT_CTRL)) < 1e-6
def test_an_emergency_plan_still_passes(self):
sc = StoppingController(-2.0)
out = run(sc, PID, cs(2.5), -1.8, -0.40, 0.8)
assert abs(out - (-1.8)) < 1e-6
def test_crawl_follow_is_untouched(self):
sc = StoppingController(-2.0)
out = run(sc, PID, cs(2.0), -0.10, -0.10, 0.5)
assert abs(out - (-0.10)) < 1e-6
assert not sc.end_active
def test_above_blend_v_nothing_changes(self):
sc = StoppingController(-2.0)
out = run(sc, PID, cs(12.0), -0.40, -0.40, 0.5)
assert abs(out - (-0.40)) < 1e-6
assert not sc.end_active
def test_lead_moving_off_releases_the_hold(self):
sc = StoppingController(-2.0)
run(sc, PID, cs(2.0), -0.60, -0.60, 0.5)
assert sc.end_active
_, out = sc.update(PID, PID, cs(1.5), 0.50, END_LO, 0.50, LIMITS, has_lead=True)
assert not sc.end_active and abs(out - 0.50) < 1e-6
def test_stopping_state_keeps_the_line_until_the_wheels_stop_then_holds(self):
sc = StoppingController(-1.0)
run(sc, PID, cs(2.0), END_HI, END_HI, 0.5)
# 1 km/h, stopping state, the planner's curve has eased to -0.10: Kumar's lighter-follow would ease the request
out = run(sc, STOPPING, cs(1.0), -0.10, blend(sc, 2.0), 0.4)
assert abs(out - blend(sc, 1.0)) < 1e-6
# wheels stop: the last value of the line is held until the hold delay, then ramps to stopAccel at the hold rate
out_at_stop = run(sc, STOPPING, cs(0.0, standstill=True), -0.10, out, 0.9)
assert abs(out_at_stop - out) < 1e-6 and END_HI < out_at_stop <= END_LO + 1e-6
out_held = run(sc, STOPPING, cs(0.0, standstill=True), -0.10, out_at_stop, 1.0)
assert out_held < out_at_stop - 0.3
assert out_held >= -1.0 - 1e-6
class TestHoldRelease(OpenpilotTestCase):
"""2026-10-04 14:43: leaving the hold is fast while the request is far below zero, then linear and slow through the
hand-over band, and the plan is never exceeded."""
def test_release_is_slow_through_the_handover_band(self):
sc = StoppingController(-2.0)
out = -2.0
t = 0.0
t_lo = t_hi = None
state = STOPPING # controlsd feeds the returned state back as prev_state (the 0.2 s exit debounce lives there)
for _ in range(300):
state, out = sc.update(state, PID, cs(0.0, standstill=True), 0.8, out, 0.8, LIMITS, has_lead=True)
t += DT_CTRL
if t_lo is None and out >= StoppingController.RELEASE_BAND_LO:
t_lo = t
if t_hi is None and out >= StoppingController.RELEASE_BAND_HI:
t_hi = t
assert out <= 0.8 + 1e-9
assert t_lo is not None and t_lo < 0.6 # 0.2 s debounce, then -2.0 -> -0.6 at the fast rate (0.35 s)
assert t_hi is not None and 1.15 < t_hi < 1.3 # then -0.6 -> +0.4 at 1.5 m/s^3 (0.67 s)
assert abs(out - 0.8) < 1e-9 and not sc.releasing
def test_a_plan_that_drops_again_ends_the_release(self):
sc = StoppingController(-2.0)
state, out = STOPPING, -2.0
for _ in range(30): # through the exit debounce
state, out = sc.update(state, PID, cs(0.0, standstill=True), 0.5, out, 0.5, LIMITS, has_lead=True)
assert sc.releasing and -2.0 < out < 0.5
_, out = sc.update(state, PID, cs(0.0, standstill=True), -2.0, out, -2.0, LIMITS, has_lead=True)
assert abs(out - (-2.0)) < 1e-9 and not sc.releasing
class TestEngageAtStandstill(OpenpilotTestCase):
def _engage(self, has_lead, secs):
sc = StoppingController(-2.0)
car = cs(0.0, standstill=True)
for _ in range(300): # standing in the auto hold, openpilot not engaged
sc.update(OFF, OFF, car, 0.0, 0.0, 0.0, LIMITS, has_lead=has_lead)
out = 0.0
for _ in range(int(secs / DT_CTRL)):
_, out = sc.update(STOPPING, STOPPING, car, 0.0, out, 0.0, LIMITS, has_lead=has_lead)
return out, sc
def test_no_brake_pulse_right_after_engaging_without_lead(self):
# route 0000010a 23:58:49: the hold ramp reached -0.21 in 0.2 s and the PCM braked 1600 N before the launch
out, sc = self._engage(False, 0.5)
assert out == 0.0
def test_the_hold_ramp_follows_after_the_grace(self):
out, sc = self._engage(False, StoppingController.ENGAGE_STANDSTILL_GRACE + 0.3)
assert out < -0.2
def test_with_a_lead_the_hold_starts_at_once(self):
out, _ = self._engage(True, 0.3)
assert out < -0.2
def stand(sc, secs, out=StoppingController.END_LO, pitch=None, a_target=-0.1):
state = STOPPING
for _ in range(int(secs / DT_CTRL)):
state, out = sc.update(state, STOPPING, cs(0.0, standstill=True), a_target, out, a_target, LIMITS, has_lead=True, pitch=pitch)
return out
FLAT = math.radians(1.0)
SLOPE = math.radians(-4.0)
class TestRerollHold(OpenpilotTestCase):
"""route 0000010e 17:38:48: stood at -0.30, rolled at 0.8 km/h for 1.1 s inside the hold delay"""
def _reroll(self, sc):
out = stand(sc, 0.9)
state = STOPPING
outs = []
for _ in range(20):
state, out = sc.update(state, STOPPING, cs(0.8), -0.05, out, -0.05, LIMITS, has_lead=True)
outs.append(out)
return outs
def test_steps_to_the_reroll_request_within_0_1s(self):
outs = self._reroll(StoppingController(-2.0))
assert outs[4] <= StoppingController.REROLL_ACCEL + 1e-9 # 0.05 s
assert all(b - a >= -StoppingController.REROLL_JERK * DT_CTRL - 1e-9 for a, b in zip(outs, outs[1:], strict=False))
class TestFlatHold(OpenpilotTestCase):
def test_flat_hold_stops_at_minus_1_2(self):
out = stand(StoppingController(-2.0), 4.0, pitch=FLAT)
assert abs(out - StoppingController.FLAT_HOLD_ACCEL) < 1e-9
def test_slope_or_no_pitch_keeps_stop_accel(self):
assert stand(StoppingController(-2.0), 4.0, pitch=SLOPE) <= -2.0 + 1e-6
assert stand(StoppingController(-2.0), 4.0, pitch=None) <= -2.0 + 1e-6
def test_a_creep_on_the_flat_hold_goes_deeper(self):
sc = StoppingController(-2.0)
out = stand(sc, 4.0, pitch=FLAT)
state = STOPPING
for _ in range(150):
state, out = sc.update(state, STOPPING, cs(0.8), -0.1, out, -0.1, LIMITS, has_lead=True, pitch=FLAT)
assert out < StoppingController.FLAT_HOLD_ACCEL - 0.5
class TestCreepFollowStop(OpenpilotTestCase):
"""route 0000010d 23:56:59: lead moved ~2 m, the car followed at 2.5 km/h and the brake bit at -0.88"""
def _creep(self, sc, plan, v_kph=2.0, secs=2.0, out=0.4):
stand(sc, 2.0)
outs = []
for _ in range(int(secs / DT_CTRL)):
_, out = sc.update(PID, PID, cs(v_kph), plan, out, plan, LIMITS, has_lead=True)
outs.append(out)
return outs
def test_stops_with_minus_0_24_not_the_end_line(self):
outs = self._creep(StoppingController(-2.0), -0.40)
assert outs[0] <= 0.0 # a positive request is dropped at once
assert abs(outs[-1] - StoppingController.CF_END) < 1e-9
steps = [a - b for a, b in zip(outs, outs[1:], strict=False)]
assert max(steps) <= StoppingController.CF_RATE * DT_CTRL + 1e-9
def test_a_firm_plan_still_wins(self):
outs = self._creep(StoppingController(-2.0), -1.0)
assert abs(outs[-1] - (-1.0)) < 1e-9
def test_light_plan_keeps_minus_0_24_to_the_stop(self):
sc = StoppingController(-2.0)
outs = self._creep(sc, -0.40, secs=1.0)
_, out = sc.update(PID, PID, cs(1.0), -0.10, outs[-1], -0.10, LIMITS, has_lead=True)
assert abs(out - StoppingController.CF_END) < 1e-9
def test_not_after_a_real_launch(self):
sc = StoppingController(-2.0)
stand(sc, 2.0)
out = 0.5
for _ in range(300):
_, out = sc.update(PID, PID, cs(15.0), 0.3, out, 0.3, LIMITS, has_lead=True)
_, out = sc.update(PID, PID, cs(6.0), -0.40, out, -0.40, LIMITS, has_lead=True)
assert sc.cf_t is None
def test_lead_moving_off_ends_it(self):
sc = StoppingController(-2.0)
outs = self._creep(sc, -0.40, secs=0.5)
_, out = sc.update(PID, PID, cs(2.0), 0.5, outs[-1], 0.5, LIMITS, has_lead=True)
assert out == 0.5 and not sc.cf_stopping
def test_after_the_creep_stop_a_little_brake_at_once_then_the_hold_slowly(self):
"""route 00000112 23:16:57: -0.24 for the 1.0 s hold delay, then -1.2 within ~1 s felt like a second brake"""
sc = StoppingController(-2.0)
outs = self._creep(sc, -0.40)
out, state, trace = outs[-1], STOPPING, []
for _ in range(300):
state, out = sc.update(state, STOPPING, cs(0.0, standstill=True), -0.05, out, -0.05, LIMITS, has_lead=True,
pitch=FLAT)
trace.append(out)
assert trace[50] <= StoppingController.CF_SETTLE_ACCEL + 1e-9 # -0.6 within 0.5 s, no hold delay
assert trace[10] > StoppingController.CF_SETTLE_ACCEL # but gradually
assert abs(trace[150] - (StoppingController.CF_SETTLE_ACCEL - 1.0 * StoppingController.CF_HOLD_RATE)) < 0.02
assert abs(trace[-1] - StoppingController.FLAT_HOLD_ACCEL) < 1e-9 # the flat hold after ~2.5 s
steps = [a - b for a, b in zip(trace, trace[1:], strict=False)]
assert max(steps) <= StoppingController.CF_SETTLE_JERK * DT_CTRL + 1e-9
def test_an_ordinary_stop_keeps_the_hold_delay(self):
sc = StoppingController(-2.0)
out = stand(sc, 0.9, pitch=FLAT)
assert abs(out - END_LO) < 1e-9
class TestConfirmedStopEarlyHold(OpenpilotTestCase):
"""hold 0.2 s after a CONFIRMED stop (standstill flag + the body's deceleration collapsing), else the 1.0 s delay"""
def _stop(self, sc, collapse):
out, state = -0.40, PID
for k in range(100): # rolling to the stop at -0.45 m/s^2 (body)
state, out = sc.update(state, STOPPING, cs(2.0 - 0.019 * k), -0.3, out, -0.3, LIMITS, has_lead=True, pitch=FLAT, a_long=-0.45)
trace = []
for k in range(150): # standing: the body pitches back (deceleration collapses) after 0.1 s, or not
a = -0.05 if (collapse and k >= 10) else -0.45
state, out = sc.update(state, STOPPING, cs(0.0, True), -0.05, out, -0.05, LIMITS, has_lead=True, pitch=FLAT, a_long=a)
trace.append(out)
return trace
def test_confirmed_stop_holds_after_0_2s(self):
sc = StoppingController(-2.0)
tr = self._stop(sc, True)
assert tr[25] == tr[0] # 0.1 s to the pitch-back + 0.2 s: nothing yet at 0.25 s
assert tr[40] < tr[0] - 0.05 # holding by 0.4 s
def test_no_confirmation_keeps_the_1s_delay(self):
sc = StoppingController(-2.0)
tr = self._stop(sc, False)
assert tr[90] == tr[0] and tr[110] < tr[0]
def test_no_pose_keeps_the_1s_delay(self):
sc = StoppingController(-2.0)
assert not sc.stop_confirm.update(True, 0.0, None)
@@ -1652,6 +1652,18 @@
"widget": "toggle",
"title": "Use Metric System",
"description": "Display speed in km/h instead of mph."
},
{
"key": "SunnypilotStoppingController",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Stopping Controller",
"description": "Take over the end of a stop. The request follows the speed down to the wheel stop, then holds the car at a standstill.",
"enablement": [
{
"type": "offroad_only"
}
]
}
]
},
@@ -2360,6 +2372,17 @@
}
]
},
{
"key": "ToyotaBrakeOnset",
"widget": "toggle",
"needs_onroad_cycle": true,
"title": "Toyota: soft brake onset, overshoot limit and regen hand-over",
"enablement": [
{
"type": "not_engaged"
}
]
},
{
"key": "ToyotaEnhancedBsm",
"widget": "toggle",
@@ -32,6 +32,14 @@ sections:
widget: toggle
title: Use Metric System
description: Display speed in km/h instead of mph.
- key: SunnypilotStoppingController
widget: toggle
needs_onroad_cycle: true
title: Stopping Controller
description: Take over the end of a stop. The request follows the speed down to the wheel stop, then holds the car at a
standstill.
enablement:
- $ref: '#/macros/offroad'
- id: recording
title: Recording
description: Camera and audio recording during drives
@@ -88,6 +88,12 @@ sections:
title: 'Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS'
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaBrakeOnset
widget: toggle
needs_onroad_cycle: true
title: 'Toyota: soft brake onset, overshoot limit and regen hand-over'
enablement:
- $ref: '#/macros/not_engaged'
- key: ToyotaEnhancedBsm
widget: toggle
needs_onroad_cycle: true