diff --git a/openpilot/cereal/custom.capnp b/openpilot/cereal/custom.capnp index c20bf923be..dc6a2f598c 100644 --- a/openpilot/cereal/custom.capnp +++ b/openpilot/cereal/custom.capnp @@ -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 { diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 536af5d441..e98b9fa4f1 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -241,6 +241,10 @@ inline static std::unordered_map 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"}}, diff --git a/openpilot/common/tests/test_params.py b/openpilot/common/tests/test_params.py index a81d346b06..ab60fa7c71 100644 --- a/openpilot/common/tests/test_params.py +++ b/openpilot/common/tests/test_params.py @@ -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 diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index bf91c5e1f9..aaab23d2e1 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -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), diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index b4e8d76d6d..96974efd56 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -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: diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index ee76b7e4cc..30d4271c6b 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -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) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 2dba124df5..fa10c486f0 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -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 diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index cad40d7d01..9977c99d91 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -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): diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/__init__.py b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/accel_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/accel_controller.py new file mode 100644 index 0000000000..cadccaf1b1 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/accel_controller.py @@ -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])) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/__init__.py b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller.py new file mode 100644 index 0000000000..eb7de2d831 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller.py @@ -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 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller_closed_loop.py b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller_closed_loop.py new file mode 100644 index 0000000000..5477eeb195 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/accel_controller/tests/test_accel_controller_closed_loop.py @@ -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) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/lead_departure_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/lead_departure_controller.py new file mode 100644 index 0000000000..3adeef15a5 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/lead_departure_controller.py @@ -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 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py index f1e0c36416..eece2a0b14 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/longitudinal_planner.py @@ -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 diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lead_departure_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lead_departure_controller.py new file mode 100644 index 0000000000..a2583c0d3c --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lead_departure_controller.py @@ -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 diff --git a/openpilot/sunnypilot/selfdrive/test/__init__.py b/openpilot/sunnypilot/selfdrive/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/__init__.py b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/plant.py new file mode 100644 index 0000000000..58389842f6 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -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), + } diff --git a/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/tests/__init__.py b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/tests/test_plant_sp.py b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/tests/test_plant_sp.py new file mode 100644 index 0000000000..e99e578101 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/test/longitudinal_maneuvers/tests/test_plant_sp.py @@ -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) diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 041401e92f..2e0c730b94 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -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", diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml index 21c5874bc7..1407d40169 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -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) diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py index 8ecd28a613..ce13f37671 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py @@ -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):