Make acceleration profiles smooth and predictable

This commit is contained in:
rav4kumar
2026-08-23 12:23:30 -07:00
parent 94754d94da
commit cbbc33bf76
8 changed files with 405 additions and 45 deletions
@@ -147,7 +147,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
is_e2e = self.is_e2e(sm)
max_accel_override = self.get_max_accel_override(v_ego, is_e2e)
max_accel_override = self.get_max_accel_override(v_ego, v_cruise, is_e2e)
v_cruise = self.get_cruise_target_override(v_ego, v_cruise, is_e2e)
a_cruise_prev = self.a_cruise
gated_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego, a_cruise_prev, steer_angle_without_offset,
self.CP, self.dt, accel_coast, self.allow_throttle, max_accel_override)
@@ -28,10 +28,10 @@ DESCRIPTIONS = {
"your steering wheel distance button."
),
"AccelPersonalityEnabled": tr_noop(
"Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged."
"Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency 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."
"Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive."
),
"IsLdwEnabled": tr_noop(
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
@@ -16,10 +16,17 @@ 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],
AccelProfile.eco: [1.50, 1.42, 0.90, 0.52, 0.44, 0.30, 0.23],
AccelProfile.normal: [1.60, 1.48, 1.00, 0.60, 0.50, 0.36, 0.28],
AccelProfile.sport: [1.80, 1.70, 1.12, 0.70, 0.60, 0.44, 0.35],
}
TARGET_SPEED_DEADBAND = 0.2 # m/s
TARGET_SPEED_APPROACH_WINDOW = 2.0 # m/s
TARGET_SPEED_APPROACH_GAIN = 0.5
TARGET_SPEED_APPROACH_MIN_SPEED = 3.0 # m/s
TARGET_SPEED_APPROACH_FULL_SPEED = 5.0 # m/s
CATCHUP_ERROR_BREAKPOINTS = [TARGET_SPEED_DEADBAND, 0.5, 1.0, 2.0, 3.0, 4.0]
CATCHUP_ACCEL_SCALE = [0.0, 0.3, 0.5, 0.7, 0.85, 1.0]
class AccelController:
@@ -42,6 +49,30 @@ class AccelController:
def is_enabled(self) -> bool:
return self._enabled
def get_max_accel(self, v_ego: float) -> float:
def get_max_accel(self, v_ego: float, v_target: float | None = None) -> float:
v_ego = max(0.0, v_ego)
return float(np.interp(v_ego, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
max_accel = float(np.interp(v_ego, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
if v_target is None or v_ego <= TARGET_SPEED_APPROACH_MIN_SPEED or v_target <= v_ego:
return max_accel
speed_error = v_target - v_ego
speed_blend = float(np.interp(v_ego, [TARGET_SPEED_APPROACH_MIN_SPEED, TARGET_SPEED_APPROACH_FULL_SPEED], [0.0, 1.0]))
catchup_scale = float(np.interp(speed_error, CATCHUP_ERROR_BREAKPOINTS, CATCHUP_ACCEL_SCALE))
raw_accel = min(speed_error, max_accel)
catchup_accel = min(speed_error, max_accel * catchup_scale)
return float(raw_accel + speed_blend * (catchup_accel - raw_accel))
def get_cruise_target(self, v_ego: float, v_target: float) -> float:
if v_ego <= TARGET_SPEED_APPROACH_MIN_SPEED:
return v_target
speed_error = v_target - v_ego
if speed_error >= 0.0:
return v_target
speed_blend = float(np.interp(v_ego, [TARGET_SPEED_APPROACH_MIN_SPEED, TARGET_SPEED_APPROACH_FULL_SPEED], [0.0, 1.0]))
target_blend = float(np.interp(abs(speed_error), [TARGET_SPEED_DEADBAND, TARGET_SPEED_APPROACH_WINDOW], [1.0, 0.0]))
deadband = TARGET_SPEED_DEADBAND * speed_blend * target_blend
adjusted_error = np.sign(speed_error) * max(0.0, abs(speed_error) - deadband)
gain = 1.0 - (1.0 - TARGET_SPEED_APPROACH_GAIN) * speed_blend * target_blend
return float(v_ego + gain * adjusted_error)
@@ -14,7 +14,9 @@ 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,
AccelController, AccelProfile, CATCHUP_ACCEL_SCALE, CATCHUP_ERROR_BREAKPOINTS, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
TARGET_SPEED_APPROACH_FULL_SPEED, TARGET_SPEED_APPROACH_GAIN, TARGET_SPEED_APPROACH_MIN_SPEED, TARGET_SPEED_APPROACH_WINDOW,
TARGET_SPEED_DEADBAND,
)
@@ -49,15 +51,120 @@ class TestAccelController(OpenpilotTestCase):
assert value <= previous[profile]
previous[profile] = value
def test_normal_matches_stock(self):
def test_normal_stays_below_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)
assert controller.get_max_accel(speed) <= get_max_accel(speed) + 1e-12
def test_profiles_taper_below_stock_at_road_speed(self):
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
controller = self.set_profile(profile)
for speed in np.linspace(8.0, 40.0, 321):
assert controller.get_max_accel(speed) < get_max_accel(speed)
def test_launch_caps_stay_close_to_stock(self):
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
controller = self.set_profile(profile)
for speed in np.linspace(0.0, 3.0, 61):
assert controller.get_max_accel(speed) >= 0.9 * get_max_accel(speed)
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
assert controller.get_max_accel(speed) >= 0.35 * get_max_accel(speed) - 1e-12
def test_profile_caps_drop_quickly_after_launch(self):
for values in MAX_ACCEL_PROFILES.values():
assert values[2] <= 0.7 * values[1]
assert values[3] <= 0.5 * values[1]
def test_sport_stays_below_reported_route_acceleration(self):
controller = self.set_profile(AccelProfile.sport)
route_samples = ((8.1, 0.877), (12.0, 0.858), (15.5, 0.802), (18.8, 0.750), (21.7, 0.654))
for speed, recorded_accel in route_samples:
assert controller.get_max_accel(speed) <= 0.85 * recorded_accel
def test_positive_catchup_limit_is_continuous_and_monotonic(self):
controller = self.set_profile(AccelProfile.normal)
v_ego = 20.0
max_accel = controller.get_max_accel(v_ego)
errors = np.linspace(1e-4, 8.0, 321)
accel_limits = np.asarray([controller.get_max_accel(v_ego, v_ego + error) for error in errors])
assert np.all(np.isfinite(accel_limits))
assert np.all(np.diff(accel_limits) >= -1e-12)
assert np.all(accel_limits <= errors + 1e-12)
assert np.all(accel_limits <= max_accel + 1e-12)
assert controller.get_max_accel(v_ego, v_ego + TARGET_SPEED_DEADBAND) == 0.0
assert np.isclose(controller.get_max_accel(v_ego, v_ego + CATCHUP_ERROR_BREAKPOINTS[-1]), max_accel)
for error, scale in zip(CATCHUP_ERROR_BREAKPOINTS, CATCHUP_ACCEL_SCALE, strict=True):
expected_accel = min(error, max_accel * scale)
assert np.isclose(controller.get_max_accel(v_ego, v_ego + error), expected_accel)
below = controller.get_max_accel(v_ego, v_ego + error - 1e-6)
above = controller.get_max_accel(v_ego, v_ego + error + 1e-6)
assert above >= below
assert above - below < 1e-4
def test_cruise_decel_settling_is_continuous(self):
controller = self.set_profile(AccelProfile.normal)
v_ego = 20.0
near_error = 0.5
target_blend = (TARGET_SPEED_APPROACH_WINDOW - near_error) / (TARGET_SPEED_APPROACH_WINDOW - TARGET_SPEED_DEADBAND)
adjusted_error = near_error - TARGET_SPEED_DEADBAND * target_blend
gain = 1.0 - (1.0 - TARGET_SPEED_APPROACH_GAIN) * target_blend
expected_error = adjusted_error * gain
assert np.isclose(controller.get_cruise_target(v_ego, v_ego - near_error), v_ego - expected_error)
assert controller.get_cruise_target(v_ego, v_ego - TARGET_SPEED_DEADBAND) == v_ego
assert controller.get_cruise_target(v_ego, v_ego - TARGET_SPEED_APPROACH_WINDOW) == v_ego - TARGET_SPEED_APPROACH_WINDOW
errors = np.linspace(-5.0, 0.0, 201)
shaped_errors = np.asarray([controller.get_cruise_target(v_ego, v_ego + error) - v_ego for error in errors])
assert np.all(np.isfinite(shaped_errors))
assert np.all(np.diff(shaped_errors) >= 0.0)
assert np.all(np.abs(shaped_errors) <= np.abs(errors) + 1e-12)
def test_catchup_limit_does_not_touch_launch(self):
controller = self.set_profile(AccelProfile.sport)
def command(speed: float, target: float, dynamic: bool = True) -> float:
max_accel = controller.get_max_accel(speed, target) if dynamic else controller.get_max_accel(speed)
return get_cruise_accel(False, target, speed, 0.0, 0.0, _fake_cp(), 10.0, 0.0, True, max_accel)
for speed in np.linspace(0.0, TARGET_SPEED_APPROACH_MIN_SPEED, 101):
for target in (0.0, 8.0, 30.0):
assert command(speed, target) == command(speed, target, dynamic=False)
below = command(TARGET_SPEED_APPROACH_MIN_SPEED - 1e-3, TARGET_SPEED_APPROACH_MIN_SPEED + 0.5 - 1e-3)
at = command(TARGET_SPEED_APPROACH_MIN_SPEED, TARGET_SPEED_APPROACH_MIN_SPEED + 0.5)
above = command(TARGET_SPEED_APPROACH_MIN_SPEED + 1e-3, TARGET_SPEED_APPROACH_MIN_SPEED + 0.5 + 1e-3)
assert np.isclose(below, 0.5)
assert np.isclose(at, 0.5)
assert abs(above - at) < 1e-3
below_full = command(TARGET_SPEED_APPROACH_FULL_SPEED - 1e-3, TARGET_SPEED_APPROACH_FULL_SPEED + 0.5 - 1e-3)
at_full = command(TARGET_SPEED_APPROACH_FULL_SPEED, TARGET_SPEED_APPROACH_FULL_SPEED + 0.5)
above_full = command(TARGET_SPEED_APPROACH_FULL_SPEED + 1e-3, TARGET_SPEED_APPROACH_FULL_SPEED + 0.5 + 1e-3)
assert abs(below_full - at_full) < 1e-3
assert abs(above_full - at_full) < 1e-3
def test_cruise_target_deadband_removes_small_sign_flips(self):
controller = self.set_profile(AccelProfile.normal)
v_ego = 20.0
errors = np.asarray([0.10, -0.10, 0.15, -0.15, 0.30, -0.30])
commands = []
for error in errors:
raw_target = v_ego + error
target = controller.get_cruise_target(v_ego, raw_target)
max_accel = controller.get_max_accel(v_ego, raw_target)
commands.append(get_cruise_accel(False, target, v_ego, 0.0, 0.0, _fake_cp(), 10.0, 0.0, True, max_accel))
shaped = np.asarray(commands)
assert np.count_nonzero(shaped[:4]) == 0
assert shaped[4] > 0.0
assert shaped[5] < 0.0
assert np.all(np.abs(shaped) <= np.abs(errors) + 1e-12)
def test_negative_speed_uses_standstill_value(self):
controller = self.set_profile(AccelProfile.sport)
@@ -98,7 +205,7 @@ class TestPlannerIntegration(OpenpilotTestCase):
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):
def test_profiles_do_not_change_far_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
@@ -112,31 +219,73 @@ class TestPlannerIntegration(OpenpilotTestCase):
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
def test_cruise_accel_tapers_before_target(self):
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
self.params.put("AccelPersonality", AccelProfile.normal, block=True)
controller = AccelController()
speed = 20.0
speed_errors = (4.0, 3.0, 2.0, 1.0, 0.5, TARGET_SPEED_DEADBAND)
targets = [controller.get_cruise_target(speed, speed + error) for error in speed_errors]
max_accels = [controller.get_max_accel(speed, speed + error) for error in speed_errors]
accels = [get_cruise_accel(False, target, speed, 0.0, 0.0, _fake_cp(), 10.0, 0.0, True, max_accel)
for target, max_accel in zip(targets, max_accels, strict=True)]
assert np.isclose(accels[0], controller.get_max_accel(speed))
assert accels[1] < controller.get_max_accel(speed)
assert all(current > following for current, following in zip(accels, accels[1:], strict=False))
assert accels[-1] == 0.0
def test_disabled_leaves_stock_limit_active(self):
planner = _bare_planner()
for e2e in (False, True):
assert planner.get_max_accel_override(5.0, 30.0, e2e=e2e) is None
assert planner.get_cruise_target_override(20.0, 20.5, e2e=e2e) == 20.5
assert planner.accel_controller_active is False
def test_e2e_uses_enabled_profile(self):
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
assert planner.get_max_accel_override(5.0, 30.0, e2e=True) == MAX_ACCEL_PROFILES[AccelProfile.normal][2]
assert planner.accel_controller_active is True
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 planner.get_max_accel_override(5.0, 30.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):
def test_normal_profile_uses_tuned_limit(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.get_max_accel_override(5.0, 30.0, e2e=False) == MAX_ACCEL_PROFILES[AccelProfile.normal][2]
assert planner.accel_controller_active is True
def test_planner_applies_cruise_settling_only_when_safe(self):
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanSource
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
planner = _bare_planner()
expected_limit = planner.accel_controller.get_max_accel(20.0, 20.5)
expected_decel_target = planner.accel_controller.get_cruise_target(20.5, 20.0)
assert planner.get_cruise_target_override(20.0, 20.5, e2e=False) == 20.5
assert np.isclose(planner.get_cruise_target_override(20.5, 20.0, e2e=False), expected_decel_target)
assert np.isclose(planner.get_max_accel_override(20.0, 20.5, e2e=False), expected_limit)
planner.allow_throttle = False
assert planner.get_cruise_target_override(20.0, 20.5, e2e=False) == 20.5
assert planner.get_max_accel_override(20.0, 20.5, e2e=False) is None
assert planner.accel_controller_active is False
assert planner.get_cruise_target_override(20.0, 20.5, e2e=True) == 20.5
assert np.isclose(planner.get_max_accel_override(20.0, 20.5, e2e=True), expected_limit)
assert planner.accel_controller_active is True
planner.source = LongitudinalPlanSource.sccVision
assert planner.get_cruise_target_override(20.0, 20.5, e2e=True) == 20.5
assert planner.get_max_accel_override(20.0, 20.5, e2e=True) == planner.accel_controller.get_max_accel(20.0)
def _fake_cp():
@@ -148,9 +297,11 @@ def _fake_cp():
def _bare_planner():
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
planner.accel_controller = AccelController()
planner.accel_controller_active = False
planner.allow_throttle = True
planner.source = LongitudinalPlanSource.cruise
return planner
@@ -7,12 +7,18 @@ See the LICENSE.md file in the root directory for more details.
from collections.abc import Callable
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.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
from openpilot.selfdrive.controls.lib.longitudinal_planner import A_CRUISE_MAX_BP, J_CRUISE_VALS, get_cruise_accel
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource, T_IDXS as T_IDXS_MPC
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
AccelController, AccelProfile, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES, TARGET_SPEED_DEADBAND,
)
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
class CarParams:
@@ -20,8 +26,19 @@ class CarParams:
wheelbase = 2.7
def _set_mpc_acceleration(plant: PlantSP, acceleration: float = 2.0) -> None:
def update(_radar_state, **_kwargs):
mpc = plant.planner.mpc
mpc.source = LongitudinalPlanSource.lead0
mpc.v_solution[:] = mpc.x0[1] + acceleration * T_IDXS_MPC
mpc.a_solution.fill(acceleration)
mpc.j_solution.fill(0.0)
plant.planner.mpc.update = update
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):
v_cruise_fn: Callable[[int], float] | None = None, e2e: bool = False, steps: int = 120):
params = Params()
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
params.put("AccelPersonality", profile, block=True)
@@ -31,26 +48,138 @@ def run_profile(profile: int, *, enabled: bool = True, speed: float = 0.0, v_cru
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)
use_profile = controller.is_enabled()
cruise_target = controller.get_cruise_target(speed, target_speed) if use_profile else target_speed
max_accel_override = controller.get_max_accel(speed, target_speed) if use_profile else None
accel = get_cruise_accel(e2e, cruise_target, 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):
def test_blended_positive_model_request_uses_profile_cruise_cap(self):
params = Params()
params.put_bool("DynamicExperimentalControl", False, block=True)
params.put_bool("AccelPersonalityEnabled", True, block=True)
params.put("AccelPersonality", AccelProfile.eco, block=True)
def request_acceleration(_current_time: float, _speed: float, _acceleration: float) -> tuple[float, bool]:
return 2.0, False
plant = PlantSP(speed=15.0, e2e=True, model_action_fn=request_acceleration)
_set_mpc_acceleration(plant)
results = [plant.step(v_cruise=35.0) for _ in range(20)]
settled = results[-1]
eco_limit = float(np.interp(settled["published_v_ego"], MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
self.assertTrue(settled["controller_active"])
self.assertEqual(settled["mpc_source"], LongitudinalPlanSource.cruise)
self.assertAlmostEqual(settled["a_target"], eco_limit, delta=0.01)
self.assertLess(settled["a_target"], settled["model_action"]["desiredAcceleration"])
def test_blended_profile_does_not_change_model_braking(self):
params = Params()
params.put_bool("DynamicExperimentalControl", False, block=True)
params.put("AccelPersonality", AccelProfile.eco, block=True)
def request_braking(_current_time: float, _speed: float, _acceleration: float) -> tuple[float, bool]:
return -0.8, False
traces = {}
for enabled in (False, True):
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
plant = PlantSP(speed=20.0, e2e=True, model_action_fn=request_braking)
_set_mpc_acceleration(plant)
traces[enabled] = [plant.step(v_cruise=30.0) for _ in range(10)]
self.assertTrue(all(row["mpc_source"] == LongitudinalPlanSource.e2e for row in traces[True]))
self.assertTrue(all(row["controller_active"] for row in traces[True]))
self.assertTrue(all(not row["controller_active"] for row in traces[False]))
for key in ("a_target", "should_stop", "mpc_source"):
self.assertEqual([row[key] for row in traces[True]], [row[key] for row in traces[False]])
def test_profile_does_not_change_model_stop_request(self):
params = Params()
params.put_bool("DynamicExperimentalControl", False, block=True)
params.put("AccelPersonality", AccelProfile.eco, block=True)
def request_stop(_current_time: float, _speed: float, _acceleration: float) -> tuple[float, bool]:
return -0.8, True
traces = {}
for enabled in (False, True):
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
plant = PlantSP(speed=1.0, e2e=True, model_action_fn=request_stop)
_set_mpc_acceleration(plant)
traces[enabled] = [plant.step(v_cruise=30.0) for _ in range(10)]
for key in ("a_target", "should_stop", "mpc_source"):
self.assertEqual([row[key] for row in traces[True]], [row[key] for row in traces[False]])
def test_profile_does_not_change_lead_braking(self):
params = Params()
params.put_bool("DynamicExperimentalControl", False, block=True)
params.put("AccelPersonality", AccelProfile.eco, block=True)
traces = {}
for enabled in (False, True):
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
plant = PlantSP(speed=20.0)
_set_mpc_acceleration(plant, -0.8)
traces[enabled] = [plant.step(v_cruise=30.0) for _ in range(10)]
self.assertTrue(all(row["mpc_source"] == LongitudinalPlanSource.lead0 for row in traces[True]))
for key in ("a_target", "should_stop", "mpc_source"):
self.assertEqual([row[key] for row in traces[True]], [row[key] for row in traces[False]])
def test_blended_cruise_settling_reduces_small_corrections_both_directions(self):
params = Params()
params.put_bool("DynamicExperimentalControl", False, block=True)
params.put("AccelPersonality", AccelProfile.normal, block=True)
def request_acceleration(_current_time: float, _speed: float, _acceleration: float) -> tuple[float, bool]:
return 2.0, False
peak_corrections = {}
for enabled in (False, True):
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
for direction, speed, cruise in (("accelerate", 20.0, 20.5), ("decelerate", 20.5, 20.0)):
plant = PlantSP(speed=speed, e2e=True, model_action_fn=request_acceleration)
_set_mpc_acceleration(plant)
trace = [plant.step(v_cruise=cruise) for _ in range(20)]
peak_corrections[enabled, direction] = max(abs(row["a_target"]) for row in trace)
for direction in ("accelerate", "decelerate"):
self.assertLess(peak_corrections[True, direction], peak_corrections[False, direction])
def test_normal_is_less_aggressive_than_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)
self.assertTrue(all(tuned[1] <= original[1] + 1e-12 for tuned, original in zip(normal, stock, strict=True)))
self.assertLess(normal[-1][0], stock[-1][0])
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_profiles_do_not_change_far_braking(self):
for e2e in (False, True):
stock = run_profile(AccelProfile.normal, enabled=False, speed=20.0, v_cruise=0.0, e2e=e2e, steps=100)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
self.assertEqual(run_profile(profile, speed=20.0, v_cruise=0.0, e2e=e2e, steps=100), stock)
def test_blended_launch_respects_profiles(self):
traces = {
profile: run_profile(profile, v_cruise=8.0, e2e=True, steps=180)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
}
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.assertLess(time_to_five[AccelProfile.sport], time_to_five[AccelProfile.normal])
self.assertLess(time_to_five[AccelProfile.normal], time_to_five[AccelProfile.eco])
def test_launch_ordering_without_departure_delay(self):
stock = run_profile(AccelProfile.normal, enabled=False, v_cruise=8.0, steps=160)
traces = {
profile: run_profile(profile, v_cruise=8.0, steps=160)
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
@@ -63,8 +192,17 @@ class TestAccelControllerClosedLoop(OpenpilotTestCase):
profile: next(frame for frame, row in enumerate(rows) if row[0] >= 5.0) * DT_MDL
for profile, rows in traces.items()
}
stock_first_motion = next(frame for frame, row in enumerate(stock) if row[0] > 0.01)
stock_time_to_two = next(frame for frame, row in enumerate(stock) if row[0] >= 2.0) * DT_MDL
stock_time_to_three = next(frame for frame, row in enumerate(stock) if row[0] >= 3.0) * DT_MDL
self.assertEqual(len(set(first_motion.values())), 1)
self.assertTrue(all(frame == stock_first_motion for frame in first_motion.values()))
for rows in traces.values():
time_to_two = next(frame for frame, row in enumerate(rows) if row[0] >= 2.0) * DT_MDL
time_to_three = next(frame for frame, row in enumerate(rows) if row[0] >= 3.0) * DT_MDL
self.assertLessEqual(time_to_two, stock_time_to_two + DT_MDL)
self.assertLessEqual(time_to_three, stock_time_to_three + 0.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])
@@ -78,6 +216,37 @@ class TestAccelControllerClosedLoop(OpenpilotTestCase):
self.assertGreaterEqual(gains[AccelProfile.sport], gains[AccelProfile.normal])
self.assertGreaterEqual(gains[AccelProfile.eco], 0.72 * gains[AccelProfile.normal])
def test_catchup_settles_inside_deadband_without_oscillation(self):
target_speed = 24.0
rows = run_profile(AccelProfile.normal, speed=20.0, v_cruise=target_speed, steps=800)
final_deficit = target_speed - rows[-1][0]
self.assertGreaterEqual(final_deficit, -1e-9)
self.assertLessEqual(final_deficit, TARGET_SPEED_DEADBAND + 0.01)
self.assertTrue(all(row[1] >= -1e-12 for row in rows))
self.assertTrue(all(current[0] <= following[0] for current, following in zip(rows, rows[1:], strict=False)))
def test_smaller_target_gap_uses_stock_downward_jerk(self):
def target_speed(frame: int) -> float:
return 24.0 if frame < 40 else 21.5
rows = run_profile(AccelProfile.normal, speed=20.0, v_cruise_fn=target_speed, steps=80)
command_drop = rows[39][1] - rows[40][1]
self.assertLessEqual(command_drop, max(J_CRUISE_VALS) * DT_MDL + 1e-12)
self.assertTrue(all(row[1] >= -1e-12 for row in rows[40:]))
def test_full_catchup_trace_respects_stock_jerk(self):
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
rows = run_profile(profile, v_cruise=30.0, steps=300)
previous_speed = 0.0
previous_accel = 0.0
for speed, accel, _should_stop in rows:
jerk_step = float(np.interp(previous_speed, A_CRUISE_MAX_BP, J_CRUISE_VALS)) * DT_MDL
self.assertLessEqual(abs(accel - previous_accel), jerk_step + 1e-12)
previous_speed = speed
previous_accel = accel
def test_stop_release_frame_is_profile_independent(self):
def target_speed(frame: int) -> float:
return 0.0 if frame < 20 else 8.0
@@ -90,4 +259,7 @@ class TestAccelControllerClosedLoop(OpenpilotTestCase):
profile: next(frame for frame, row in enumerate(rows) if frame >= 20 and not row[2])
for profile, rows in traces.items()
}
stock = run_profile(AccelProfile.normal, enabled=False, v_cruise_fn=target_speed, steps=80)
stock_release_frame = next(frame for frame, row in enumerate(stock) if frame >= 20 and not row[2])
self.assertEqual(len(set(release_frames.values())), 1)
self.assertTrue(all(frame == stock_release_frame for frame in release_frames.values()))
@@ -11,7 +11,7 @@ from openpilot.cereal import messaging, custom, log
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.accel_controller.accel_controller import AccelController
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
@@ -52,12 +52,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)
def get_max_accel_override(self, v_ego: float, v_target: float, e2e: bool) -> float | None:
self.accel_controller_active = bool(self.accel_controller.is_enabled() and (e2e or self.allow_throttle))
if not self.accel_controller_active:
return None
return self.accel_controller.get_max_accel(v_ego)
target = v_target if self.source == LongitudinalPlanSource.cruise else None
return self.accel_controller.get_max_accel(v_ego, target)
def get_cruise_target_override(self, v_ego: float, v_target: float, e2e: bool) -> float:
if not self.accel_controller.is_enabled() or self.source != LongitudinalPlanSource.cruise or not (e2e or self.allow_throttle):
return v_target
return self.accel_controller.get_cruise_target(v_ego, v_target)
def update_allow_throttle(self, throttle_prob: float, low_speed_override: bool, threshold: float) -> bool:
return self.throttle_intent_controller.update(throttle_prob, low_speed_override=low_speed_override, threshold=threshold)
@@ -656,7 +656,7 @@
"key": "AccelPersonalityEnabled",
"widget": "toggle",
"title": "Enable Accel Controller",
"description": "Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged.",
"description": "Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency braking and stopping are unchanged.",
"visibility": [
{
"type": "capability",
@@ -676,7 +676,7 @@
"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.",
"description": "Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive.",
"options": [
{
"value": 0,
@@ -46,7 +46,8 @@ sections:
- key: AccelPersonalityEnabled
widget: toggle
title: Enable Accel Controller
description: Lets you choose the acceleration response. Lead following, braking, and stopping are unchanged.
description: Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency braking and stopping are
unchanged.
visibility:
- $ref: '#/macros/longitudinal'
enablement:
@@ -54,8 +55,7 @@ sections:
- 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.
description: Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive.
options:
- value: 0
label: Eco