Files
StarPilot/selfdrive/controls/tests/test_longitudinal_planner_hem.py
T
2026-08-27 14:36:33 -04:00

285 lines
12 KiB
Python

#!/usr/bin/env python3
import numpy as np
import pytest
from types import SimpleNamespace
from cereal import log
from opendbc.car.honda.interface import CarInterface
from opendbc.car.honda.values import CAR
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner, get_vehicle_min_accel
from openpilot.selfdrive.modeld.constants import ModelConstants
TRAJ_LEN = len(ModelConstants.T_IDXS)
def make_lead(*, status, d_rel=200.0, v_lead=0.0, a_lead=0.0, radar=False, model_prob=0.0):
lead = log.RadarState.LeadData.new_message()
lead.status = status
lead.dRel = d_rel
lead.vLead = v_lead
lead.vLeadK = v_lead
lead.aLeadK = a_lead
lead.vRel = 0.0
lead.aRel = 0.0
lead.yRel = 0.0
lead.modelProb = model_prob
lead.radar = radar
return lead
def make_model(v_ego, desired_accel, *, velocity_traj=None, should_stop=False):
model = log.ModelDataV2.new_message()
model.init('leadsV3', 3)
t_idxs = ModelConstants.T_IDXS
n = len(t_idxs)
model.position.x = [float(v_ego * t) for t in t_idxs]
model.position.y = [0.0] * n
model.position.z = [0.0] * n
model.position.t = [float(t) for t in t_idxs]
if velocity_traj is None:
model.velocity.x = [float(v_ego)] * n
else:
model.velocity.x = [float(x) for x in velocity_traj]
model.velocity.y = [0.0] * n
model.velocity.z = [0.0] * n
model.velocity.t = [float(t) for t in t_idxs]
model.acceleration.x = [0.0] * n
model.acceleration.y = [0.0] * n
model.acceleration.z = [0.0] * n
model.acceleration.t = [float(t) for t in t_idxs]
model.action.desiredAcceleration = desired_accel
model.action.shouldStop = should_stop
return model
def make_sm(v_ego, desired_accel, min_accel, *, experimental_mode=True, tracking_lead=False,
lead_one=None, velocity_traj=None, should_stop=False):
if lead_one is None:
lead_one = make_lead(status=False)
return {
"carControl": SimpleNamespace(orientationNED=[0.0, 0.0, 0.0]),
"carState": SimpleNamespace(
vEgo=v_ego, vEgoCluster=v_ego, aEgo=0.0, vCruise=100.0, standstill=False,
steeringAngleDeg=0.0,
),
"controlsState": SimpleNamespace(longControlState=0, forceDecel=False),
"liveParameters": SimpleNamespace(angleOffsetDeg=0.0),
"modelV2": make_model(v_ego, desired_accel, velocity_traj=velocity_traj, should_stop=should_stop),
"radarState": SimpleNamespace(leadOne=lead_one, leadTwo=make_lead(status=False)),
"selfdriveState": SimpleNamespace(enabled=True, experimentalMode=experimental_mode, personality=0),
"starpilotCarState": SimpleNamespace(accelPressed=False),
"starpilotPlan": SimpleNamespace(
vCruise=v_ego + 5.0,
minAcceleration=min_accel,
maxAcceleration=2.0,
disableThrottle=False,
trackingLead=tracking_lead,
accelerationJerk=5.0,
dangerJerk=5.0,
speedJerk=5.0,
dangerFactor=1.0,
tFollow=1.45,
forcingStop=False,
redLight=False,
forcingStopLength=2,
),
}
def make_toggles(*, hybrid=False, exp_bias=0.0, sens=1.0):
return SimpleNamespace(
taco_tune=False,
classic_model=False,
tinygrad_model=True,
model_version="v11",
vEgoStopping=0.5,
radar_takeoffs=False,
hybrid_experimental_mode=hybrid,
hybrid_exp_bias=exp_bias,
hybrid_vision_brake_sensitivity=sens,
)
def assert_outputs_equal(p1, p2, label=""):
assert p1.output_a_target == pytest.approx(p2.output_a_target, abs=1e-9), f"{label} aTarget mismatch"
assert p1.output_should_stop == p2.output_should_stop, f"{label} shouldStop mismatch"
np.testing.assert_allclose(p1.v_desired_trajectory, p2.v_desired_trajectory, atol=1e-9)
np.testing.assert_allclose(p1.a_desired_trajectory, p2.a_desired_trajectory, atol=1e-9)
# 1. Toggle equivalence (regression safety)
def _run_equivalence_scenarios(experimental_mode):
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
scenarios = [
dict(v_ego=20.0, desired_accel=0.4, min_accel=-2.0, tracking_lead=False,
lead_one=None, velocity_traj=None, should_stop=False, label="chill-cruise"),
dict(v_ego=20.0, desired_accel=-0.6, min_accel=-2.0, tracking_lead=True,
lead_one=make_lead(status=True, d_rel=30.0, v_lead=18.0, radar=True, model_prob=1.0),
velocity_traj=None, should_stop=False, label="slow-lead"),
dict(v_ego=25.0, desired_accel=-1.2, min_accel=-3.0, tracking_lead=True,
lead_one=make_lead(status=True, d_rel=12.0, v_lead=3.0, radar=True, model_prob=1.0),
velocity_traj=list(np.linspace(25.0, 2.0, TRAJ_LEN)), should_stop=True, label="stop-approach"),
]
for scen in scenarios:
label = scen.pop("label")
hybrid_planner = LongitudinalPlanner(CP, init_v=scen["v_ego"])
stock_planner = LongitudinalPlanner(CP, init_v=scen["v_ego"])
sm = make_sm(experimental_mode=experimental_mode, **scen)
hybrid_planner.update(sm, make_toggles(hybrid=True) if False else make_toggles(hybrid=False))
stock_planner.update(sm, make_toggles())
assert_outputs_equal(hybrid_planner, stock_planner, label=f"chill/{label}" if not experimental_mode else f"exp/{label}")
def test_hem_off_matches_stock_chill_mode():
_run_equivalence_scenarios(experimental_mode=False)
def test_hem_off_matches_stock_experimental_mode():
_run_equivalence_scenarios(experimental_mode=True)
# 2. Stop sign / red light approach
def test_stop_sign_approach_commands_pure_model_braking():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=20.0)
toggles = make_toggles(hybrid=True)
sm = make_sm(20.0, -2.5, -4.0, experimental_mode=False,
velocity_traj=list(np.linspace(20.0, 0.0, TRAJ_LEN)), should_stop=True)
# Enough frames for the jerk slew limiter to ramp to full vision braking.
for _ in range(20):
planner.update(sm, toggles)
assert planner.output_a_target <= -2.0, "Exp stop braking must not be diluted by cruise throttle"
assert planner.output_a_target < 0.0, "Cruise throttle must be locked out during a vision stop"
def test_stop_line_handshake_asserts_should_stop():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=0.3)
toggles = make_toggles(hybrid=True)
sm = make_sm(0.3, -1.0, -4.0, experimental_mode=False,
velocity_traj=[0.0] * TRAJ_LEN, should_stop=True)
for _ in range(6):
planner.update(sm, toggles)
assert planner.output_should_stop, "Standstill at a predicted stop must assert shouldStop"
assert planner.output_a_target <= -0.4, "Standstill brake must be held at the stop line"
# 3. Slower-lead approach (radar + vision blend within safety)
def test_slower_lead_approach_blends_vision_prebrake_within_safety():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=20.0)
toggles = make_toggles(hybrid=True)
lead = make_lead(status=True, d_rel=25.0, v_lead=0.4, radar=True, model_prob=1.0)
sm = make_sm(20.0, -1.5, -4.0, experimental_mode=False, tracking_lead=True, lead_one=lead,
velocity_traj=list(np.linspace(20.0, 5.0, TRAJ_LEN)), should_stop=False)
for _ in range(15):
planner.update(sm, toggles)
# Vision pre-braking must be engaged while closing on a slower lead.
assert planner.output_a_target <= -1.0, "Vision pre-brake should be blended in on a slow lead"
# ...but it must never exceed the physical / commanded deceleration floors.
assert planner.output_a_target >= get_vehicle_min_accel(CP, 20.0)
assert planner.output_a_target >= -4.0
# 4. Green light / lead departure (instant unlatch)
def test_green_light_departure_instantly_clears_should_stop():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=0.3)
toggles = make_toggles(hybrid=True)
sm_stop = make_sm(0.3, -1.0, -4.0, experimental_mode=False,
velocity_traj=[0.0] * TRAJ_LEN, should_stop=True)
for _ in range(6):
planner.update(sm_stop, toggles)
assert planner.output_should_stop
assert planner.output_a_target <= -0.4
# Green light / lead pulls away: horizon ramps up and Exp accelerates. Run a
# few frames so the jerk slew ramps the commanded accel up from the brake hold.
sm_go = make_sm(0.3, 1.5, -4.0, experimental_mode=False,
velocity_traj=list(np.linspace(0.0, 8.0, TRAJ_LEN)), should_stop=False)
sm_go["starpilotPlan"].vCruise = 15.0
for _ in range(6):
planner.update(sm_go, toggles)
assert not planner.output_should_stop, "Departure must instantly clear the stop latch"
assert planner.output_a_target > 0.0, "Departure must deliver positive cruise acceleration"
def test_lead_departure_clears_stop_without_sticky_latch():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=0.3)
toggles = make_toggles(hybrid=True)
sm_stop = make_sm(0.3, -1.0, -4.0, experimental_mode=False, tracking_lead=True,
lead_one=make_lead(status=True, d_rel=3.0, v_lead=0.1, radar=True, model_prob=1.0),
velocity_traj=[0.0] * TRAJ_LEN, should_stop=True)
for _ in range(6):
planner.update(sm_stop, toggles)
assert planner.output_should_stop
# Lead accelerates away from standstill and opens up a safe gap.
sm_go = make_sm(0.3, 1.0, -4.0, experimental_mode=False, tracking_lead=True,
lead_one=make_lead(status=True, d_rel=25.0, v_lead=10.0, radar=True, model_prob=1.0),
velocity_traj=list(np.linspace(0.0, 7.0, TRAJ_LEN)), should_stop=False)
sm_go["starpilotPlan"].vCruise = 15.0
for _ in range(6):
planner.update(sm_go, toggles)
# HEM's own vision latch releases instantly (no sticky vision authority).
assert planner.hybrid_controller.w_vision == 0.0
assert not planner.output_should_stop, "Lead departure must clear shouldStop without lag"
assert planner.output_a_target > 0.0
# 5. Safety clamping preservation
def test_radar_cut_in_preserves_chill_floor_within_physical_limits():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
planner = LongitudinalPlanner(CP, init_v=20.0)
toggles = make_toggles(hybrid=True)
lead = make_lead(status=True, d_rel=6.0, v_lead=10.0, radar=True, model_prob=1.0)
sm = make_sm(20.0, 0.0, -3.5, experimental_mode=False, tracking_lead=True, lead_one=lead,
velocity_traj=[20.0] * TRAJ_LEN, should_stop=False)
for _ in range(15):
planner.update(sm, toggles)
assert planner.output_a_target <= -1.0, "Radar cut-in must brake hard at the planner output"
assert planner.output_a_target >= -3.5, "Output must never exceed the commanded accel_min floor"
assert planner.output_a_target >= get_vehicle_min_accel(CP, 20.0), "Physical decel limit respected"
def test_hem_output_clamped_to_physical_limits_under_corrupt_exp():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
lo = get_vehicle_min_accel(CP, 20.0)
for a_exp in (float("nan"), -100.0, 100.0):
planner = LongitudinalPlanner(CP, init_v=20.0)
sm = make_sm(20.0, a_exp, -3.5, experimental_mode=False,
velocity_traj=[20.0] * TRAJ_LEN, should_stop=False)
for _ in range(3):
planner.update(sm, make_toggles(hybrid=True))
assert np.isfinite(planner.output_a_target), f"corrupt a_exp={a_exp} must not propagate"
assert lo - 1e-6 <= planner.output_a_target <= 2.0 + 1e-6
def test_output_stays_within_physical_limits_across_scenarios():
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
scenarios = [
dict(v_ego=20.0, desired_accel=-2.5, min_accel=-4.0, tracking_lead=False,
lead_one=None, velocity_traj=list(np.linspace(20.0, 0.0, TRAJ_LEN)), should_stop=True),
dict(v_ego=20.0, desired_accel=0.5, min_accel=-3.0, tracking_lead=True,
lead_one=make_lead(status=True, d_rel=40.0, v_lead=22.0, radar=True, model_prob=1.0),
velocity_traj=None, should_stop=False),
]
for scen in scenarios:
planner = LongitudinalPlanner(CP, init_v=scen["v_ego"])
sm = make_sm(experimental_mode=False, **scen)
for _ in range(6):
planner.update(sm, make_toggles(hybrid=True))
assert planner.output_a_target >= get_vehicle_min_accel(CP, scen["v_ego"])
assert planner.output_a_target <= 2.0