feat(long): add acceleration profiles

This commit is contained in:
rav4kumar
2026-08-22 01:36:59 -07:00
parent d8c713b352
commit 721dce9941
24 changed files with 1452 additions and 8 deletions
+12
View File
@@ -203,6 +203,7 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
aTarget @5 :Float32;
events @6 :List(OnroadEventSP.Event);
e2eAlerts @7 :E2eAlerts;
accelController @8 :AccelController;
struct DynamicExperimentalControl {
state @0 :DynamicExperimentalControlState;
@@ -305,6 +306,17 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
greenLightAlert @0 :Bool;
leadDepartAlert @1 :Bool;
}
struct AccelController {
enabled @0 :Bool;
active @1 :Bool;
profile @2 :Profile;
enum Profile {
eco @0;
normal @1;
sport @2;
}
}
}
struct OnroadEventSP @0xda96579883444c35 {
+4
View File
@@ -241,6 +241,10 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
// Accel Controller profiles (Eco / Normal / Sport)
{"AccelPersonalityEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
{"AccelPersonality", {PERSISTENT | BACKUP, INT, "1"}},
// sunnypilot model params
{"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}},
{"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}},
+4
View File
@@ -117,12 +117,16 @@ class TestParams(OpenpilotTestCase):
def test_params_default_value(self):
self.params.remove("LanguageSetting")
self.params.remove("LongitudinalPersonality")
self.params.remove("AccelPersonalityEnabled")
self.params.remove("AccelPersonality")
self.params.remove("LiveParametersV2")
assert self.params.get("LanguageSetting") is None
assert self.params.get("LanguageSetting", return_default=False) is None
assert isinstance(self.params.get("LanguageSetting", return_default=True), str)
assert isinstance(self.params.get("LongitudinalPersonality", return_default=True), int)
assert self.params.get("AccelPersonalityEnabled", return_default=True) is False
assert self.params.get("AccelPersonality", return_default=True) == 1
assert self.params.get("LiveParametersV2") is None
assert self.params.get("LiveParametersV2", return_default=True) is None
@@ -35,9 +35,12 @@ def get_max_accel(v_ego):
def get_coast_accel(pitch):
return np.sin(pitch) * -5.65 - 0.3 # fitted from data using xx/projects/allow_throttle/compute_coast_accel.py
def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle):
max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego)
def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle,
max_accel_override=None):
if max_accel_override is not None:
max_accel = max_accel_override
else:
max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego)
if not e2e:
a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V)
a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase)
@@ -135,14 +138,16 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
output_a_target_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX,
action_t=action_t)
output_should_stop_mpc = should_stop(v_ego, output_a_target_mpc)
output_should_stop_mpc = self.update_lead_departure(sm, output_a_target_mpc, output_should_stop_mpc, reset_state)
output_a_target_e2e = sm['modelV2'].action.desiredAcceleration
output_should_stop_e2e = sm['modelV2'].action.shouldStop
is_e2e = self.is_e2e(sm)
max_accel_override = self.get_max_accel_override(v_ego, is_e2e)
self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego,
self.a_cruise, steer_angle_without_offset, self.CP, self.dt,
accel_coast, self.allow_throttle)
accel_coast, self.allow_throttle, max_accel_override)
cruise_should_stop = should_stop(v_ego, self.a_cruise)
candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc),
@@ -11,6 +11,15 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPl
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
class PlannerSM(dict):
def __init__(self, radar_frame: int, services: dict):
super().__init__(services)
self.frame = radar_frame
self.logMonoTime = {"radarState": radar_frame}
self.valid = {"radarState": True}
self.alive = {"radarState": True}
class Plant:
messaging_initialized = False
@@ -132,7 +141,7 @@ class Plant:
car_control.carControl.orientationNED = [0., float(pitch), 0.]
# ******** get controlsState messages for plotting ***
sm = {'radarState': radar.radarState,
sm = PlannerSM(self.rk.frame, {'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
@@ -141,7 +150,7 @@ class Plant:
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation}
'gpsLocation': gps_data.gpsLocation})
self.planner.update(sm)
self.acceleration = self.planner.output_a_target
if self.planner.output_should_stop:
@@ -27,6 +27,12 @@ DESCRIPTIONS = {
"In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
"your steering wheel distance button."
),
"AccelPersonalityEnabled": tr_noop(
"Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged."
),
"AccelPersonality": tr_noop(
"Eco accelerates more gently, Normal matches the stock cruise response, and Sport adds stronger low-speed acceleration."
),
"IsLdwEnabled": tr_noop(
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
"without a turn signal activated while driving over 31 mph (50 km/h)."
@@ -106,6 +112,24 @@ class TogglesLayout(Widget):
icon="speed_limit.png"
)
self._accel_controller_enabled = toggle_item(
lambda: tr("Enable Accel Controller"),
lambda: tr(DESCRIPTIONS["AccelPersonalityEnabled"]),
self._params.get_bool("AccelPersonalityEnabled"),
callback=self._set_accel_controller_enabled,
icon="speed_limit.png",
)
self._accel_personality_setting = multiple_button_item(
lambda: tr("Acceleration Profile"),
lambda: tr(DESCRIPTIONS["AccelPersonality"]),
buttons=[lambda: tr("Eco"), lambda: tr("Normal"), lambda: tr("Sport")],
button_width=300,
callback=self._set_accel_personality,
selected_index=self._params.get("AccelPersonality", return_default=True),
icon="speed_limit.png"
)
self._toggles = {}
self._locked_toggles = set()
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
@@ -135,9 +159,11 @@ class TogglesLayout(Widget):
self._toggles[param] = toggle
# insert longitudinal personality after NDOG toggle
# insert longitudinal personality and Accel Controller settings after NDOG toggle
if param == "DisengageOnAccelerator":
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
self._toggles["AccelPersonalityEnabled"] = self._accel_controller_enabled
self._toggles["AccelPersonality"] = self._accel_personality_setting
self._update_experimental_mode_icon()
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
@@ -158,6 +184,7 @@ class TogglesLayout(Widget):
def _update_toggles(self):
ui_state.update_params()
accel_controller_enabled = self._params.get_bool("AccelPersonalityEnabled")
e2e_description = tr(
"sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " +
@@ -176,11 +203,15 @@ class TogglesLayout(Widget):
self._toggles["ExperimentalMode"].action_item.set_enabled(True)
self._toggles["ExperimentalMode"].set_description(e2e_description)
self._long_personality_setting.action_item.set_enabled(True)
self._accel_controller_enabled.action_item.set_enabled(True)
self._accel_personality_setting.action_item.set_enabled(True)
else:
# no long for now
self._toggles["ExperimentalMode"].action_item.set_enabled(False)
self._toggles["ExperimentalMode"].action_item.set_state(False)
self._long_personality_setting.action_item.set_enabled(False)
self._accel_controller_enabled.action_item.set_enabled(False)
self._accel_personality_setting.action_item.set_enabled(False)
self._params.remove("ExperimentalMode")
unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.")
@@ -203,6 +234,8 @@ class TogglesLayout(Widget):
# refresh toggles from params to mirror external changes
for param in self._toggle_defs:
self._toggles[param].action_item.set_state(self._params.get_bool(param))
self._accel_controller_enabled.action_item.set_state(accel_controller_enabled)
self._accel_personality_setting.action_item.set_selected_button(self._params.get("AccelPersonality", return_default=True))
# these toggles need restart, block while engaged
for toggle_def in self._toggle_defs:
@@ -247,3 +280,9 @@ class TogglesLayout(Widget):
def _set_longitudinal_personality(self, button_index: int):
self._params.put("LongitudinalPersonality", button_index, block=True)
def _set_accel_personality(self, button_index: int):
self._params.put("AccelPersonality", button_index, block=True)
def _set_accel_controller_enabled(self, state: bool):
self._params.put_bool("AccelPersonalityEnabled", state, block=True)
@@ -42,6 +42,8 @@ class TogglesLayoutMici(NavScroller):
super().__init__()
self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
self._accel_controller_enabled = BigParamControl("enable accel controller", "AccelPersonalityEnabled")
self._accel_personality_toggle = BigMultiParamToggle("acceleration profile", "AccelPersonality", ["eco", "normal", "sport"])
self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"),
toggle_callback=self._on_experimental_mode)
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
@@ -53,6 +55,8 @@ class TogglesLayoutMici(NavScroller):
self._scroller.add_widgets([
self._personality_toggle,
self._accel_controller_enabled,
self._accel_personality_toggle,
self._experimental_btn,
is_metric_toggle,
ldw_toggle,
@@ -65,6 +69,7 @@ class TogglesLayoutMici(NavScroller):
# Toggle lists
self._refresh_toggles = (
("ExperimentalMode", self._experimental_btn),
("AccelPersonalityEnabled", self._accel_controller_enabled),
("IsMetric", is_metric_toggle),
("IsLdwEnabled", ldw_toggle),
("AlwaysOnDM", always_on_dm_toggle),
@@ -104,17 +109,23 @@ class TogglesLayoutMici(NavScroller):
if ui_state.has_longitudinal_control:
self._experimental_btn.set_visible(True)
self._personality_toggle.set_visible(True)
self._accel_controller_enabled.set_visible(True)
self._accel_personality_toggle.set_visible(True)
else:
# no long for now
self._experimental_btn.set_visible(False)
self._experimental_btn.set_checked(False)
self._personality_toggle.set_visible(False)
self._accel_controller_enabled.set_visible(False)
self._accel_personality_toggle.set_visible(False)
ui_state.params.remove("ExperimentalMode")
# Refresh toggles from params to mirror external changes
for key, item in self._refresh_toggles:
item.set_checked(ui_state.params.get_bool(key))
self._accel_personality_toggle.refresh()
def _on_experimental_mode(self, state: bool):
if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"):
# Don't show enabled state until confirm
@@ -385,13 +385,18 @@ class BigMultiParamToggle(BigMultiToggle):
self._load_value()
def _load_value(self):
self.set_value(self._options[self._params.get(self._param) or 0])
value = self._params.get(self._param, return_default=True)
index = value if isinstance(value, int) else 0
self.set_value(self._options[max(0, min(index, len(self._options) - 1))])
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
new_idx = self._options.index(self.value)
self._params.put(self._param, new_idx)
def refresh(self):
self._load_value()
class BigParamControl(BigToggle):
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None):
@@ -0,0 +1,47 @@
"""
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.cereal import custom
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.sunnypilot import get_sanitize_int_param
AccelProfile = custom.LongitudinalPlanSP.AccelController.Profile
MAX_ACCEL_BREAKPOINTS = [0., 3., 5., 8., 10., 25., 40.]
MAX_ACCEL_PROFILES = {
AccelProfile.eco: [1.45, 1.40, 1.20, 0.96, 0.90, 0.60, 0.45],
AccelProfile.normal: [1.60, 1.48, 1.40, 1.28, 1.20, 0.80, 0.60],
AccelProfile.sport: [2.00, 1.99, 1.95, 1.45, 1.30, 0.80, 0.60],
}
class AccelController:
def __init__(self):
self.params = Params()
self.frame = 0
self._profile = get_sanitize_int_param("AccelPersonality", AccelProfile.eco, AccelProfile.sport, self.params)
self._enabled = self.params.get_bool("AccelPersonalityEnabled")
def update(self) -> None:
self.frame += 1
if self.frame % int(1.0 / DT_MDL) == 0:
self._profile = get_sanitize_int_param("AccelPersonality", AccelProfile.eco, AccelProfile.sport, self.params)
self._enabled = self.params.get_bool("AccelPersonalityEnabled")
@property
def profile(self) -> int:
return self._profile
def is_enabled(self) -> bool:
return self._enabled
def get_max_accel(self, v_ego: float) -> float:
v_ego = max(0.0, v_ego)
return float(np.interp(v_ego, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
@@ -0,0 +1,156 @@
"""
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.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.longitudinal_planner import (
A_CRUISE_MAX_BP, A_CRUISE_MIN, J_CRUISE_VALS, get_cruise_accel, get_max_accel,
)
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
AccelController, AccelProfile, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
)
class TestAccelController(OpenpilotTestCase):
def setUp(self):
self.params = Params()
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
self.params.put("AccelPersonality", AccelProfile.normal, block=True)
def set_profile(self, profile: int) -> AccelController:
self.params.put("AccelPersonality", profile, block=True)
return AccelController()
def test_table_breakpoints(self):
for profile, values in MAX_ACCEL_PROFILES.items():
controller = self.set_profile(profile)
for speed, expected in zip(MAX_ACCEL_BREAKPOINTS, values, strict=True):
assert controller.get_max_accel(speed) == expected
def test_profile_ordering_and_bounds(self):
controllers = {
AccelProfile.eco: self.set_profile(AccelProfile.eco),
AccelProfile.normal: self.set_profile(AccelProfile.normal),
AccelProfile.sport: self.set_profile(AccelProfile.sport),
}
previous = {profile: float("inf") for profile in controllers}
for speed in np.linspace(0.0, 55.0, 551):
values = {profile: controller.get_max_accel(speed) for profile, controller in controllers.items()}
assert 0.0 <= values[AccelProfile.eco] <= values[AccelProfile.normal] <= values[AccelProfile.sport] <= 2.0
for profile, value in values.items():
assert value <= previous[profile]
previous[profile] = value
def test_normal_matches_stock(self):
controller = self.set_profile(AccelProfile.normal)
for speed in np.linspace(0.0, 55.0, 551):
assert np.isclose(controller.get_max_accel(speed), get_max_accel(speed), rtol=0.0, atol=1e-12)
def test_eco_keeps_useful_road_speed_acceleration(self):
controller = self.set_profile(AccelProfile.eco)
for speed in np.linspace(8.0, 40.0, 321):
assert controller.get_max_accel(speed) >= 0.75 * get_max_accel(speed) - 1e-12
def test_negative_speed_uses_standstill_value(self):
controller = self.set_profile(AccelProfile.sport)
assert controller.get_max_accel(-1.0) == MAX_ACCEL_PROFILES[AccelProfile.sport][0]
def test_profile_change_has_no_controller_filter(self):
controller = self.set_profile(AccelProfile.normal)
self.params.put("AccelPersonality", AccelProfile.sport, block=True)
controller.frame = int(1.0 / DT_MDL) - 1
controller.update()
assert controller.get_max_accel(8.0) == MAX_ACCEL_PROFILES[AccelProfile.sport][3]
def test_params_refresh_once_per_second(self):
controller = self.set_profile(AccelProfile.normal)
self.params.put("AccelPersonality", AccelProfile.sport, block=True)
controller.update()
assert controller.profile == AccelProfile.normal
controller.frame = int(1.0 / DT_MDL) - 1
controller.update()
assert controller.profile == AccelProfile.sport
def test_enabled_param_refresh(self):
controller = self.set_profile(AccelProfile.normal)
self.params.put_bool("AccelPersonalityEnabled", False, block=True)
controller.frame = int(1.0 / DT_MDL) - 1
controller.update()
assert not controller.is_enabled()
class TestPlannerIntegration(OpenpilotTestCase):
def setUp(self):
self.params = Params()
self.params.put_bool("AccelPersonalityEnabled", False, block=True)
def test_none_override_matches_stock(self):
for e2e in (False, True):
for allow_throttle in (False, True):
args = (e2e, 30.0, 12.0, 0.2, 4.0, _fake_cp(), DT_MDL, -0.3, allow_throttle)
assert get_cruise_accel(*args) == get_cruise_accel(*args, max_accel_override=None)
def test_profiles_do_not_change_braking(self):
args = (False, 0.0, 20.0, 0.0, 0.0, _fake_cp(), 10.0, -0.3, True)
stock = get_cruise_accel(*args)
assert stock == A_CRUISE_MIN
for profile_values in MAX_ACCEL_PROFILES.values():
assert get_cruise_accel(*args, max_accel_override=profile_values[0]) == stock
def test_stock_jerk_limit_still_owns_smoothing(self):
speed = 8.0
sport_limit = np.interp(speed, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.sport])
target = get_cruise_accel(False, 30.0, speed, 0.0, 0.0, _fake_cp(), DT_MDL, 0.0, True, sport_limit)
jerk_limit = np.interp(speed, A_CRUISE_MAX_BP, J_CRUISE_VALS) * DT_MDL
assert np.isclose(target, jerk_limit)
def test_disabled_and_e2e_leave_stock_limit_active(self):
planner = _bare_planner()
assert planner.get_max_accel_override(5.0, e2e=False) is None
assert planner.accel_controller_active is False
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
planner = _bare_planner()
assert planner.get_max_accel_override(5.0, e2e=True) is None
assert planner.accel_controller_active is False
def test_enabled_acc_uses_python_native_telemetry_types(self):
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
self.params.put("AccelPersonality", AccelProfile.sport, block=True)
planner = _bare_planner()
assert planner.get_max_accel_override(5.0, e2e=False) == MAX_ACCEL_PROFILES[AccelProfile.sport][2]
assert type(planner.accel_controller_active) is bool
assert type(planner.accel_controller.is_enabled()) is bool
assert type(planner.accel_controller.profile) is int
def test_normal_profile_uses_exact_stock_path(self):
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
self.params.put("AccelPersonality", AccelProfile.normal, block=True)
planner = _bare_planner()
assert planner.get_max_accel_override(5.0, e2e=False) is None
assert planner.accel_controller_active is False
def _fake_cp():
class CP:
steerRatio = 15.0
wheelbase = 2.7
return CP()
def _bare_planner():
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.accel_controller = AccelController()
planner.accel_controller_active = False
return planner
@@ -0,0 +1,93 @@
"""
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.
"""
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.drive_helpers import should_stop
from openpilot.selfdrive.controls.lib.longitudinal_planner import get_cruise_accel
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelProfile
class CarParams:
steerRatio = 15.0
wheelbase = 2.7
def run_profile(profile: int, *, enabled: bool = True, speed: float = 0.0, v_cruise: float = 30.0,
v_cruise_fn: Callable[[int], float] | None = None, steps: int = 120):
params = Params()
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
params.put("AccelPersonality", profile, block=True)
controller = AccelController()
accel = 0.0
rows = []
for frame in range(steps):
target_speed = v_cruise if v_cruise_fn is None else v_cruise_fn(frame)
custom_profile = controller.is_enabled() and controller.profile != AccelProfile.normal
max_accel_override = controller.get_max_accel(speed) if custom_profile else None
accel = get_cruise_accel(False, target_speed, speed, accel, 0.0, CarParams(), DT_MDL, 2.0, True, max_accel_override)
speed = max(0.0, speed + accel * DT_MDL)
rows.append((speed, accel, should_stop(speed, accel)))
return rows
class TestAccelControllerClosedLoop(OpenpilotTestCase):
def test_normal_matches_disabled_stock_path(self):
stock = run_profile(AccelProfile.normal, enabled=False, speed=4.0, steps=120)
normal = run_profile(AccelProfile.normal, speed=4.0, steps=120)
self.assertEqual(normal, stock)
def test_profiles_do_not_change_braking(self):
stock = run_profile(AccelProfile.normal, enabled=False, speed=20.0, v_cruise=0.0, steps=100)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
self.assertEqual(run_profile(profile, speed=20.0, v_cruise=0.0, steps=100), stock)
def test_launch_ordering_without_departure_delay(self):
traces = {
profile: run_profile(profile, v_cruise=8.0, steps=160)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
}
first_motion = {
profile: next(frame for frame, row in enumerate(rows) if row[0] > 0.01)
for profile, rows in traces.items()
}
time_to_five = {
profile: next(frame for frame, row in enumerate(rows) if row[0] >= 5.0) * DT_MDL
for profile, rows in traces.items()
}
self.assertEqual(len(set(first_motion.values())), 1)
self.assertLessEqual(time_to_five[AccelProfile.sport], time_to_five[AccelProfile.normal])
self.assertLessEqual(time_to_five[AccelProfile.normal], time_to_five[AccelProfile.eco])
self.assertLessEqual(time_to_five[AccelProfile.eco], 1.25 * time_to_five[AccelProfile.normal])
def test_road_speed_catchup_stays_useful(self):
traces = {
profile: run_profile(profile, speed=20.0, v_cruise=30.0, steps=100)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
}
gains = {profile: rows[-1][0] - 20.0 for profile, rows in traces.items()}
self.assertGreaterEqual(gains[AccelProfile.sport], gains[AccelProfile.normal])
self.assertGreaterEqual(gains[AccelProfile.eco], 0.72 * gains[AccelProfile.normal])
def test_stop_release_frame_is_profile_independent(self):
def target_speed(frame: int) -> float:
return 0.0 if frame < 20 else 8.0
traces = {
profile: run_profile(profile, v_cruise_fn=target_speed, steps=80)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
}
release_frames = {
profile: next(frame for frame, row in enumerate(rows) if frame >= 20 and not row[2])
for profile, rows in traces.items()
}
self.assertEqual(len(set(release_frames.values())), 1)
@@ -0,0 +1,115 @@
"""
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.
"""
from collections import deque
import math
from typing import Any
from openpilot.cereal import log
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
LEAD_DEPARTURE_MIN_SPEED = 0.6
LEAD_DEPARTURE_CONFIRM_FRAMES = 3
LEAD_DEPARTURE_MIN_DISTANCE = 0.03
LEAD_DEPARTURE_MAX_EGO_SPEED = 0.3
MpcPlanSource = log.LongitudinalPlan.LongitudinalPlanSource
class LeadDepartureController:
def __init__(self, enabled: bool):
self.enabled = enabled
self._track_id: int | None = None
self._distances: deque[float] = deque(maxlen=LEAD_DEPARTURE_CONFIRM_FRAMES)
self._active = False
@property
def active(self) -> bool:
return self._active
def reset(self) -> None:
self._track_id = None
self._distances.clear()
self._active = False
@staticmethod
def _selected_lead(radar_state: Any, source: Any) -> Any | None:
if source == MpcPlanSource.lead0:
return radar_state.leadOne
if source == MpcPlanSource.lead1:
return radar_state.leadTwo
return None
@staticmethod
def _radar_has_errors(radar_state: Any) -> bool:
errors = radar_state.radarErrors
return errors.canError or errors.radarFault or errors.wrongConfig or errors.radarUnavailableTemporary
def update(self, sm: Any, source: Any, a_target: float, should_stop: bool, reset: bool, radar_valid: bool) -> bool:
CS = sm['carState']
CC = sm['carControl']
controls_state = sm['controlsState']
radar_state = sm['radarState']
blocked = (
not self.enabled
or reset
or not CC.longActive
or CC.cruiseControl.override
or CS.gasPressed
or CS.brakePressed
or controls_state.forceDecel
or controls_state.longControlState == LongCtrlState.off
or not radar_valid
or self._radar_has_errors(radar_state)
)
if blocked or not math.isfinite(CS.vEgo) or CS.vEgo >= LEAD_DEPARTURE_MAX_EGO_SPEED or not math.isfinite(a_target):
self.reset()
return should_stop
lead = self._selected_lead(radar_state, source)
lead_valid = (
lead is not None
and lead.present
and lead.radar
and lead.radarTrackId >= 0
and all(math.isfinite(value) for value in (lead.dRel, lead.vLeadK, lead.vRel))
and lead.dRel > 0.0
and lead.vLeadK >= LEAD_DEPARTURE_MIN_SPEED
and lead.vRel >= LEAD_DEPARTURE_MIN_SPEED
and a_target >= 0.0
)
if not lead_valid:
self.reset()
return should_stop
track_id = int(lead.radarTrackId)
if self._active:
if track_id != self._track_id:
self.reset()
return should_stop
return False
if not should_stop:
self.reset()
return False
if controls_state.longControlState != LongCtrlState.stopping:
self.reset()
return should_stop
if track_id != self._track_id:
self._track_id = track_id
self._distances.clear()
self._distances.append(float(lead.dRel))
if len(self._distances) == LEAD_DEPARTURE_CONFIRM_FRAMES and self._distances[-1] - self._distances[0] >= LEAD_DEPARTURE_MIN_DISTANCE:
self._active = True
return False
return should_stop
@@ -9,8 +9,10 @@ from openpilot.cereal import messaging, custom
from opendbc.car import structs
from openpilot.common.constants import CV
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController, AccelProfile
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper
from openpilot.sunnypilot.selfdrive.controls.lib.lead_departure_controller import LeadDepartureController
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver
@@ -23,6 +25,9 @@ LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
class LongitudinalPlannerSP:
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
self.accel_controller = AccelController()
self.accel_controller_active = False
self.lead_departure_controller = LeadDepartureController(CP.openpilotLongitudinalControl and CP.autoResumeSng and not CP.notCar)
self.events_sp = EventsSP()
self.resolver = SpeedLimitResolver()
self.dec = DynamicExperimentalController(CP, mpc)
@@ -43,6 +48,17 @@ class LongitudinalPlannerSP:
return experimental_mode and self.dec.mode() == "blended"
def get_max_accel_override(self, v_ego: float, e2e: bool) -> float | None:
custom_profile = self.accel_controller.profile != AccelProfile.normal
self.accel_controller_active = bool(self.accel_controller.is_enabled() and custom_profile and not e2e)
if not self.accel_controller_active:
return None
return self.accel_controller.get_max_accel(v_ego)
def update_lead_departure(self, sm: messaging.SubMaster, a_target: float, should_stop: bool, reset: bool) -> bool:
radar_valid = sm.valid.get('radarState', False) and getattr(sm, 'alive', {}).get('radarState', False)
return self.lead_departure_controller.update(sm, self.mpc.source, a_target, should_stop, reset, radar_valid)
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
CS = sm['carState']
v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX)
@@ -74,6 +90,7 @@ class LongitudinalPlannerSP:
return self.output_v_target, self.output_a_target
def update(self, sm: messaging.SubMaster) -> None:
self.accel_controller.update()
self.events_sp.clear()
self.dec.update(sm)
self.e2e_alerts_helper.update(sm, self.events_sp)
@@ -95,6 +112,11 @@ class LongitudinalPlannerSP:
dec.enabled = self.dec.enabled()
dec.active = self.dec.active()
accel_controller = longitudinalPlanSP.accelController
accel_controller.enabled = bool(self.accel_controller.is_enabled())
accel_controller.active = bool(self.accel_controller_active)
accel_controller.profile = int(self.accel_controller.profile)
# Smart Cruise Control
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
# Vision Control
@@ -0,0 +1,272 @@
"""
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.
"""
from types import SimpleNamespace
from unittest import mock
from openpilot.cereal import log
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
from openpilot.sunnypilot.selfdrive.controls.lib.lead_departure_controller import LEAD_DEPARTURE_MIN_SPEED, LeadDepartureController
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, PlantSP
MpcPlanSource = log.LongitudinalPlan.LongitudinalPlanSource
def make_lead(*, d_rel: float = 4.0, v_lead: float = 0.7, v_rel: float = 0.7, present: bool = True, radar: bool = True, track_id: int = 7):
return SimpleNamespace(dRel=d_rel, vLeadK=v_lead, vRel=v_rel, present=present, radar=radar, radarTrackId=track_id)
def make_sm(
*,
lead_one=None,
lead_two=None,
v_ego: float = 0.0,
long_active: bool = True,
long_state=LongCtrlState.stopping,
gas: bool = False,
brake: bool = False,
override: bool = False,
force_decel: bool = False,
radar_error: str | None = None,
):
errors = SimpleNamespace(canError=False, radarFault=False, wrongConfig=False, radarUnavailableTemporary=False)
if radar_error is not None:
setattr(errors, radar_error, True)
return {
'carState': SimpleNamespace(vEgo=v_ego, gasPressed=gas, brakePressed=brake),
'carControl': SimpleNamespace(longActive=long_active, cruiseControl=SimpleNamespace(override=override)),
'controlsState': SimpleNamespace(longControlState=long_state, forceDecel=force_decel),
'radarState': SimpleNamespace(leadOne=lead_one or make_lead(), leadTwo=lead_two or make_lead(track_id=8), radarErrors=errors),
}
def update(controller, sm, *, source=MpcPlanSource.lead0, a_target: float = 0.05, should_stop: bool = True, reset: bool = False, radar_valid: bool = True):
return controller.update(sm, source, a_target, should_stop, reset, radar_valid)
def activate(controller: LeadDepartureController):
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.00)))
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.01)))
assert not update(controller, make_sm(lead_one=make_lead(d_rel=4.04)))
assert controller.active
def run_closed_loop(controller_enabled: bool, gap: float, lead_speed, duration: float, model_should_stop: bool | None = None):
def observe_lead(_t, _name, truth):
truth.update(radar=True, radarTrackId=7)
return truth
def model_action(_t, _v_ego, _a_ego):
return 0.0, bool(model_should_stop)
plant = PlantSP(
lead_relevancy=True,
speed=0.0,
distance_lead=gap,
lead_observation_fn=observe_lead,
actuator_model=PRIUS_TSS2_ROUTE_MODEL,
run_long_control=True,
e2e=model_should_stop is not None,
model_action_fn=model_action if model_should_stop is not None else None,
)
plant.planner.lead_departure_controller.enabled = controller_enabled
original_update = plant.planner.update
def long_active_update(sm):
sm['carControl'].longActive = True
original_update(sm)
solver_resets = 0
original_reset = plant.planner.mpc.reset
def counted_reset(*args, **kwargs):
nonlocal solver_resets
if plant.planner.mpc.solution_status != 0:
solver_resets += 1
return original_reset(*args, **kwargs)
rows = []
active = []
with (
mock.patch.object(plant.planner, 'get_max_accel_override', return_value=None),
mock.patch.object(plant.planner, 'update', side_effect=long_active_update),
mock.patch.object(plant.planner.mpc, 'reset', side_effect=counted_reset),
):
for _ in range(round(duration / DT_MDL)):
t = plant.current_time
result = plant.step(v_lead=lead_speed(t), v_cruise=8.0)
rows.append(
(t, result['speed'], result['distance'], result['distance_lead'] - result['distance'], result['actuator_command'], result['should_stop'], result['fcw'])
)
active.append(plant.planner.lead_departure_controller.active)
return rows, active, solver_resets
def first_delay(rows, cue: float, column: int, predicate):
return next(row[0] - cue for row in rows if row[0] >= cue and predicate(row[column]))
class TestLeadDepartureController(OpenpilotTestCase):
def test_requires_three_coherent_radar_frames(self):
controller = LeadDepartureController(True)
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.00)))
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.01)))
assert not update(controller, make_sm(lead_one=make_lead(d_rel=4.04)))
assert controller.active
def test_distance_confirmation_uses_a_sliding_three_frame_window(self):
controller = LeadDepartureController(True)
for d_rel in (4.00, 4.01, 4.02, 4.03):
assert update(controller, make_sm(lead_one=make_lead(d_rel=d_rel)))
assert not controller.active
assert not update(controller, make_sm(lead_one=make_lead(d_rel=4.06)))
assert controller.active
def test_persistent_false_speed_cue_with_static_range_never_arms(self):
controller = LeadDepartureController(True)
for _ in range(10):
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.0)))
assert not controller.active
def test_slow_lead_creep_never_arms(self):
controller = LeadDepartureController(True)
for frame in range(20):
lead = make_lead(d_rel=4.0 + frame * 0.03, v_lead=LEAD_DEPARTURE_MIN_SPEED - 0.05, v_rel=LEAD_DEPARTURE_MIN_SPEED - 0.05)
assert update(controller, make_sm(lead_one=lead))
assert not controller.active
def test_same_track_can_move_between_lead_slots(self):
controller = LeadDepartureController(True)
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.00)), source=MpcPlanSource.lead0)
assert update(controller, make_sm(lead_two=make_lead(d_rel=4.01)), source=MpcPlanSource.lead1)
assert not update(controller, make_sm(lead_one=make_lead(d_rel=4.04)), source=MpcPlanSource.lead0)
def test_different_track_restarts_confirmation(self):
controller = LeadDepartureController(True)
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.00, track_id=7)))
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.02, track_id=7)))
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.20, track_id=9)))
assert update(controller, make_sm(lead_one=make_lead(d_rel=4.22, track_id=9)))
assert not update(controller, make_sm(lead_one=make_lead(d_rel=4.24, track_id=9)))
def test_active_release_latches_through_native_threshold_churn(self):
controller = LeadDepartureController(True)
activate(controller)
sm = make_sm(lead_one=make_lead(d_rel=4.10), long_state=LongCtrlState.pid)
assert not update(controller, sm, a_target=0.12, should_stop=False)
assert not update(controller, sm, a_target=0.05, should_stop=True)
assert controller.active
def test_active_release_latches_across_same_track_source_churn(self):
controller = LeadDepartureController(True)
activate(controller)
sm = make_sm(lead_two=make_lead(d_rel=4.10), long_state=LongCtrlState.pid)
assert not update(controller, sm, source=MpcPlanSource.lead1)
assert controller.active
def test_active_release_cancels_on_invalid_state(self):
cases = (
('lead lost', make_sm(lead_one=make_lead(present=False))),
('vision lead', make_sm(lead_one=make_lead(radar=False))),
('track changed', make_sm(lead_one=make_lead(track_id=9))),
('lead too slow', make_sm(lead_one=make_lead(v_lead=LEAD_DEPARTURE_MIN_SPEED - 0.01))),
('relative speed too low', make_sm(lead_one=make_lead(v_rel=LEAD_DEPARTURE_MIN_SPEED - 0.01))),
('gas', make_sm(gas=True)),
('brake', make_sm(brake=True)),
('override', make_sm(override=True)),
('force decel', make_sm(force_decel=True)),
('long inactive', make_sm(long_active=False)),
('long control off', make_sm(long_state=LongCtrlState.off)),
('ego rolling', make_sm(v_ego=0.3)),
('radar CAN error', make_sm(radar_error='canError')),
('radar fault', make_sm(radar_error='radarFault')),
('radar config', make_sm(radar_error='wrongConfig')),
('radar unavailable', make_sm(radar_error='radarUnavailableTemporary')),
)
for name, sm in cases:
with self.subTest(name=name):
controller = LeadDepartureController(True)
activate(controller)
assert update(controller, sm)
assert not controller.active
def test_active_release_cancels_on_invalid_update_input(self):
cases = (('negative target', -0.01, False, True), ('reset', 0.05, True, True), ('radar invalid', 0.05, False, False))
for name, a_target, reset, radar_valid in cases:
with self.subTest(name=name):
controller = LeadDepartureController(True)
activate(controller)
assert update(controller, make_sm(), a_target=a_target, reset=reset, radar_valid=radar_valid)
assert not controller.active
def test_inactive_controller_arms_only_from_native_stop_and_stopping_state(self):
controller = LeadDepartureController(True)
for d_rel in (4.00, 4.02, 4.04):
assert not update(controller, make_sm(lead_one=make_lead(d_rel=d_rel)), should_stop=False)
for d_rel in (4.00, 4.02, 4.04):
assert update(controller, make_sm(lead_one=make_lead(d_rel=d_rel), long_state=LongCtrlState.pid))
assert not controller.active
def test_capability_gate_disables_controller(self):
controller = LeadDepartureController(False)
for d_rel in (4.00, 4.02, 4.04):
assert update(controller, make_sm(lead_one=make_lead(d_rel=d_rel)))
assert not controller.active
def test_closed_loop_departure_releases_earlier_without_a_safety_regression(self):
lead_accel = 0.31
cue = 1.0 + 0.4 / lead_accel
def lead_speed(t):
return 0.0 if t < 1.0 else min(5.0, lead_accel * (t - 1.0))
stock, stock_active, stock_resets = run_closed_loop(False, 3.81, lead_speed, 8.0)
controller, controller_active, controller_resets = run_closed_loop(True, 3.81, lead_speed, 8.0)
stock_release = first_delay(stock, cue, 5, lambda should_stop: not should_stop)
controller_release = first_delay(controller, cue, 5, lambda should_stop: not should_stop)
stock_motion = first_delay(stock, cue, 1, lambda speed: speed > 0.01)
controller_motion = first_delay(controller, cue, 1, lambda speed: speed > 0.01)
stock_v01 = first_delay(stock, cue, 1, lambda speed: speed > 0.1)
controller_v01 = first_delay(controller, cue, 1, lambda speed: speed > 0.1)
assert any(controller_active) and not any(stock_active)
assert stock_resets == controller_resets == 0
assert not any(row[6] for row in stock + controller)
assert controller_release <= stock_release - 0.5
assert stock_motion - controller_motion >= DT_MDL
assert stock_v01 - controller_v01 >= DT_MDL
assert min(row[3] for row in controller) >= min(row[3] for row in stock)
assert max(abs(right[4] - left[4]) for left, right in zip(controller, controller[1:], strict=False)) <= max(
abs(right[4] - left[4]) for left, right in zip(stock, stock[1:], strict=False)
)
def test_model_stop_remains_authoritative(self):
def lead_speed(t):
return 0.0 if t < 1.0 else min(5.0, 0.8 * (t - 1.0))
rows, active, solver_resets = run_closed_loop(True, 4.0, lead_speed, 6.0, model_should_stop=True)
assert any(active)
assert solver_resets == 0
assert all(row[5] for row in rows)
assert all(row[1] == 0.0 and row[2] == 0.0 for row in rows)
assert not any(row[6] for row in rows)
def test_stationary_lead_remains_stock_identical(self):
stock, stock_active, stock_resets = run_closed_loop(False, 8.0, lambda _t: 0.0, 12.0)
controller, controller_active, controller_resets = run_closed_loop(True, 8.0, lambda _t: 0.0, 12.0)
assert stock == controller
assert not any(stock_active) and not any(controller_active)
assert stock_resets == controller_resets == 0
@@ -0,0 +1,394 @@
"""
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.
"""
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
import math
import time
from typing import Any
import numpy as np
from openpilot.cereal import log, messaging
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
from openpilot.common.realtime import DT_CTRL, DT_MDL, Ratekeeper
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant, PlannerSM
LeadObservation = dict[str, Any]
LeadObservationFn = Callable[[float, str, LeadObservation], LeadObservation | None]
ModelActionFn = Callable[[float, float, float], tuple[float, bool]]
EgoObservationFn = Callable[[float, float, float], tuple[float, float]]
@dataclass(frozen=True)
class ActuatorModel:
planner_delay: float
transport_delay: float
actuator_lag: float
command_rate_limit: float
stopping_acceleration: float
standstill_breakaway_acceleration: float
standstill_breakaway_time: float
def __post_init__(self):
nonnegative_fields = {
"planner_delay": self.planner_delay,
"transport_delay": self.transport_delay,
"actuator_lag": self.actuator_lag,
"standstill_breakaway_acceleration": self.standstill_breakaway_acceleration,
"standstill_breakaway_time": self.standstill_breakaway_time,
}
if any(not math.isfinite(value) or value < 0.0 for value in nonnegative_fields.values()):
raise ValueError(f"ActuatorModel fields must be finite and non-negative: {nonnegative_fields}")
if not math.isfinite(self.command_rate_limit) or self.command_rate_limit <= 0.0:
raise ValueError("command_rate_limit must be finite and positive")
if not math.isfinite(self.stopping_acceleration) or self.stopping_acceleration > 0.0:
raise ValueError("stopping_acceleration must be finite and non-positive")
# Conservative Prius TSS2 actuator model.
PRIUS_TSS2_ROUTE_MODEL = ActuatorModel(
planner_delay=0.05,
transport_delay=0.0,
actuator_lag=0.20,
command_rate_limit=4.0,
stopping_acceleration=-2.0,
standstill_breakaway_acceleration=1.0,
standstill_breakaway_time=0.05,
)
class PlantSP(Plant):
"""Closed-loop plant with configurable observations and actuator response."""
def __init__(
self,
lead_relevancy=False,
speed=0.0,
distance_lead=2.0,
enabled=True,
only_lead2=False,
only_radar=False,
e2e=False,
personality=0,
force_decel=False,
lead_observation_fn: LeadObservationFn | None = None,
model_action_fn: ModelActionFn | None = None,
ego_observation_fn: EgoObservationFn | None = None,
actuator_delay: float | None = None,
actuator_lag: float = 0.0,
actuator_model: ActuatorModel | None = None,
run_long_control: bool = False,
):
if actuator_delay is not None and (not math.isfinite(actuator_delay) or actuator_delay < 0.0):
raise ValueError("actuator_delay must be finite and non-negative")
if not math.isfinite(actuator_lag) or actuator_lag < 0.0:
raise ValueError("actuator_lag must be finite and non-negative")
self.rate = 1.0 / DT_MDL
if not Plant.messaging_initialized:
Plant.radar = messaging.pub_sock('radarState')
Plant.controls_state = messaging.pub_sock('controlsState')
Plant.selfdrive_state = messaging.pub_sock('selfdriveState')
Plant.car_state = messaging.pub_sock('carState')
Plant.plan = messaging.sub_sock('longitudinalPlan')
Plant.messaging_initialized = True
self.v_lead_prev = 0.0
self.distance = 0.0
self.speed = speed
self.should_stop = False
self.acceleration = 0.0
self.a_target = 0.0
self.actuator_command = 0.0
self.applied_actuator_command = 0.0
self.breakaway_confirmed = False
self._breakaway_timer = 0.0
# lead car
self.lead_relevancy = lead_relevancy
self.distance_lead = distance_lead
self.enabled = enabled
self.only_lead2 = only_lead2
self.only_radar = only_radar
self.e2e = e2e
self.personality = personality
self.force_decel = force_decel
self.lead_observation_fn = lead_observation_fn
self.model_action_fn = model_action_fn
self.ego_observation_fn = ego_observation_fn
self.actuator_model = actuator_model
self.actuator_delay = actuator_model.planner_delay if actuator_model is not None else actuator_delay
self.transport_delay = actuator_model.transport_delay if actuator_model is not None else actuator_delay
self.actuator_lag = actuator_model.actuator_lag if actuator_model is not None else actuator_lag
self.publish_realized_a_ego = any((lead_observation_fn is not None, model_action_fn is not None, ego_observation_fn is not None,
actuator_delay is not None, actuator_lag > 0.0, actuator_model is not None, run_long_control))
self.rk = Ratekeeper(self.rate, print_delay_threshold=100.0)
self.ts = 1.0 / self.rate
time.sleep(0.1)
self.sm = messaging.SubMaster(['longitudinalPlan'])
from opendbc.car.honda.values import CAR
from opendbc.car.honda.interface import CarInterface
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
if self.actuator_delay is not None:
CP.longitudinalActuatorDelay = self.actuator_delay
CP_SP = CarInterface.get_non_essential_params_sp(CP, CAR.HONDA_CIVIC)
self.planner = LongitudinalPlanner(CP, CP_SP, init_v=self.speed)
self.long_control = LongControl(CP, CP_SP) if run_long_control else None
if self.actuator_model is not None and self.speed >= 0.01:
self.breakaway_confirmed = True
self.integration_dt = DT_CTRL if run_long_control else self.ts
delay_steps = 0 if self.transport_delay is None else round(self.transport_delay / self.integration_dt)
self._actuator_delay_queue = deque([self.acceleration] * delay_steps)
@staticmethod
def _lead_message(observation: LeadObservation):
lead = log.RadarState.LeadData.new_message()
for field, value in observation.items():
setattr(lead, field, value)
return lead
def _observe_lead(self, lead_name: str, truth: LeadObservation, present_by_default: bool) -> LeadObservation | None:
if self.lead_observation_fn is None:
return dict(truth) if present_by_default else None
observed = self.lead_observation_fn(self.current_time, lead_name, dict(truth))
if observed is None:
return None
complete_observation = dict(truth)
complete_observation.update(observed)
return complete_observation
def _update_actuator(self, command: float) -> tuple[float, float]:
if self._actuator_delay_queue:
self._actuator_delay_queue.append(command)
delayed_command = self._actuator_delay_queue.popleft()
else:
delayed_command = command
if self.actuator_model is not None:
max_command_delta = self.actuator_model.command_rate_limit * self.integration_dt
self.applied_actuator_command = float(np.clip(delayed_command,
self.applied_actuator_command - max_command_delta,
self.applied_actuator_command + max_command_delta))
if self.speed < 0.01:
if self.applied_actuator_command <= 0.0:
self.breakaway_confirmed = False
self._breakaway_timer = 0.0
elif not self.breakaway_confirmed:
breakaway_ready = self.applied_actuator_command + 1e-9 >= self.actuator_model.standstill_breakaway_acceleration
if breakaway_ready:
self._breakaway_timer += self.integration_dt
else:
self._breakaway_timer = 0.0
self.breakaway_confirmed = breakaway_ready and self._breakaway_timer + 1e-9 >= self.actuator_model.standstill_breakaway_time
if not self.breakaway_confirmed:
self.acceleration = 0.0
return delayed_command, self.acceleration
else:
self.breakaway_confirmed = True
response_command = self.applied_actuator_command
else:
self.applied_actuator_command = delayed_command
response_command = delayed_command
if self.actuator_lag > 0.0:
alpha = 1.0 - math.exp(-self.integration_dt / self.actuator_lag)
self.acceleration += alpha * (response_command - self.acceleration)
else:
self.acceleration = response_command
return delayed_command, self.acceleration
def _integrate_ego(self, dt: float, stop_at_standstill: bool = False) -> None:
self.speed += self.acceleration * dt
if self.speed <= 0.0 or stop_at_standstill and self.speed < 0.01 and self.actuator_command <= 0.0:
self.speed = self.acceleration = 0.0
self.distance += self.speed * dt
def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.0, pitch=0.0, prob_throttle=1.0):
# ******** publish a fake model going straight and fake calibration ********
# note that this is worst case for MPC, since model will delay long mpc by one time step
radar = messaging.new_message('radarState')
control = messaging.new_message('controlsState')
ss = messaging.new_message('selfdriveState')
car_state = messaging.new_message('carState')
vehicle_parameters = messaging.new_message('vehicleParameters')
car_control = messaging.new_message('carControl')
model = messaging.new_message('modelV2')
car_state_sp = messaging.new_message('carStateSP')
live_map_data_sp = messaging.new_message('liveMapDataSP')
gps_data = messaging.new_message('gpsLocation')
a_lead = (v_lead - self.v_lead_prev) / self.ts
self.v_lead_prev = v_lead
if self.lead_relevancy:
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
v_rel = v_lead - self.speed
if self.only_radar:
status = True
elif prob_lead > 0.5:
status = True
else:
status = False
else:
d_rel = 200.0
v_rel = 0.0
prob_lead = 0.0
status = False
truth_lead: LeadObservation = {
"dRel": float(d_rel),
"yRel": 0.0,
"vRel": float(v_rel),
"vLead": float(v_lead),
"vLeadK": float(v_lead),
"aLeadK": float(a_lead),
"present": bool(status),
# TODO use real radard logic for this
"aLeadTau": float(_LEAD_ACCEL_TAU),
"modelProb": float(prob_lead),
"radar": bool(self.only_radar),
"radarTrackId": -1,
}
lead_one_observation = self._observe_lead("leadOne", truth_lead, not self.only_lead2)
lead_two_observation = self._observe_lead("leadTwo", truth_lead, True)
if lead_one_observation is not None:
radar.radarState.leadOne = self._lead_message(lead_one_observation)
if lead_two_observation is not None:
radar.radarState.leadTwo = self._lead_message(lead_two_observation)
# Simulate model predicting slightly faster speed
# this is to ensure lead policy is effective when model
# does not predict slowdown in e2e mode
position = log.XYZTData.new_message()
position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)]
model.modelV2.position = position
if self.model_action_fn is None:
model_acceleration, model_should_stop = self.acceleration + 0.5, False
else:
model_acceleration, model_should_stop = self.model_action_fn(self.current_time, self.speed, self.acceleration)
model.modelV2.action.desiredAcceleration = float(model_acceleration)
model.modelV2.action.shouldStop = bool(model_should_stop)
velocity = log.XYZTData.new_message()
velocity.x = [float(x) for x in (self.speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)]
velocity.x[0] = float(self.speed) # always start at current speed
model.modelV2.velocity = velocity
acceleration = log.XYZTData.new_message()
acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)]
model.modelV2.acceleration = acceleration
model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)]
control.controlsState.longControlState = self.long_control.long_control_state if self.long_control is not None else (
LongCtrlState.pid if self.enabled else LongCtrlState.off)
ss.selfdriveState.experimentalMode = self.e2e
ss.selfdriveState.personality = self.personality
control.controlsState.forceDecel = self.force_decel
true_v_ego = self.speed
true_a_ego = self.acceleration
published_v_ego = true_v_ego
published_a_ego = true_a_ego if self.publish_realized_a_ego else 0.0
if self.ego_observation_fn is not None:
published_v_ego, published_a_ego = self.ego_observation_fn(self.current_time, true_v_ego, true_a_ego)
car_state.carState.vEgo = float(published_v_ego)
car_state.carState.aEgo = float(published_a_ego)
car_state.carState.standstill = bool(self.speed < 0.01)
car_state.carState.vCruise = float(v_cruise * 3.6)
car_control.carControl.orientationNED = [0.0, float(pitch), 0.0]
# ******** get controlsState messages for plotting ***
sm = PlannerSM(self.rk.frame, {
'radarState': radar.radarState,
'carState': car_state.carState,
'carControl': car_control.carControl,
'controlsState': control.controlsState,
'selfdriveState': ss.selfdriveState,
'vehicleParameters': vehicle_parameters.vehicleParameters,
'modelV2': model.modelV2,
'carStateSP': car_state_sp.carStateSP,
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
'gpsLocation': gps_data.gpsLocation,
})
self.planner.update(sm)
self.a_target = self.planner.output_a_target
if self.long_control is None:
self.actuator_command = self.a_target
if self.planner.output_should_stop:
stopping_acceleration = -0.5 if self.actuator_model is None else self.actuator_model.stopping_acceleration
self.actuator_command = min(stopping_acceleration, self.actuator_command)
self._update_actuator(self.actuator_command)
self._integrate_ego(self.ts)
else:
for _ in range(round(self.ts / DT_CTRL)):
car_state.carState.vEgo = self.speed
car_state.carState.aEgo = self.acceleration
car_state.carState.standstill = self.speed < 0.01
self.actuator_command = self.long_control.update(
self.enabled, car_state.carState, self.a_target, self.planner.output_should_stop, (ACCEL_MIN, ACCEL_MAX),
)
self._update_actuator(self.actuator_command)
self._integrate_ego(DT_CTRL, stop_at_standstill=True)
self.should_stop = self.planner.output_should_stop
fcw = self.planner.fcw
self.distance_lead = self.distance_lead + v_lead * self.ts
# *** radar model ***
if self.lead_relevancy:
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
v_rel = v_lead - self.speed
else:
d_rel = 200.0
v_rel = 0.0
# print at 5hz
# if (self.rk.frame % (self.rate // 5)) == 0:
# print("%2.2f sec %6.2f m %6.2f m/s %6.2f m/s2 lead_rel: %6.2f m %6.2f m/s"
# % (self.current_time, self.distance, self.speed, self.acceleration, d_rel, v_rel))
# ******** update prevs ********
self.rk.monitor_time()
return {
"distance": self.distance,
"speed": self.speed,
"acceleration": self.acceleration,
"realized_acceleration": self.acceleration,
"a_target": self.a_target,
"actuator_command": self.actuator_command,
"published_a_ego": published_a_ego,
"published_v_ego": published_v_ego,
"should_stop": self.should_stop,
"long_control_state": (int(self.long_control.long_control_state) if self.long_control is not None
else control.controlsState.longControlState.raw),
"distance_lead": self.distance_lead,
"fcw": fcw,
"mpc_source": self.planner.mpc.source,
"dec_mode": self.planner.dec.mode(),
"controller_active": self.planner.accel_controller_active,
"model_action": {
"desiredAcceleration": float(model_acceleration),
"shouldStop": bool(model_should_stop),
},
"truth_lead": dict(truth_lead),
"lead_one_observation": None if lead_one_observation is None else dict(lead_one_observation),
"lead_two_observation": None if lead_two_observation is None else dict(lead_two_observation),
}
@@ -0,0 +1,164 @@
from collections.abc import Callable
import math
from typing import cast
from openpilot.common.parameterized import parameterized
from openpilot.common.realtime import DT_MDL
from openpilot.common.test import OpenpilotTestCase
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
STOCK_STEP_KEYS = ("distance", "speed", "acceleration", "should_stop", "distance_lead", "fcw")
def departing_lead(current_time: float) -> float:
return 0.0 if current_time < 1.0 else min(2.0, 2.0 * (current_time - 1.0))
def stopped_lead(_current_time: float) -> float:
return 0.0
PARITY_SCENARIOS = {
"approach_stopped_lead": {"lead_relevancy": True, "speed": 15.0, "distance_lead": 60.0, "v_cruise": 20.0, "v_lead": stopped_lead, "steps": 80},
"stop_then_depart": {"lead_relevancy": True, "speed": 0.0, "distance_lead": 6.0, "v_cruise": 8.0, "v_lead": departing_lead, "steps": 120},
}
def _drive(cls, *, v_cruise: float, v_lead: Callable[[float], float], steps: int, **kwargs):
plant = cls(**kwargs)
plant.v_lead_prev = v_lead(0.0)
solver_failures = 0
original_reset = plant.planner.mpc.reset
def counting_reset(*args, **kw):
nonlocal solver_failures
if plant.planner.mpc.solution_status != 0:
solver_failures += 1
return original_reset(*args, **kw)
plant.planner.mpc.reset = counting_reset
results = []
for _ in range(steps):
lead_speed = v_lead(plant.current_time)
result = plant.step(v_lead=lead_speed, v_cruise=v_cruise)
results.append((result, plant.planner.mpc.source, plant.planner.output_a_target))
return results, solver_failures
class TestPlantSP(OpenpilotTestCase):
@parameterized.expand(PARITY_SCENARIOS, names=("scenario",), ids=lambda scenario: scenario)
def test_plant_sp_matches_stock_plant_on_shared_kwargs(self, scenario: str):
kwargs = dict(PARITY_SCENARIOS[scenario])
v_cruise = cast(float, kwargs.pop("v_cruise"))
v_lead = cast(Callable[[float], float], kwargs.pop("v_lead"))
steps = cast(int, kwargs.pop("steps"))
stock_results, stock_failures = _drive(Plant, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
sp_results, sp_failures = _drive(PlantSP, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
assert stock_failures == 0, f"stock Plant solver failed {stock_failures} times in {scenario!r}"
assert sp_failures == 0, f"PlantSP solver failed {sp_failures} times in {scenario!r}"
for frame, ((stock_result, stock_source, stock_a_target), (sp_result, sp_source, sp_a_target)) in enumerate(
zip(stock_results, sp_results, strict=True),
):
for key in STOCK_STEP_KEYS:
if isinstance(stock_result[key], float):
self.assertAlmostEqual(sp_result[key], stock_result[key], msg=f"{scenario} frame {frame} key {key}")
else:
assert sp_result[key] == stock_result[key], f"{scenario} frame {frame} key {key}"
assert sp_source == stock_source, f"{scenario} frame {frame} mpc.source"
self.assertAlmostEqual(sp_a_target, stock_a_target, msg=f"{scenario} frame {frame} output_a_target")
if scenario == "stop_then_depart":
departure_frame = round(1.0 / DT_MDL)
for results in (stock_results, sp_results):
assert all(result["speed"] < 0.01 for result, _, _ in results[:departure_frame])
assert results[departure_frame - 1][0]["should_stop"]
assert any(not result["should_stop"] for result, _, _ in results[departure_frame:])
assert any(result["speed"] > 0.05 for result, _, _ in results[departure_frame:])
stock_release = next(frame for frame, (result, _, _) in enumerate(stock_results)
if frame >= departure_frame and not result["should_stop"])
sp_release = next(frame for frame, (result, _, _) in enumerate(sp_results)
if frame >= departure_frame and not result["should_stop"])
stock_motion = next(frame for frame, (result, _, _) in enumerate(stock_results)
if frame >= departure_frame and result["speed"] > 0.05)
sp_motion = next(frame for frame, (result, _, _) in enumerate(sp_results)
if frame >= departure_frame and result["speed"] > 0.05)
assert sp_release == stock_release
assert sp_motion == stock_motion
def test_full_lead_observation_is_independent_from_truth(self):
callback_inputs = []
def observe_lead(current_time, lead_name, truth):
callback_inputs.append((current_time, lead_name, truth))
if lead_name == "leadOne":
return {
"dRel": 12.5,
"vRel": -4.0,
"vLead": 6.0,
"vLeadK": 5.5,
"aLeadK": -1.25,
"aLeadTau": 0.7,
"present": True,
"modelProb": 0.9,
"radarTrackId": 42,
}
return None
plant = PlantSP(lead_relevancy=True, speed=10.0, distance_lead=50.0, lead_observation_fn=observe_lead)
result = plant.step(v_lead=8.0)
assert [entry[1] for entry in callback_inputs] == ["leadOne", "leadTwo"]
self.assertAlmostEqual(callback_inputs[0][2]["dRel"], 50.0)
self.assertAlmostEqual(result["truth_lead"]["dRel"], 50.0)
self.assertAlmostEqual(result["lead_one_observation"]["dRel"], 12.5)
assert result["lead_one_observation"]["radarTrackId"] == 42
assert result["lead_two_observation"] is None
self.assertAlmostEqual(result["distance_lead"], 50.0 + 8.0 * DT_MDL)
def test_model_action_realized_acceleration_and_source_logging(self):
def model_action(current_time, v_ego, a_ego):
return -1.25, True
plant = PlantSP(speed=10.0, e2e=True, force_decel=True, model_action_fn=model_action, actuator_lag=0.5)
first = plant.step()
second = plant.step()
assert first["model_action"] == {"desiredAcceleration": -1.25, "shouldStop": True}
self.assertAlmostEqual(first["published_a_ego"], 0.0)
self.assertAlmostEqual(second["published_a_ego"], first["realized_acceleration"])
assert first["acceleration"] == first["realized_acceleration"]
assert abs(first["realized_acceleration"]) < abs(first["actuator_command"])
assert first["mpc_source"] is not None
assert first["dec_mode"] in ("acc", "blended")
assert "controller_active" in first
assert first["lead_one_observation"] is not None
assert first["truth_lead"] == first["lead_one_observation"]
def test_default_model_action_matches_stock_plant(self):
result = PlantSP(speed=10.0).step()
self.assertAlmostEqual(result["model_action"]["desiredAcceleration"], 0.5)
assert not result["model_action"]["shouldStop"]
def test_configurable_transport_delay_and_first_order_lag(self):
plant = PlantSP(speed=10.0, actuator_delay=2 * DT_MDL, actuator_lag=0.2)
self.assertAlmostEqual(plant.planner.CP.longitudinalActuatorDelay, 2 * DT_MDL)
delayed_commands = [plant._update_actuator(-1.0) for _ in range(3)]
assert [command for command, _ in delayed_commands[:2]] == [0.0, 0.0]
expected_acceleration = -(1.0 - math.exp(-DT_MDL / 0.2))
assert delayed_commands[2][0] == -1.0
self.assertAlmostEqual(delayed_commands[2][1], expected_acceleration)
@parameterized.expand(
[(-0.1, 0.0), (float("nan"), 0.0), (float("inf"), 0.0), (None, -0.1), (None, float("nan")), (None, float("inf"))],
names=("delay", "lag"),
)
def test_invalid_actuator_dynamics(self, delay, lag):
with self.assertRaises(ValueError):
PlantSP(actuator_delay=delay, actuator_lag=lag)
@@ -652,6 +652,53 @@
}
]
},
{
"key": "AccelPersonalityEnabled",
"widget": "toggle",
"title": "Enable Accel Controller",
"description": "Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged.",
"visibility": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
],
"enablement": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
]
},
{
"key": "AccelPersonality",
"widget": "multiple_button",
"title": "Acceleration Profile",
"description": "Eco accelerates more gently, Normal matches the stock cruise response, and Sport adds stronger low-speed acceleration.",
"options": [
{
"value": 0,
"label": "Eco"
},
{
"value": 1,
"label": "Normal"
},
{
"value": 2,
"label": "Sport"
}
],
"enablement": [
{
"type": "capability",
"field": "has_longitudinal_control",
"equals": true
}
]
},
{
"key": "IntelligentCruiseButtonManagement",
"widget": "toggle",
@@ -43,6 +43,28 @@ sections:
label: Relaxed
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonalityEnabled
widget: toggle
title: Enable Accel Controller
description: Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged.
visibility:
- $ref: '#/macros/longitudinal'
enablement:
- $ref: '#/macros/longitudinal'
- key: AccelPersonality
widget: multiple_button
title: Acceleration Profile
description: Eco accelerates more gently, Normal matches the stock cruise response, and Sport adds stronger low-speed
acceleration.
options:
- value: 0
label: Eco
- value: 1
label: Normal
- value: 2
label: Sport
enablement:
- $ref: '#/macros/longitudinal'
- key: IntelligentCruiseButtonManagement
widget: toggle
title: Intelligent Cruise Button Management (ICBM) (Alpha)
@@ -276,6 +276,29 @@ class TestKnownPanels(OpenpilotTestCase):
enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"}
assert "NeuralNetworkLateralControl" in enhanced_enable_keys
def test_accel_controller_profile_mapping_and_enablement(self, schema):
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
items = {item["key"]: item for item in _iter_panel_items(cruise)}
assert items["AccelPersonalityEnabled"]["widget"] == "toggle"
assert items["AccelPersonality"]["options"] == [
{"value": 0, "label": "Eco"},
{"value": 1, "label": "Normal"},
{"value": 2, "label": "Sport"},
]
assert {
"type": "capability",
"field": "has_longitudinal_control",
"equals": True,
} in items["AccelPersonalityEnabled"]["enablement"]
assert {
"type": "capability",
"field": "has_longitudinal_control",
"equals": True,
} in items["AccelPersonality"]["enablement"]
profile_enable_keys = {rule.get("key") for rule in items["AccelPersonality"]["enablement"] if rule.get("type") == "param"}
assert "AccelPersonalityEnabled" not in profile_enable_keys
class TestKnownVehicleSettings(OpenpilotTestCase):
def test_hyundai_has_longitudinal_tuning(self, schema):