mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-07-15 02:22:07 +08:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 449ae8a984 | |||
| 93520b7e99 | |||
| 7006814ced | |||
| 4a7cd03efc | |||
| 24af9ca14b | |||
| 499ed0e59d | |||
| 6a9550d54b | |||
| 65812911fb | |||
| 878e560c60 | |||
| 7b2cb04e97 | |||
| 432b253f48 | |||
| 6131a64f0d | |||
| f5087f238a | |||
| 59e9e828e5 | |||
| a2fbe20532 | |||
| 75416274ab | |||
| c1c1440707 | |||
| 1d8538f14e | |||
| d044951bdc | |||
| e65a39e749 | |||
| eea9dbcf28 | |||
| cb0bc5746d | |||
| 15ddcc2026 | |||
| 5cfa627b1e | |||
| c241b7f9e5 | |||
| 1cae2e14b9 | |||
| bc498ea6af | |||
| c0e08181df | |||
| bbe7b01adc | |||
| 9f3fa8ceb7 | |||
| df54f8e083 | |||
| 4d351bdcad | |||
| bca4be26cd | |||
| cc8115141f | |||
| d7af8bfc4d | |||
| b30e52261e | |||
| e09bc59ea3 | |||
| f9e7974e02 | |||
| 8c902576dc | |||
| bc96b6a6ce | |||
| 0a68face78 | |||
| 78307a31f1 | |||
| d10577dc2c | |||
| 37f19a35b6 | |||
| 2276e9d47d | |||
| 0a167d5024 | |||
| 21e0583752 | |||
| 88dad50ee1 | |||
| 9f20c56b2f | |||
| a2b50e5a8b | |||
| bebe8f0b04 | |||
| aa23fd0e4a | |||
| 8f942eefd7 | |||
| 9487f880e2 | |||
| 17b097b434 | |||
| 17eca0ab43 | |||
| 634ba8f6b2 | |||
| a45487f829 | |||
| e790ce047b | |||
| 96d7850888 | |||
| 6bf721a8c9 | |||
| bc6dbf8ca1 | |||
| ffb7bbbbc4 | |||
| 83de89e253 | |||
| 04224e8747 | |||
| 6012ebb7c7 | |||
| e91dbe351e | |||
| e25061fd08 | |||
| baf56ae324 | |||
| 9badd3fa40 | |||
| 2220e7fc11 | |||
| e862935209 | |||
| 9bd504a5cb |
@@ -4,6 +4,7 @@
|
||||
[submodule "opendbc"]
|
||||
path = opendbc_repo
|
||||
url = https://github.com/sunnypilot/opendbc.git
|
||||
branch = tn
|
||||
[submodule "msgq"]
|
||||
path = msgq_repo
|
||||
url = https://github.com/commaai/msgq.git
|
||||
|
||||
@@ -194,6 +194,7 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
aTarget @5 :Float32;
|
||||
events @6 :List(OnroadEventSP.Event);
|
||||
e2eAlerts @7 :E2eAlerts;
|
||||
acceleration @8 :Acceleration;
|
||||
|
||||
struct DynamicExperimentalControl {
|
||||
state @0 :DynamicExperimentalControlState;
|
||||
@@ -296,6 +297,26 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
greenLightAlert @0 :Bool;
|
||||
leadDepartAlert @1 :Bool;
|
||||
}
|
||||
|
||||
# Acceleration Personality (Eco / Normal / Sport)
|
||||
struct Acceleration {
|
||||
personality @0 :AccelerationPersonality;
|
||||
enabled @1 :Bool;
|
||||
maxAccel @2 :Float32; # current speed-indexed accel ceiling
|
||||
brakeNeed @3 :Float32; # repurposed: follow-gap widen added on top of the stock t_follow (s)
|
||||
decelTarget @4 :Float32; # repurposed: t_follow handed to the MPC (s)
|
||||
smoothActive @5 :Bool; # repurposed: follow-gap widen currently active
|
||||
bypassed @6 :Bool; # unused (input-shaping design has no output post-shaping / bypass)
|
||||
comfortStopActive @7 :Bool; # low-speed comfort decel-to-stop floor currently governing (behind a near-stopped lead)
|
||||
comfortStopFloor @8 :Float32; # comfort-stop floor commanded (m/s^2, negative; 0 when not engaged)
|
||||
leadUnstable @9 :Bool; # RadarDistance lead-instability telemetry (bimodal/bouncing radar lead; informational, no control effect yet)
|
||||
}
|
||||
|
||||
enum AccelerationPersonality {
|
||||
eco @0;
|
||||
normal @1;
|
||||
sport @2;
|
||||
}
|
||||
}
|
||||
|
||||
struct OnroadEventSP @0xda96579883444c35 {
|
||||
@@ -342,6 +363,7 @@ struct OnroadEventSP @0xda96579883444c35 {
|
||||
speedLimitChanged @21;
|
||||
speedLimitPending @22;
|
||||
e2eChime @23;
|
||||
laneChangeRoadEdge @24;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +470,8 @@ struct LiveMapDataSP @0xf416ec09499d9d19 {
|
||||
|
||||
struct ModelDataV2SP @0xa1680744031fdb2d {
|
||||
laneTurnDirection @0 :TurnDirection;
|
||||
leftLaneChangeEdgeBlock @1 :Bool;
|
||||
rightLaneChangeEdgeBlock @2 :Bool;
|
||||
|
||||
enum TurnDirection {
|
||||
none @0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <unordered_map>
|
||||
|
||||
#include "cereal/gen/cpp/log.capnp.h"
|
||||
#include "cereal/gen/cpp/custom.capnp.h"
|
||||
|
||||
inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"AccessToken", {CLEAR_ON_MANAGER_START | DONT_LOG, STRING}},
|
||||
@@ -179,12 +180,19 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"RainbowMode", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"RoadEdgeLaneChangeEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"RocketFuel", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// toyota specific params
|
||||
{"ToyotaAutoHold", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaEnhancedBsm", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaTSS2Long", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaDriveMode", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// MADS params
|
||||
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"MadsMainCruiseAllowed", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
@@ -228,6 +236,14 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// Acceleration Personality (Eco / Normal / Sport)
|
||||
{"AccelPersonalityEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"AccelPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast<int>(cereal::LongitudinalPlanSP::AccelerationPersonality::NORMAL))}},
|
||||
|
||||
// Radar Distance: de-noise the lead the MPC follows (flicker-hold + churn smoother); never reports a
|
||||
// farther/faster lead than reality, so braking stays >= stock
|
||||
{"RadarDistance", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// sunnypilot model params
|
||||
{"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}},
|
||||
{"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
|
||||
+1
-1
Submodule opendbc_repo updated: 10e654bf21...7752485b92
@@ -10,7 +10,7 @@ from cereal import car, log, custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog, ForwardingHandler
|
||||
|
||||
from opendbc.safety import ALTERNATIVE_EXPERIENCE
|
||||
from opendbc.car import DT_CTRL, structs
|
||||
from opendbc.car.can_definitions import CanData, CanRecvCallable, CanSendCallable
|
||||
from opendbc.car.carlog import carlog
|
||||
@@ -121,7 +121,13 @@ class Car:
|
||||
self.CI, self.CP, self.CP_SP = CI, CI.CP, CI.CP_SP
|
||||
self.RI = RI
|
||||
|
||||
# set alternative experiences from parameters
|
||||
sp_toyota_auto_brake_hold = self.params.get_bool("ToyotaAutoHold")
|
||||
self.CP.alternativeExperience = 0
|
||||
if sp_toyota_auto_brake_hold:
|
||||
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ALLOW_AEB
|
||||
|
||||
|
||||
# mads
|
||||
set_alternative_experience(self.CP, self.CP_SP, self.params)
|
||||
set_car_specific_params(self.CP, self.CP_SP, self.params)
|
||||
|
||||
@@ -56,7 +56,7 @@ class DesireHelper:
|
||||
def get_lane_change_direction(CS):
|
||||
return LaneChangeDirection.left if CS.leftBlinker else LaneChangeDirection.right
|
||||
|
||||
def update(self, carstate, lateral_active, lane_change_prob):
|
||||
def update(self, carstate, lateral_active, lane_change_prob, left_edge_detected=False, right_edge_detected=False):
|
||||
self.alc.update_params()
|
||||
self.lane_turn_controller.update_params()
|
||||
v_ego = carstate.vEgo
|
||||
@@ -88,8 +88,8 @@ class DesireHelper:
|
||||
((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right))
|
||||
blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
self.alc.update_lane_change(blindspot_detected, carstate.brakePressed)
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ class LongitudinalMpc:
|
||||
self.solver = AcadosOcpSolverCython(MODEL_NAME, ACADOS_SOLVER_TYPE, N)
|
||||
self.reset()
|
||||
self.source = LongitudinalPlanSource.cruise
|
||||
self.t_follow_fn = None # sunnypilot: optional (t_follow, v_ego)->t_follow override; None == byte-stock
|
||||
|
||||
def reset(self):
|
||||
self.solver.reset()
|
||||
@@ -267,8 +268,8 @@ class LongitudinalMpc:
|
||||
for i in range(N):
|
||||
self.solver.cost_set(i, 'Zl', Zl)
|
||||
|
||||
def set_weights(self, prev_accel_constraint=True, personality=log.LongitudinalPersonality.standard):
|
||||
jerk_factor = get_jerk_factor(personality)
|
||||
def set_weights(self, prev_accel_constraint=True, personality=log.LongitudinalPersonality.standard, jerk_scale=1.0):
|
||||
jerk_factor = get_jerk_factor(personality) * jerk_scale
|
||||
a_change_cost = A_CHANGE_COST if prev_accel_constraint else 0
|
||||
cost_weights = [X_EGO_OBSTACLE_COST, X_EGO_COST, V_EGO_COST, A_EGO_COST, jerk_factor * a_change_cost, jerk_factor * J_EGO_COST]
|
||||
constraint_cost_weights = [LIMIT_COST, LIMIT_COST, LIMIT_COST, DANGER_ZONE_COST]
|
||||
@@ -316,6 +317,8 @@ class LongitudinalMpc:
|
||||
def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard):
|
||||
t_follow = get_T_FOLLOW(personality)
|
||||
v_ego = self.x0[1]
|
||||
if self.t_follow_fn is not None:
|
||||
t_follow = self.t_follow_fn(t_follow, v_ego)
|
||||
self.status = radarstate.leadOne.status or radarstate.leadTwo.status
|
||||
|
||||
lead_xv_0 = self.process_lead(radarstate.leadOne)
|
||||
|
||||
@@ -52,6 +52,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
self.CP = CP
|
||||
self.mpc = LongitudinalMpc(dt=dt)
|
||||
LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc)
|
||||
self.mpc.t_follow_fn = self.accel.get_t_follow # Acceleration Personality: add-only follow-gap widen
|
||||
self.fcw = False
|
||||
self.dt = dt
|
||||
self.allow_throttle = True
|
||||
@@ -110,7 +111,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
# No change cost when user is controlling the speed, or when standstill
|
||||
prev_accel_constraint = not (reset_state or sm['carState'].standstill)
|
||||
|
||||
accel_clip = [ACCEL_MIN, get_max_accel(v_ego)]
|
||||
accel_clip = [ACCEL_MIN, self.accel.get_max_accel(v_ego)]
|
||||
steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['liveParameters'].angleOffsetDeg
|
||||
accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, accel_clip, self.CP)
|
||||
|
||||
@@ -118,6 +119,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
self.v_desired_filter.x = v_ego
|
||||
# Clip aEgo to cruise limits to prevent large accelerations when becoming active
|
||||
self.a_desired = np.clip(sm['carState'].aEgo, accel_clip[0], accel_clip[1])
|
||||
self.accel.reset() # drop any accumulated follow-gap widen so it re-ramps cleanly on re-engage
|
||||
self._e2e_transition_guard.reset() # drop the tracked baseline so it doesn't apply a stale limit on re-engage
|
||||
|
||||
# Prevent divergence, smooth in current v_ego
|
||||
self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego))
|
||||
@@ -136,9 +139,9 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
if force_slow_decel:
|
||||
v_cruise = 0.0
|
||||
|
||||
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
|
||||
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality, jerk_scale=self.accel.get_jerk_scale(v_ego))
|
||||
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
|
||||
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality)
|
||||
self.mpc.update(self.smooth_radarstate(sm['radarState']), v_cruise, personality=sm['selfdriveState'].personality)
|
||||
|
||||
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
|
||||
self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution)
|
||||
@@ -160,7 +163,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
output_a_target_e2e = sm['modelV2'].action.desiredAcceleration
|
||||
output_should_stop_e2e = sm['modelV2'].action.shouldStop
|
||||
|
||||
if self.is_e2e(sm):
|
||||
is_e2e = self.is_e2e(sm)
|
||||
if is_e2e:
|
||||
output_a_target = min(output_a_target_e2e, output_a_target_mpc)
|
||||
self.output_should_stop = output_should_stop_e2e or output_should_stop_mpc
|
||||
if output_a_target < output_a_target_mpc:
|
||||
@@ -169,8 +173,15 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
output_a_target = output_a_target_mpc
|
||||
self.output_should_stop = output_should_stop_mpc
|
||||
|
||||
for idx in range(2):
|
||||
accel_clip[idx] = np.clip(accel_clip[idx], self.prev_accel_clip[idx] - 0.05, self.prev_accel_clip[idx] + 0.05)
|
||||
output_a_target = self.smooth_e2e_transition(output_a_target)
|
||||
|
||||
# Acceleration Personality shapes only MPC INPUTS (accel ceiling above + t_follow via mpc.t_follow_fn),
|
||||
# never the output accel -- output_a_target passes through byte-stock so the MPC owns the trajectory.
|
||||
|
||||
# Lower (braking) bound and the ceiling's downward slew stay at the stock rate; only the ceiling's
|
||||
# upward slew is tier-dependent (Acceleration Personality).
|
||||
accel_clip[0] = np.clip(accel_clip[0], self.prev_accel_clip[0] - 0.05, self.prev_accel_clip[0] + 0.05)
|
||||
accel_clip[1] = np.clip(accel_clip[1], self.prev_accel_clip[1] - 0.05, self.prev_accel_clip[1] + self.accel.get_rise_rate(v_ego))
|
||||
self.output_a_target = np.clip(output_a_target, accel_clip[0], accel_clip[1])
|
||||
self.prev_accel_clip = accel_clip
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def test_smoothing_params_default_off():
|
||||
params_keys = (REPO_ROOT / "common/params_keys.h").read_text()
|
||||
|
||||
assert re.search(r'"AccelPersonalityEnabled", \{PERSISTENT \| BACKUP, BOOL, "0"\}', params_keys)
|
||||
assert re.search(r'"RadarDistance", \{PERSISTENT \| BACKUP, BOOL, "0"\}', params_keys)
|
||||
# the retired dRel-bias sub-toggles must not return (their features were deleted in the input-shaping rewrite)
|
||||
assert '"StopGapBias"' not in params_keys
|
||||
assert '"LeadDecelAnticipate"' not in params_keys
|
||||
|
||||
|
||||
def test_output_is_byte_stock_and_inputs_are_shaped():
|
||||
update_src = inspect.getsource(LongitudinalPlanner.update)
|
||||
|
||||
# INPUT shaping only: the accel ceiling and the radar-conditioning seam are present...
|
||||
assert "self.accel.get_max_accel(v_ego)" in update_src
|
||||
assert "self.mpc.update(self.smooth_radarstate(sm['radarState'])" in update_src
|
||||
# ...and the OUTPUT is never post-shaped (byte-stock output; no accel shaping, no should_stop override).
|
||||
assert "smooth_target_accel" not in update_src
|
||||
assert "sng_should_stop" not in update_src # reverted: the should_stop hysteresis caused a high-speed under-brake
|
||||
|
||||
|
||||
def test_t_follow_hook_wired_and_identity_default():
|
||||
init_src = inspect.getsource(LongitudinalPlanner.__init__)
|
||||
assert "self.mpc.t_follow_fn = self.accel.get_t_follow" in init_src # planner wires the add-only widen
|
||||
|
||||
mpc_init = inspect.getsource(LongitudinalMpc.__init__)
|
||||
assert "self.t_follow_fn = None" in mpc_init # default None == byte-stock identity
|
||||
|
||||
mpc_update = inspect.getsource(LongitudinalMpc.update)
|
||||
assert "if self.t_follow_fn is not None:" in mpc_update # guarded hook, only fires when set
|
||||
|
||||
|
||||
# Tokens for the reverted input-side DEC model-stop-target (capped v_target into the MPC pre-solve). It was
|
||||
# superseded by DEC blended-mode and chased a source-fixed radar gate; it must not silently return.
|
||||
_DEC_MODEL_STOP_TOKENS = ("apply_model_stop_target", "force_stop_requested", "_update_model_stop", "MODEL_STOP_TARGET_TIME")
|
||||
|
||||
|
||||
def test_dec_model_stop_target_not_reintroduced():
|
||||
this_file = Path(__file__).resolve()
|
||||
for sub in ("selfdrive/controls", "sunnypilot/selfdrive/controls"):
|
||||
for path in (REPO_ROOT / sub).rglob("*.py"):
|
||||
if path.resolve() == this_file:
|
||||
continue # this guard names the tokens as strings
|
||||
src = path.read_text()
|
||||
for token in _DEC_MODEL_STOP_TOKENS:
|
||||
assert token not in src, f"reverted DEC model-stop-target ({token}) re-introduced in {path}"
|
||||
@@ -28,6 +28,7 @@ from openpilot.selfdrive.modeld.helpers import usbgpu_present, modeld_pkl_path,
|
||||
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
|
||||
PROCESS_NAME = "selfdrive.modeld.modeld"
|
||||
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
|
||||
@@ -223,6 +224,7 @@ def main(demo=False):
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
|
||||
DH = DesireHelper()
|
||||
RELC = RoadEdgeLaneChangeController(DH)
|
||||
|
||||
while True:
|
||||
# Keep receiving frames until we are at least 1 frame ahead of previous extra frame
|
||||
@@ -325,7 +327,10 @@ def main(demo=False):
|
||||
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
|
||||
r_lane_change_prob = desire_state[log.Desire.laneChangeRight]
|
||||
lane_change_prob = l_lane_change_prob + r_lane_change_prob
|
||||
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob)
|
||||
RELC.update(modelv2_send.modelV2.roadEdgeStds, modelv2_send.modelV2.laneLineProbs, v_ego, modelv2_send.modelV2.roadEdges)
|
||||
mdv2sp_send.modelDataV2SP.leftLaneChangeEdgeBlock = RELC.left_edge_detected
|
||||
mdv2sp_send.modelDataV2SP.rightLaneChangeEdgeBlock = RELC.right_edge_detected
|
||||
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, RELC.left_edge_detected, RELC.right_edge_detected)
|
||||
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
|
||||
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
|
||||
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
|
||||
|
||||
@@ -321,9 +321,16 @@ class SelfdriveD(CruiseHelper):
|
||||
# Handle lane change
|
||||
if self.sm['modelV2'].meta.laneChangeState == LaneChangeState.preLaneChange:
|
||||
direction = self.sm['modelV2'].meta.laneChangeDirection
|
||||
mdv2sp = self.sm['modelDataV2SP']
|
||||
|
||||
if (CS.leftBlindspot and direction == LaneChangeDirection.left) or \
|
||||
(CS.rightBlindspot and direction == LaneChangeDirection.right):
|
||||
(CS.rightBlindspot and direction == LaneChangeDirection.right):
|
||||
self.events.add(EventName.laneChangeBlocked)
|
||||
|
||||
elif (mdv2sp.leftLaneChangeEdgeBlock and direction == LaneChangeDirection.left) or \
|
||||
(mdv2sp.rightLaneChangeEdgeBlock and direction == LaneChangeDirection.right):
|
||||
self.events_sp.add(custom.OnroadEventSP.EventName.laneChangeRoadEdge)
|
||||
|
||||
else:
|
||||
if direction == LaneChangeDirection.left:
|
||||
self.events.add(EventName.preLaneChangeLeft)
|
||||
|
||||
@@ -15,7 +15,17 @@ class Plant:
|
||||
messaging_initialized = False
|
||||
|
||||
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):
|
||||
enabled=True, only_lead2=False, only_radar=False, e2e=False, personality=0, force_decel=False,
|
||||
lead_dRel_glitch_fn=None, e2e_accel_fn=None):
|
||||
# lead_dRel_glitch_fn(current_time, d_rel, v_rel) -> (d_rel, v_rel): optional per-step override of the
|
||||
# REPORTED radar lead dRel/vRel, independent of the true physics used to advance distance_lead/speed --
|
||||
# models a sensor/fusion glitch (what radard actually reports can diverge from the real world) without
|
||||
# corrupting the closed-loop ego dynamics the rest of the planner reacts to. None -> no override (default).
|
||||
# e2e_accel_fn(current_time, ego_speed, ego_accel) -> float: optional override of the e2e model's
|
||||
# action.desiredAcceleration (default is a mild self.acceleration+0.1 echo, which can't independently
|
||||
# disagree with the MPC enough to exercise is_e2e()-gated behavior). None -> default echo (unchanged).
|
||||
self.lead_dRel_glitch_fn = lead_dRel_glitch_fn
|
||||
self.e2e_accel_fn = e2e_accel_fn
|
||||
self.rate = 1. / DT_MDL
|
||||
|
||||
if not Plant.messaging_initialized:
|
||||
@@ -90,6 +100,9 @@ class Plant:
|
||||
prob_lead = 0.0
|
||||
status = False
|
||||
|
||||
if self.lead_dRel_glitch_fn is not None and self.lead_relevancy:
|
||||
d_rel, v_rel = self.lead_dRel_glitch_fn(self.current_time, d_rel, v_rel)
|
||||
|
||||
lead = log.RadarState.LeadData.new_message()
|
||||
lead.dRel = float(d_rel)
|
||||
lead.yRel = 0.0
|
||||
@@ -112,7 +125,10 @@ class Plant:
|
||||
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
|
||||
model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.1)
|
||||
if self.e2e_accel_fn is not None:
|
||||
model.modelV2.action.desiredAcceleration = float(self.e2e_accel_fn(self.current_time, self.speed, self.acceleration))
|
||||
else:
|
||||
model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.1)
|
||||
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
|
||||
|
||||
@@ -31,6 +31,15 @@ DESCRIPTIONS = {
|
||||
"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)."
|
||||
),
|
||||
"AccelPersonalityEnabled": tr_noop("Enable Eco/Normal/Sport acceleration profiles, including early soft braking."),
|
||||
"AccelPersonality": tr_noop(
|
||||
"Eco accelerates gently and brakes early and soft; Sport accelerates briskly. " +
|
||||
"Hard-braking authority is always preserved."
|
||||
),
|
||||
"RadarDistance": tr_noop(
|
||||
"Hold a lead through brief radar flicker/dropout so sunnypilot does not lose and re-grab it, " +
|
||||
"smoothing the hard/late brakes that radar drop-outs cause. Braking is never reduced below stock."
|
||||
),
|
||||
"AlwaysOnDM": tr_noop("Enable driver monitoring even when sunnypilot is not engaged."),
|
||||
'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."),
|
||||
"IsMetric": tr_noop("Display speed in km/h instead of mph."),
|
||||
@@ -64,6 +73,12 @@ class TogglesLayout(Widget):
|
||||
"disengage_on_accelerator.png",
|
||||
False,
|
||||
),
|
||||
"RadarDistance": (
|
||||
lambda: tr("Radar Distance"),
|
||||
DESCRIPTIONS["RadarDistance"],
|
||||
"speed_limit.png",
|
||||
False,
|
||||
),
|
||||
"IsLdwEnabled": (
|
||||
lambda: tr("Enable Lane Departure Warnings"),
|
||||
DESCRIPTIONS["IsLdwEnabled"],
|
||||
@@ -106,6 +121,24 @@ class TogglesLayout(Widget):
|
||||
icon="speed_limit.png"
|
||||
)
|
||||
|
||||
self._accel_personality_enabled = toggle_item(
|
||||
lambda: tr("Enable Acceleration Profiles"),
|
||||
lambda: tr(DESCRIPTIONS["AccelPersonalityEnabled"]),
|
||||
self._params.get_bool("AccelPersonalityEnabled"),
|
||||
callback=self._set_accel_personality_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 +168,11 @@ class TogglesLayout(Widget):
|
||||
|
||||
self._toggles[param] = toggle
|
||||
|
||||
# insert longitudinal personality after NDOG toggle
|
||||
# insert longitudinal + acceleration personality after NDOG toggle
|
||||
if param == "DisengageOnAccelerator":
|
||||
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
|
||||
self._toggles["AccelPersonalityEnabled"] = self._accel_personality_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)
|
||||
@@ -176,11 +211,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_personality_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_personality_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.")
|
||||
@@ -247,3 +286,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_personality_enabled(self, state: bool):
|
||||
self._params.put_bool("AccelPersonalityEnabled", state, block=True)
|
||||
|
||||
@@ -13,6 +13,7 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad import OnroadViewContainerSP as AugmentedRoadView
|
||||
|
||||
ONROAD_DELAY = 2.5 # seconds
|
||||
|
||||
@@ -118,13 +119,15 @@ class MiciMainLayout(Scroller):
|
||||
|
||||
# FIXME: these two pops can interrupt user interacting in the settings
|
||||
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._onroad_time_delay = None
|
||||
|
||||
# When car leaves standstill, pop nav stack and scroll to onroad
|
||||
CS = ui_state.sm["carState"]
|
||||
if not CS.standstill and self._prev_standstill:
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._prev_standstill = CS.standstill
|
||||
|
||||
def _on_interactive_timeout(self):
|
||||
|
||||
@@ -51,11 +51,17 @@ class LaneChangeSettingsLayout(Widget):
|
||||
description=lambda: tr("Toggle to enable a delay timer for seamless lane changes when blind spot monitoring " +
|
||||
"(BSM) detects a obstructing vehicle, ensuring safe maneuvering."),
|
||||
)
|
||||
self._road_edge_block = toggle_item_sp(
|
||||
param="RoadEdgeLaneChangeEnabled",
|
||||
title=lambda: tr("Block Lane Change: Road Edge Detection"),
|
||||
description=lambda: tr("Blocks the lane change if the model sees a road edge on your signaled side."),
|
||||
)
|
||||
|
||||
items = [
|
||||
self._lane_change_timer,
|
||||
LineSeparatorSP(40),
|
||||
self._bsm_delay,
|
||||
self._road_edge_block,
|
||||
]
|
||||
|
||||
return items
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
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 openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
|
||||
|
||||
class MiciMainLayoutSP(MiciMainLayout):
|
||||
def _should_auto_scroll_to_onroad(self) -> bool:
|
||||
return not self._onroad_layout.is_on_info_panel()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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 pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroller_sp import ScrollerSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.augmented_road_view import AugmentedRoadViewSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad_info_panel import OnroadInfoPanel
|
||||
|
||||
CONFIDENCE_BALL_VISIBLE_RATIO = 0.4
|
||||
HORIZONTAL_SETTLE_PX = 5
|
||||
HORIZONTAL_RESET_RATIO = 0.5
|
||||
|
||||
|
||||
class OnroadViewContainerSP(ScrollerSP):
|
||||
def __init__(self, bookmark_callback=None):
|
||||
super().__init__(horizontal=False, snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
|
||||
self.road_view = AugmentedRoadViewSP(bookmark_callback=bookmark_callback)
|
||||
self.onroad_info_panel = OnroadInfoPanel(bookmark_callback=bookmark_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self.road_view,
|
||||
self.onroad_info_panel,
|
||||
])
|
||||
self._scroller.set_reset_scroll_at_show(False)
|
||||
self._scroller.set_scrolling_enabled(lambda: abs(self.rect.x) < HORIZONTAL_SETTLE_PX)
|
||||
|
||||
for child in (self.road_view, self.onroad_info_panel):
|
||||
inner_touch_valid = child._touch_valid_callback
|
||||
child.set_touch_valid_callback(
|
||||
lambda inner=inner_touch_valid: self._touch_valid() and (inner() if inner else True)
|
||||
)
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle):
|
||||
super().set_rect(rect)
|
||||
self.road_view.set_rect(rect)
|
||||
self.onroad_info_panel.set_rect(rect)
|
||||
return self
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
return self.road_view.is_swiping_left() or self.onroad_info_panel.is_swiping_left()
|
||||
|
||||
def set_click_callback(self, callback) -> None:
|
||||
self.road_view.set_click_callback(callback)
|
||||
self.onroad_info_panel.set_click_callback(callback)
|
||||
|
||||
def is_on_info_panel(self) -> bool:
|
||||
"""True when scrolled past halfway toward onroad_info_panel (used by main layout
|
||||
to skip auto-pop-back-to-camera while user is reading the info panel)."""
|
||||
return abs(self._scroller.scroll_panel.get_offset()) > self._rect.height / 2
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if abs(self.rect.x) > gui_app.width * HORIZONTAL_RESET_RATIO:
|
||||
self._scroller.scroll_panel.set_offset(0)
|
||||
|
||||
vertical_offset = self._scroller.scroll_panel.get_offset()
|
||||
show_ball = abs(vertical_offset) < rect.height * CONFIDENCE_BALL_VISIBLE_RATIO
|
||||
self.road_view.set_show_confidence_ball(show_ball)
|
||||
|
||||
super()._render(rect)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
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 pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.application import MousePos
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import BookmarkIcon
|
||||
|
||||
METER_TO_KM = 0.001
|
||||
METER_TO_MILE = 0.000621371
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnroadInfoPanelColors:
|
||||
white: rl.Color = rl.WHITE
|
||||
black: rl.Color = rl.BLACK
|
||||
red: rl.Color = rl.Color(255, 0, 0, 255)
|
||||
green: rl.Color = rl.Color(0, 255, 0, 255)
|
||||
grey: rl.Color = rl.Color(190, 195, 190, 255)
|
||||
light_grey: rl.Color = rl.Color(200, 200, 200, 255)
|
||||
dark_grey: rl.Color = rl.Color(100, 100, 100, 255)
|
||||
bg_dark: rl.Color = rl.Color(0, 0, 0, 255)
|
||||
card_bg: rl.Color = rl.Color(50, 50, 50, 200)
|
||||
badge_bg: rl.Color = rl.Color(60, 60, 60, 255)
|
||||
|
||||
|
||||
COLORS = OnroadInfoPanelColors()
|
||||
|
||||
|
||||
class OnroadInfoPanel(Widget):
|
||||
def __init__(self, bookmark_callback=None):
|
||||
super().__init__()
|
||||
self.speed_limit: float = 0.0
|
||||
self.speed_limit_valid: bool = False
|
||||
self.speed_limit_offset: float = 0.0
|
||||
self.next_speed_limit: float = 0.0
|
||||
self.next_speed_limit_distance: float = 0.0
|
||||
self.road_name: str = ""
|
||||
self.current_speed: float = 0.0
|
||||
self.set_speed: float = 0.0
|
||||
self.cruise_enabled: bool = False
|
||||
|
||||
self._sign_slide: float = 0.0
|
||||
|
||||
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
|
||||
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
self._marquee_offset: float = 0.0
|
||||
self._marquee_direction: int = 1
|
||||
self._marquee_pause_timer: float = 0.0
|
||||
self._marquee_speed: float = 40.0
|
||||
self._marquee_pause_duration: float = 1.5
|
||||
|
||||
self._alert_renderer = AlertRenderer()
|
||||
self._alert_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._bookmark_icon = BookmarkIcon(bookmark_callback)
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
return self._bookmark_icon.is_swiping_left()
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
# Mirror stock AugmentedRoadView: suppress click while bookmark gesture active
|
||||
if not self._bookmark_icon.interacting():
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
sm = ui_state.sm
|
||||
speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
|
||||
if sm.valid["longitudinalPlanSP"]:
|
||||
lp_sp = sm["longitudinalPlanSP"]
|
||||
resolver = lp_sp.speedLimit.resolver
|
||||
self.speed_limit = resolver.speedLimit * speed_conv
|
||||
self.speed_limit_valid = resolver.speedLimitValid
|
||||
self.speed_limit_offset = resolver.speedLimitOffset * speed_conv
|
||||
|
||||
if sm.valid["liveMapDataSP"]:
|
||||
lmd = sm["liveMapDataSP"]
|
||||
self.next_speed_limit = lmd.speedLimitAhead * speed_conv
|
||||
self.next_speed_limit_distance = lmd.speedLimitAheadDistance
|
||||
self.road_name = lmd.roadName
|
||||
|
||||
if sm.updated["carState"]:
|
||||
self.current_speed = sm["carState"].vEgo * speed_conv
|
||||
|
||||
if sm.valid["carState"] and sm.valid["controlsState"]:
|
||||
self.cruise_enabled = sm["carState"].cruiseState.enabled
|
||||
v_cruise_cluster = sm["carState"].vCruiseCluster
|
||||
set_speed_kph = sm["controlsState"].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
|
||||
self.set_speed = set_speed_kph * (METER_TO_MILE / METER_TO_KM) if not ui_state.is_metric else set_speed_kph
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
self._update_state()
|
||||
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), COLORS.bg_dark)
|
||||
margin = 20
|
||||
mid_y = rect.y + rect.height / 2
|
||||
|
||||
left_x = rect.x + margin
|
||||
|
||||
if self.cruise_enabled:
|
||||
unit = tr("MAX")
|
||||
display_speed = self.set_speed
|
||||
else:
|
||||
unit = tr("km/h") if ui_state.is_metric else tr("MPH")
|
||||
display_speed = self.current_speed
|
||||
|
||||
speed_val = str(round(display_speed))
|
||||
if self.speed_limit_valid and display_speed > self.speed_limit:
|
||||
speed_color = COLORS.red
|
||||
else:
|
||||
speed_color = COLORS.white
|
||||
|
||||
rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, mid_y - 95), 38, 0, COLORS.grey)
|
||||
rl.draw_text_ex(self._font_bold, speed_val, rl.Vector2(left_x, mid_y - 60), 110, 0, speed_color)
|
||||
|
||||
sign_width = 135
|
||||
sign_height = 135 if ui_state.is_metric else 175
|
||||
|
||||
has_next = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit
|
||||
target_slide = 1.0 if has_next else 0.0
|
||||
slide_speed = 3.0 * rl.get_frame_time()
|
||||
if self._sign_slide < target_slide:
|
||||
self._sign_slide = min(self._sign_slide + slide_speed, target_slide)
|
||||
elif self._sign_slide > target_slide:
|
||||
self._sign_slide = max(self._sign_slide - slide_speed, target_slide)
|
||||
|
||||
next_w = int(sign_width * 0.7)
|
||||
next_h = int(sign_height * 0.7)
|
||||
next_peek = int(next_w * 0.85) + 5
|
||||
centered_x = rect.x + rect.width - sign_width - margin
|
||||
shifted_x = rect.x + rect.width - sign_width - margin - next_peek
|
||||
sign_x = centered_x + (shifted_x - centered_x) * self._sign_slide
|
||||
sign_y = rect.y + (rect.height - sign_height) / 2
|
||||
|
||||
road_y = mid_y + 55
|
||||
road_width = sign_x - left_x - margin
|
||||
self._draw_road_name(left_x, road_y, road_width)
|
||||
|
||||
if has_next and self._sign_slide > 0.01:
|
||||
next_val = str(round(self.next_speed_limit))
|
||||
dist_str = self._format_distance(self.next_speed_limit_distance)
|
||||
next_x = sign_x + sign_width - int(next_w * 0.15)
|
||||
next_y = sign_y + (sign_height - next_h) / 2
|
||||
|
||||
next_speed_color = COLORS.black
|
||||
if ui_state.is_metric:
|
||||
self._draw_vienna_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True)
|
||||
else:
|
||||
self._draw_mutcd_sign(next_x, next_y, next_w, next_h, next_val, next_speed_color, is_upcoming=True)
|
||||
|
||||
dist_size = measure_text_cached(self._font_medium, dist_str, 24)
|
||||
rl.draw_text_ex(self._font_medium, dist_str, rl.Vector2(next_x + next_w / 2 - dist_size.x / 2, next_y + next_h + 4), 24, 0, COLORS.grey)
|
||||
|
||||
self._draw_speed_limit_sign(sign_x, sign_y, sign_width, sign_height)
|
||||
|
||||
if self.speed_limit_offset != 0 and self.speed_limit_valid:
|
||||
offset_val = str(abs(round(self.speed_limit_offset)))
|
||||
badge_sz = 42
|
||||
badge_x = sign_x + sign_width - badge_sz * 0.85
|
||||
badge_y = sign_y - badge_sz * 0.25
|
||||
|
||||
if ui_state.is_metric:
|
||||
badge_r = badge_sz / 2
|
||||
badge_cx = badge_x + badge_r
|
||||
badge_cy = badge_y + badge_r
|
||||
rl.draw_circle(int(badge_cx), int(badge_cy), badge_r + 2, COLORS.dark_grey)
|
||||
rl.draw_circle(int(badge_cx), int(badge_cy), badge_r, COLORS.badge_bg)
|
||||
self._draw_text_centered(self._font_bold, offset_val, 24, rl.Vector2(badge_cx, badge_cy), COLORS.white)
|
||||
else:
|
||||
mutcd_badge_x = sign_x + sign_width - badge_sz * 0.65
|
||||
mutcd_badge_y = sign_y - badge_sz * 0.50
|
||||
badge_rect = rl.Rectangle(mutcd_badge_x, mutcd_badge_y, badge_sz, badge_sz)
|
||||
rl.draw_rectangle_rounded(badge_rect, 0.25, 10, COLORS.badge_bg)
|
||||
rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.25, 10, 2, COLORS.dark_grey)
|
||||
self._draw_text_centered(self._font_bold, offset_val, 24, rl.Vector2(mutcd_badge_x + badge_sz / 2, mutcd_badge_y + badge_sz / 2), COLORS.white)
|
||||
|
||||
# SCC
|
||||
speed_size = measure_text_cached(self._font_bold, speed_val, 110)
|
||||
scc_x = left_x + speed_size.x + 30
|
||||
scc_y = mid_y - 50
|
||||
self._draw_scc_icons(scc_x, scc_y)
|
||||
|
||||
self._bookmark_icon.render(rect)
|
||||
|
||||
if ui_state.started:
|
||||
alert_obj, no_alert = self._alert_renderer.will_render()
|
||||
self._alert_alpha_filter.update(0 if no_alert else 1)
|
||||
alpha = self._alert_alpha_filter.x
|
||||
if alpha > 0.01:
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, int(150 * alpha)))
|
||||
self._alert_renderer.render(rect)
|
||||
|
||||
def _draw_scc_icons(self, x: float, y: float) -> None:
|
||||
sm = ui_state.sm
|
||||
if not sm.valid["longitudinalPlanSP"]:
|
||||
return
|
||||
scc = sm["longitudinalPlanSP"].smartCruiseControl
|
||||
|
||||
box_w, box_h = 100, 36
|
||||
gap = 6
|
||||
drawn = 0
|
||||
|
||||
for label, active in [("SCC-V", scc.vision.active), ("SCC-M", scc.map.active)]:
|
||||
if not active:
|
||||
continue
|
||||
bx = x
|
||||
by = y + drawn * (box_h + gap)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(bx, by, box_w, box_h), 0.3, 10, COLORS.green)
|
||||
self._draw_text_centered(self._font_bold, label, 20, rl.Vector2(bx + box_w / 2, by + box_h / 2), COLORS.black)
|
||||
drawn += 1
|
||||
|
||||
def _draw_speed_limit_sign(self, x: float, y: float, sign_width: float, sign_height: float) -> None:
|
||||
speed_str = str(round(self.speed_limit)) if self.speed_limit_valid and self.speed_limit > 0 else "--"
|
||||
speed_color = COLORS.black if not self.speed_limit_valid or self.current_speed <= self.speed_limit else COLORS.red
|
||||
|
||||
if ui_state.is_metric:
|
||||
self._draw_vienna_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
|
||||
else:
|
||||
self._draw_mutcd_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
|
||||
|
||||
def _draw_road_name(self, x: float, y: float, width: float) -> None:
|
||||
road_display = self.road_name if self.road_name else "--"
|
||||
font_size = 30
|
||||
road_size = measure_text_cached(self._font_semi_bold, road_display, font_size)
|
||||
text_width = road_size.x
|
||||
|
||||
if text_width <= width:
|
||||
self._marquee_offset = 0.0
|
||||
self._marquee_direction = 1
|
||||
self._marquee_pause_timer = 0.0
|
||||
rl.draw_text_ex(self._font_semi_bold, road_display, rl.Vector2(x, y), font_size, 0, COLORS.white)
|
||||
else:
|
||||
overflow = text_width - width
|
||||
dt = rl.get_frame_time()
|
||||
|
||||
if self._marquee_pause_timer > 0:
|
||||
self._marquee_pause_timer -= dt
|
||||
else:
|
||||
self._marquee_offset += self._marquee_direction * self._marquee_speed * dt
|
||||
|
||||
if self._marquee_offset >= overflow:
|
||||
self._marquee_offset = overflow
|
||||
self._marquee_direction = -1
|
||||
self._marquee_pause_timer = self._marquee_pause_duration
|
||||
elif self._marquee_offset <= 0:
|
||||
self._marquee_offset = 0
|
||||
self._marquee_direction = 1
|
||||
self._marquee_pause_timer = self._marquee_pause_duration
|
||||
|
||||
rl.begin_scissor_mode(int(x), int(y), int(width), int(road_size.y + 4))
|
||||
text_pos = rl.Vector2(x - self._marquee_offset, y)
|
||||
rl.draw_text_ex(self._font_semi_bold, road_display, text_pos, font_size, 0, COLORS.white)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_vienna_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
|
||||
center = rl.Vector2(x + width / 2, y + height / 2)
|
||||
outer_radius = min(width, height) / 2
|
||||
|
||||
rl.draw_circle_v(center, outer_radius, COLORS.white)
|
||||
ring_width = outer_radius * 0.18
|
||||
rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red)
|
||||
|
||||
font_size = outer_radius * (0.7 if len(speed_str) >= 3 else 0.9)
|
||||
text_size = measure_text_cached(self._font_bold, speed_str, int(font_size))
|
||||
text_pos = rl.Vector2(center.x - text_size.x / 2, center.y - text_size.y / 2)
|
||||
rl.draw_text_ex(self._font_bold, speed_str, text_pos, font_size, 0, speed_color)
|
||||
|
||||
def _draw_mutcd_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
|
||||
sign_rect = rl.Rectangle(x, y, width, height)
|
||||
rl.draw_rectangle_rounded(sign_rect, 0.35, 10, COLORS.white)
|
||||
|
||||
inset = max(4, width * 0.05)
|
||||
inner_rect = rl.Rectangle(x + inset, y + inset, width - inset * 2, height - inset * 2)
|
||||
outer_radius = 0.35 * width / 2.0
|
||||
inner_radius = outer_radius - inset
|
||||
inner_roundness = inner_radius / (inner_rect.width / 2.0)
|
||||
rl.draw_rectangle_rounded_lines_ex(inner_rect, inner_roundness, 10, 3, COLORS.black)
|
||||
|
||||
mid_x = x + width / 2
|
||||
label_size = max(18, int(width * 0.26))
|
||||
if is_upcoming:
|
||||
self._draw_text_centered(self._font_bold, tr("AHEAD"), label_size, rl.Vector2(mid_x, y + height * 0.27), COLORS.black)
|
||||
else:
|
||||
self._draw_text_centered(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black)
|
||||
self._draw_text_centered(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black)
|
||||
|
||||
speed_font_size = int(width * 0.52) if len(speed_str) >= 3 else int(width * 0.62)
|
||||
self._draw_text_centered(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color)
|
||||
|
||||
def _draw_text_centered(self, font, text, size, pos_center, color):
|
||||
sz = measure_text_cached(font, text, size)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color)
|
||||
|
||||
def _format_distance(self, distance: float) -> str:
|
||||
if ui_state.is_metric:
|
||||
if distance < 50:
|
||||
return tr("Near")
|
||||
if distance >= 1000:
|
||||
return f"{distance * METER_TO_KM:.1f}" + tr("km")
|
||||
if distance < 200:
|
||||
rounded = max(10, int(distance / 10) * 10)
|
||||
else:
|
||||
rounded = int(distance / 100) * 100
|
||||
return str(rounded) + tr("m")
|
||||
else:
|
||||
distance_mi = distance * METER_TO_MILE
|
||||
if distance_mi < 0.1:
|
||||
return tr("Near")
|
||||
return f"{distance_mi:.1f}" + tr("mi")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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 pyray as rl
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
|
||||
|
||||
|
||||
class _SuppressedConfidenceBall:
|
||||
def render(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
class AugmentedRoadViewSP(AugmentedRoadView):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._show_confidence_ball: bool = True
|
||||
self._real_confidence_ball = self._confidence_ball
|
||||
self._confidence_ball = _SuppressedConfidenceBall()
|
||||
|
||||
def set_show_confidence_ball(self, show: bool) -> None:
|
||||
self._show_confidence_ball = show
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
super()._render(rect)
|
||||
if self._show_confidence_ball:
|
||||
self._real_confidence_ball.render(self.rect)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
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 pyray as rl
|
||||
from openpilot.system.ui.lib.application import MouseEvent
|
||||
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
|
||||
|
||||
|
||||
class GuiScrollPanel2SP(GuiScrollPanel2):
|
||||
"""Reject orthogonal-dominant drags so nested scrollers (outer horizontal +
|
||||
inner vertical) don't both engage on a slightly diagonal swipe.
|
||||
|
||||
Implemented as a post-super state rollback rather than reimplementing the
|
||||
PRESSED state machine — keeps stock behaviour authoritative."""
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
|
||||
content_size: float) -> None:
|
||||
pre_state = self._state
|
||||
super()._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
|
||||
|
||||
if self._state == ScrollState.MANUAL_SCROLL and pre_state == ScrollState.PRESSED and \
|
||||
self._initial_click_event is not None:
|
||||
diff_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x)
|
||||
diff_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y)
|
||||
along = diff_x if self._horizontal else diff_y
|
||||
anti = diff_y if self._horizontal else diff_x
|
||||
if anti > along:
|
||||
self._state = ScrollState.STEADY
|
||||
self._velocity = 0.0
|
||||
self._velocity_buffer.clear()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
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 openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
|
||||
|
||||
|
||||
class ScrollerSP(Scroller):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
inner = self._scroller
|
||||
inner.scroll_panel = GuiScrollPanel2SP(inner._horizontal, handle_out_of_bounds=not inner._snap_items)
|
||||
@@ -10,6 +10,9 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.main import MiciMainLayoutSP as MiciMainLayout
|
||||
|
||||
BIG_UI = gui_app.big_ui()
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelp
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
|
||||
PROCESS_NAME = "selfdrive.modeld.modeld_tinygrad"
|
||||
|
||||
@@ -329,6 +330,7 @@ def main(demo=False):
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
|
||||
DH = DesireHelper()
|
||||
RELC = RoadEdgeLaneChangeController(DH)
|
||||
meta_constants = load_meta_constants()
|
||||
|
||||
while True:
|
||||
@@ -433,7 +435,10 @@ def main(demo=False):
|
||||
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
|
||||
r_lane_change_prob = desire_state[log.Desire.laneChangeRight]
|
||||
lane_change_prob = l_lane_change_prob + r_lane_change_prob
|
||||
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob)
|
||||
RELC.update(modelv2_send.modelV2.roadEdgeStds, modelv2_send.modelV2.laneLineProbs, v_ego, modelv2_send.modelV2.roadEdges)
|
||||
mdv2sp_send.modelDataV2SP.leftLaneChangeEdgeBlock = RELC.left_edge_detected
|
||||
mdv2sp_send.modelDataV2SP.rightLaneChangeEdgeBlock = RELC.right_edge_detected
|
||||
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, RELC.left_edge_detected, RELC.right_edge_detected)
|
||||
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
|
||||
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
|
||||
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Acceleration Personality (ECO / NORMAL / SPORT). Tunes only MPC INPUTS, never the output:
|
||||
* positive-accel ceiling + its per-cycle open-rate -> tier-scaled take-off from a stop;
|
||||
* jerk-cost relaxation -> smooth accel/decel onset near a stop, on a fresh direction change, a
|
||||
hard-braking lead, or a fast-closing gap;
|
||||
* add-only, speed-dependent follow-gap widen on the MPC t_follow.
|
||||
Disabled => stock everywhere (byte-stock).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from cereal import messaging
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot import get_sanitize_int_param
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import \
|
||||
NORMAL, PERSONALITY_MIN, PERSONALITY_MAX, A_CRUISE_MAX_BP, A_CRUISE_MAX_V, STOCK_A_CRUISE_MAX_V, \
|
||||
RISE_RATE_BP, RISE_RATE_V, STOCK_RISE_RATE, RISE_RATE_LEAD_VREL_GATE, JERK_SCALE_BP, JERK_SCALE_V, \
|
||||
ONSET_DEADBAND, ONSET_RAMP_S, ONSET_FLOOR, RELAX_RAMP_S, LEAD_BRAKE_ALEAD_BP, LEAD_BRAKE_FACTOR_V, \
|
||||
CLOSING_VREL_BP, CLOSING_FACTOR_V, TF_WIDEN_V_BP, TF_WIDEN_BASE_V, TF_WIDEN_TIER, TF_WIDEN_MAX, \
|
||||
TF_SLEW_PER_S, TF_DECEL_HOLD_A
|
||||
|
||||
|
||||
class _OnsetRelax:
|
||||
# Snaps to a tier-scaled floor on a fresh accel<->decel direction change, eases back to 1.0 over ONSET_RAMP_S.
|
||||
def __init__(self):
|
||||
self._prev_sign = 0
|
||||
self._ramp = 1.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self._prev_sign = 0
|
||||
self._ramp = 1.0
|
||||
|
||||
def update(self, a_ego: float, floor: float) -> float:
|
||||
sign = 0
|
||||
if a_ego > ONSET_DEADBAND:
|
||||
sign = 1
|
||||
elif a_ego < -ONSET_DEADBAND:
|
||||
sign = -1
|
||||
|
||||
if sign != 0 and sign != self._prev_sign:
|
||||
self._ramp = floor
|
||||
self._prev_sign = sign
|
||||
else:
|
||||
self._ramp = min(1.0, self._ramp + (1.0 - floor) * (DT_MDL / ONSET_RAMP_S))
|
||||
return self._ramp
|
||||
|
||||
|
||||
class _TransientRelax:
|
||||
# Same shape as _OnsetRelax, but triggered by a level instead of a sign flip: snap to a fresh/escalating
|
||||
# factor, then ease back to 1.0 over ramp_s regardless of whether the raw factor is still low. A pinned
|
||||
# floor destabilizes the MPC's re-solve over a sustained episode, so only the onset gets softened.
|
||||
def __init__(self):
|
||||
self._was_active = False
|
||||
self._episode_floor = 1.0
|
||||
self._ramp = 1.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self._was_active = False
|
||||
self._episode_floor = 1.0
|
||||
self._ramp = 1.0
|
||||
|
||||
def update(self, raw_factor: float, ramp_s: float) -> float:
|
||||
active = raw_factor < 1.0 - 1e-3
|
||||
if active and (not self._was_active or raw_factor < self._episode_floor):
|
||||
self._episode_floor = raw_factor
|
||||
self._ramp = raw_factor
|
||||
else:
|
||||
self._ramp = min(1.0, self._ramp + DT_MDL / ramp_s)
|
||||
if not active:
|
||||
self._episode_floor = 1.0
|
||||
self._was_active = active
|
||||
return self._ramp
|
||||
|
||||
|
||||
class AccelController:
|
||||
def __init__(self, CP: structs.CarParams, mpc=None, params=None):
|
||||
# CP/mpc accepted for the planner's constructor signature; unused (shapes MPC inputs only).
|
||||
self._params = params or Params()
|
||||
self._frame = 0
|
||||
self._enabled = False
|
||||
self._personality = NORMAL
|
||||
self._v_ego = 0.0
|
||||
self._a_ego = 0.0
|
||||
self._widen = 0.0 # current slewed follow-gap widen (s), add-only
|
||||
self._t_follow = 0.0 # last t_follow handed to the MPC (telemetry)
|
||||
self._onset_relax = _OnsetRelax()
|
||||
self._lead_brake_relax = _TransientRelax()
|
||||
self._closing_relax = _TransientRelax()
|
||||
self._onset_factor = 1.0
|
||||
self._lead_brake_factor = 1.0
|
||||
self._closing_factor = 1.0
|
||||
self._closing_on_lead = False
|
||||
self._read_params()
|
||||
|
||||
def _read_params(self) -> None:
|
||||
self._enabled = self._params.get_bool("AccelPersonalityEnabled")
|
||||
if not self._enabled:
|
||||
self._personality = NORMAL
|
||||
return
|
||||
self._personality = get_sanitize_int_param("AccelPersonality", PERSONALITY_MIN, PERSONALITY_MAX, self._params)
|
||||
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
if self._frame % int(1. / DT_MDL) == 0:
|
||||
self._read_params()
|
||||
self._v_ego = float(sm['carState'].vEgo)
|
||||
self._a_ego = float(sm['carState'].aEgo)
|
||||
|
||||
if self._enabled:
|
||||
lead = sm['radarState'].leadOne
|
||||
self._onset_factor = self._onset_relax.update(self._a_ego, ONSET_FLOOR[self._personality])
|
||||
self._lead_brake_factor = self._lead_brake_relax.update(self._get_lead_brake_factor(lead), RELAX_RAMP_S)
|
||||
self._closing_factor = self._closing_relax.update(self._get_closing_factor(lead), RELAX_RAMP_S)
|
||||
self._closing_on_lead = lead.status and lead.vRel < RISE_RATE_LEAD_VREL_GATE
|
||||
else:
|
||||
self._onset_relax.reset()
|
||||
self._lead_brake_relax.reset()
|
||||
self._closing_relax.reset()
|
||||
self._onset_factor = 1.0
|
||||
self._lead_brake_factor = 1.0
|
||||
self._closing_factor = 1.0
|
||||
self._closing_on_lead = False
|
||||
|
||||
self._frame += 1
|
||||
|
||||
def _get_lead_brake_factor(self, lead) -> float:
|
||||
# Raw, uneased tier-scaled factor for THIS cycle -- fed through _TransientRelax in update(), never used
|
||||
# directly as the published factor (see the class docstring for why: a level-pinned floor destabilizes
|
||||
# the MPC's re-solve over a sustained episode).
|
||||
if not lead.status:
|
||||
return 1.0
|
||||
return float(np.interp(lead.aLeadK, LEAD_BRAKE_ALEAD_BP, LEAD_BRAKE_FACTOR_V[self._personality]))
|
||||
|
||||
def _get_closing_factor(self, lead) -> float:
|
||||
# Raw, uneased tier-scaled factor for THIS cycle -- see _get_lead_brake_factor's note.
|
||||
if not lead.status:
|
||||
return 1.0
|
||||
return float(np.interp(lead.vRel, CLOSING_VREL_BP, CLOSING_FACTOR_V[self._personality]))
|
||||
|
||||
def reset(self) -> None:
|
||||
# Drop the accumulated widen (e.g. on disengage / standstill re-init) so it re-ramps cleanly.
|
||||
self._widen = 0.0
|
||||
self._onset_relax.reset()
|
||||
self._lead_brake_relax.reset()
|
||||
self._closing_relax.reset()
|
||||
self._onset_factor = 1.0
|
||||
self._lead_brake_factor = 1.0
|
||||
self._closing_factor = 1.0
|
||||
|
||||
def get_max_accel(self, v_ego: float) -> float:
|
||||
# Disabled -> stock ceiling (off == stock, independent of the NORMAL profile so NORMAL is free to differ).
|
||||
table = A_CRUISE_MAX_V[self._personality] if self._enabled else STOCK_A_CRUISE_MAX_V
|
||||
return float(np.interp(v_ego, A_CRUISE_MAX_BP, table))
|
||||
|
||||
def get_rise_rate(self, v_ego: float) -> float:
|
||||
# Disabled, or still closing on a lead -> stock ceiling open-rate.
|
||||
if not self._enabled or self._closing_on_lead:
|
||||
return STOCK_RISE_RATE
|
||||
return float(np.interp(v_ego, RISE_RATE_BP, RISE_RATE_V[self._personality]))
|
||||
|
||||
def get_jerk_scale(self, v_ego: float) -> float:
|
||||
# Most-relaxed of: near a stop, a fresh onset, a hard-braking lead, a fast-closing gap. Never > 1.0 (stock).
|
||||
if not self._enabled:
|
||||
return 1.0
|
||||
near_stop = float(np.interp(v_ego, JERK_SCALE_BP, JERK_SCALE_V[self._personality]))
|
||||
return min(near_stop, self._onset_factor, self._lead_brake_factor, self._closing_factor)
|
||||
|
||||
def get_t_follow(self, t_follow: float, v_ego: float) -> float:
|
||||
# MPC t_follow hook. Adds a slewed, decel-held, speed-dependent comfort widen on top of the stock
|
||||
# t_follow. Identity when disabled => byte-stock. Add-only => desired distance >= stock => brake >= stock.
|
||||
t_follow = float(t_follow)
|
||||
if not self._enabled:
|
||||
self._widen = 0.0
|
||||
self._t_follow = t_follow
|
||||
return t_follow
|
||||
|
||||
target = float(np.interp(v_ego, TF_WIDEN_V_BP, TF_WIDEN_BASE_V)) * TF_WIDEN_TIER[self._personality]
|
||||
target = min(target, TF_WIDEN_MAX)
|
||||
step = TF_SLEW_PER_S * DT_MDL
|
||||
|
||||
if self._a_ego <= TF_DECEL_HOLD_A and target < self._widen:
|
||||
pass # decel-hold: don't ease the gap in while braking
|
||||
elif target > self._widen:
|
||||
self._widen = min(target, self._widen + step) # open the gap, slewed
|
||||
else:
|
||||
self._widen = max(target, self._widen - step) # close the gap, slewed
|
||||
|
||||
self._widen = max(0.0, self._widen) # add-only guard
|
||||
self._t_follow = t_follow + self._widen
|
||||
return self._t_follow
|
||||
|
||||
# --- telemetry (published to cereal LongitudinalPlanSP.acceleration; no control effect) ---
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def personality(self):
|
||||
return self._personality
|
||||
|
||||
def max_accel(self) -> float:
|
||||
return self.get_max_accel(self._v_ego)
|
||||
|
||||
def t_follow(self) -> float:
|
||||
return self._t_follow
|
||||
|
||||
def follow_widen(self) -> float:
|
||||
return self._widen
|
||||
|
||||
def widen_active(self) -> bool:
|
||||
return self._enabled and self._widen > 0.005
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Acceleration Personality tuning tables. The controller shapes only what the longitudinal MPC CONSUMES
|
||||
(the positive-accel ceiling + its open-rate, and an add-only follow-gap widen fed to the MPC's t_follow);
|
||||
it never post-shapes the MPC's output accel. Disabled => every getter returns the upstream stock value,
|
||||
so off == byte-stock.
|
||||
"""
|
||||
|
||||
from cereal import custom
|
||||
|
||||
AccelerationPersonality = custom.LongitudinalPlanSP.AccelerationPersonality
|
||||
ECO = AccelerationPersonality.eco
|
||||
NORMAL = AccelerationPersonality.normal
|
||||
SPORT = AccelerationPersonality.sport
|
||||
|
||||
PERSONALITY_MIN = min(AccelerationPersonality.schema.enumerants.values())
|
||||
PERSONALITY_MAX = max(AccelerationPersonality.schema.enumerants.values())
|
||||
|
||||
# --- Positive-accel ceiling (launch/cruise) + its upward open-rate ---------------------------------------
|
||||
# Disabled -> falls back to STOCK_* (byte-stock). ACCEL_MAX (opendbc) hard-caps the ceiling at 2.0 m/s^2.
|
||||
A_CRUISE_MAX_BP = [0., 10., 25., 40.] # m/s (matches upstream A_CRUISE_MAX_BP)
|
||||
STOCK_A_CRUISE_MAX_V = [1.6, 1.2, 0.8, 0.6] # upstream A_CRUISE_MAX_VALS
|
||||
STOCK_RISE_RATE = 0.05 # upstream ceiling open-rate (m/s^2 per cycle)
|
||||
A_CRUISE_MAX_V = {
|
||||
ECO: [1.55, 0.75, 0.35, 0.20],
|
||||
NORMAL: [2.00, 1.40, 0.95, 0.70],
|
||||
SPORT: [2.00, 1.70, 1.20, 0.90],
|
||||
}
|
||||
# Ceiling open-rate (m/s^2 per cycle): fast near v=0 so launch isn't delayed, tapers by v=5.
|
||||
RISE_RATE_BP = [0., 5.] # m/s
|
||||
RISE_RATE_V = {
|
||||
ECO: [0.80, 0.07],
|
||||
NORMAL: [1.00, 0.16],
|
||||
SPORT: [1.20, 0.24],
|
||||
}
|
||||
# The fast near-stop rise-rate above only looks at v_ego, so it also fires while still closing on a lead
|
||||
# that hasn't cleared yet, letting the ceiling snap open faster than the actual situation calls for. Fall
|
||||
# back to the stock rate whenever still closing (vRel this negative or more).
|
||||
RISE_RATE_LEAD_VREL_GATE = -0.5 # m/s
|
||||
|
||||
# --- Launch jerk-cost relaxation (MPC INPUT: scales the core MPC's own jerk_factor) -----------------------
|
||||
# Bounded near a stop, ramped back to 1.0 (stock) by cruise speed. v=0 knot closed-loop verified; SPORT tied
|
||||
# to NORMAL rather than pushed lower (lower destabilizes the solver at this knot).
|
||||
JERK_SCALE_BP = [0., 5.] # m/s
|
||||
JERK_SCALE_V = {
|
||||
ECO: [0.60, 1.0],
|
||||
NORMAL: [0.45, 1.0],
|
||||
SPORT: [0.45, 1.0],
|
||||
}
|
||||
|
||||
# --- Onset jerk-cost relaxation (MPC INPUT: any accel<->decel direction change, not just launch) ----------
|
||||
ONSET_DEADBAND = 0.15 # m/s^2: ignore aEgo noise this small around a zero-crossing
|
||||
ONSET_RAMP_S = 0.4 # s: ease back to stock over this long
|
||||
ONSET_FLOOR = {ECO: 0.75, NORMAL: 0.65, SPORT: 0.50}
|
||||
|
||||
# Ramp shared by the two level-triggered factors below (lead-braking, closing-rate): a sustained pinned
|
||||
# floor destabilizes the MPC's re-solve, so both ease back to stock over this window instead.
|
||||
RELAX_RAMP_S = 0.4
|
||||
|
||||
# --- Lead-braking jerk-cost relaxation (MPC INPUT: react faster to a hard-braking lead) --------------------
|
||||
LEAD_BRAKE_ALEAD_BP = [-3.0, -0.5] # m/s^2, lead's own aLeadK
|
||||
LEAD_BRAKE_FACTOR_V = {
|
||||
ECO: [0.75, 1.0],
|
||||
NORMAL: [0.60, 1.0],
|
||||
SPORT: [0.45, 1.0],
|
||||
}
|
||||
|
||||
# --- Closing-rate jerk-cost relaxation (MPC INPUT: react faster to a fast-closing gap, any cause) ----------
|
||||
CLOSING_VREL_BP = [-6.0, -1.5] # m/s, closing rate (negative = closing)
|
||||
CLOSING_FACTOR_V = {
|
||||
ECO: [0.75, 1.0],
|
||||
NORMAL: [0.60, 1.0],
|
||||
SPORT: [0.45, 1.0],
|
||||
}
|
||||
|
||||
# --- Follow-gap widen (add-only, fed to the MPC t_follow) ------------------------------------------------
|
||||
TF_WIDEN_V_BP = [14.0, 28.0] # m/s: widen ramps in across this band, flat above
|
||||
TF_WIDEN_BASE_V = [0.0, 0.30] # s: base follow-time added at the band ends (pre-tier)
|
||||
TF_WIDEN_TIER = {ECO: 1.30, NORMAL: 1.00, SPORT: 0.50}
|
||||
TF_WIDEN_MAX = 0.45 # s: absolute cap on the added gap
|
||||
TF_SLEW_PER_S = 0.50 # s per second: max rate the widen may open/close
|
||||
TF_DECEL_HOLD_A = -0.20 # m/s^2: at/below this a_ego the widen won't shrink
|
||||
@@ -0,0 +1,505 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
AccelController is an INPUT shaper for the longitudinal MPC: a per-tier positive-accel ceiling + open-rate
|
||||
(launch), and an ADD-ONLY, slewed, decel-held follow-gap widen fed to the MPC t_follow. It never shapes the
|
||||
MPC output, so these tests pin: off == byte-stock; tier ordering; and the t_follow invariants (add-only,
|
||||
zero below the gate, slew-bounded, decel-hold, capped).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.accel_controller import AccelController
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import \
|
||||
ECO, NORMAL, SPORT, PERSONALITY_MIN, PERSONALITY_MAX, A_CRUISE_MAX_BP, RISE_RATE_V, \
|
||||
STOCK_A_CRUISE_MAX_V, STOCK_RISE_RATE, JERK_SCALE_BP, JERK_SCALE_V, ONSET_DEADBAND, ONSET_RAMP_S, \
|
||||
ONSET_FLOOR, RELAX_RAMP_S, LEAD_BRAKE_ALEAD_BP, LEAD_BRAKE_FACTOR_V, CLOSING_VREL_BP, CLOSING_FACTOR_V, \
|
||||
TF_WIDEN_V_BP, TF_WIDEN_BASE_V, TF_WIDEN_TIER, TF_WIDEN_MAX, TF_SLEW_PER_S, TF_DECEL_HOLD_A, AccelerationPersonality
|
||||
|
||||
_EPS = 1e-6
|
||||
_TF_STOCK = 1.45 # a representative stock t_follow (standard personality); the widen is add-only on top
|
||||
_SLEW_STEP = TF_SLEW_PER_S * DT_MDL
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def __init__(self, store=None):
|
||||
self.store = dict(store or {})
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.store.get(key, False))
|
||||
|
||||
def get(self, key, return_default=False):
|
||||
return int(self.store.get(key, 1))
|
||||
|
||||
def put(self, key, val, block=False):
|
||||
self.store[key] = val
|
||||
|
||||
|
||||
def make_lead(status=False, aLeadK=0.0, vRel=0.0):
|
||||
return SimpleNamespace(status=status, aLeadK=aLeadK, vRel=vRel)
|
||||
|
||||
|
||||
def make_sm(v_ego=20.0, a_ego=0.0, lead=None):
|
||||
return {'carState': SimpleNamespace(vEgo=v_ego, aEgo=a_ego),
|
||||
'radarState': SimpleNamespace(leadOne=lead or make_lead())}
|
||||
|
||||
|
||||
def make_controller(enabled=True, personality=NORMAL):
|
||||
store = {"AccelPersonalityEnabled": enabled, "AccelPersonality": int(personality)}
|
||||
ctrl = AccelController(CP=SimpleNamespace(), mpc=SimpleNamespace(), params=FakeParams(store))
|
||||
ctrl.update(make_sm())
|
||||
return ctrl
|
||||
|
||||
|
||||
def settle(ctrl, v_ego, a_ego=0.0, t_follow=_TF_STOCK, n=400):
|
||||
ctrl.update(make_sm(v_ego=v_ego, a_ego=a_ego))
|
||||
out = t_follow
|
||||
for _ in range(n):
|
||||
out = ctrl.get_t_follow(t_follow, v_ego)
|
||||
return out
|
||||
|
||||
|
||||
# --- Profiles / off == stock ------------------------------------------------------------------------------
|
||||
|
||||
def test_enum_source_parity():
|
||||
assert (ECO, NORMAL, SPORT) == (AccelerationPersonality.eco, AccelerationPersonality.normal, AccelerationPersonality.sport)
|
||||
assert (PERSONALITY_MIN, PERSONALITY_MAX) == (0, 2)
|
||||
|
||||
|
||||
def test_disabled_forces_normal_and_stock_ceiling():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
assert ctrl.personality() == NORMAL
|
||||
assert not ctrl.enabled()
|
||||
for v in (0.0, 10.0, 25.0, 40.0):
|
||||
assert ctrl.get_max_accel(v) == pytest.approx(np.interp(v, A_CRUISE_MAX_BP, STOCK_A_CRUISE_MAX_V))
|
||||
# off == stock, regardless of v_ego (the speed-dependent open-rate table is bypassed entirely when disabled)
|
||||
for v in (0.0, 5.0, 20.0, 40.0):
|
||||
assert ctrl.get_rise_rate(v) == STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_disabled_t_follow_is_identity():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
for v in (2.0, 10.0, 20.0, 30.0):
|
||||
assert ctrl.get_t_follow(_TF_STOCK, v) == pytest.approx(_TF_STOCK)
|
||||
assert ctrl.follow_widen() == 0.0
|
||||
assert not ctrl.widen_active()
|
||||
|
||||
|
||||
def test_stock_ceiling_matches_upstream():
|
||||
# off must equal upstream get_max_accel table so the feature is byte-stock when disabled.
|
||||
assert STOCK_A_CRUISE_MAX_V == [1.6, 1.2, 0.8, 0.6]
|
||||
assert A_CRUISE_MAX_BP == [0., 10., 25., 40.]
|
||||
assert STOCK_RISE_RATE == 0.05
|
||||
|
||||
|
||||
def test_ceiling_ordering_eco_le_normal_le_sport():
|
||||
eco = make_controller(personality=ECO)
|
||||
nrm = make_controller(personality=NORMAL)
|
||||
spt = make_controller(personality=SPORT)
|
||||
for v in (0.0, 10.0, 25.0, 40.0):
|
||||
assert eco.get_max_accel(v) <= nrm.get_max_accel(v) + _EPS
|
||||
assert nrm.get_max_accel(v) <= spt.get_max_accel(v) + _EPS
|
||||
# strictly distinct where the tables diverge (mid speed)
|
||||
assert make_controller(personality=ECO).get_max_accel(25.0) < make_controller(personality=SPORT).get_max_accel(25.0)
|
||||
|
||||
|
||||
def test_rise_rate_ordering_and_above_stock():
|
||||
# ordering holds at both knots: near a stop (v=0) and at the steady-state speed (v=5)
|
||||
assert RISE_RATE_V[ECO][0] < RISE_RATE_V[NORMAL][0] < RISE_RATE_V[SPORT][0]
|
||||
assert RISE_RATE_V[ECO][1] < RISE_RATE_V[NORMAL][1] < RISE_RATE_V[SPORT][1]
|
||||
# every tier opens the ceiling faster than stock at both knots (fast take-off, never slower than stock)
|
||||
assert RISE_RATE_V[ECO][0] > STOCK_RISE_RATE
|
||||
assert RISE_RATE_V[ECO][1] > STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_rise_rate_fast_near_stop_tapers_to_steady_state():
|
||||
# Near a stop (v=0) the open-rate must be large/non-binding (NOT the old flat 0.07/0.16/0.24) so launch
|
||||
# is never delayed. At/above the v=5 knot it must match the old flat, telemetry-verified steady-state
|
||||
# values exactly, so cruise/resume behavior at speed is unchanged.
|
||||
for personality, steady_state in ((ECO, 0.07), (NORMAL, 0.16), (SPORT, 0.24)):
|
||||
ctrl = make_controller(personality=personality)
|
||||
assert ctrl.get_rise_rate(0.0) >= 0.5
|
||||
assert ctrl.get_rise_rate(0.0) > steady_state
|
||||
assert ctrl.get_rise_rate(5.0) == pytest.approx(steady_state)
|
||||
assert ctrl.get_rise_rate(20.0) == pytest.approx(steady_state) # flat above the v=5 knot
|
||||
|
||||
|
||||
def test_rise_rate_falls_back_to_stock_while_closing_on_a_lead():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=2.0, lead=make_lead(status=True, vRel=-2.0)))
|
||||
assert ctrl.get_rise_rate(2.0) == STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_rise_rate_unaffected_by_a_departing_or_absent_lead():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=2.0, lead=make_lead(status=True, vRel=0.5)))
|
||||
assert ctrl.get_rise_rate(2.0) != STOCK_RISE_RATE
|
||||
ctrl.update(make_sm(v_ego=2.0, lead=make_lead(status=False)))
|
||||
assert ctrl.get_rise_rate(2.0) != STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_rise_rate_lead_gate_disabled_is_still_stock():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=2.0, lead=make_lead(status=True, vRel=-2.0)))
|
||||
assert ctrl.get_rise_rate(2.0) == STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_normal_is_distinct_from_stock():
|
||||
nrm = make_controller(personality=NORMAL)
|
||||
# enabled NORMAL differs from stock (so NORMAL is a real profile, not a stock alias)
|
||||
assert nrm.get_max_accel(25.0) != pytest.approx(np.interp(25.0, A_CRUISE_MAX_BP, STOCK_A_CRUISE_MAX_V))
|
||||
assert nrm.get_rise_rate(0.0) != STOCK_RISE_RATE
|
||||
assert nrm.get_rise_rate(5.0) != STOCK_RISE_RATE
|
||||
|
||||
|
||||
def test_eco_ceiling_matches_lowered_table():
|
||||
# ECO's cruise/resume-range ceiling was lowered (launch knot at v=0 unchanged at 1.55).
|
||||
eco = make_controller(personality=ECO)
|
||||
for v, expected in zip(A_CRUISE_MAX_BP, (1.55, 0.75, 0.35, 0.20), strict=True):
|
||||
assert eco.get_max_accel(v) == pytest.approx(expected)
|
||||
|
||||
|
||||
# --- jerk-scale: launch jerk-cost relaxation (MPC input, feeds long_mpc.set_weights) ----------------------
|
||||
|
||||
def test_jerk_scale_disabled_is_stock():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
for v in (0.0, 2.5, 5.0, 20.0):
|
||||
assert ctrl.get_jerk_scale(v) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_jerk_scale_relaxed_near_stop_flat_at_speed():
|
||||
for personality, relaxed in ((ECO, 0.60), (NORMAL, 0.45), (SPORT, 0.45)):
|
||||
ctrl = make_controller(personality=personality)
|
||||
assert ctrl.get_jerk_scale(0.0) == pytest.approx(relaxed)
|
||||
assert ctrl.get_jerk_scale(5.0) == pytest.approx(1.0) # back to stock by the v=5 knot
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0) # flat above the knot
|
||||
|
||||
|
||||
def test_jerk_scale_never_exceeds_stock():
|
||||
# relaxation only ever LOWERS the jerk cost (more responsive), never raises it above 1.0 (stock)
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
for v in np.linspace(0.0, 40.0, 20):
|
||||
assert ctrl.get_jerk_scale(float(v)) <= 1.0 + _EPS
|
||||
|
||||
|
||||
def test_jerk_scale_tier_ordering_at_stop():
|
||||
# NOT a strict SPORT<NORMAL<ECO ordering here (unlike every other tier table in this file) -- verified via
|
||||
# a closed-loop MPC harness that pushing the v=0 knot lower than ~0.45 is counterproductive (the MPC
|
||||
# back-loads the ramp instead of front-loading it), so SPORT is pinned to NORMAL's value instead of being
|
||||
# pushed lower for tier-consistency. See the JERK_SCALE_V comment in constants.py.
|
||||
eco = make_controller(personality=ECO).get_jerk_scale(0.0)
|
||||
nrm = make_controller(personality=NORMAL).get_jerk_scale(0.0)
|
||||
spt = make_controller(personality=SPORT).get_jerk_scale(0.0)
|
||||
assert spt == pytest.approx(nrm)
|
||||
assert nrm < eco
|
||||
|
||||
|
||||
def test_jerk_scale_table_matches_constants():
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
for v in (0.0, 2.0, 5.0, 15.0):
|
||||
assert ctrl.get_jerk_scale(v) == pytest.approx(np.interp(v, JERK_SCALE_BP, JERK_SCALE_V[personality]))
|
||||
|
||||
|
||||
def test_jerk_scale_v0_knot_stays_out_of_the_counterproductive_zone():
|
||||
# Regression guard for the verified finding: pushing the v=0 knot below ~0.45 stops helping and starts
|
||||
# hurting (measured via a closed-loop MPC harness -- 0.30 came back slower than stock in every scenario
|
||||
# tested, and below ~0.15 the solver itself destabilizes). No tier's launch floor should regress into that
|
||||
# zone even if someone re-tunes ECO/NORMAL/SPORT independently later.
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
assert JERK_SCALE_V[personality][0] >= 0.40
|
||||
|
||||
|
||||
# --- onset relax: fresh accel<->decel direction change, any speed ------------------------------------------
|
||||
|
||||
def test_onset_disabled_is_stock():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=2.0))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_onset_no_effect_at_steady_state():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
for _ in range(10):
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=0.0))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0) # within the deadband, no direction to flip from
|
||||
|
||||
|
||||
def test_onset_drops_to_floor_on_fresh_direction_change():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0)) # establish "accelerating"
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0)) # fresh flip to decelerating
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(ONSET_FLOOR[NORMAL])
|
||||
|
||||
|
||||
def test_onset_eases_back_to_stock_over_ramp_s():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0))
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(ONSET_FLOOR[NORMAL])
|
||||
n = int(ONSET_RAMP_S / DT_MDL)
|
||||
for _ in range(n):
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0)) # sustained decel, no further flip
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_onset_never_relaxes_below_its_own_floor():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0))
|
||||
for _ in range(50):
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0 if _ % 2 == 0 else -1.2)) # repeated same-direction wiggle
|
||||
assert ctrl.get_jerk_scale(20.0) >= ONSET_FLOOR[SPORT] - _EPS
|
||||
|
||||
|
||||
def test_onset_tier_ordering():
|
||||
ordered = []
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0))
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0))
|
||||
ordered.append(ctrl.get_jerk_scale(20.0))
|
||||
assert ordered[2] < ordered[1] < ordered[0] # SPORT relaxes most, ECO least
|
||||
|
||||
|
||||
def test_onset_ignores_deadband_noise():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=0.3))
|
||||
for _ in range(10):
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=ONSET_DEADBAND - 0.02)) # tiny wiggle, never crosses -deadband
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
# --- lead-braking relax: hard-braking lead relaxes jerk cost regardless of speed ---------------------------
|
||||
|
||||
def test_lead_brake_no_lead_is_stock():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=False, aLeadK=-5.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_lead_brake_relaxes_with_lead_decel():
|
||||
for personality, floor in ((ECO, 0.75), (NORMAL, 0.60), (SPORT, 0.45)):
|
||||
ctrl = make_controller(personality=personality)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, aLeadK=-3.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(floor)
|
||||
|
||||
|
||||
def test_lead_brake_gentle_lead_decel_is_stock():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, aLeadK=-0.2)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0) # above the -0.5 gate -> no relax
|
||||
|
||||
|
||||
def test_lead_brake_matches_constants_table():
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
for a_lead in (-0.5, -1.5, -3.0):
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, aLeadK=a_lead)))
|
||||
expected = np.interp(a_lead, LEAD_BRAKE_ALEAD_BP, LEAD_BRAKE_FACTOR_V[personality])
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(expected)
|
||||
|
||||
|
||||
# --- closing-rate relax: fast-closing gap relaxes jerk cost proactively, any cause -------------------------
|
||||
|
||||
def test_closing_no_lead_is_stock():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=False, vRel=-8.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_closing_relaxes_with_fast_closing_lead():
|
||||
for personality, floor in ((ECO, 0.75), (NORMAL, 0.60), (SPORT, 0.45)):
|
||||
ctrl = make_controller(personality=personality)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(floor)
|
||||
|
||||
|
||||
def test_closing_slow_closing_is_stock():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-0.5)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0) # above the -1.5 gate -> no relax
|
||||
|
||||
|
||||
def test_closing_opening_gap_is_stock():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=3.0))) # lead pulling away
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_closing_matches_constants_table():
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
for v_rel in (-1.5, -3.0, -6.0):
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=v_rel)))
|
||||
expected = np.interp(v_rel, CLOSING_VREL_BP, CLOSING_FACTOR_V[personality])
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_closing_disabled_is_stock():
|
||||
ctrl = make_controller(enabled=False, personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_closing_fires_before_a_ego_moves():
|
||||
# The whole point: on the VERY FIRST cycle a fast-closing lead appears, before a_ego has had any chance to
|
||||
# react (still 0.0, so onset-relax is untouched) -- the closing factor alone must already be relaxed.
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=0.0, lead=make_lead(status=True, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(CLOSING_FACTOR_V[NORMAL][0])
|
||||
|
||||
|
||||
# --- transient relax: closing/lead-brake factors dip then ease back, never hold a sustained low floor ------
|
||||
# Closed-loop-verified (selfdrive/test/longitudinal_maneuvers-based): holding jerk_scale at a fixed low value
|
||||
# for the duration of a sustained closing/braking episode destabilizes the MPC's real-time-iteration re-solve
|
||||
# into an oscillation (30+ m/s^3 jerk vs 0 with the factor disabled, for an identical scenario). These pin
|
||||
# the fix -- a sustained, UNCHANGING severe closing rate must ease back toward stock, not stay pinned.
|
||||
|
||||
def test_closing_factor_eases_back_despite_sustained_severe_vrel():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(CLOSING_FACTOR_V[SPORT][0])
|
||||
for _ in range(int(RELAX_RAMP_S / DT_MDL)):
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0))) # same severity, every cycle
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0, abs=1e-3) # eased back despite no improvement
|
||||
|
||||
|
||||
def test_lead_brake_factor_eases_back_despite_sustained_hard_braking_lead():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, aLeadK=-3.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(LEAD_BRAKE_FACTOR_V[SPORT][0])
|
||||
for _ in range(int(RELAX_RAMP_S / DT_MDL)):
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, aLeadK=-3.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_closing_factor_resnaps_on_escalation_not_just_first_onset():
|
||||
# A worsening (not just sustained) closing rate must not be ignored because we're mid-ramp-back --
|
||||
# re-snap to the newly-lower floor.
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-2.0))) # mild, partial relax
|
||||
partial = ctrl.get_jerk_scale(20.0)
|
||||
assert 1.0 - _EPS > partial > CLOSING_FACTOR_V[SPORT][0] + _EPS
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0))) # escalates to the full floor
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(CLOSING_FACTOR_V[SPORT][0])
|
||||
|
||||
|
||||
def test_closing_factor_reset_clears_transient_state():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
for _ in range(5):
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=True, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) < 1.0 - _EPS
|
||||
ctrl.reset()
|
||||
ctrl.update(make_sm(v_ego=20.0, lead=make_lead(status=False)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# --- combined: get_jerk_scale takes the most-relaxed of all three factors ----------------------------------
|
||||
|
||||
def test_combined_takes_most_relaxed_factor():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0))
|
||||
# onset flip (floor=0.65) + hard-braking lead (floor=0.60 at aLeadK=-3.0) -> min of the two, near-stop is 1.0 at v=20
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0, lead=make_lead(status=True, aLeadK=-3.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(min(ONSET_FLOOR[NORMAL], LEAD_BRAKE_FACTOR_V[NORMAL][0]))
|
||||
|
||||
|
||||
def test_reset_clears_onset_and_lead_brake_state():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=1.0))
|
||||
ctrl.update(make_sm(v_ego=20.0, a_ego=-1.0, lead=make_lead(status=True, aLeadK=-3.0, vRel=-6.0)))
|
||||
assert ctrl.get_jerk_scale(20.0) < 1.0 - _EPS
|
||||
ctrl.reset()
|
||||
assert ctrl.get_jerk_scale(20.0) == pytest.approx(1.0)
|
||||
|
||||
|
||||
# --- t_follow: add-only speed widen -----------------------------------------------------------------------
|
||||
|
||||
def test_t_follow_zero_below_gate():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
out = settle(ctrl, v_ego=TF_WIDEN_V_BP[0] - 1.0) # below the widen onset
|
||||
assert out == pytest.approx(_TF_STOCK)
|
||||
assert ctrl.follow_widen() == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_t_follow_widens_at_speed():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
out = settle(ctrl, v_ego=TF_WIDEN_V_BP[1] + 5.0) # flat-widen region, above the band
|
||||
expected = _TF_STOCK + TF_WIDEN_BASE_V[1] * TF_WIDEN_TIER[NORMAL]
|
||||
assert out == pytest.approx(expected, abs=1e-3)
|
||||
assert ctrl.widen_active()
|
||||
|
||||
|
||||
def test_t_follow_add_only_random_walk():
|
||||
rng = np.random.default_rng(0)
|
||||
for personality in (ECO, NORMAL, SPORT):
|
||||
ctrl = make_controller(personality=personality)
|
||||
for _ in range(500):
|
||||
v = float(rng.uniform(0.0, 40.0))
|
||||
a = float(rng.uniform(-3.0, 1.5))
|
||||
ctrl.update(make_sm(v_ego=v, a_ego=a))
|
||||
out = ctrl.get_t_follow(_TF_STOCK, v)
|
||||
assert out >= _TF_STOCK - _EPS # never tighter than the stock gap => brake >= stock
|
||||
assert ctrl.follow_widen() <= TF_WIDEN_MAX + _EPS # widen capped
|
||||
|
||||
|
||||
def test_t_follow_tier_ordering_at_speed():
|
||||
v = TF_WIDEN_V_BP[1] + 5.0
|
||||
eco = settle(make_controller(personality=ECO), v_ego=v)
|
||||
nrm = settle(make_controller(personality=NORMAL), v_ego=v)
|
||||
spt = settle(make_controller(personality=SPORT), v_ego=v)
|
||||
assert eco > nrm > spt # ECO roomiest, SPORT tightest
|
||||
|
||||
|
||||
def test_t_follow_slew_bounded():
|
||||
ctrl = make_controller(personality=ECO)
|
||||
ctrl.update(make_sm(v_ego=35.0, a_ego=0.0)) # big target widen, start from 0
|
||||
prev = 0.0
|
||||
for _ in range(50):
|
||||
ctrl.get_t_follow(_TF_STOCK, 35.0)
|
||||
assert ctrl.follow_widen() - prev <= _SLEW_STEP + _EPS # opens no faster than the slew cap
|
||||
prev = ctrl.follow_widen()
|
||||
|
||||
|
||||
def test_t_follow_decel_hold_does_not_shrink_gap():
|
||||
ctrl = make_controller(personality=NORMAL)
|
||||
settle(ctrl, v_ego=35.0, a_ego=0.0) # open the gap fully
|
||||
held = ctrl.follow_widen()
|
||||
assert held > 0.1
|
||||
# now braking (a_ego below the hold threshold) while speed drops into the zero-widen region
|
||||
for _ in range(50):
|
||||
ctrl.update(make_sm(v_ego=8.0, a_ego=TF_DECEL_HOLD_A - 1.0))
|
||||
ctrl.get_t_follow(_TF_STOCK, 8.0)
|
||||
assert ctrl.follow_widen() >= held - _EPS # gap does not ease in while braking
|
||||
# once no longer braking, the gap eases back toward the (zero) target
|
||||
for _ in range(200):
|
||||
ctrl.update(make_sm(v_ego=8.0, a_ego=0.0))
|
||||
ctrl.get_t_follow(_TF_STOCK, 8.0)
|
||||
assert ctrl.follow_widen() == pytest.approx(0.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_reset_clears_widen():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
settle(ctrl, v_ego=35.0)
|
||||
assert ctrl.follow_widen() > 0.0
|
||||
ctrl.reset()
|
||||
assert ctrl.follow_widen() == 0.0
|
||||
|
||||
|
||||
def test_out_of_range_personality_clamps():
|
||||
ctrl = make_controller(personality=99)
|
||||
assert ctrl.personality() == PERSONALITY_MAX
|
||||
|
||||
|
||||
def test_max_accel_uses_stored_v_ego():
|
||||
ctrl = make_controller(personality=SPORT)
|
||||
ctrl.update(make_sm(v_ego=0.0))
|
||||
assert ctrl.max_accel() == pytest.approx(ctrl.get_max_accel(0.0))
|
||||
@@ -1,17 +1,47 @@
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
|
||||
|
||||
class WMACConstants:
|
||||
# Lead detection parameters
|
||||
LEAD_WINDOW_SIZE = 6 # Stable detection window
|
||||
LEAD_PROB = 0.45 # Balanced threshold for lead detection
|
||||
TRAJECTORY_SIZE = 33
|
||||
PARAM_READ_FRAMES = max(1, int(round(1.0 / DT_MDL)))
|
||||
|
||||
# Slow down detection parameters
|
||||
SLOW_DOWN_WINDOW_SIZE = 5 # Responsive but stable
|
||||
SLOW_DOWN_PROB = 0.3 # Balanced threshold for slow down scenarios
|
||||
EMERGENCY_HOLD_FRAMES = max(1, int(round(0.75 / DT_MDL)))
|
||||
MIN_MODE_DURATION = {'acc': max(1, int(round(0.6 / DT_MDL))), 'blended': max(1, int(round(0.5 / DT_MDL)))}
|
||||
ENTER_BLENDED_FRAMES = max(1, int(round(0.4 / DT_MDL)))
|
||||
EXIT_BLENDED_FRAMES = max(1, int(round(0.35 / DT_MDL)))
|
||||
STANDSTILL_FRAMES = max(1, int(round(0.2 / DT_MDL)))
|
||||
TRANSITION_SMOOTH_FRAMES = max(1, int(round(0.5 / DT_MDL)))
|
||||
|
||||
# Optimized slow down distance curve - smooth and progressive
|
||||
LEAD_PROB = 0.45
|
||||
LEAD_EXIT_PROB = 0.25
|
||||
LEAD_RISE_RATE = 1.0
|
||||
LEAD_FALL_RATE = 0.35
|
||||
RADAR_LEAD_ACC_PROB = 0.5
|
||||
RADAR_LEAD_ACC_EXIT_PROB = 0.4
|
||||
RADAR_LEAD_ACC_RISE_RATE = 1.0
|
||||
RADAR_LEAD_ACC_FALL_RATE = 0.25
|
||||
RADAR_LEAD_ACC_MAX_DREL = 80.0
|
||||
RADAR_LEAD_ACC_MAX_TTC = 6.0
|
||||
RADAR_LEAD_ACC_MIN_CLOSING_SPEED = -0.5
|
||||
|
||||
SLOW_DOWN_PROB = 0.5
|
||||
SLOW_DOWN_EXIT_PROB = 0.4
|
||||
SLOW_DOWN_RISE_RATE = 0.65
|
||||
SLOW_DOWN_FALL_RATE = 0.15
|
||||
SLOW_DOWN_BP = [0., 10., 20., 30., 40., 50., 55., 60.]
|
||||
SLOW_DOWN_DIST = [32., 46., 64., 86., 108., 130., 145., 165.]
|
||||
URGENT_SLOW_DOWN_PROB = 0.85
|
||||
|
||||
# Slowness detection parameters
|
||||
SLOWNESS_WINDOW_SIZE = 10 # Stable slowness detection
|
||||
SLOWNESS_PROB = 0.55 # Clear threshold for slowness
|
||||
SLOWNESS_CRUISE_OFFSET = 1.025 # Conservative cruise speed offset
|
||||
MODEL_DECEL_START = -0.5
|
||||
MODEL_DECEL_RANGE = 2.0
|
||||
ENDPOINT_URGENCY_GAIN = 1.3
|
||||
CRITICAL_ENDPOINT_FACTOR = 0.3
|
||||
CRITICAL_URGENCY_GAIN = 1.5
|
||||
SPEED_URGENCY_MIN = 25.0
|
||||
SPEED_URGENCY_RANGE = 80.0
|
||||
|
||||
SLOWNESS_PROB = 0.55
|
||||
SLOWNESS_EXIT_PROB = 0.45
|
||||
SLOWNESS_RISE_RATE = 0.35
|
||||
SLOWNESS_FALL_RATE = 0.5
|
||||
SLOWNESS_CRUISE_OFFSET = 1.025
|
||||
|
||||
@@ -6,129 +6,121 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
# Version = 2025-6-30
|
||||
|
||||
from cereal import messaging
|
||||
from opendbc.car import structs
|
||||
from numpy import interp
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
|
||||
from typing import Literal
|
||||
|
||||
# d-e2e, from modeldata.h
|
||||
TRAJECTORY_SIZE = 33
|
||||
SET_MODE_TIMEOUT = 15
|
||||
from cereal import messaging
|
||||
from numpy import interp
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
|
||||
|
||||
# Define the valid mode types
|
||||
ModeType = Literal['acc', 'blended']
|
||||
|
||||
|
||||
class SmoothKalmanFilter:
|
||||
"""Enhanced Kalman filter with smoothing for stable decision making."""
|
||||
def clip01(value: float) -> float:
|
||||
return max(0.0, min(1.0, float(value)))
|
||||
|
||||
def __init__(self, initial_value=0, measurement_noise=0.1, process_noise=0.01,
|
||||
alpha=1.0, smoothing_factor=0.85):
|
||||
self.x = initial_value
|
||||
self.P = 1.0
|
||||
self.R = measurement_noise
|
||||
self.Q = process_noise
|
||||
self.alpha = alpha
|
||||
self.smoothing_factor = smoothing_factor
|
||||
self.initialized = False
|
||||
self.history = []
|
||||
self.max_history = 10
|
||||
self.confidence = 0.0
|
||||
|
||||
def add_data(self, measurement):
|
||||
if len(self.history) >= self.max_history:
|
||||
self.history.pop(0)
|
||||
self.history.append(measurement)
|
||||
class SmoothedSignal:
|
||||
def __init__(self, rise_rate: float, fall_rate: float, initial_value: float = 0.0):
|
||||
self.rise_rate = clip01(rise_rate)
|
||||
self.fall_rate = clip01(fall_rate)
|
||||
self.value = clip01(initial_value)
|
||||
|
||||
if not self.initialized:
|
||||
self.x = measurement
|
||||
self.initialized = True
|
||||
self.confidence = 0.1
|
||||
return
|
||||
def update(self, measurement: float) -> float:
|
||||
measurement = clip01(measurement)
|
||||
rate = self.rise_rate if measurement > self.value else self.fall_rate
|
||||
self.value += (measurement - self.value) * rate
|
||||
return self.value
|
||||
|
||||
self.P = self.alpha * self.P + self.Q
|
||||
def reset(self, value: float = 0.0) -> None:
|
||||
self.value = clip01(value)
|
||||
|
||||
K = self.P / (self.P + self.R)
|
||||
effective_K = K * (1.0 - self.smoothing_factor) + self.smoothing_factor * 0.1
|
||||
|
||||
innovation = measurement - self.x
|
||||
self.x = self.x + effective_K * innovation
|
||||
self.P = (1 - effective_K) * self.P
|
||||
class HysteresisSignal:
|
||||
def __init__(self, enter_threshold: float, exit_threshold: float, rise_rate: float, fall_rate: float):
|
||||
self.enter_threshold = clip01(enter_threshold)
|
||||
self.exit_threshold = clip01(exit_threshold)
|
||||
self.filter = SmoothedSignal(rise_rate, fall_rate)
|
||||
self.active = False
|
||||
|
||||
if abs(innovation) < 0.1:
|
||||
self.confidence = min(1.0, self.confidence + 0.05)
|
||||
else:
|
||||
self.confidence = max(0.1, self.confidence - 0.02)
|
||||
def update(self, measurement: float) -> bool:
|
||||
value = self.filter.update(measurement)
|
||||
threshold = self.exit_threshold if self.active else self.enter_threshold
|
||||
self.active = value > threshold
|
||||
return self.active
|
||||
|
||||
def get_value(self):
|
||||
return self.x if self.initialized else None
|
||||
def reset(self) -> None:
|
||||
self.filter.reset()
|
||||
self.active = False
|
||||
|
||||
def get_confidence(self):
|
||||
return self.confidence
|
||||
|
||||
def reset_data(self):
|
||||
self.initialized = False
|
||||
self.history = []
|
||||
self.confidence = 0.0
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self.filter.value
|
||||
|
||||
|
||||
class ModeTransitionManager:
|
||||
"""Manages smooth transitions between driving modes with hysteresis."""
|
||||
|
||||
def __init__(self):
|
||||
self.current_mode: ModeType = 'acc'
|
||||
self.mode_confidence = {'acc': 1.0, 'blended': 0.0}
|
||||
self.transition_timeout = 0
|
||||
self.min_mode_duration = 10
|
||||
self.mode_duration = 0
|
||||
self.emergency_override = False
|
||||
self._pending_mode: ModeType = 'acc'
|
||||
self._pending_count = 0
|
||||
self._blended_hold_frames = 0
|
||||
self.frames_since_switch = WMACConstants.TRANSITION_SMOOTH_FRAMES
|
||||
self.last_switch_was_immediate = False
|
||||
|
||||
def request_mode(self, mode: ModeType, confidence: float = 1.0, emergency: bool = False):
|
||||
# Emergency override for critical situations (stops, collisions)
|
||||
if emergency:
|
||||
self.emergency_override = True
|
||||
self.current_mode = mode
|
||||
self.transition_timeout = SET_MODE_TIMEOUT
|
||||
self.mode_duration = 0
|
||||
def request_mode(self, mode: ModeType, immediate: bool = False, hold_frames: int = 0, cancel_hold: bool = False) -> None:
|
||||
if immediate:
|
||||
self._blended_hold_frames = max(self._blended_hold_frames, hold_frames)
|
||||
self._pending_mode = mode
|
||||
self._pending_count = 0
|
||||
self._switch_mode(mode, immediate=True)
|
||||
return
|
||||
|
||||
self.mode_confidence[mode] = min(1.0, self.mode_confidence[mode] + 0.1 * confidence)
|
||||
for m in self.mode_confidence:
|
||||
if m != mode:
|
||||
self.mode_confidence[m] = max(0.0, self.mode_confidence[m] - 0.05)
|
||||
if cancel_hold and mode == 'acc':
|
||||
self._blended_hold_frames = 0
|
||||
|
||||
# Require minimum duration in current mode (unless emergency)
|
||||
if self.mode_duration < self.min_mode_duration and not self.emergency_override:
|
||||
if self._blended_hold_frames > 0:
|
||||
mode = 'blended'
|
||||
|
||||
if mode == self.current_mode:
|
||||
self._pending_mode = mode
|
||||
self._pending_count = 0
|
||||
return
|
||||
|
||||
# Hysteresis: higher threshold for mode changes
|
||||
confidence_threshold = 0.6 if mode != self.current_mode else 0.3 # Lower threshold for faster response
|
||||
if mode != self._pending_mode:
|
||||
self._pending_mode = mode
|
||||
self._pending_count = 1
|
||||
else:
|
||||
self._pending_count += 1
|
||||
|
||||
if self.mode_confidence[mode] > confidence_threshold:
|
||||
if mode != self.current_mode and self.transition_timeout == 0:
|
||||
self.transition_timeout = SET_MODE_TIMEOUT
|
||||
self.current_mode = mode
|
||||
self.mode_duration = 0
|
||||
if self.mode_duration < WMACConstants.MIN_MODE_DURATION[self.current_mode]:
|
||||
return
|
||||
|
||||
def update(self):
|
||||
if self.transition_timeout > 0:
|
||||
self.transition_timeout -= 1
|
||||
required_count = WMACConstants.ENTER_BLENDED_FRAMES if mode == 'blended' else WMACConstants.EXIT_BLENDED_FRAMES
|
||||
if self._pending_count >= required_count:
|
||||
self._switch_mode(mode, immediate=False)
|
||||
|
||||
def update(self) -> None:
|
||||
if self._blended_hold_frames > 0:
|
||||
self._blended_hold_frames -= 1
|
||||
self.mode_duration += 1
|
||||
|
||||
# Reset emergency override after some time
|
||||
if self.emergency_override and self.mode_duration > 20:
|
||||
self.emergency_override = False
|
||||
|
||||
# Gradual confidence decay
|
||||
for mode in self.mode_confidence:
|
||||
self.mode_confidence[mode] *= 0.98
|
||||
self.frames_since_switch += 1
|
||||
|
||||
def get_mode(self) -> ModeType:
|
||||
return self.current_mode
|
||||
|
||||
def _switch_mode(self, mode: ModeType, immediate: bool) -> None:
|
||||
if mode == self.current_mode:
|
||||
return
|
||||
|
||||
self.current_mode = mode
|
||||
self.mode_duration = 0
|
||||
self._pending_mode = mode
|
||||
self._pending_count = 0
|
||||
self.frames_since_switch = 0
|
||||
self.last_switch_was_immediate = immediate
|
||||
|
||||
|
||||
class DynamicExperimentalController:
|
||||
def __init__(self, CP: structs.CarParams, mpc, params=None):
|
||||
@@ -142,35 +134,33 @@ class DynamicExperimentalController:
|
||||
|
||||
self._mode_manager = ModeTransitionManager()
|
||||
|
||||
# Smooth filters for stable decision making with faster response for critical scenarios
|
||||
self._lead_filter = SmoothKalmanFilter(
|
||||
measurement_noise=0.15,
|
||||
process_noise=0.05,
|
||||
alpha=1.02,
|
||||
smoothing_factor=0.8
|
||||
self._lead_tracker = HysteresisSignal(
|
||||
enter_threshold=WMACConstants.LEAD_PROB,
|
||||
exit_threshold=WMACConstants.LEAD_EXIT_PROB,
|
||||
rise_rate=WMACConstants.LEAD_RISE_RATE,
|
||||
fall_rate=WMACConstants.LEAD_FALL_RATE,
|
||||
)
|
||||
self._radar_acc_lead_tracker = HysteresisSignal(
|
||||
enter_threshold=WMACConstants.RADAR_LEAD_ACC_PROB,
|
||||
exit_threshold=WMACConstants.RADAR_LEAD_ACC_EXIT_PROB,
|
||||
rise_rate=WMACConstants.RADAR_LEAD_ACC_RISE_RATE,
|
||||
fall_rate=WMACConstants.RADAR_LEAD_ACC_FALL_RATE,
|
||||
)
|
||||
self._slow_down_tracker = HysteresisSignal(
|
||||
enter_threshold=WMACConstants.SLOW_DOWN_PROB,
|
||||
exit_threshold=WMACConstants.SLOW_DOWN_EXIT_PROB,
|
||||
rise_rate=WMACConstants.SLOW_DOWN_RISE_RATE,
|
||||
fall_rate=WMACConstants.SLOW_DOWN_FALL_RATE,
|
||||
)
|
||||
self._slowness_tracker = HysteresisSignal(
|
||||
enter_threshold=WMACConstants.SLOWNESS_PROB,
|
||||
exit_threshold=WMACConstants.SLOWNESS_EXIT_PROB,
|
||||
rise_rate=WMACConstants.SLOWNESS_RISE_RATE,
|
||||
fall_rate=WMACConstants.SLOWNESS_FALL_RATE,
|
||||
)
|
||||
|
||||
self._slow_down_filter = SmoothKalmanFilter(
|
||||
measurement_noise=0.1,
|
||||
process_noise=0.1,
|
||||
alpha=1.05,
|
||||
smoothing_factor=0.7
|
||||
)
|
||||
|
||||
self._slowness_filter = SmoothKalmanFilter(
|
||||
measurement_noise=0.1,
|
||||
process_noise=0.06,
|
||||
alpha=1.015,
|
||||
smoothing_factor=0.92
|
||||
)
|
||||
|
||||
self._mpc_fcw_filter = SmoothKalmanFilter(
|
||||
measurement_noise=0.2,
|
||||
process_noise=0.1,
|
||||
alpha=1.1,
|
||||
smoothing_factor=0.5
|
||||
)
|
||||
self._has_lead_filtered = False
|
||||
self._has_radar_acc_lead = False
|
||||
self._has_slow_down = False
|
||||
self._has_slowness = False
|
||||
self._has_mpc_fcw = False
|
||||
@@ -179,13 +169,14 @@ class DynamicExperimentalController:
|
||||
self._has_standstill = False
|
||||
self._mpc_fcw_crash_cnt = 0
|
||||
self._standstill_count = 0
|
||||
# debug
|
||||
|
||||
self._endpoint_x = float('inf')
|
||||
self._expected_distance = 0.0
|
||||
self._trajectory_valid = False
|
||||
self._raw_urgency = 0.0
|
||||
|
||||
def _read_params(self) -> None:
|
||||
if self._frame % int(1. / DT_MDL) == 0:
|
||||
if self._frame % WMACConstants.PARAM_READ_FRAMES == 0:
|
||||
self._enabled = self._params.get_bool("DynamicExperimentalControl")
|
||||
|
||||
def mode(self) -> str:
|
||||
@@ -197,8 +188,32 @@ class DynamicExperimentalController:
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
def has_radar_acc_lead(self) -> bool:
|
||||
# Same criterion _desired_mode() uses to force 'acc' mode: a near or fast-closing radar lead is trusted
|
||||
# enough that the e2e model's own opinion should never blend in over pure MPC. Computed every cycle
|
||||
# regardless of the DEC param/active() state (see _update_calculations, called unconditionally from
|
||||
# update()), so callers can use this as a lead-safety baseline independent of whether DEC itself is on.
|
||||
return not self._CP.radarUnavailable and self._has_radar_acc_lead
|
||||
|
||||
def is_urgent(self) -> bool:
|
||||
# Same "immediate" criterion _desired_mode() uses to decide a mode switch can't wait: an FCW-flagged MPC,
|
||||
# or a model slow-down whose smoothed hysteresis has latched AND whose raw severity clears the urgent
|
||||
# threshold. Public so callers outside the mode-switch decision (e.g. smoothing_transition) can reuse the
|
||||
# identical, already-tuned definition of "this is not a routine moment" instead of inventing a second one.
|
||||
return self._has_mpc_fcw or (self._has_slow_down and self._raw_urgency > WMACConstants.URGENT_SLOW_DOWN_PROB)
|
||||
|
||||
def smoothing_transition(self) -> bool:
|
||||
# True continuously while blended and NOT currently urgent -- not just for a short window right after the
|
||||
# switch. The e2e model's own action.desiredAcceleration can otherwise drop sharply frame-to-frame at any
|
||||
# point while steady-state in blended (not only at the switch instant), with zero jerk shaping from
|
||||
# either this fork's MPC-side relax (jerk_scale never touches the e2e path) or the old switch-scoped-only
|
||||
# version of this guard. Checking is_urgent() every cycle (not just "was the switch immediate") also
|
||||
# covers a new emergency that appears AFTER a routine switch, which the old snapshot-at-switch-time
|
||||
# check couldn't. Never true during a genuine emergency at any point, so it never delays/softens a real
|
||||
# stop the model or FCW is trying to make happen sooner.
|
||||
return self._mode_manager.current_mode == 'blended' and not self.is_urgent()
|
||||
|
||||
def set_mpc_fcw_crash_cnt(self) -> None:
|
||||
"""Set MPC FCW crash count"""
|
||||
self._mpc_fcw_crash_cnt = self._mpc.crash_cnt
|
||||
|
||||
def _update_calculations(self, sm: messaging.SubMaster) -> None:
|
||||
@@ -210,179 +225,109 @@ class DynamicExperimentalController:
|
||||
self._v_cruise_kph = car_state.vCruise
|
||||
self._has_standstill = car_state.standstill
|
||||
|
||||
# standstill detection
|
||||
if self._has_standstill:
|
||||
self._standstill_count = min(20, self._standstill_count + 1)
|
||||
self._standstill_count = min(WMACConstants.STANDSTILL_FRAMES * 3, self._standstill_count + 1)
|
||||
else:
|
||||
self._standstill_count = max(0, self._standstill_count - 1)
|
||||
|
||||
# Lead detection
|
||||
self._lead_filter.add_data(float(lead_one.status))
|
||||
lead_value = self._lead_filter.get_value() or 0.0
|
||||
self._has_lead_filtered = lead_value > WMACConstants.LEAD_PROB
|
||||
|
||||
# MPC FCW detection
|
||||
fcw_filtered_value = self._mpc_fcw_filter.get_value() or 0.0
|
||||
self._mpc_fcw_filter.add_data(float(self._mpc_fcw_crash_cnt > 0))
|
||||
self._has_mpc_fcw = fcw_filtered_value > 0.5
|
||||
|
||||
# Slow down detection
|
||||
self._has_lead_filtered = self._lead_tracker.update(float(lead_one.status))
|
||||
self._has_radar_acc_lead = self._radar_acc_lead_tracker.update(self._radar_acc_lead_score(lead_one))
|
||||
self._has_mpc_fcw = self._mpc_fcw_crash_cnt > 0
|
||||
self._calculate_slow_down(md)
|
||||
|
||||
# Slowness detection
|
||||
if not (self._standstill_count > 5) and not self._has_slow_down:
|
||||
if self._standstill_count > WMACConstants.STANDSTILL_FRAMES or self._has_slow_down:
|
||||
self._slowness_tracker.reset()
|
||||
self._has_slowness = False
|
||||
else:
|
||||
current_slowness = float(self._v_ego_kph <= (self._v_cruise_kph * WMACConstants.SLOWNESS_CRUISE_OFFSET))
|
||||
self._slowness_filter.add_data(current_slowness)
|
||||
slowness_value = self._slowness_filter.get_value() or 0.0
|
||||
self._has_slowness = self._slowness_tracker.update(current_slowness)
|
||||
|
||||
# Hysteresis for slowness
|
||||
threshold = WMACConstants.SLOWNESS_PROB * (0.8 if self._has_slowness else 1.1)
|
||||
self._has_slowness = slowness_value > threshold
|
||||
|
||||
def _calculate_slow_down(self, md):
|
||||
"""Calculate urgency based on trajectory endpoint vs expected distance."""
|
||||
|
||||
# Reset to safe defaults
|
||||
urgency = 0.0
|
||||
def _calculate_slow_down(self, md) -> None:
|
||||
self._endpoint_x = float('inf')
|
||||
self._expected_distance = 0.0
|
||||
self._trajectory_valid = False
|
||||
|
||||
#Require exact trajectory size
|
||||
position_valid = len(md.position.x) == TRAJECTORY_SIZE
|
||||
orientation_valid = len(md.orientation.x) == TRAJECTORY_SIZE
|
||||
urgency = self._model_action_urgency(md)
|
||||
position_valid = len(md.position.x) == WMACConstants.TRAJECTORY_SIZE
|
||||
|
||||
if not (position_valid and orientation_valid):
|
||||
# Invalid trajectory - this itself might indicate a stop scenario
|
||||
# Apply moderate urgency for incomplete trajectories at speed
|
||||
if self._v_ego_kph > 20.0:
|
||||
urgency = 0.3
|
||||
if position_valid:
|
||||
self._trajectory_valid = True
|
||||
self._endpoint_x = md.position.x[WMACConstants.TRAJECTORY_SIZE - 1]
|
||||
self._expected_distance = interp(self._v_ego_kph, WMACConstants.SLOW_DOWN_BP, WMACConstants.SLOW_DOWN_DIST)
|
||||
urgency = max(urgency, self._endpoint_urgency(self._endpoint_x, self._expected_distance))
|
||||
|
||||
self._slow_down_filter.add_data(urgency)
|
||||
urgency_filtered = self._slow_down_filter.get_value() or 0.0
|
||||
self._has_slow_down = urgency_filtered > WMACConstants.SLOW_DOWN_PROB
|
||||
self._urgency = urgency_filtered
|
||||
return
|
||||
self._raw_urgency = clip01(urgency)
|
||||
self._has_slow_down = self._slow_down_tracker.update(self._raw_urgency)
|
||||
self._urgency = self._slow_down_tracker.value
|
||||
|
||||
# We have a valid full trajectory
|
||||
self._trajectory_valid = True
|
||||
def _radar_acc_lead_score(self, lead_one) -> float:
|
||||
if not lead_one.status:
|
||||
return 0.0
|
||||
|
||||
# Use the exact endpoint (33rd point, index 32)
|
||||
endpoint_x = md.position.x[TRAJECTORY_SIZE - 1]
|
||||
self._endpoint_x = endpoint_x
|
||||
d_rel = float(getattr(lead_one, 'dRel', float('inf')))
|
||||
v_rel = float(getattr(lead_one, 'vRel', 0.0))
|
||||
if d_rel <= WMACConstants.RADAR_LEAD_ACC_MAX_DREL:
|
||||
return 1.0
|
||||
if v_rel <= WMACConstants.RADAR_LEAD_ACC_MIN_CLOSING_SPEED and d_rel / max(-v_rel, 0.1) <= WMACConstants.RADAR_LEAD_ACC_MAX_TTC:
|
||||
return 1.0
|
||||
return 0.0
|
||||
|
||||
# Get expected distance based on current speed using tuned constants
|
||||
expected_distance = interp(self._v_ego_kph,
|
||||
WMACConstants.SLOW_DOWN_BP,
|
||||
WMACConstants.SLOW_DOWN_DIST)
|
||||
self._expected_distance = expected_distance
|
||||
def _model_action_urgency(self, md) -> float:
|
||||
action = getattr(md, 'action', None)
|
||||
if action is None:
|
||||
return 0.0
|
||||
|
||||
# Calculate urgency based on trajectory shortage
|
||||
if endpoint_x < expected_distance:
|
||||
shortage = expected_distance - endpoint_x
|
||||
shortage_ratio = shortage / expected_distance
|
||||
urgency = 1.0 if getattr(action, 'shouldStop', False) else 0.0
|
||||
desired_accel = getattr(action, 'desiredAcceleration', 0.0)
|
||||
if desired_accel < WMACConstants.MODEL_DECEL_START:
|
||||
urgency = max(urgency, min(1.0, (WMACConstants.MODEL_DECEL_START - desired_accel) / WMACConstants.MODEL_DECEL_RANGE))
|
||||
return urgency
|
||||
|
||||
# Base urgency on shortage ratio
|
||||
urgency = min(1.0, shortage_ratio * 2.0)
|
||||
def _endpoint_urgency(self, endpoint_x: float, expected_distance: float) -> float:
|
||||
if endpoint_x >= expected_distance:
|
||||
return 0.0
|
||||
|
||||
# Increase urgency for very short trajectories (imminent stops)
|
||||
critical_distance = expected_distance * 0.3
|
||||
if endpoint_x < critical_distance:
|
||||
urgency = min(1.0, urgency * 2.0)
|
||||
shortage_ratio = (expected_distance - endpoint_x) / expected_distance
|
||||
urgency = min(1.0, shortage_ratio * WMACConstants.ENDPOINT_URGENCY_GAIN)
|
||||
|
||||
# Speed-based urgency adjustment
|
||||
if self._v_ego_kph > 25.0:
|
||||
speed_factor = 1.0 + (self._v_ego_kph - 25.0) / 80.0
|
||||
urgency = min(1.0, urgency * speed_factor)
|
||||
if endpoint_x < expected_distance * WMACConstants.CRITICAL_ENDPOINT_FACTOR:
|
||||
urgency = min(1.0, urgency * WMACConstants.CRITICAL_URGENCY_GAIN)
|
||||
|
||||
# Apply filtering but with less smoothing for stops
|
||||
self._slow_down_filter.add_data(urgency)
|
||||
urgency_filtered = self._slow_down_filter.get_value() or 0.0
|
||||
if self._v_ego_kph > WMACConstants.SPEED_URGENCY_MIN:
|
||||
speed_factor = 1.0 + (self._v_ego_kph - WMACConstants.SPEED_URGENCY_MIN) / WMACConstants.SPEED_URGENCY_RANGE
|
||||
urgency = min(1.0, urgency * speed_factor)
|
||||
|
||||
# Update state with lower threshold for better stop detection
|
||||
self._has_slow_down = urgency_filtered > (WMACConstants.SLOW_DOWN_PROB * 0.8)
|
||||
self._urgency = urgency_filtered
|
||||
return urgency
|
||||
|
||||
def _radarless_mode(self) -> None:
|
||||
"""Radarless mode decision logic with emergency handling."""
|
||||
def _desired_mode(self) -> tuple[ModeType, bool]:
|
||||
if not self._CP.radarUnavailable and self._has_radar_acc_lead:
|
||||
return 'acc', False
|
||||
|
||||
# EMERGENCY: MPC FCW - immediate blended mode
|
||||
if self._has_mpc_fcw:
|
||||
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
|
||||
return
|
||||
return 'blended', True
|
||||
|
||||
# Standstill: use blended
|
||||
if self._standstill_count > 3:
|
||||
self._mode_manager.request_mode('blended', confidence=0.9)
|
||||
return
|
||||
standstill = self._standstill_count > WMACConstants.STANDSTILL_FRAMES
|
||||
urgent_slow_down = self._has_slow_down and self._raw_urgency > WMACConstants.URGENT_SLOW_DOWN_PROB
|
||||
|
||||
# Slow down scenarios: emergency for high urgency, normal for lower urgency
|
||||
if self._has_slow_down:
|
||||
if self._urgency > 0.7:
|
||||
# Emergency: immediate blended mode for high urgency stops
|
||||
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
|
||||
else:
|
||||
# Normal: blended with urgency-based confidence
|
||||
confidence = min(1.0, self._urgency * 1.5)
|
||||
self._mode_manager.request_mode('blended', confidence=confidence)
|
||||
return
|
||||
if self._CP.radarUnavailable:
|
||||
if standstill or self._has_slow_down:
|
||||
return 'blended', urgent_slow_down
|
||||
return 'acc', False
|
||||
|
||||
# Driving slow: use ACC (but not if actively slowing down)
|
||||
if self._has_slowness and not self._has_slow_down:
|
||||
self._mode_manager.request_mode('acc', confidence=0.8)
|
||||
return
|
||||
if standstill or self._has_slow_down:
|
||||
return 'blended', urgent_slow_down
|
||||
|
||||
# Default: ACC
|
||||
self._mode_manager.request_mode('acc', confidence=0.7)
|
||||
|
||||
def _radar_mode(self) -> None:
|
||||
"""Radar mode with emergency handling."""
|
||||
|
||||
# EMERGENCY: MPC FCW - immediate blended mode
|
||||
if self._has_mpc_fcw:
|
||||
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
|
||||
return
|
||||
|
||||
# If lead detected and not in standstill: always use ACC
|
||||
if self._has_lead_filtered and not (self._standstill_count > 3):
|
||||
self._mode_manager.request_mode('acc', confidence=1.0)
|
||||
return
|
||||
|
||||
# Slow down scenarios: emergency for high urgency, normal for lower urgency
|
||||
if self._has_slow_down:
|
||||
if self._urgency > 0.7:
|
||||
# Emergency: immediate blended mode for high urgency stops
|
||||
self._mode_manager.request_mode('blended', confidence=1.0, emergency=True)
|
||||
else:
|
||||
# Normal: blended with urgency-based confidence
|
||||
confidence = min(1.0, self._urgency * 1.3)
|
||||
self._mode_manager.request_mode('blended', confidence=confidence)
|
||||
return
|
||||
|
||||
# Standstill: use blended
|
||||
if self._standstill_count > 3:
|
||||
self._mode_manager.request_mode('blended', confidence=0.9)
|
||||
return
|
||||
|
||||
# Driving slow: use ACC (but not if actively slowing down)
|
||||
if self._has_slowness and not self._has_slow_down:
|
||||
self._mode_manager.request_mode('acc', confidence=0.8)
|
||||
return
|
||||
|
||||
# Default: ACC
|
||||
self._mode_manager.request_mode('acc', confidence=0.7)
|
||||
return 'acc', False
|
||||
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
self._read_params()
|
||||
|
||||
self.set_mpc_fcw_crash_cnt()
|
||||
|
||||
self._update_calculations(sm)
|
||||
|
||||
if self._CP.radarUnavailable:
|
||||
self._radarless_mode()
|
||||
else:
|
||||
self._radar_mode()
|
||||
|
||||
mode, immediate = self._desired_mode()
|
||||
self._mode_manager.request_mode(mode, immediate=immediate, hold_frames=WMACConstants.EMERGENCY_HOLD_FRAMES,
|
||||
cancel_hold=self._has_radar_acc_lead)
|
||||
self._mode_manager.update()
|
||||
|
||||
self._active = sm['selfdriveState'].experimentalMode and self._enabled
|
||||
self._frame += 1
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
|
||||
|
||||
class MockLeadOne:
|
||||
def __init__(self, status=0.0):
|
||||
self.status = status
|
||||
|
||||
class MockRadarState:
|
||||
def __init__(self, status=0.0):
|
||||
self.leadOne = MockLeadOne(status=status)
|
||||
|
||||
class MockCarState:
|
||||
def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False):
|
||||
self.vEgo = vEgo
|
||||
self.vCruise = vCruise
|
||||
self.standstill = standstill
|
||||
|
||||
class MockModelData:
|
||||
def __init__(self, valid=True):
|
||||
size = 33 if valid else 10 # incomplete if invalid
|
||||
self.position = type("Pos", (), {"x": [0.0] * size})()
|
||||
self.orientation = type("Ori", (), {"x": [0.0] * size})()
|
||||
|
||||
class MockSelfDriveState:
|
||||
def __init__(self, experimentalMode=False):
|
||||
self.experimentalMode = experimentalMode
|
||||
|
||||
class MockParams:
|
||||
def get_bool(self, name):
|
||||
return True
|
||||
|
||||
@pytest.fixture
|
||||
def default_sm():
|
||||
sm = {
|
||||
'carState': MockCarState(vEgo=10.0, vCruise=20.0),
|
||||
'radarState': MockRadarState(status=1.0),
|
||||
'modelV2': MockModelData(valid=True),
|
||||
'selfdriveState': MockSelfDriveState(experimentalMode=True),
|
||||
}
|
||||
return sm
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cp():
|
||||
class CP:
|
||||
radarUnavailable = False
|
||||
return CP()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mpc():
|
||||
class MPC:
|
||||
crash_cnt = 0
|
||||
return MPC()
|
||||
|
||||
# Fake Kalman Filter that always returns a given value
|
||||
class FakeKalman:
|
||||
def __init__(self, value=1.0):
|
||||
self.value = value
|
||||
def add_data(self, v): pass
|
||||
def get_value(self): return self.value
|
||||
def get_confidence(self): return 1.0
|
||||
def reset_data(self): pass
|
||||
|
||||
def test_initial_mode_is_acc(mock_cp, mock_mpc):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['carState'].standstill = True
|
||||
for _ in range(10):
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
mock_mpc.crash_cnt = 1 # simulate FCW
|
||||
for _ in range(2):
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
|
||||
# Force conditions to simulate slowdown
|
||||
controller._slow_down_filter = FakeKalman(value=1.0) # Ensure urgency triggers slowdown
|
||||
controller._v_ego_kph = 35.0
|
||||
default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory
|
||||
|
||||
for _ in range(3):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller.mode() == "blended"
|
||||
@@ -0,0 +1,352 @@
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController, HysteresisSignal
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.constants import WMACConstants
|
||||
|
||||
|
||||
class MockLeadOne:
|
||||
def __init__(self, status=0.0, dRel=30.0, vRel=0.0):
|
||||
self.status = status
|
||||
self.dRel = dRel
|
||||
self.vRel = vRel
|
||||
|
||||
|
||||
class MockRadarState:
|
||||
def __init__(self, status=0.0, dRel=30.0, vRel=0.0):
|
||||
self.leadOne = MockLeadOne(status=status, dRel=dRel, vRel=vRel)
|
||||
|
||||
|
||||
class MockCarState:
|
||||
def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False):
|
||||
self.vEgo = vEgo
|
||||
self.vCruise = vCruise
|
||||
self.standstill = standstill
|
||||
|
||||
|
||||
class MockAction:
|
||||
def __init__(self, desiredAcceleration=0.0, shouldStop=False):
|
||||
self.desiredAcceleration = desiredAcceleration
|
||||
self.shouldStop = shouldStop
|
||||
|
||||
|
||||
class MockModelData:
|
||||
def __init__(self, valid=True, endpoint_x=200.0, orientation_valid=None, desired_acceleration=0.0, should_stop=False):
|
||||
position_size = 33 if valid else 10
|
||||
orientation_size = position_size if orientation_valid is None else (33 if orientation_valid else 10)
|
||||
position_x = [0.0] * position_size
|
||||
if position_x:
|
||||
position_x[-1] = endpoint_x
|
||||
self.position = type("Pos", (), {"x": position_x})()
|
||||
self.orientation = type("Ori", (), {"x": [0.0] * orientation_size})()
|
||||
self.acceleration = type("Accel", (), {"x": [0.0] * position_size})()
|
||||
self.action = MockAction(desired_acceleration, should_stop)
|
||||
|
||||
|
||||
class MockSelfDriveState:
|
||||
def __init__(self, experimentalMode=False):
|
||||
self.experimentalMode = experimentalMode
|
||||
|
||||
|
||||
class MockParams:
|
||||
def get_bool(self, name):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_sm():
|
||||
sm = {
|
||||
'carState': MockCarState(vEgo=10.0, vCruise=20.0),
|
||||
'radarState': MockRadarState(status=1.0),
|
||||
'modelV2': MockModelData(valid=True),
|
||||
'selfdriveState': MockSelfDriveState(experimentalMode=True),
|
||||
}
|
||||
return sm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cp():
|
||||
class CP:
|
||||
radarUnavailable = False
|
||||
return CP()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mpc():
|
||||
class MPC:
|
||||
crash_cnt = 0
|
||||
return MPC()
|
||||
|
||||
|
||||
def test_initial_mode_is_acc(mock_cp, mock_mpc):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['carState'].standstill = True
|
||||
for _ in range(20):
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
mock_mpc.crash_cnt = 1
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_smoothing_transition_false_while_never_switched(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "acc"
|
||||
assert not controller.smoothing_transition()
|
||||
|
||||
|
||||
def test_smoothing_transition_true_right_after_routine_switch_and_stays_true(mock_cp, mock_mpc, default_sm):
|
||||
# a standstill-driven switch is routine (not FCW/immediate) -- smoothing must engage at the switch and,
|
||||
# since the moment stays non-urgent, keep applying continuously (no more time-boxed lapse) as long as we
|
||||
# stay in blended and nothing urgent shows up -- see test_smoothing_transition_turns_off_when_urgency_rises
|
||||
# for the case that DOES turn it off.
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['carState'].standstill = True
|
||||
saw_switch_with_smoothing = False
|
||||
prev_mode = controller.mode()
|
||||
for _ in range(20):
|
||||
controller.update(default_sm)
|
||||
if controller.mode() == "blended" and prev_mode == "acc":
|
||||
saw_switch_with_smoothing = controller.smoothing_transition()
|
||||
break
|
||||
prev_mode = controller.mode()
|
||||
assert saw_switch_with_smoothing
|
||||
for _ in range(WMACConstants.TRANSITION_SMOOTH_FRAMES + 2):
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
assert controller.smoothing_transition() # still non-urgent -- no time-based lapse anymore
|
||||
|
||||
|
||||
def test_smoothing_transition_turns_off_when_urgency_rises_while_already_blended(mock_cp, mock_mpc, default_sm):
|
||||
# The gap this fixes: entering blended for a ROUTINE reason (standstill) must not permanently exempt a
|
||||
# LATER genuine emergency that appears while already blended -- the old last_switch_was_immediate snapshot
|
||||
# only looked at how we entered, never re-checked. is_urgent() re-checks every cycle instead.
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['carState'].standstill = True
|
||||
for _ in range(20):
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
assert controller.smoothing_transition()
|
||||
assert not controller.is_urgent()
|
||||
|
||||
mock_mpc.crash_cnt = 1 # a new FCW appears, still in blended (standstill keeps it there)
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended" # no mode switch needed/observed -- already blended
|
||||
assert controller.is_urgent()
|
||||
assert not controller.smoothing_transition() # must turn off immediately, same cycle
|
||||
|
||||
|
||||
def test_is_urgent_true_on_fcw(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
mock_mpc.crash_cnt = 1
|
||||
controller.update(default_sm)
|
||||
assert controller.is_urgent()
|
||||
|
||||
|
||||
def test_is_urgent_false_for_routine_slowdown_below_threshold(mock_cp, mock_mpc, default_sm):
|
||||
# a mild slow-down (not near the URGENT_SLOW_DOWN_PROB severity) must not read as urgent.
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, desired_acceleration=-0.6) # just past MODEL_DECEL_START
|
||||
controller.update(default_sm)
|
||||
assert controller._raw_urgency < WMACConstants.URGENT_SLOW_DOWN_PROB
|
||||
assert not controller.is_urgent()
|
||||
|
||||
|
||||
def test_smoothing_transition_false_for_emergency_switch(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
mock_mpc.crash_cnt = 1
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
assert not controller.smoothing_transition() # immediate/emergency switch must never be smoothed
|
||||
|
||||
|
||||
def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_valid_position_with_missing_orientation_can_trigger_slowdown(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0, orientation_valid=False)
|
||||
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._trajectory_valid
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_incomplete_position_does_not_trigger_slowdown(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=False, endpoint_x=0.0)
|
||||
|
||||
for _ in range(3):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert not controller._trajectory_valid
|
||||
assert not controller._has_slow_down
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_slowdown_hysteresis_prevents_threshold_chatter():
|
||||
signal = HysteresisSignal(enter_threshold=0.5, exit_threshold=0.4, rise_rate=1.0, fall_rate=1.0)
|
||||
|
||||
assert signal.update(0.55)
|
||||
assert signal.update(0.45)
|
||||
assert not signal.update(0.35)
|
||||
|
||||
|
||||
def test_model_should_stop_triggers_blended_without_valid_trajectory(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=False, should_stop=True)
|
||||
|
||||
controller.update(default_sm)
|
||||
|
||||
assert not controller._trajectory_valid
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_radar_lead_keeps_acc_over_model_slowdown(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
|
||||
for _ in range(3):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_slow_down
|
||||
assert controller._has_radar_acc_lead
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_far_radar_lead_allows_blended_until_acc_relevant(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_lead_filtered
|
||||
assert not controller._has_radar_acc_lead
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
|
||||
def test_relevant_radar_lead_smoothly_returns_to_acc(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
controller.update(default_sm)
|
||||
assert controller.mode() == "blended"
|
||||
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=45.0, vRel=0.0)
|
||||
for _ in range(20):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_radar_acc_lead
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_closing_far_radar_lead_returns_to_acc(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=-25.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
|
||||
for _ in range(20):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_radar_acc_lead
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_radar_lead_keeps_acc_over_fcw_and_standstill(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0)
|
||||
default_sm['carState'].standstill = True
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0, should_stop=True)
|
||||
mock_mpc.crash_cnt = 1
|
||||
|
||||
for _ in range(10):
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_lead_filtered
|
||||
assert controller._has_mpc_fcw
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_lead_flicker_hold_prevents_one_frame_mode_flip(mock_cp, mock_mpc, default_sm):
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0)
|
||||
controller.update(default_sm)
|
||||
|
||||
default_sm['radarState'] = MockRadarState(status=0.0)
|
||||
default_sm['modelV2'] = MockModelData(valid=True, endpoint_x=0.0)
|
||||
controller.update(default_sm)
|
||||
|
||||
assert controller._has_lead_filtered
|
||||
assert controller.mode() == "acc"
|
||||
|
||||
|
||||
def test_has_radar_acc_lead_true_for_near_lead(mock_cp, mock_mpc, default_sm):
|
||||
# within RADAR_LEAD_ACC_MAX_DREL -- available regardless of whether DEC's own param/active() is on, since
|
||||
# _update_calculations runs every cycle unconditionally.
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=40.0, vRel=0.0)
|
||||
controller.update(default_sm)
|
||||
assert controller.has_radar_acc_lead()
|
||||
|
||||
|
||||
def test_has_radar_acc_lead_false_for_far_slow_lead(mock_cp, mock_mpc, default_sm):
|
||||
# beyond MAX_DREL and not closing fast enough for the TTC gate -- correctly not trusted as an ACC-safe lead.
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=120.0, vRel=0.0)
|
||||
controller.update(default_sm)
|
||||
assert not controller.has_radar_acc_lead()
|
||||
|
||||
|
||||
def test_has_radar_acc_lead_false_when_radar_unavailable(mock_cp, mock_mpc, default_sm):
|
||||
mock_cp.radarUnavailable = True
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=40.0, vRel=0.0)
|
||||
controller.update(default_sm)
|
||||
assert not controller.has_radar_acc_lead()
|
||||
|
||||
|
||||
def test_has_radar_acc_lead_independent_of_dec_param(mock_cp, mock_mpc, default_sm):
|
||||
# DEC disabled (param False) must not affect this -- it's a lead-safety baseline other callers rely on
|
||||
# regardless of whether DEC itself is on.
|
||||
class MockParamsOff:
|
||||
def get_bool(self, name):
|
||||
return False
|
||||
controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParamsOff())
|
||||
default_sm['radarState'] = MockRadarState(status=1.0, dRel=40.0, vRel=0.0)
|
||||
controller.update(default_sm)
|
||||
assert not controller.enabled()
|
||||
assert not controller.active()
|
||||
assert controller.has_radar_acc_lead()
|
||||
@@ -9,6 +9,8 @@ from 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_personality.accel_controller import AccelController
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.radar_distance.radar_distance import RadarDistanceController
|
||||
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.smart_cruise_control.smart_cruise_control import SmartCruiseControl
|
||||
@@ -20,12 +22,40 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState
|
||||
LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
|
||||
|
||||
# Bounds how fast output_a_target may drop while blended and NOT currently urgent (continuous -- not just a
|
||||
# short window right after the switch; see DynamicExperimentalController.smoothing_transition()/is_urgent()).
|
||||
# Never active during a genuine emergency (FCW, or a model slow-down past the urgent-severity threshold) at
|
||||
# any point, whether that was true at the moment of the switch or only became true later while still blended.
|
||||
TRANSITION_MAX_DROP_PER_CYCLE = 0.15 # m/s^2 per cycle
|
||||
|
||||
|
||||
class _E2ETransitionGuard:
|
||||
# Without this, the e2e model's own action.desiredAcceleration -- which the core MPC-side jerk_scale never
|
||||
# shapes (it only touches the MPC's own solution, not this raw model path) -- can drop sharply frame-to-
|
||||
# frame at any point while blended, not only at the instant DEC switches into it, producing a discontinuous
|
||||
# brake with zero jerk shaping. Only ever limits a DOWNWARD move; never delays a rise.
|
||||
def __init__(self):
|
||||
self._last = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self._last = None
|
||||
|
||||
def apply(self, output_a_target: float, smoothing_active: bool) -> float:
|
||||
if not smoothing_active or self._last is None:
|
||||
self._last = output_a_target
|
||||
return output_a_target
|
||||
limited = max(output_a_target, self._last - TRANSITION_MAX_DROP_PER_CYCLE)
|
||||
self._last = limited
|
||||
return limited
|
||||
|
||||
|
||||
class LongitudinalPlannerSP:
|
||||
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
|
||||
self.events_sp = EventsSP()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
self.dec = DynamicExperimentalController(CP, mpc)
|
||||
self.accel = AccelController(CP, mpc)
|
||||
self.radar_distance = RadarDistanceController(CP)
|
||||
self.scc = SmartCruiseControl()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
self.sla = SpeedLimitAssist(CP, CP_SP)
|
||||
@@ -35,13 +65,24 @@ class LongitudinalPlannerSP:
|
||||
|
||||
self.output_v_target = 0.
|
||||
self.output_a_target = 0.
|
||||
self._e2e_transition_guard = _E2ETransitionGuard()
|
||||
|
||||
def is_e2e(self, sm: messaging.SubMaster) -> bool:
|
||||
experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
if not self.dec.active():
|
||||
return experimental_mode
|
||||
if not experimental_mode:
|
||||
return False
|
||||
|
||||
return experimental_mode and self.dec.mode() == "blended"
|
||||
# A near/fast-closing radar lead always routes to pure MPC, regardless of whether DEC itself is on --
|
||||
# this baseline is not something the DEC toggle should be able to bypass (dec.has_radar_acc_lead() is
|
||||
# computed every cycle independent of dec.active()). DEC's own toggle only gates the OTHER blended
|
||||
# triggers (standstill, slow-down, FCW) below.
|
||||
if self.dec.has_radar_acc_lead():
|
||||
return False
|
||||
|
||||
if not self.dec.active():
|
||||
return True
|
||||
|
||||
return self.dec.mode() == "blended"
|
||||
|
||||
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
|
||||
CS = sm['carState']
|
||||
@@ -76,8 +117,16 @@ class LongitudinalPlannerSP:
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
self.events_sp.clear()
|
||||
self.dec.update(sm)
|
||||
self.accel.update(sm)
|
||||
self.radar_distance.update(sm)
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
|
||||
def smooth_radarstate(self, radarstate):
|
||||
return self.radar_distance.smooth_radarstate(radarstate)
|
||||
|
||||
def smooth_e2e_transition(self, output_a_target: float) -> float:
|
||||
return self._e2e_transition_guard.apply(output_a_target, self.dec.smoothing_transition())
|
||||
|
||||
def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None:
|
||||
plan_sp_send = messaging.new_message('longitudinalPlanSP')
|
||||
|
||||
@@ -138,4 +187,17 @@ class LongitudinalPlannerSP:
|
||||
e2eAlerts.greenLightAlert = self.e2e_alerts_helper.green_light_alert
|
||||
e2eAlerts.leadDepartAlert = self.e2e_alerts_helper.lead_depart_alert
|
||||
|
||||
# Acceleration Personality (telemetry only; brakeNeed/decelTarget/smoothActive repurposed for the
|
||||
# input-shaping design -- see cereal custom.capnp Acceleration).
|
||||
acceleration = longitudinalPlanSP.acceleration
|
||||
acceleration.personality = self.accel.personality()
|
||||
acceleration.enabled = self.accel.enabled()
|
||||
acceleration.maxAccel = float(self.accel.max_accel())
|
||||
acceleration.brakeNeed = float(self.accel.follow_widen()) # follow-gap widen added on top of stock (s)
|
||||
acceleration.decelTarget = float(self.accel.t_follow()) # t_follow handed to the MPC (s)
|
||||
acceleration.smoothActive = self.accel.widen_active() # follow-gap widen currently active
|
||||
acceleration.bypassed = False # unused (no output shaping / bypass anymore)
|
||||
acceleration.leadUnstable = bool(self.radar_distance.lead_unstable())
|
||||
|
||||
|
||||
pm.send('longitudinalPlanSP', plan_sp_send)
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
RadarDistance conditions the lead the longitudinal MPC follows on a noisy (TSS2-class) radar. It NEVER
|
||||
reports a farther-or-faster lead than reality, so braking is always >= stock. Four mechanisms:
|
||||
* jump-guard: reject a same-cycle FARTHER dRel jump on a lead that never dropped status (a vision/radar
|
||||
fusion transient during lead acquisition -- e.g. a cut-in whose vision distance estimate briefly
|
||||
disagrees with a solid radar track) by holding the last-trusted, closer reading instead of snapping back
|
||||
out. A closer jump of any size always passes immediately -- this only ever delays relief, never a brake;
|
||||
* flicker-hold: keep a just-dropped, recently-sustained lead alive (dead-reckoned) through a brief radar
|
||||
dropout so the MPC does not lose and re-grab it (which reads as a phantom release then a catch-up brake);
|
||||
* churn/noise smoother: a short EMA on a lead's dRel/vLead/vRel so the MPC stops hunting the gap (removes
|
||||
the follow-jitter that reads as rubber-banding and, on the sensor side, as a lead-detection "lurch").
|
||||
Covers two DISTINCT same-physical-object noise signatures: trackId churn (id flips frame-to-frame but the
|
||||
kinematics stay coherent -- one real lead getting re-labeled) and same-track noise (id stays constant but
|
||||
vLead itself is bimodal/bouncing -- one real lead with a noisy fusion/Doppler velocity read). Both are
|
||||
safe to EMA because the id evidence pins them to a SINGLE physical object; a bimodal vLead WITH the id
|
||||
also changing is left alone (ambiguous -- could be two really-different real objects) so this can never
|
||||
average two real tracks together. dRel is asymmetric -- closer accepted immediately, only farther is
|
||||
EMA-lagged -- so it can't hold a steadily-closing lead farther-than-true; vLead/vRel stay symmetric;
|
||||
* stop-gap: near a (near-)stopped lead at low speed report dRel a touch closer so the MPC's own smooth stop
|
||||
settles farther back (the Prius TSS2 stock crawl creeps in to ~1.5 m). Monotone (closer => brake >= stock).
|
||||
Overridden off by sustained lead motion (even slow creep) so it can't suppress a real, growing gap during
|
||||
a launch. Never runs on a held (jump-guard or flicker-hold) lead, since a hold's vLead/dRel are stale;
|
||||
Also publishes a read-only lead-instability flag (telemetry). Disabled => byte-stock passthrough always.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
|
||||
HOLD_MAX_FRAMES = 20 # ~1.0s flicker-hold cap, since the last sustained lead
|
||||
SUSTAIN_FRAMES = 2
|
||||
DROPOUT_DREL = 1.0
|
||||
FCW_PROB_CAP = 0.9
|
||||
MIN_HELD_DREL = 0.5
|
||||
|
||||
LOW_SPEED_PASSTHROUGH_V = 5.0 # m/s: below this, no flicker-hold (holding a stale lead near a stop would
|
||||
# delay the launch); the churn smoother still runs down to CREEP_PASSTHROUGH_V
|
||||
CREEP_PASSTHROUGH_V = 1.0 # m/s: below this, full byte-stock passthrough (protect the stock stop distance)
|
||||
|
||||
SWITCH_DREL = 4.0 # m, dRel jump = a track switch (used by the instability detector + jump-guard).
|
||||
|
||||
# Jump-guard: a same-cycle dRel jump this far FARTHER, on a lead that never dropped status, is treated as a
|
||||
# fusion transient (not a real sudden separation) and held at the last-trusted value for a bounded number of
|
||||
# frames. Self-heals fast so a genuinely-departing lead is never held stale for long.
|
||||
JUMP_GUARD_MAX_HOLD = 10 # frames (~0.5s)
|
||||
|
||||
# Lead-instability detector (telemetry only): flags a bimodal/bouncing radar lead.
|
||||
STABILITY_WINDOW = 5 # frames (~0.25s @ 20Hz)
|
||||
VLEAD_SPREAD = 4.0 # m/s, vLead range over the window above which the lead is "unstable"
|
||||
ID_CHURN_WINDOW = 10 # frames (~0.5s) for radarTrackId-churn detection (steady lead, flipping ids)
|
||||
ID_CHURN = 3 # trackId switches in the window above which the lead is "unstable" (follow-hunting)
|
||||
|
||||
# Churn smoother: during trackId churn the per-track dRel/vRel jitter makes the MPC hunt the follow gap. A
|
||||
# short SYMMETRIC EMA on the churning lead removes the jitter so the MPC sees a steady lead. Active ONLY
|
||||
# during churn (NOT bimodal vLead -> never averages two real tracks). Bounded symmetric lag ~LEAD_SMOOTH_TAU.
|
||||
LEAD_SMOOTH_TAU = 0.5 # s, EMA time constant
|
||||
LEAD_SMOOTH_HOLD = 20 # frames (~1s): keep smoothing through brief churn gaps (churn toggles on/off)
|
||||
|
||||
# Stop-gap: near a (near-)stopped lead at low speed, report dRel up to STOP_GAP_M closer so the MPC's own
|
||||
# smooth stop terminates that much farther back (stock Prius crawl-creeps to ~1.5 m). Monotone (only reports
|
||||
# closer => brake >= stock). Ramps in below the regime dRel and out as the lead starts moving; releases on
|
||||
# launch as ego speed rises past STOP_GAP_VEGO.
|
||||
STOP_GAP_M = 2.5 # m: max dRel reduction = added standstill gap
|
||||
STOP_GAP_VEGO = 8.0 # m/s: only below this ego speed
|
||||
STOP_GAP_VLEAD = 1.5 # m/s: only behind a (near-)stopped lead; ramps out as vLead rises to this
|
||||
STOP_GAP_REGIME_DREL = 12.0 # m: bias ramps in below this dRel
|
||||
STOP_GAP_RAMP_BAND = 2.0 # m: ramp-in band (full offset below REGIME_DREL - RAMP_BAND)
|
||||
STOP_GAP_MIN_DREL = 2.0 # m: never report a lead closer than this
|
||||
|
||||
# Stop-gap creep override: a lead creeping forward slowly can sit under STOP_GAP_VLEAD for many seconds
|
||||
# without crossing it, so the bias keeps suppressing a real, growing gap. Sustained motion this long overrides
|
||||
# the bias off regardless of how slow. The counter decays (not just holds) on a sub-threshold frame, so it
|
||||
# takes sustained motion, not noise straddling the threshold, to reach the cap.
|
||||
STOP_GAP_CREEP_V = 0.03 # m/s: a truly-stopped lead reads exactly 0.0; treat anything above this as motion
|
||||
STOP_GAP_CREEP_HOLD_S = 1.5 # s: this much sustained motion overrides the bias off
|
||||
STOP_GAP_CREEP_HOLD_FRAMES = int(STOP_GAP_CREEP_HOLD_S / DT_MDL)
|
||||
|
||||
|
||||
class _BiasedLead:
|
||||
__slots__ = ('status', 'dRel', 'yRel', 'vRel', 'vLead', 'vLeadK', 'aLeadK', 'aLeadTau', 'modelProb')
|
||||
|
||||
def __init__(self, src, dRel):
|
||||
self.status = src.status
|
||||
self.dRel = dRel
|
||||
self.yRel = src.yRel
|
||||
self.vRel = src.vRel
|
||||
self.vLead = src.vLead
|
||||
self.vLeadK = src.vLeadK
|
||||
self.aLeadK = src.aLeadK
|
||||
self.aLeadTau = src.aLeadTau
|
||||
self.modelProb = src.modelProb
|
||||
|
||||
|
||||
class _SmoothedLead:
|
||||
__slots__ = ('status', 'dRel', 'yRel', 'vRel', 'vLead', 'vLeadK', 'aLeadK', 'aLeadTau', 'modelProb')
|
||||
|
||||
def __init__(self, src, dRel, vLead, vRel):
|
||||
self.status = src.status
|
||||
self.dRel = dRel
|
||||
self.yRel = src.yRel
|
||||
self.vRel = vRel
|
||||
self.vLead = vLead
|
||||
self.vLeadK = vLead
|
||||
self.aLeadK = src.aLeadK
|
||||
self.aLeadTau = src.aLeadTau
|
||||
self.modelProb = src.modelProb
|
||||
|
||||
|
||||
class _HeldLead:
|
||||
__slots__ = ('status', 'dRel', 'yRel', 'vRel', 'vLead', 'vLeadK', 'aLeadK', 'aLeadTau', 'modelProb')
|
||||
|
||||
def __init__(self, dRel, vRel, vLead, aLeadK, aLeadTau, modelProb):
|
||||
self.status = True
|
||||
self.dRel = dRel
|
||||
self.vRel = vRel
|
||||
self.vLead = vLead
|
||||
self.vLeadK = vLead
|
||||
self.aLeadK = aLeadK
|
||||
self.aLeadTau = aLeadTau
|
||||
self.modelProb = modelProb
|
||||
self.yRel = 0.0
|
||||
|
||||
|
||||
class _RadarStateProxy:
|
||||
__slots__ = ('leadOne', 'leadTwo')
|
||||
|
||||
def __init__(self, lead_one, lead_two):
|
||||
self.leadOne = lead_one
|
||||
self.leadTwo = lead_two
|
||||
|
||||
|
||||
class _LeadSmoother:
|
||||
# EMA on a noisy same-physical-object lead's dRel/vLead/vRel (jitter removal; see _LeadStability for what
|
||||
# qualifies as "same object"). A hold keeps it active through brief noise gaps (the trigger toggles on/off);
|
||||
# passthrough + reset only after the hold lapses. dRel is ASYMMETRIC: a closer raw reading is accepted
|
||||
# immediately (never delay awareness of closer -- the file's own invariant), only a FARTHER raw reading is
|
||||
# EMA-lagged (reject noise in that direction). Without this, a lead that's genuinely closing steadily while
|
||||
# noisy (even briefly) gets held farther-than-true for the full LEAD_SMOOTH_HOLD window, then snaps -- a
|
||||
# false-relief-then-correction that itself becomes a hard brake.
|
||||
def __init__(self):
|
||||
self._d = None
|
||||
self._vl = None
|
||||
self._vr = None
|
||||
self._hold = 0
|
||||
|
||||
def reset(self):
|
||||
self._d = None
|
||||
self._vl = None
|
||||
self._vr = None
|
||||
self._hold = 0
|
||||
|
||||
def update(self, lead, noisy: bool):
|
||||
# A held lead's dRel/vLead is a stale extrapolation -- feeding it into the EMA would both hide that from
|
||||
# downstream (wraps it into a _SmoothedLead) and pollute _d/_vl/_vr, lagging the real value's recovery.
|
||||
if isinstance(lead, _HeldLead):
|
||||
self.reset()
|
||||
return lead
|
||||
self._hold = LEAD_SMOOTH_HOLD if noisy else self._hold - 1
|
||||
if self._hold <= 0 or not lead.status:
|
||||
self.reset()
|
||||
return lead
|
||||
if self._d is None:
|
||||
self._d, self._vl, self._vr = lead.dRel, lead.vLead, lead.vRel
|
||||
return lead
|
||||
a = DT_MDL / LEAD_SMOOTH_TAU
|
||||
self._d = lead.dRel if lead.dRel < self._d else self._d + (lead.dRel - self._d) * a
|
||||
self._vl += (lead.vLead - self._vl) * a
|
||||
self._vr += (lead.vRel - self._vr) * a
|
||||
return _SmoothedLead(lead, self._d, self._vl, self._vr)
|
||||
|
||||
|
||||
class _JumpGuard:
|
||||
# Rejects a same-cycle FARTHER dRel jump on a lead that never dropped status (a vision/radar fusion
|
||||
# transient, e.g. a cut-in whose vision distance estimate briefly disagrees with a solid radar track before
|
||||
# the match locks on) by holding the last-trusted reading, extrapolated by its own vRel, for a bounded
|
||||
# number of frames. A CLOSER jump of any size always passes through immediately -- this can only ever delay
|
||||
# relief, never a brake -- and it self-heals after JUMP_GUARD_MAX_HOLD frames if the jump was real.
|
||||
# modelProb is capped at FCW_PROB_CAP on the held lead (same as _LeadHold's flicker-hold, below) -- a held
|
||||
# reading is no longer confirmed fresh, so it must not carry enough confidence to trip the stock FCW gate.
|
||||
def __init__(self):
|
||||
self._last = None
|
||||
self._hold = 0
|
||||
self._grace_used = False
|
||||
|
||||
def reset(self):
|
||||
self._last = None
|
||||
self._hold = 0
|
||||
self._grace_used = False
|
||||
|
||||
def step(self, raw):
|
||||
if not raw.status:
|
||||
self.reset()
|
||||
return raw
|
||||
|
||||
if self._last is not None and (raw.dRel - self._last[0]) > SWITCH_DREL and self._hold < JUMP_GUARD_MAX_HOLD:
|
||||
dRel0, vRel0, vLead0, aLeadK0, aLeadTau0, prob0 = self._last
|
||||
self._hold += 1
|
||||
held_dRel = max(MIN_HELD_DREL, dRel0 - max(-vRel0, 0.0) * DT_MDL)
|
||||
self._last = (held_dRel, vRel0, vLead0, aLeadK0, aLeadTau0, prob0)
|
||||
return _HeldLead(held_dRel, vRel0, vLead0, aLeadK0, aLeadTau0, min(prob0, FCW_PROB_CAP))
|
||||
|
||||
# Hold cap reached on a lead that was closing: self-healing straight onto raw here would adopt a farther
|
||||
# reading than the trajectory already tracked, i.e. report a farther lead than reality for at least one
|
||||
# more cycle. Take exactly one bounded extra cycle at the last-held value first -- never a second, so this
|
||||
# can't turn into an indefinite hold on a lead that genuinely departed.
|
||||
if (self._hold >= JUMP_GUARD_MAX_HOLD and not self._grace_used and self._last is not None and
|
||||
self._last[1] < 0.0 and (raw.dRel - self._last[0]) > SWITCH_DREL):
|
||||
dRel0, vRel0, vLead0, aLeadK0, aLeadTau0, prob0 = self._last
|
||||
self._grace_used = True
|
||||
return _HeldLead(dRel0, vRel0, vLead0, aLeadK0, aLeadTau0, min(prob0, FCW_PROB_CAP))
|
||||
|
||||
self._hold = 0
|
||||
self._grace_used = False
|
||||
self._last = (raw.dRel, raw.vRel, raw.vLead, raw.aLeadK, raw.aLeadTau, raw.modelProb)
|
||||
return raw
|
||||
|
||||
|
||||
class _LeadHold:
|
||||
# step() takes the caller's absolute frame counter rather than counting its own calls: below
|
||||
# LOW_SPEED_PASSTHROUGH_V the caller stops calling step() at all (see smooth_radarstate), and a
|
||||
# self-incrementing counter would then stay frozen at whatever it was for however long that lasts -- on
|
||||
# resume it would read as "just a few frames since the last real sighting" no matter how much real time
|
||||
# (a full stop, a slow zone) actually passed, and could hand HOLD_MAX_FRAMES worth of stale credit to a
|
||||
# sighting from arbitrarily long ago. Comparing against the caller's frame counter makes the elapsed-frames
|
||||
# check correct regardless of how many cycles were skipped in between.
|
||||
def __init__(self):
|
||||
self._last = None
|
||||
self._sustained = 0
|
||||
self._real_frame = 0
|
||||
self._armed = False
|
||||
self._held_dRel = 0.0
|
||||
|
||||
def reset(self):
|
||||
self.__init__()
|
||||
|
||||
def step(self, raw, frame):
|
||||
if raw.status and raw.dRel > DROPOUT_DREL:
|
||||
self._last = (raw.dRel, raw.vRel, raw.vLead, raw.aLeadK, raw.aLeadTau, raw.modelProb)
|
||||
self._sustained += 1
|
||||
if self._sustained >= SUSTAIN_FRAMES:
|
||||
self._real_frame = frame
|
||||
self._armed = True
|
||||
return raw
|
||||
|
||||
self._sustained = 0
|
||||
since_real = frame - self._real_frame
|
||||
if self._armed and self._last is not None and since_real <= HOLD_MAX_FRAMES:
|
||||
dRel0, vRel0, vLead0, aLeadK0, aLeadTau0, prob0 = self._last
|
||||
if since_real == 1:
|
||||
self._held_dRel = dRel0
|
||||
self._held_dRel = max(MIN_HELD_DREL, self._held_dRel - max(-vRel0, 0.0) * DT_MDL)
|
||||
return _HeldLead(self._held_dRel, vRel0, vLead0, min(aLeadK0, 0.0), aLeadTau0, min(prob0, FCW_PROB_CAP))
|
||||
|
||||
self._armed = False
|
||||
return raw
|
||||
|
||||
|
||||
class _LeadStability:
|
||||
# Read-only monitor: flags an unstable leadOne -- bimodal/bouncing vLead, dRel track-switch jumps, or
|
||||
# radarTrackId churn (a steady lead flipping track ids -> vRel jitter -> follow-hunting). Telemetry only.
|
||||
# Also derives same_track_noise: a bimodal/bouncing vLead while radarTrackId sat CONSTANT the whole window
|
||||
# -- i.e. the id evidence pins the noise to one physical object (a Doppler/fusion-noisy velocity read on one
|
||||
# real lead), so it is safe to feed the smoother (see _LeadSmoother). A bimodal vLead WITH the id also
|
||||
# changing stays outside same_track_noise (could be two really-different real objects at different speeds)
|
||||
# and is left unmitigated, same as before. dRel track-jumps are deliberately excluded here: while status
|
||||
# stays True (this class's own precondition), a repeated FARTHER dRel jump is already absorbed by
|
||||
# _JumpGuard upstream (same SWITCH_DREL threshold), so adding it here would just double up on the same
|
||||
# signal rather than covering a real gap.
|
||||
def __init__(self):
|
||||
self._v = deque(maxlen=STABILITY_WINDOW)
|
||||
self._d = deque(maxlen=STABILITY_WINDOW)
|
||||
self._id = deque(maxlen=ID_CHURN_WINDOW)
|
||||
self.unstable = False
|
||||
self.churn = False
|
||||
self.same_track_noise = False
|
||||
|
||||
def reset(self):
|
||||
self._v.clear()
|
||||
self._d.clear()
|
||||
self._id.clear()
|
||||
self.unstable = False
|
||||
self.churn = False
|
||||
self.same_track_noise = False
|
||||
|
||||
def update(self, lead, v_ego: float) -> None:
|
||||
if not lead.status or v_ego < CREEP_PASSTHROUGH_V:
|
||||
self.reset()
|
||||
return
|
||||
self._v.append(float(lead.vLead))
|
||||
self._d.append(float(lead.dRel))
|
||||
self._id.append(int(getattr(lead, 'radarTrackId', -1)))
|
||||
if len(self._v) < STABILITY_WINDOW:
|
||||
self.unstable = False
|
||||
return
|
||||
v_spread = max(self._v) - min(self._v)
|
||||
d_jumps = sum(abs(b - a) > SWITCH_DREL for a, b in zip(self._d, list(self._d)[1:], strict=False))
|
||||
ids = list(self._id)
|
||||
id_churn = sum(1 for a, b in zip(ids, ids[1:], strict=False) if a != b and a > 0 and b > 0)
|
||||
recent_ids = ids[-STABILITY_WINDOW:]
|
||||
same_track = recent_ids[0] > 0 and len(set(recent_ids)) == 1
|
||||
self.churn = id_churn >= ID_CHURN and v_spread <= VLEAD_SPREAD # steady lead, flipping ids (not bimodal)
|
||||
self.same_track_noise = same_track and v_spread > VLEAD_SPREAD
|
||||
self.unstable = v_spread > VLEAD_SPREAD or d_jumps >= 2 or self.churn
|
||||
|
||||
|
||||
class RadarDistanceController:
|
||||
def __init__(self, CP: structs.CarParams, params=None):
|
||||
# CP accepted for the planner's constructor signature; unused.
|
||||
self._params = params or Params()
|
||||
self._frame = 0
|
||||
self._v_ego = 0.0
|
||||
self._enabled = self._params.get_bool("RadarDistance")
|
||||
self._jump_guard = _JumpGuard()
|
||||
self._one = _LeadHold()
|
||||
self._two = _LeadHold()
|
||||
self._stability = _LeadStability()
|
||||
self._smoother = _LeadSmoother()
|
||||
self._creep_frames = 0
|
||||
self._creep_released = False
|
||||
|
||||
def _read_params(self) -> None:
|
||||
enabled = self._params.get_bool("RadarDistance")
|
||||
if not enabled and self._enabled:
|
||||
self._jump_guard.reset()
|
||||
self._one.reset()
|
||||
self._two.reset()
|
||||
self._smoother.reset()
|
||||
self._creep_frames = 0
|
||||
self._creep_released = False
|
||||
self._enabled = enabled
|
||||
|
||||
def update(self, sm) -> None:
|
||||
if self._frame % int(1. / DT_MDL) == 0:
|
||||
self._read_params()
|
||||
self._v_ego = float(sm['carState'].vEgo)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def lead_unstable(self) -> bool:
|
||||
return self._stability.unstable
|
||||
|
||||
def _stop_gap_bias(self, lead):
|
||||
# Report a (near-)stopped lead up to STOP_GAP_M closer at low speed so the MPC's own smooth stop ends
|
||||
# that much farther back. Monotone (only ever reports closer). No-op outside the regime.
|
||||
in_regime = (lead.status and lead.vLead <= STOP_GAP_VLEAD and
|
||||
self._v_ego <= STOP_GAP_VEGO and lead.dRel > STOP_GAP_MIN_DREL)
|
||||
if not in_regime:
|
||||
self._creep_frames = 0
|
||||
self._creep_released = False
|
||||
return lead
|
||||
|
||||
# A held (stale) lead's near-zero vLead can still satisfy the regime check on a lead that's departed --
|
||||
# skip biasing it, but freeze the creep latch rather than resetting it (an unrelated jump-guard glitch
|
||||
# shouldn't undo motion the lead already earned).
|
||||
if isinstance(lead, _HeldLead):
|
||||
return lead
|
||||
|
||||
if lead.vLead > STOP_GAP_CREEP_V:
|
||||
self._creep_frames = min(self._creep_frames + 1, STOP_GAP_CREEP_HOLD_FRAMES)
|
||||
if self._creep_frames >= STOP_GAP_CREEP_HOLD_FRAMES:
|
||||
self._creep_released = True
|
||||
else:
|
||||
# decay: a sub-threshold frame undoes one frame of "motion" credit, so only SUSTAINED motion (not
|
||||
# cumulative noise straddling the threshold) can reach the cap.
|
||||
self._creep_frames = max(self._creep_frames - 1, 0)
|
||||
if self._creep_released: # once latched: sticky -- a single-frame near-zero blip
|
||||
return lead # afterward (e.g. sensor noise mid-creep) can't re-suppress it
|
||||
|
||||
d_ramp = min(max((STOP_GAP_REGIME_DREL - lead.dRel) / STOP_GAP_RAMP_BAND, 0.0), 1.0)
|
||||
v_ramp = min(max((STOP_GAP_VLEAD - lead.vLead) / STOP_GAP_VLEAD, 0.0), 1.0)
|
||||
offset = STOP_GAP_M * d_ramp * v_ramp
|
||||
if offset < 0.05:
|
||||
return lead
|
||||
return _BiasedLead(lead, max(lead.dRel - offset, STOP_GAP_MIN_DREL))
|
||||
|
||||
def smooth_radarstate(self, radarstate):
|
||||
self._frame += 1 # step()'s elapsed-frames basis; see _LeadHold
|
||||
self._stability.update(radarstate.leadOne, self._v_ego) # telemetry, runs every cycle
|
||||
if not self._enabled:
|
||||
return radarstate # off: byte-stock passthrough
|
||||
two = radarstate.leadTwo
|
||||
noisy = self._stability.churn or self._stability.same_track_noise
|
||||
if self._v_ego >= LOW_SPEED_PASSTHROUGH_V:
|
||||
one = self._jump_guard.step(radarstate.leadOne) # reject a same-cycle farther-jump transient ...
|
||||
one = self._one.step(one, self._frame) # ... + flicker-hold ...
|
||||
two = self._two.step(radarstate.leadTwo, self._frame)
|
||||
one = self._smoother.update(one, noisy) # ... + same-object de-jitter (anti follow-hunt)
|
||||
elif self._v_ego >= CREEP_PASSTHROUGH_V:
|
||||
# creep band: de-jitter ONLY (symmetric EMA), no flicker-hold (a stale held lead would delay launch)
|
||||
one = self._smoother.update(radarstate.leadOne, noisy)
|
||||
else:
|
||||
one = radarstate.leadOne # full standstill: no hold/smoothing
|
||||
one = self._stop_gap_bias(one) # low-speed near-stopped: settle farther back
|
||||
if one is radarstate.leadOne and two is radarstate.leadTwo:
|
||||
return radarstate # nothing changed -> byte-stock object
|
||||
return _RadarStateProxy(one, two)
|
||||
@@ -0,0 +1,704 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
RadarDistance is a pure lead DE-NOISER: flicker-hold + churn smoother + instability telemetry, and nothing
|
||||
else (no dRel biasing). These tests pin: off / low-speed == byte-stock (stock stop distance); the hold is
|
||||
obstacle-monotone (brake >= stock) and bounded; the churn smoother de-jitters only a track-flipping lead;
|
||||
and the instability flag is telemetry that runs regardless of the gate.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.radar_distance.radar_distance import \
|
||||
RadarDistanceController, HOLD_MAX_FRAMES, FCW_PROB_CAP, LOW_SPEED_PASSTHROUGH_V, CREEP_PASSTHROUGH_V, \
|
||||
DROPOUT_DREL, STOP_GAP_MIN_DREL, STOP_GAP_VEGO, STOP_GAP_VLEAD, STOP_GAP_REGIME_DREL, SWITCH_DREL, \
|
||||
JUMP_GUARD_MAX_HOLD, STOP_GAP_CREEP_V, STOP_GAP_CREEP_HOLD_FRAMES
|
||||
|
||||
COMFORT_BRAKE = 2.5
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def __init__(self, store=None):
|
||||
self.store = dict(store or {})
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.store.get(key, False))
|
||||
|
||||
|
||||
def lead(status=True, dRel=40.0, vRel=-2.0, vLead=18.0, aLeadK=0.0, aLeadTau=1.5, modelProb=0.95, radarTrackId=-1):
|
||||
return SimpleNamespace(status=status, dRel=dRel, yRel=0.0, vRel=vRel, vLead=vLead, vLeadK=vLead,
|
||||
aLeadK=aLeadK, aLeadTau=aLeadTau, modelProb=modelProb, radarTrackId=radarTrackId)
|
||||
|
||||
|
||||
def rs(one, two=None):
|
||||
return SimpleNamespace(leadOne=one, leadTwo=two or lead(status=False, dRel=0.0, modelProb=0.0))
|
||||
|
||||
|
||||
def obstacle(ld):
|
||||
return ld.dRel + ld.vLead ** 2 / (2 * COMFORT_BRAKE)
|
||||
|
||||
|
||||
def ctrl(enabled=True, v_ego=10.0):
|
||||
c = RadarDistanceController(CP=SimpleNamespace(), params=FakeParams({'RadarDistance': enabled}))
|
||||
c._v_ego = v_ego # above the low-speed gate so the hold + smoother run
|
||||
return c
|
||||
|
||||
|
||||
def churn_frames(n, d_a=40.0, d_b=42.0, vLead=18.0):
|
||||
# a steady lead whose radarTrackId flips every frame (dRel jitters with it) -> the churn detector fires and
|
||||
# the smoother should de-jitter dRel. vLead is steady so it is NOT flagged bimodal (never averages 2 tracks).
|
||||
for i in range(n):
|
||||
even = i % 2 == 0
|
||||
yield lead(dRel=d_a if even else d_b, vLead=vLead, vRel=-1.0, radarTrackId=1 if even else 2)
|
||||
|
||||
|
||||
# --- off / low-speed == byte-stock ------------------------------------------------------------------------
|
||||
|
||||
def test_disabled_is_identity():
|
||||
c = ctrl(enabled=False)
|
||||
r = rs(lead())
|
||||
assert c.smooth_radarstate(r) is r # byte-stock passthrough
|
||||
|
||||
|
||||
def test_valid_lead_passthrough():
|
||||
c = ctrl()
|
||||
one = lead(dRel=40.0)
|
||||
out = c.smooth_radarstate(rs(one))
|
||||
assert out.leadOne is one # clean lead, no churn -> unchanged
|
||||
|
||||
|
||||
def test_full_standstill_outside_stopgap_is_passthrough():
|
||||
# Full standstill (< CREEP_PASSTHROUGH_V), lead OUTSIDE the stop-gap regime (far): no hold, no smoothing,
|
||||
# no bias -> the EXACT raw radarstate object (byte-identical). The stop-gap only engages inside its regime.
|
||||
c = ctrl(v_ego=CREEP_PASSTHROUGH_V - 0.5)
|
||||
r = rs(lead(dRel=STOP_GAP_REGIME_DREL + 8.0, vLead=0.5))
|
||||
assert c.smooth_radarstate(r) is r
|
||||
|
||||
|
||||
def test_creep_dejitters_churn_but_no_hold():
|
||||
# Creep band [CREEP, LOW_SPEED): the churn smoother runs (de-jitter -> smooth stop-and-go), but the
|
||||
# flicker-hold does NOT (a dropped/departed lead must not be held, or launch would be delayed).
|
||||
# vLead>STOP_GAP_VLEAD so the stop-gap stays out and this isolates the EMA.
|
||||
c = ctrl(v_ego=(CREEP_PASSTHROUGH_V + LOW_SPEED_PASSTHROUGH_V) / 2)
|
||||
out = None
|
||||
for f in churn_frames(30, d_a=6.0, d_b=8.0, vLead=3.0):
|
||||
out = c.smooth_radarstate(rs(f))
|
||||
assert 6.0 < out.leadOne.dRel < 8.0 # jitter smoothed
|
||||
# a dropout in the creep band is NOT held -> raw passes through (no stale lead)
|
||||
drop = rs(lead(status=False, dRel=0.0, modelProb=0.0))
|
||||
assert c.smooth_radarstate(drop) is drop
|
||||
|
||||
|
||||
def test_creep_clean_lead_passthrough():
|
||||
# creep band, steady moving lead (no churn, outside stop-gap regime) -> exact raw object (unbiased)
|
||||
c = ctrl(v_ego=(CREEP_PASSTHROUGH_V + LOW_SPEED_PASSTHROUGH_V) / 2)
|
||||
r = rs(lead(dRel=4.0, vLead=2.5, radarTrackId=3))
|
||||
assert c.smooth_radarstate(r) is r
|
||||
|
||||
|
||||
# --- stop-gap (settle farther back from a near-stopped lead) ----------------------------------------------
|
||||
|
||||
def test_stop_gap_pulls_stopped_lead_closer():
|
||||
c = ctrl(v_ego=2.0)
|
||||
one = lead(dRel=6.0, vLead=0.0, vRel=-1.0)
|
||||
out = c.smooth_radarstate(rs(one))
|
||||
assert out.leadOne.dRel < 6.0 # reported closer -> MPC stops farther back
|
||||
assert obstacle(out.leadOne) <= obstacle(one) + 1e-6 # brake >= stock (obstacle never farther)
|
||||
|
||||
|
||||
def test_stop_gap_monotone_never_farther():
|
||||
c = ctrl(v_ego=3.0)
|
||||
for d in (4.0, 6.0, 9.0, 11.0):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vLead=0.0)))
|
||||
assert out.leadOne.dRel <= d + 1e-6
|
||||
|
||||
|
||||
def test_stop_gap_min_floor():
|
||||
c = ctrl(v_ego=2.0)
|
||||
out = c.smooth_radarstate(rs(lead(dRel=STOP_GAP_MIN_DREL + 0.5, vLead=0.0)))
|
||||
assert out.leadOne.dRel >= STOP_GAP_MIN_DREL - 1e-6
|
||||
|
||||
|
||||
def test_stop_gap_off_when_disabled():
|
||||
c = ctrl(enabled=False, v_ego=2.0)
|
||||
r = rs(lead(dRel=6.0, vLead=0.0))
|
||||
assert c.smooth_radarstate(r) is r # disabled -> stock stop distance
|
||||
|
||||
|
||||
def test_stop_gap_moving_lead_no_change():
|
||||
c = ctrl(v_ego=2.0)
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=STOP_GAP_VLEAD + 1.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(6.0) # lead moving -> not a stop
|
||||
|
||||
|
||||
def test_stop_gap_high_speed_no_change():
|
||||
c = ctrl(v_ego=STOP_GAP_VEGO + 2.0)
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=0.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(6.0) # above the stop regime -> unbiased
|
||||
|
||||
|
||||
def test_stop_gap_far_lead_no_change():
|
||||
c = ctrl(v_ego=2.0)
|
||||
d = STOP_GAP_REGIME_DREL + 5.0
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vLead=0.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(d) # beyond the ramp-in regime -> unbiased
|
||||
|
||||
|
||||
# --- stop-gap creep override (sustained lead motion releases the bias even below STOP_GAP_VLEAD) ----------
|
||||
|
||||
def test_stop_gap_creep_releases_after_sustained_motion():
|
||||
# route 550a71ee4c7a7fbe/000004a4--c9c4691959, t~1126-1138: a lead crept forward at 0.3-0.6 m/s (well under
|
||||
# STOP_GAP_VLEAD) for 9+ seconds; without the override the bias suppressed the whole real gap growth the
|
||||
# entire time, producing a 9+ second launch delay. Real dRel grows steadily 4.0 -> 4.5m over this window.
|
||||
c = ctrl(v_ego=0.0)
|
||||
d = 4.0
|
||||
out = None
|
||||
for i in range(STOP_GAP_CREEP_HOLD_FRAMES + 5):
|
||||
d += 0.05
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vLead=0.4)))
|
||||
if i < STOP_GAP_CREEP_HOLD_FRAMES - 1:
|
||||
assert out.leadOne.dRel < d - 1e-6 # still suppressed while creep hasn't sustained yet
|
||||
assert out.leadOne.dRel == pytest.approx(d) # released after STOP_GAP_CREEP_HOLD_FRAMES of motion
|
||||
|
||||
|
||||
def test_stop_gap_creep_release_is_sticky_through_a_momentary_zero_blip():
|
||||
# A single-frame return to exactly 0.0 (real sensor behavior mid-creep, not just noise) must not re-arm the
|
||||
# bias once sustained creep has already released it -- this was the actual bug: the real route's lead
|
||||
# dipped to vLead=0.00 for one frame mid-launch and the bias briefly re-suppressed the gap right as a result.
|
||||
c = ctrl(v_ego=0.0)
|
||||
d = 4.0
|
||||
for _ in range(STOP_GAP_CREEP_HOLD_FRAMES):
|
||||
d += 0.05
|
||||
c.smooth_radarstate(rs(lead(dRel=d, vLead=0.4)))
|
||||
d += 0.05
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vLead=0.0))) # exact-zero blip
|
||||
assert out.leadOne.dRel == pytest.approx(d) # still released, not re-suppressed
|
||||
d += 0.05
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vLead=0.3))) # creep resumes
|
||||
assert out.leadOne.dRel == pytest.approx(d)
|
||||
|
||||
|
||||
def test_stop_gap_creep_counter_resets_on_a_genuine_new_stop():
|
||||
# Leaving the bias regime entirely (lead departs, or ego speeds past STOP_GAP_VEGO) must re-arm the
|
||||
# override so a LATER, unrelated near-stop encounter isn't permanently exempted by a stale latch.
|
||||
c = ctrl(v_ego=0.0)
|
||||
d = 4.0
|
||||
for _ in range(STOP_GAP_CREEP_HOLD_FRAMES):
|
||||
d += 0.05
|
||||
c.smooth_radarstate(rs(lead(dRel=d, vLead=0.4)))
|
||||
c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0))) # lead lost -> regime exit
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=0.0))) # fresh near-stop encounter
|
||||
assert out.leadOne.dRel < 6.0 # bias re-armed, active again
|
||||
|
||||
|
||||
def test_stop_gap_creep_below_threshold_never_releases():
|
||||
c = ctrl(v_ego=0.0)
|
||||
out = None
|
||||
for _ in range(STOP_GAP_CREEP_HOLD_FRAMES + 10):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=STOP_GAP_CREEP_V * 0.5)))
|
||||
assert out.leadOne.dRel < 6.0 - 1e-6 # vLead never exceeds the creep threshold -> stays biased
|
||||
|
||||
|
||||
def test_stop_gap_creep_intermittent_noise_never_releases():
|
||||
# route 550a71ee4c7a7fbe/000004b6--d4a8ac3352, t~678-690s: a genuinely-stopped lead's vLead noise blipped
|
||||
# above STOP_GAP_CREEP_V on roughly half the frames (never below zero motion overall, never sustained) --
|
||||
# the old monotonic-only counter still accumulated those blips to the cap over enough frames and falsely
|
||||
# latched the bias off mid-stop, producing a same-cycle jump in the reported gap. Alternating strictly
|
||||
# above/below the threshold every frame, for far longer than STOP_GAP_CREEP_HOLD_FRAMES, must never release.
|
||||
c = ctrl(v_ego=0.0)
|
||||
out = None
|
||||
for i in range(STOP_GAP_CREEP_HOLD_FRAMES * 4):
|
||||
vLead = STOP_GAP_CREEP_V + 0.05 if i % 2 == 0 else 0.0
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=vLead)))
|
||||
assert out.leadOne.dRel < 6.0 - 1e-6 # never sustained -> never releases, even after 4x the hold
|
||||
|
||||
|
||||
def test_stop_gap_creep_sustained_after_intermittent_noise_still_releases():
|
||||
# the decay must not make the override permanently harder to reach -- real sustained motion right after a
|
||||
# noisy patch still releases within the normal HOLD window (decay only undoes noise, doesn't add a penalty).
|
||||
c = ctrl(v_ego=0.0)
|
||||
for i in range(STOP_GAP_CREEP_HOLD_FRAMES):
|
||||
vLead = STOP_GAP_CREEP_V + 0.05 if i % 2 == 0 else 0.0
|
||||
c.smooth_radarstate(rs(lead(dRel=6.0, vLead=vLead)))
|
||||
out = None
|
||||
for _ in range(STOP_GAP_CREEP_HOLD_FRAMES + 5):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=6.0, vLead=STOP_GAP_CREEP_V + 0.05)))
|
||||
assert out.leadOne.dRel == pytest.approx(6.0) # sustained motion still releases in bounded time
|
||||
|
||||
|
||||
def test_stop_gap_does_not_double_bias_a_jump_guard_held_lead():
|
||||
# route 550a71ee4c7a7fbe/000004bc--d9e0efd5ac, t~1563.4-1563.9: a lead sitting near-stopped (vLead~0,
|
||||
# inside the stop-gap regime) departs fast enough that a single raw dRel jump exceeds jump-guard's
|
||||
# SWITCH_DREL. Jump-guard holds its OLD, stale (near-zero vLead0) reading rather than the new, fast, real
|
||||
# one -- and that held vLead0 STILL satisfies the stop-gap regime check (it looks near-stopped), so without
|
||||
# the skip, stop-gap piles a SECOND closer-bias on top of an already-stale value. On the real route this
|
||||
# compounding dropped the reported gap far enough (raw ~16m -> ~2-5m) to fool the MPC's own forward-solve
|
||||
# into a spurious FCW during a real launch. Fixed: stop-gap must skip a held (stale) lead entirely.
|
||||
v_ego = (LOW_SPEED_PASSTHROUGH_V + STOP_GAP_VEGO) / 2 # in-band for jump-guard hold AND stop-gap regime
|
||||
c = ctrl(v_ego=v_ego)
|
||||
dRel0, vRel0, vLead0 = 7.5, -0.1, 0.2 # near-stopped baseline, trusted
|
||||
c.smooth_radarstate(rs(lead(dRel=dRel0, vRel=vRel0, vLead=vLead0)))
|
||||
jumped = c.smooth_radarstate(rs(lead(dRel=dRel0 + SWITCH_DREL + 1.0, vRel=6.5, vLead=6.8))) # real, fast departure
|
||||
expected_held = dRel0 - max(-vRel0, 0.0) * DT_MDL # jump-guard's own extrapolation -- no further bias
|
||||
assert jumped.leadOne.dRel == pytest.approx(expected_held, abs=1e-6)
|
||||
|
||||
|
||||
def test_smoother_does_not_launder_a_jump_guard_hold_during_churn():
|
||||
# A churn episode (real radarTrackId flapping, steady kinematics) actively engaging the smoother right as a
|
||||
# jump-guard hold begins must not let the smoother wrap the held lead into a _SmoothedLead -- that would hide
|
||||
# "this is stale" from stop-gap's held-lead check and let a second closer-bias stack on top.
|
||||
v_ego = (LOW_SPEED_PASSTHROUGH_V + STOP_GAP_VEGO) / 2 # in-band for jump-guard hold AND stop-gap regime
|
||||
c = ctrl(v_ego=v_ego)
|
||||
d = 9.0
|
||||
for i in range(8):
|
||||
tid = 1 if i % 2 == 0 else 2
|
||||
c.smooth_radarstate(rs(lead(dRel=d, vRel=-6.3, vLead=0.2, radarTrackId=tid)))
|
||||
dRel0 = d
|
||||
d -= 0.15
|
||||
assert c.lead_unstable() # churn primed the smoother
|
||||
vRel0 = -6.3
|
||||
jumped = c.smooth_radarstate(rs(lead(dRel=dRel0 + SWITCH_DREL + 1.0, vRel=6.5, vLead=6.8, radarTrackId=1)))
|
||||
expected_held = dRel0 - max(-vRel0, 0.0) * DT_MDL
|
||||
assert type(jumped.leadOne).__name__ == '_HeldLead'
|
||||
assert jumped.leadOne.dRel == pytest.approx(expected_held, abs=1e-6)
|
||||
|
||||
|
||||
def test_stop_gap_creep_latch_survives_an_unrelated_jump_guard_hold():
|
||||
# A creep-release already earned (sustained real motion) must not be wiped by an unrelated jump-guard hold
|
||||
# that happens to land on the same lead -- the hold is a one-cycle fusion transient, not evidence the lead
|
||||
# stopped moving again.
|
||||
v_ego = (LOW_SPEED_PASSTHROUGH_V + STOP_GAP_VEGO) / 2 # in-band for jump-guard hold AND stop-gap regime
|
||||
c = ctrl(v_ego=v_ego)
|
||||
d = 4.0
|
||||
for _ in range(STOP_GAP_CREEP_HOLD_FRAMES + 2):
|
||||
d += 0.05
|
||||
c.smooth_radarstate(rs(lead(dRel=d, vRel=-6.0, vLead=0.4)))
|
||||
assert c._creep_released
|
||||
dRel0 = d
|
||||
held = c.smooth_radarstate(rs(lead(dRel=dRel0 + SWITCH_DREL + 1.0, vRel=6.5, vLead=6.8)))
|
||||
assert type(held.leadOne).__name__ == '_HeldLead' # bias correctly skipped on the held cycle
|
||||
assert c._creep_released # but the earned latch must survive the glitch
|
||||
d += 0.05
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vRel=-0.4, vLead=0.4)))
|
||||
assert out.leadOne.dRel == pytest.approx(d) # creep resumes unbiased, no re-suppression
|
||||
|
||||
|
||||
def test_low_speed_override_lead_passthrough():
|
||||
# radard low_speed_override emits a real closest-track lead with modelProb=0.0. It must be honored, not
|
||||
# rejected in favor of a stale farther held lead (which would under-brake / stop too close).
|
||||
c = ctrl()
|
||||
one = lead(status=True, dRel=2.5, vRel=0.0, vLead=0.0, modelProb=0.0)
|
||||
out = c.smooth_radarstate(rs(one))
|
||||
assert out.leadOne is one
|
||||
|
||||
|
||||
# --- jump-guard (reject a same-cycle farther fusion transient) --------------------------------------------
|
||||
|
||||
def test_jump_guard_holds_farther_transient():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=27.92, vRel=-5.60, vLead=24.47, radarTrackId=1058)))
|
||||
out = c.smooth_radarstate(rs(lead(dRel=38.88, vRel=-3.19, vLead=26.91, radarTrackId=-1)))
|
||||
assert out.leadOne.dRel < 30.0 # farther jump rejected, held near the trusted value
|
||||
assert out.leadOne.status is True
|
||||
|
||||
|
||||
def test_jump_guard_passes_closer_jump_immediately():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-2.0, vLead=18.0)))
|
||||
out = c.smooth_radarstate(rs(lead(dRel=27.0, vRel=-5.0, vLead=15.0))) # big CLOSER jump
|
||||
assert out.leadOne.dRel == pytest.approx(27.0) # closer always passes through -- never delays a brake
|
||||
|
||||
|
||||
def test_jump_guard_replays_real_route_whiplash():
|
||||
# route 550a71ee4c7a7fbe/00000498--0704864d6a, t~402.2-402.8: a merging lead's vision distance estimate
|
||||
# whiplashed 27.92 -> 38.88 -> 37.69 -> 37.20 -> 26.84 for ~0.3s while a solid radar track sat at ~27m the
|
||||
# whole time. The guard should smooth the farther excursion into a monotone converge toward the real value.
|
||||
c = ctrl()
|
||||
raw = [
|
||||
(74.18, -4.05, 25.77, -1), (53.21, -3.55, 26.33, -1), (47.42, -3.23, 26.67, -1),
|
||||
(42.64, -3.50, 26.42, -1), (43.22, -3.49, 26.49, -1), (40.03, -3.04, 26.96, -1),
|
||||
(39.50, -3.29, 26.74, -1), (27.92, -5.60, 24.47, 1058), (38.88, -3.19, 26.91, -1),
|
||||
(37.69, -3.09, 27.04, -1), (37.20, -2.77, 27.39, -1), (26.84, -5.80, 24.37, 1058),
|
||||
]
|
||||
out = None
|
||||
for dRel, vRel, vLead, tid in raw:
|
||||
out = c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=vLead, radarTrackId=tid)))
|
||||
assert out.leadOne.dRel == pytest.approx(26.84) # real value recovered exactly once raw resumes reporting it
|
||||
# peak reported dRel during the excursion never revisits the raw 38.88 spike
|
||||
seen = []
|
||||
c = ctrl()
|
||||
for dRel, vRel, vLead, tid in raw:
|
||||
seen.append(c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=vLead, radarTrackId=tid))).leadOne.dRel)
|
||||
assert max(seen[8:11]) < 30.0 # the 3 farther-jump frames are all held near ~27m, not ~37-39m
|
||||
|
||||
|
||||
def test_jump_guard_self_heals_after_cap():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=20.0, vRel=-1.0, vLead=19.0)))
|
||||
for _ in range(JUMP_GUARD_MAX_HOLD):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0)))
|
||||
assert out.leadOne.dRel < 40.0 # held while under the cap
|
||||
# cap just reached on a lead that was closing -- one bounded grace cycle before accepting a farther raw value
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0)))
|
||||
assert out.leadOne.dRel < 40.0 # grace cycle: still held, not yet accepted
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(40.0) # grace spent -> accepts the real (departing) value
|
||||
|
||||
|
||||
def test_jump_guard_self_heals_immediately_when_not_closing():
|
||||
# The grace cycle only protects a lead that was closing when the cap was hit -- a lead that was already
|
||||
# steady/opening (vRel >= 0) self-heals on the very first cap-exceeding frame, same as before this fix.
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=20.0, vRel=0.5, vLead=19.0)))
|
||||
for _ in range(JUMP_GUARD_MAX_HOLD):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=0.5, vLead=19.0)))
|
||||
assert out.leadOne.dRel < 40.0
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=0.5, vLead=19.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(40.0) # no grace needed -> heals immediately, unchanged behavior
|
||||
|
||||
|
||||
def test_jump_guard_grace_is_used_at_most_once_per_hold_episode():
|
||||
# The grace cycle must be bounded -- a lead that keeps reading farther after the grace is spent must not
|
||||
# get a second grace before genuinely accepting the new value (else a departed lead could be held forever).
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=20.0, vRel=-1.0, vLead=19.0)))
|
||||
for _ in range(JUMP_GUARD_MAX_HOLD):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0)))
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0))) # grace cycle, spent
|
||||
out = c.smooth_radarstate(rs(lead(dRel=70.0, vRel=-1.0, vLead=19.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(70.0) # grace already spent this episode -> accepts immediately
|
||||
|
||||
|
||||
def test_jump_guard_replays_real_route_dropout_catchup():
|
||||
# route 550a71ee4c7a7fbe/000004c6--ed1b6d7f95, t~1337.9-1338.5: a spurious closer misread (31.08 -> 24.94)
|
||||
# passes through immediately (closer always does), poisoning the guard's anchor. The lead's real, continuing
|
||||
# trajectory (~31m, closing) then reads as a farther jump against that bad anchor and gets held for the full
|
||||
# cap. Without the grace cycle, the guard self-healed straight onto a farther transitional misread (56.52)
|
||||
# right as a real dropout began, and _LeadHold then flicker-held THAT value through the whole dropout --
|
||||
# reporting a lead ~2x farther and opening instead of closing, easing the MPC off right before a real
|
||||
# catch-up brake. The grace cycle must keep the held value close to the real trajectory across this handoff.
|
||||
c = ctrl(v_ego=14.4)
|
||||
raw = [
|
||||
(30.62, -0.45, 1), (38.36, -3.33, -1), (38.20, -3.42, -1), (38.08, -3.45, -1), (37.88, -3.53, -1),
|
||||
(37.72, -3.58, -1), (47.53, 0.35, -1), (24.94, -1.90, -1), (31.44, -3.72, -1), (31.20, -3.97, -1),
|
||||
(31.08, -3.95, 2), (74.32, 3.65, 3), (74.52, 3.70, 3), (74.92, 3.85, 3), (75.12, 3.88, 3),
|
||||
(75.28, 3.90, 3), (75.64, 3.95, 3), (75.64, 3.95, 3), (56.52, -2.09, -1),
|
||||
]
|
||||
out = None
|
||||
for dRel, vRel, tid in raw:
|
||||
out = c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=10.5, radarTrackId=tid)))
|
||||
assert out.leadOne.dRel < 30.0 # grace cycle: still held near the real trajectory
|
||||
dropout_held = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0))).leadOne
|
||||
assert dropout_held.status is True
|
||||
assert dropout_held.dRel < 30.0 # flicker-hold seeds from the grace-held value, not 56.52
|
||||
|
||||
|
||||
def test_jump_guard_resets_on_dropout():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=20.0, vRel=-1.0, vLead=19.0)))
|
||||
c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vRel=-1.0, vLead=19.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(40.0) # a real dropout in between is not a same-cycle jump
|
||||
|
||||
|
||||
def test_jump_guard_off_when_disabled():
|
||||
c = ctrl(enabled=False)
|
||||
c.smooth_radarstate(rs(lead(dRel=27.92, vRel=-5.60, vLead=24.47)))
|
||||
r = rs(lead(dRel=38.88, vRel=-3.19, vLead=26.91))
|
||||
assert c.smooth_radarstate(r) is r # disabled -> raw passthrough, no guard
|
||||
|
||||
|
||||
def test_jump_guard_replays_real_route_sub_threshold_bounce():
|
||||
# route 550a71ee4c7a7fbe/000004b4--2bd66184db, t~976.08-976.48: dRel bounced 17.70 -> 12.32 -> ... -> 17.15
|
||||
# -> 12.04m across ~0.4s while vRel stayed -0.8 to -2.4 m/s -- physically impossible for one real object
|
||||
# at that closing speed (5m in ~0.1s would need ~50 m/s, not ~1-2). This is the case that motivated
|
||||
# lowering SWITCH_DREL from 8.0 to 4.0: the farther excursion (12.24 -> 17.15, a 4.91m jump) sailed through
|
||||
# unguarded at the old threshold, producing a false-relief-then-correction whipsaw. A closer jump (e.g.
|
||||
# 17.70 -> 12.32) always passes immediately regardless of threshold -- that invariant is untouched here.
|
||||
c = ctrl()
|
||||
raw = [
|
||||
(19.12, -2.32, 9.22, -0.67, -1), (17.95, -2.06, 9.39, -0.58, -1), (18.06, -1.90, 9.49, -0.60, -1),
|
||||
(17.70, -1.84, 9.44, -0.52, -1), (12.32, -1.20, 10.01, -0.02, 2449), (12.12, -1.40, 9.75, -1.60, 2427),
|
||||
(12.56, -1.20, 9.87, -1.45, 2427), (12.24, -1.05, 9.92, -1.29, 2427), (17.15, -2.39, 8.53, -0.85, -1),
|
||||
(12.04, -0.82, 10.02, -0.97, 2427), (12.04, -0.82, 9.94, -0.85, 2427), (11.80, -0.85, 9.81, -0.78, 2427),
|
||||
]
|
||||
out = None
|
||||
seen = []
|
||||
for dRel, vRel, vLead, aLeadK, tid in raw:
|
||||
out = c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=vLead, aLeadK=aLeadK, radarTrackId=tid)))
|
||||
seen.append(out.leadOne.dRel)
|
||||
assert seen[4] == pytest.approx(12.32) # the initial closer jump (17.70->12.32) passes immediately
|
||||
assert seen[8] < 14.0 # the 12.24->17.15 farther excursion is held, not passed
|
||||
assert out.leadOne.dRel == pytest.approx(11.80) # recovers exactly once raw resumes reporting close values
|
||||
|
||||
|
||||
def test_jump_guard_hold_caps_model_prob_for_fcw():
|
||||
# route 550a71ee4c7a7fbe/000004bc--d9e0efd5ac, t~1563.5: a real, high-confidence (modelProb 0.999) lead
|
||||
# departs and gets held near-stationary by the guard mid-launch. The stock crash_cnt FCW gate fires on
|
||||
# radarState.leadOne.modelProb > 0.9 -- a held (stale, no longer confirmed-fresh) reading must not carry
|
||||
# enough confidence on its own to satisfy that gate, matching _LeadHold's existing flicker-hold cap.
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=15.72, vRel=1.80, vLead=6.79, modelProb=0.999)))
|
||||
held = c.smooth_radarstate(rs(lead(dRel=15.72 + SWITCH_DREL + 1.0, vRel=6.5, vLead=6.8, modelProb=0.999))).leadOne
|
||||
assert held.modelProb <= FCW_PROB_CAP
|
||||
|
||||
|
||||
def test_jump_guard_boundary_not_triggered():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-2.0, vLead=18.0)))
|
||||
out = c.smooth_radarstate(rs(lead(dRel=30.0 + SWITCH_DREL - 0.1, vRel=-2.0, vLead=18.0)))
|
||||
assert out.leadOne.dRel == pytest.approx(30.0 + SWITCH_DREL - 0.1) # under threshold -> passes through
|
||||
|
||||
|
||||
# --- flicker-hold -----------------------------------------------------------------------------------------
|
||||
|
||||
def test_holds_after_sustained_dropout():
|
||||
c = ctrl()
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-4.0, vLead=16.0)))
|
||||
held = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0))).leadOne
|
||||
assert held.status is True
|
||||
assert held.dRel < 30.0 # dead-reckoned closer
|
||||
assert held.dRel == pytest.approx(30.0 - 4.0 * 0.05, abs=1e-6)
|
||||
|
||||
|
||||
def test_no_hold_without_sustained_lead():
|
||||
c = ctrl()
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0))) # single frame < SUSTAIN_FRAMES
|
||||
out = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
assert out.leadOne.status is False # no hold armed
|
||||
|
||||
|
||||
def test_hold_does_not_resurrect_a_stale_lead_after_an_extended_low_speed_gap():
|
||||
# Below LOW_SPEED_PASSTHROUGH_V the hold is never stepped at all (see smooth_radarstate), so an elapsed-
|
||||
# frames check must be based on real cycles, not "cycles since step() was last called" -- otherwise resuming
|
||||
# above the gate looks like no time passed no matter how long the low-speed period actually was, and a hold
|
||||
# armed on a real lead long before the gap can resurrect as if it were still fresh.
|
||||
c = ctrl(v_ego=LOW_SPEED_PASSTHROUGH_V + 1.0)
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-3.0, vLead=5.0)))
|
||||
c._v_ego = LOW_SPEED_PASSTHROUGH_V - 1.0 # below the gate: step() stops being called on the hold
|
||||
for _ in range(HOLD_MAX_FRAMES * 3):
|
||||
c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
c._v_ego = LOW_SPEED_PASSTHROUGH_V + 1.0 # back above the gate, lead still gone
|
||||
out = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
assert out.leadOne.status is False # must not resurrect the old hold
|
||||
|
||||
|
||||
def test_hold_survives_a_brief_low_speed_dip_within_the_cap():
|
||||
# A short dip below the gate (well under HOLD_MAX_FRAMES real cycles) is the case flicker-hold exists for --
|
||||
# it must still bridge, same as a same-speed dropout of the same real duration would.
|
||||
c = ctrl(v_ego=LOW_SPEED_PASSTHROUGH_V + 1.0)
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-3.0, vLead=5.0)))
|
||||
c._v_ego = LOW_SPEED_PASSTHROUGH_V - 1.0
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
c._v_ego = LOW_SPEED_PASSTHROUGH_V + 1.0
|
||||
out = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
assert out.leadOne.status is True
|
||||
assert out.leadOne.dRel < 30.0
|
||||
|
||||
|
||||
def test_releases_after_hold_cap():
|
||||
c = ctrl()
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-2.0)))
|
||||
drop = rs(lead(status=False, dRel=0.0, modelProb=0.0))
|
||||
for _ in range(HOLD_MAX_FRAMES):
|
||||
assert c.smooth_radarstate(drop).leadOne.status is True
|
||||
assert c.smooth_radarstate(drop).leadOne.status is False # released after the cap
|
||||
|
||||
|
||||
def test_obstacle_monotone_during_hold():
|
||||
c = ctrl()
|
||||
for _ in range(3):
|
||||
real = lead(dRel=30.0, vRel=-3.0, vLead=15.0)
|
||||
c.smooth_radarstate(rs(real))
|
||||
base = obstacle(real)
|
||||
drop = rs(lead(status=False, dRel=0.0, modelProb=0.0))
|
||||
prev = base
|
||||
for _ in range(HOLD_MAX_FRAMES):
|
||||
held = c.smooth_radarstate(drop).leadOne
|
||||
assert obstacle(held) <= prev + 1e-6 # never reports a farther obstacle -> brake >= stock
|
||||
prev = obstacle(held)
|
||||
|
||||
|
||||
def test_fcw_prob_capped_and_aleadk_not_positive():
|
||||
c = ctrl()
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, aLeadK=1.5, modelProb=0.99)))
|
||||
held = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0))).leadOne
|
||||
assert held.modelProb <= FCW_PROB_CAP
|
||||
assert held.aLeadK <= 0.0
|
||||
|
||||
|
||||
def test_flicker_does_not_reset_wall_clock():
|
||||
c = ctrl()
|
||||
for _ in range(3):
|
||||
c.smooth_radarstate(rs(lead(dRel=30.0, vRel=-2.0)))
|
||||
# alternating drop/reacquire must not refill the hold budget: after > HOLD_MAX_FRAMES wall time it releases
|
||||
for i in range(HOLD_MAX_FRAMES + 4):
|
||||
frame = rs(lead(status=False, dRel=0.0, modelProb=0.0)) if i % 2 else rs(lead(dRel=0.5)) # dRel<=DROPOUT: not real
|
||||
c.smooth_radarstate(frame)
|
||||
out = c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
assert out.leadOne.status is False
|
||||
assert DROPOUT_DREL == 1.0
|
||||
|
||||
|
||||
# --- churn smoother ---------------------------------------------------------------------------------------
|
||||
|
||||
def test_churn_smoother_removes_jitter():
|
||||
c = ctrl()
|
||||
out = None
|
||||
for f in churn_frames(30):
|
||||
out = c.smooth_radarstate(rs(f))
|
||||
assert c.lead_unstable() # churn detected
|
||||
assert 40.0 < out.leadOne.dRel < 42.0 # EMA settled between the two jittering tracks
|
||||
assert out.leadOne.dRel not in (40.0, 42.0) # not the raw alternating value
|
||||
|
||||
|
||||
def test_churn_smoother_off_when_disabled():
|
||||
c = ctrl(enabled=False)
|
||||
out = None
|
||||
for f in churn_frames(30):
|
||||
r = rs(f)
|
||||
out = c.smooth_radarstate(r)
|
||||
assert out is r # disabled -> raw passthrough, no smoothing
|
||||
|
||||
|
||||
def test_smoother_inactive_without_churn():
|
||||
c = ctrl()
|
||||
one = lead(dRel=40.0, radarTrackId=7)
|
||||
for _ in range(10):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, radarTrackId=7)))
|
||||
out = c.smooth_radarstate(rs(one))
|
||||
assert out.leadOne is one # steady id -> no churn -> exact passthrough
|
||||
|
||||
|
||||
def test_churn_smoother_closer_accepted_immediately():
|
||||
# A steadily-closing lead that also briefly churns must never be held farther than the current raw value --
|
||||
# otherwise the EMA lags a real closing lead for the whole LEAD_SMOOTH_HOLD window, then snaps (a false
|
||||
# relief followed by a hard catch-up brake -- route 550a71ee4c7a7fbe/00000499, t~1387, real regression).
|
||||
c = ctrl()
|
||||
d = 82.0
|
||||
for i in range(40):
|
||||
tid = 1 if i % 3 else 2 # enough id-churn to keep the smoother engaged
|
||||
out = c.smooth_radarstate(rs(lead(dRel=d, vRel=-6.0, vLead=24.0, radarTrackId=tid)))
|
||||
assert out.leadOne.dRel <= d + 1e-6 # never farther than the latest raw reading
|
||||
d -= 0.4 # steadily closing
|
||||
|
||||
|
||||
def test_churn_smoother_replays_real_route_late_acquisition():
|
||||
# route 550a71ee4c7a7fbe/00000499--7f57e1d000, t~1386.9-1388.4: radard toggles between two real candidate
|
||||
# tracks (id 4611 ~110m, id 4609 ~82m closing) while acquiring, then a couple of vision-fallback frames
|
||||
# (id -1) report ~104-109m mid-acquisition. The real dRel (track 4609) closes smoothly 82.0 -> 73.2m the
|
||||
# whole time. Old symmetric EMA held the reported dRel near ~82m (farther than truth) for ~1s after the
|
||||
# brief churn window, then snapped -- this is the false-relief-then-correction pattern being fixed here.
|
||||
c = ctrl()
|
||||
raw = [
|
||||
(110.84, -1.75, 4611), (82.04, -3.78, 4609), (110.60, -1.85, 4611), (81.68, -3.80, 4609),
|
||||
(82.28, -4.13, 4609), (110.40, -2.05, 4611), (110.16, -1.93, 4611), (110.08, -2.00, 4611),
|
||||
(110.00, -2.00, 4611), (80.12, -4.83, 4609), (79.88, -4.95, 4609), (79.64, -5.08, 4609),
|
||||
(79.32, -5.20, 4609), (79.48, -5.38, 4609), (79.08, -5.55, 4609), (78.64, -5.70, 4609),
|
||||
(78.20, -5.88, 4609), (77.84, -6.00, 4609), (77.60, -6.18, 4609), (77.48, -6.30, 4609),
|
||||
(76.96, -6.50, 4609), (76.48, -6.65, 4609), (103.52, -1.75, -1), (76.08, -6.90, 4609),
|
||||
(75.52, -7.05, 4609), (108.97, -2.02, -1), (104.23, -2.15, -1), (103.64, -2.13, -1),
|
||||
(74.16, -7.43, 4609), (73.72, -7.60, 4609), (73.24, -7.70, 4609),
|
||||
]
|
||||
out = None
|
||||
for dRel, vRel, tid in raw:
|
||||
out = c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=24.0 + vRel, radarTrackId=tid)))
|
||||
assert out.leadOne.dRel == pytest.approx(73.24, abs=0.5) # tracks the true closing value, no lag
|
||||
# at no point does the reported dRel sit meaningfully farther than the most recent real (id>0) reading
|
||||
c = ctrl()
|
||||
worst_overshoot = 0.0
|
||||
last_real = None
|
||||
for dRel, vRel, tid in raw:
|
||||
out = c.smooth_radarstate(rs(lead(dRel=dRel, vRel=vRel, vLead=24.0 + vRel, radarTrackId=tid)))
|
||||
if tid > 0:
|
||||
last_real = dRel
|
||||
if last_real is not None:
|
||||
worst_overshoot = max(worst_overshoot, out.leadOne.dRel - last_real)
|
||||
assert worst_overshoot < 1.0 # old code overshot by ~6-9m for up to ~1s
|
||||
|
||||
|
||||
# --- instability telemetry --------------------------------------------------------------------------------
|
||||
|
||||
def test_stability_quiet_on_clean_lead():
|
||||
c = ctrl()
|
||||
for _ in range(10):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0, radarTrackId=5)))
|
||||
assert not c.lead_unstable()
|
||||
|
||||
|
||||
def test_stability_flags_bimodal_lead():
|
||||
c = ctrl()
|
||||
for i in range(10):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0 if i % 2 else 10.0, radarTrackId=5)))
|
||||
assert c.lead_unstable()
|
||||
|
||||
|
||||
def test_stability_flags_trackid_churn():
|
||||
c = ctrl()
|
||||
for f in churn_frames(20):
|
||||
c.smooth_radarstate(rs(f))
|
||||
assert c.lead_unstable()
|
||||
|
||||
|
||||
def test_stability_resets_on_dropout():
|
||||
c = ctrl()
|
||||
for i in range(10):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0 if i % 2 else 10.0)))
|
||||
assert c.lead_unstable()
|
||||
c.smooth_radarstate(rs(lead(status=False, dRel=0.0, modelProb=0.0)))
|
||||
assert not c.lead_unstable()
|
||||
|
||||
|
||||
def test_stability_runs_even_when_disabled():
|
||||
c = ctrl(enabled=False)
|
||||
for i in range(10):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0 if i % 2 else 10.0)))
|
||||
assert c.lead_unstable() # telemetry not gated by the RadarDistance param
|
||||
|
||||
|
||||
# --- same-track noise smoother (bimodal vLead / repeated dRel jump on a CONSTANT radarTrackId) -------------
|
||||
|
||||
def test_smoother_dejitters_bimodal_vlead_on_same_track():
|
||||
# Same physical object (radarTrackId constant) but a bouncing velocity read (Doppler/fusion noise) -- the
|
||||
# id evidence pins this to ONE real lead, so it's safe to EMA (unlike a bimodal read with a changing id).
|
||||
c = ctrl()
|
||||
out = None
|
||||
for i in range(30):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0 if i % 2 else 10.0, vRel=-1.0, radarTrackId=9)))
|
||||
assert c.lead_unstable()
|
||||
assert 10.0 < out.leadOne.vLead < 18.0 # EMA settled between the two bouncing readings
|
||||
assert out.leadOne.vLead not in (10.0, 18.0)
|
||||
|
||||
|
||||
def test_smoother_inactive_on_bimodal_vlead_with_changing_track():
|
||||
# Same bimodal vLead signature, but radarTrackId ALSO changes -- ambiguous (could be two really-different
|
||||
# real objects at different speeds), so this must NOT be smoothed, unlike the same-track case above.
|
||||
c = ctrl()
|
||||
one = lead(dRel=40.0, vLead=18.0, radarTrackId=1)
|
||||
for i in range(10):
|
||||
c.smooth_radarstate(rs(lead(dRel=40.0, vLead=18.0 if i % 2 else 10.0, radarTrackId=1 if i % 2 else 2)))
|
||||
out = c.smooth_radarstate(rs(one))
|
||||
assert out.leadOne is one # exact passthrough -- not averaged across tracks
|
||||
|
||||
|
||||
def test_smoother_same_track_noise_ignores_drel_jump():
|
||||
# dRel track-jumps are excluded from same_track_noise on purpose: while status stays True, a repeated
|
||||
# farther jump this large is already absorbed by _JumpGuard upstream, so the smoother never even sees the
|
||||
# raw alternation here -- confirms the two mechanisms don't double up on the same signal.
|
||||
c = ctrl()
|
||||
out = None
|
||||
for i in range(30):
|
||||
out = c.smooth_radarstate(rs(lead(dRel=40.0 if i % 2 == 0 else 55.0, vLead=18.0, vRel=-1.0, radarTrackId=4)))
|
||||
assert out.leadOne.dRel < 45.0 # held near the trusted value by the jump-guard, not 55
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Copyright (c) 2021-, rav4kumar, 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.constants import CV
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.params import Params
|
||||
|
||||
NEARSIDE_PROB = 0.25
|
||||
EDGE_PROB = 0.35
|
||||
EDGE_REACTION_TIME = 1.0
|
||||
EDGE_CLEAR_TIME = 0.3
|
||||
MIN_SPEED = 20 * CV.MPH_TO_MS
|
||||
NEAR_EDGE_DISTANCE = 4.5
|
||||
LEFT_NEARSIDE_LANE_IDX = 1
|
||||
RIGHT_NEARSIDE_LANE_IDX = 2
|
||||
|
||||
|
||||
class RoadEdgeLaneChangeController:
|
||||
def __init__(self, desire_helper):
|
||||
self.DH = desire_helper
|
||||
self.params = Params()
|
||||
self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled")
|
||||
self.param_read_counter = 0
|
||||
self.left_edge_detected = False
|
||||
self.right_edge_detected = False
|
||||
self.left_edge_timer = 0.0
|
||||
self.right_edge_timer = 0.0
|
||||
self.left_clear_timer = 0.0
|
||||
self.right_clear_timer = 0.0
|
||||
|
||||
def read_params(self) -> None:
|
||||
self.enabled = self.params.get_bool("RoadEdgeLaneChangeEnabled")
|
||||
|
||||
def update_params(self) -> None:
|
||||
if self.param_read_counter % 50 == 0:
|
||||
self.read_params()
|
||||
self.param_read_counter += 1
|
||||
|
||||
def reset(self) -> None:
|
||||
self.left_edge_detected = False
|
||||
self.right_edge_detected = False
|
||||
self.left_edge_timer = 0.0
|
||||
self.right_edge_timer = 0.0
|
||||
self.left_clear_timer = 0.0
|
||||
self.right_clear_timer = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _road_edge_y(road_edges, idx: int) -> float | None:
|
||||
if road_edges is None or len(road_edges) <= idx or len(road_edges[idx].y) == 0:
|
||||
return None
|
||||
return road_edges[idx].y[0]
|
||||
|
||||
@staticmethod
|
||||
def _edge_is_near(edge_y: float | None, left: bool) -> bool:
|
||||
if edge_y is None:
|
||||
return False
|
||||
if left:
|
||||
return bool(-NEAR_EDGE_DISTANCE < edge_y < 0.0)
|
||||
return bool(0.0 < edge_y < NEAR_EDGE_DISTANCE)
|
||||
|
||||
def update(self, road_edge_stds, lane_line_probs, v_ego: float, road_edges=None) -> None:
|
||||
self.update_params()
|
||||
|
||||
if not self.enabled or v_ego < MIN_SPEED:
|
||||
self.reset()
|
||||
return
|
||||
|
||||
left_edge_prob = np.clip(1.0 - road_edge_stds[0], 0.0, 1.0)
|
||||
right_edge_prob = np.clip(1.0 - road_edge_stds[1], 0.0, 1.0)
|
||||
left_lane_prob = lane_line_probs[LEFT_NEARSIDE_LANE_IDX]
|
||||
right_lane_prob = lane_line_probs[RIGHT_NEARSIDE_LANE_IDX]
|
||||
|
||||
left_edge_y = self._road_edge_y(road_edges, 0)
|
||||
right_edge_y = self._road_edge_y(road_edges, 1)
|
||||
left_edge_near = self._edge_is_near(left_edge_y, True)
|
||||
right_edge_near = self._edge_is_near(right_edge_y, False)
|
||||
|
||||
left_cond = left_edge_prob > EDGE_PROB and (left_edge_near or (left_edge_y is None and left_lane_prob < NEARSIDE_PROB))
|
||||
right_cond = right_edge_prob > EDGE_PROB and (right_edge_near or (right_edge_y is None and right_lane_prob < NEARSIDE_PROB))
|
||||
|
||||
if left_cond:
|
||||
self.left_edge_timer = min(self.left_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME)
|
||||
self.left_clear_timer = 0.0
|
||||
if self.left_edge_timer > EDGE_REACTION_TIME:
|
||||
self.left_edge_detected = True
|
||||
else:
|
||||
self.left_clear_timer += DT_MDL
|
||||
if self.left_clear_timer > EDGE_CLEAR_TIME:
|
||||
self.left_edge_timer = 0.0
|
||||
self.left_edge_detected = False
|
||||
|
||||
if right_cond:
|
||||
self.right_edge_timer = min(self.right_edge_timer + DT_MDL, EDGE_REACTION_TIME + EDGE_CLEAR_TIME)
|
||||
self.right_clear_timer = 0.0
|
||||
if self.right_edge_timer > EDGE_REACTION_TIME:
|
||||
self.right_edge_detected = True
|
||||
else:
|
||||
self.right_clear_timer += DT_MDL
|
||||
if self.right_clear_timer > EDGE_CLEAR_TIME:
|
||||
self.right_edge_timer = 0.0
|
||||
self.right_edge_detected = False
|
||||
@@ -92,8 +92,7 @@ class SpeedLimitAssist:
|
||||
self._minus_hold = 0.
|
||||
self._last_carstate_ts = 0.
|
||||
|
||||
# TODO-SP: SLA's own output_a_target for planner
|
||||
# Solution functions mapped to respective states
|
||||
# Solution functions mapped to respective states (dispatched from get_a_target_from_control)
|
||||
self.acceleration_solutions = {
|
||||
SpeedLimitAssistState.disabled: self.get_current_acceleration_as_target,
|
||||
SpeedLimitAssistState.inactive: self.get_current_acceleration_as_target,
|
||||
@@ -135,9 +134,14 @@ class SpeedLimitAssist:
|
||||
# Fallback
|
||||
return V_CRUISE_UNSET
|
||||
|
||||
# TODO-SP: SLA's own output_a_target for planner
|
||||
def get_a_target_from_control(self) -> float:
|
||||
return self.a_ego
|
||||
# Dispatches to acceleration_solutions[self.state] -- a real per-state decel/accel estimate (kinematic
|
||||
# distance-to-limit formula while adapting, offset-over-horizon while active/holding) instead of echoing
|
||||
# a_ego. Clipped to LIMIT_MIN_ACC/LIMIT_MAX_ACC: this is a comfort bound on the VALUE HANDED TO THE MPC AS
|
||||
# ITS INITIAL STATE (x0), not a brake cap -- the MPC's own solve is free to command more decel than this
|
||||
# from that starting point, so clipping here can't reduce achievable braking.
|
||||
solution = self.acceleration_solutions.get(self.state, self.get_current_acceleration_as_target)
|
||||
return max(LIMIT_MIN_ACC, min(LIMIT_MAX_ACC, solution()))
|
||||
|
||||
def update_params(self) -> None:
|
||||
if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0:
|
||||
|
||||
@@ -16,12 +16,14 @@ from openpilot.common.constants import CV
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
|
||||
from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import PCM_LONG_REQUIRED_MAX_SET_SPEED
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Mode
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist import SpeedLimitAssist, \
|
||||
PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES
|
||||
PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES, LIMIT_MIN_ACC, LIMIT_MAX_ACC
|
||||
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
|
||||
|
||||
SpeedLimitAssistState = custom.LongitudinalPlanSP.SpeedLimit.AssistState
|
||||
@@ -243,7 +245,36 @@ class TestSpeedLimitAssist:
|
||||
|
||||
self.sla.update(True, False, current_speed, 0, self.pcm_long_max_set_speed, target_speed, target_speed, True, distance, self.events_sp)
|
||||
assert self.sla.state == SpeedLimitAssistState.adapting
|
||||
assert self.sla.output_v_target == target_speed # TODO-SP: assert expected accel, need to enable self.acceleration_solutions
|
||||
assert self.sla.output_v_target == target_speed
|
||||
# kinematic (target^2 - current^2) / (2*distance) is well past LIMIT_MIN_ACC here -> clipped
|
||||
expected_raw = (target_speed ** 2 - current_speed ** 2) / (2. * distance)
|
||||
assert expected_raw < LIMIT_MIN_ACC
|
||||
assert self.sla.output_a_target == pytest.approx(LIMIT_MIN_ACC)
|
||||
|
||||
def test_active_state_uses_offset_over_horizon_formula(self):
|
||||
self.initialize_active_state(self.pcm_long_max_set_speed)
|
||||
target_speed = SPEED_LIMITS['highway']
|
||||
current_speed = target_speed - 1.0 # small offset, stays within the clip band
|
||||
|
||||
self.sla.update(True, False, current_speed, 0, self.pcm_long_max_set_speed, target_speed, target_speed, True, 0, self.events_sp)
|
||||
assert self.sla.state == SpeedLimitAssistState.active
|
||||
expected = (target_speed - current_speed) / float(ModelConstants.T_IDXS[CONTROL_N])
|
||||
assert LIMIT_MIN_ACC < expected < LIMIT_MAX_ACC # sanity: this case is meant to land inside the clip band
|
||||
assert self.sla.output_a_target == pytest.approx(expected)
|
||||
|
||||
def test_a_target_clipped_to_max_acc(self):
|
||||
self.sla.state = SpeedLimitAssistState.adapting
|
||||
self.sla.v_cruise_cluster_prev = self.pcm_long_max_set_speed
|
||||
self.sla.prev_v_cruise_cluster_conv = round(self.pcm_long_max_set_speed * self.speed_conv)
|
||||
|
||||
distance = 1.0 # tiny distance -> a large positive kinematic solution -> clipped
|
||||
current_speed = SPEED_LIMITS['city']
|
||||
target_speed = SPEED_LIMITS['highway']
|
||||
|
||||
self.sla.update(True, False, current_speed, 0, self.pcm_long_max_set_speed, target_speed, target_speed, True, distance, self.events_sp)
|
||||
expected_raw = (target_speed ** 2 - current_speed ** 2) / (2. * distance)
|
||||
assert expected_raw > LIMIT_MAX_ACC
|
||||
assert self.sla.output_a_target == pytest.approx(LIMIT_MAX_ACC)
|
||||
|
||||
def test_long_disengaged_to_disabled(self):
|
||||
self.initialize_active_state(self.pcm_long_max_set_speed)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Closed-loop regression tests for sunnypilot's longitudinal comfort/smoothness fixes, built on the real
|
||||
LongitudinalPlanner via selfdrive/test/longitudinal_maneuvers/plant.py. Unlike a static log replay (which
|
||||
can only recompute an isolated input factor from recorded data), this drives the REAL MPC solver every
|
||||
cycle and feeds its output back into ego's own simulated speed/distance -- so it can catch regressions in
|
||||
the actual re-solved trajectory, not just in one factor's value. Params are set via the real Params() store;
|
||||
pytest's autouse openpilot_function_fixture (root conftest.py) gives each test function a fresh isolated
|
||||
prefix, so this is safe to run without touching real device state.
|
||||
|
||||
Each test targets a SPECIFIC bug found and fixed this session, and is verified to actually fail if that fix
|
||||
is reverted (see the commit history / memory notes referenced in each test's docstring) -- these aren't
|
||||
just plausible-looking assertions, they have demonstrated teeth.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.maneuver import Maneuver
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.constants import NORMAL
|
||||
|
||||
|
||||
def enable_sunnypilot_longitudinal(params: Params, accel_personality=True, radar_distance=True, dec=False):
|
||||
params.put_bool("AccelPersonalityEnabled", accel_personality)
|
||||
if accel_personality:
|
||||
params.put("AccelPersonality", int(NORMAL))
|
||||
params.put_bool("RadarDistance", radar_distance)
|
||||
params.put_bool("DynamicExperimentalControl", dec)
|
||||
|
||||
|
||||
def count_launch_brake_cycles(accel: np.ndarray, speed: np.ndarray, launch_th=0.3, settle_speed=0.5) -> int:
|
||||
# Count distinct "launch" events (acceleration crosses above launch_th while ego is near-stopped) followed
|
||||
# by a return to near-zero/negative acceleration -- the "creep brake creep brake" signature. Only counts
|
||||
# launches while speed stays low (settle_speed) -- a real, sustained departure isn't a cycle.
|
||||
cycles = 0
|
||||
launched = False
|
||||
for a, v in zip(accel, speed, strict=True):
|
||||
if not launched and a > launch_th and v < settle_speed:
|
||||
launched = True
|
||||
cycles += 1
|
||||
elif launched and a < 0.0:
|
||||
launched = False
|
||||
return cycles
|
||||
|
||||
|
||||
def test_creep_noise_never_causes_repeated_launch_brake_cycling():
|
||||
# Route 550a71ee4c7a7fbe/000004b6, t~678-690s: a genuinely-stopped lead's vLead sensor noise (small blips
|
||||
# above STOP_GAP_CREEP_V=0.03 m/s, never sustained) previously accumulated in radar_distance.py's
|
||||
# stop-gap creep-override counter (monotonic, never decayed) until it falsely latched the bias off,
|
||||
# producing a same-cycle gap-widening the MPC read as "room to launch" -- see
|
||||
# lead_unstable_gate_revert / routes_04b5_04b6_creep_bug memory notes; fixed in commit 73bea3866f
|
||||
# (counter now decays on sub-threshold frames). This reproduces the noise pattern in closed loop: ego
|
||||
# approaches and settles behind a near-stopped lead, then the lead's speed hovers with intermittent
|
||||
# noise (never sustained motion) for 40s. Must never repeatedly launch-then-brake.
|
||||
params = Params()
|
||||
enable_sunnypilot_longitudinal(params)
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
breakpoints = [0.0, 8.0]
|
||||
speed_lead_values = [3.0, 0.0]
|
||||
# 40s of intermittent noise: alternating 0.0 / small blip every 0.5s -- never two consecutive "moving" ticks,
|
||||
# so no real sustained motion, matching the real route's noise signature.
|
||||
noise_t = np.arange(8.0, 48.0, 0.5)
|
||||
noise_v = np.where(rng.random(len(noise_t)) > 0.5, 0.08, 0.0)
|
||||
breakpoints += list(noise_t)
|
||||
speed_lead_values += list(noise_v)
|
||||
|
||||
man = Maneuver(
|
||||
'stopped lead with intermittent vLead noise, no sustained motion',
|
||||
duration=48.0,
|
||||
initial_speed=8.0,
|
||||
lead_relevancy=True,
|
||||
initial_distance_lead=30.0,
|
||||
speed_lead_values=speed_lead_values,
|
||||
breakpoints=breakpoints,
|
||||
)
|
||||
valid, logs = man.evaluate()
|
||||
assert valid
|
||||
# logs columns: time, distance, distance_lead, speed, speed_lead, acceleration, d_rel
|
||||
t, speed, accel = logs[:, 0], logs[:, 3], logs[:, 5]
|
||||
settled = t > 10.0 # after the initial approach, while noise is active
|
||||
cycles = count_launch_brake_cycles(accel[settled], speed[settled])
|
||||
assert cycles <= 1, f'expected at most one settle-launch, got {cycles} launch-brake cycles from pure sensor noise'
|
||||
|
||||
|
||||
def test_drel_glitch_does_not_whipsaw_accel():
|
||||
# Route 550a71ee4c7a7fbe/000004b4, t~976.1s: raw dRel bounced 17.7->12.3->17.15->12.0m across ~0.3s while
|
||||
# vRel stayed -1 to -2 m/s -- physically impossible for one real object, a fusion glitch. SWITCH_DREL was
|
||||
# 8.0 (too coarse to catch the ~5m bounce); lowered to 4.0 in commit f978c923a4. This reproduces the exact
|
||||
# bounce pattern via Plant's lead_dRel_glitch_fn hook (overrides only dRel, leaving the true physics-based
|
||||
# vRel/speed evolution intact for the closed loop) and checks the MPC's actual commanded accel doesn't
|
||||
# whipsaw in response.
|
||||
params = Params()
|
||||
enable_sunnypilot_longitudinal(params)
|
||||
|
||||
glitch_window = (5.0, 5.5) # apply the bounce for a short window mid-maneuver
|
||||
|
||||
def glitch_fn(t, d_rel, v_rel):
|
||||
if glitch_window[0] <= t < glitch_window[1]:
|
||||
# alternate between the true (closer) reading and a ~5m-farther bounce, matching the real route
|
||||
phase = int((t - glitch_window[0]) / 0.05) % 2
|
||||
if phase == 1:
|
||||
return d_rel + 5.0, v_rel
|
||||
return d_rel, v_rel
|
||||
|
||||
plant = Plant(lead_relevancy=True, speed=8.0, distance_lead=20.0, e2e=False,
|
||||
lead_dRel_glitch_fn=glitch_fn)
|
||||
|
||||
accels = []
|
||||
while plant.current_time < 10.0:
|
||||
log = plant.step(v_lead=1.5)
|
||||
accels.append(log['acceleration'])
|
||||
accels = np.array(accels)
|
||||
|
||||
jerk = np.diff(accels) / (1.0 / plant.rate)
|
||||
# focus on the glitch window and its immediate aftermath
|
||||
t_arr = np.arange(len(accels)) / plant.rate
|
||||
during = (t_arr[1:] >= glitch_window[0]) & (t_arr[1:] < glitch_window[1] + 0.5)
|
||||
assert during.any()
|
||||
peak_jerk = np.max(np.abs(jerk[during]))
|
||||
assert peak_jerk < 3.0, f'dRel glitch produced a {peak_jerk:.2f} m/s^3 accel whipsaw -- glitch is leaking into the commanded accel'
|
||||
|
||||
|
||||
def test_fast_closing_lead_onset_is_ramped_not_snapped():
|
||||
# A severe closing-rate lead (matching route 000004b5's flagship regression episode, vRel to -16.5 m/s)
|
||||
# legitimately requires a large final decel -- that's not a bug (see routes_04b5_04b6_creep_bug memory:
|
||||
# "very firm brake... looks legitimate"). What IS a bug is an instantaneous snap rather than a ramped
|
||||
# onset. This checks onset smoothness (peak jerk during the transition) without asserting the final
|
||||
# magnitude must be small.
|
||||
params = Params()
|
||||
enable_sunnypilot_longitudinal(params)
|
||||
|
||||
man = Maneuver(
|
||||
'severe closing-rate lead, onset must ramp not snap',
|
||||
duration=12.0,
|
||||
initial_speed=20.0,
|
||||
lead_relevancy=True,
|
||||
initial_distance_lead=160.0,
|
||||
speed_lead_values=[3.5, 3.5],
|
||||
breakpoints=[0.0, 12.0],
|
||||
)
|
||||
valid, logs = man.evaluate()
|
||||
assert valid
|
||||
t, accel = logs[:, 0], logs[:, 5]
|
||||
dt = np.diff(t)
|
||||
dt[dt <= 0] = np.nan
|
||||
jerk = np.diff(accel) / dt
|
||||
onset = (t[1:] > 0.5) & (t[1:] < 3.0) # after the first solve settles, during the initial hard reaction
|
||||
assert onset.any()
|
||||
peak_onset_jerk = np.nanmax(np.abs(jerk[onset]))
|
||||
assert peak_onset_jerk < 4.0, f'onset jerk {peak_onset_jerk:.2f} m/s^3 -- braking snapped instead of ramping'
|
||||
# sanity: this scenario genuinely needs real braking (not asserting it stays small)
|
||||
assert np.min(accel) < -1.0
|
||||
|
||||
|
||||
def test_dec_on_off_agree_with_lead_present():
|
||||
# is_e2e() previously only enforced "near/closing radar lead -> pure MPC, never blend e2e" inside DEC's
|
||||
# active()-gated branch -- DEC off silently dropped the whole check, letting the e2e model's opinion
|
||||
# blend in via min() regardless of the lead. Fixed in commit 7ff32eafea (dec.has_radar_acc_lead() checked
|
||||
# unconditionally, before dec.active() is even consulted). This closed-loop test uses an adversarial
|
||||
# e2e_accel_fn (independently opinionated, not the harness's default mild self.acceleration+0.1 echo) so
|
||||
# DEC-on vs DEC-off would visibly diverge if the fix regressed.
|
||||
def adversarial_e2e(t, speed, accel):
|
||||
return -2.0 # e2e model insists on a hard brake, independent of the MPC's own view
|
||||
|
||||
def run(dec_enabled):
|
||||
params = Params()
|
||||
enable_sunnypilot_longitudinal(params, dec=dec_enabled)
|
||||
plant = Plant(lead_relevancy=True, speed=15.0, distance_lead=40.0, e2e=True,
|
||||
e2e_accel_fn=adversarial_e2e)
|
||||
accels = []
|
||||
while plant.current_time < 5.0:
|
||||
log = plant.step(v_lead=13.0) # near lead, closing slowly -- within RADAR_LEAD_ACC_MAX_DREL
|
||||
accels.append(log['acceleration'])
|
||||
return np.array(accels)
|
||||
|
||||
accel_dec_on = run(dec_enabled=True)
|
||||
accel_dec_off = run(dec_enabled=False)
|
||||
np.testing.assert_allclose(accel_dec_on, accel_dec_off, atol=0.05,
|
||||
err_msg='DEC on vs off disagree with an identical near/closing lead present')
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
_E2ETransitionGuard bounds how fast output_a_target may drop right after DEC switches into blended mode for
|
||||
a routine reason, so the e2e model's own (previously-hidden) desiredAcceleration can't produce a same-cycle
|
||||
discontinuous brake. It must never limit a rise, never limit anything while smoothing is inactive, and never
|
||||
apply when the caller signals an urgent/immediate transition.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import _E2ETransitionGuard, TRANSITION_MAX_DROP_PER_CYCLE
|
||||
|
||||
|
||||
def test_inactive_is_identity():
|
||||
g = _E2ETransitionGuard()
|
||||
g.apply(-0.3, False)
|
||||
assert g.apply(-1.1, False) == pytest.approx(-1.1) # smoothing inactive -> raw passthrough always
|
||||
|
||||
|
||||
def test_first_call_seeds_without_limiting():
|
||||
g = _E2ETransitionGuard()
|
||||
assert g.apply(-1.1, True) == pytest.approx(-1.1) # no prior baseline -> nothing to limit against yet
|
||||
|
||||
|
||||
def test_limits_downward_jump_when_active():
|
||||
g = _E2ETransitionGuard()
|
||||
g.apply(-0.3, False) # establish baseline while inactive
|
||||
out = g.apply(-1.1, True) # a farther-in-one-cycle jump, smoothing now active
|
||||
assert out == pytest.approx(-0.3 - TRANSITION_MAX_DROP_PER_CYCLE)
|
||||
assert out > -1.1 # not the raw discontinuous value
|
||||
|
||||
|
||||
def test_never_limits_a_rise():
|
||||
g = _E2ETransitionGuard()
|
||||
g.apply(-1.0, False)
|
||||
out = g.apply(0.5, True) # accel rising -- must never be held back
|
||||
assert out == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_converges_to_raw_within_a_few_cycles():
|
||||
g = _E2ETransitionGuard()
|
||||
g.apply(-0.3, False)
|
||||
out = -0.3
|
||||
for _ in range(20):
|
||||
out = g.apply(-1.1, True)
|
||||
assert out == pytest.approx(-1.1, abs=1e-6) # eventually tracks the sustained raw value exactly
|
||||
|
||||
|
||||
def test_replays_real_route_e2e_transition():
|
||||
# route 550a71ee4c7a7fbe/0000049f--71203acd12, t~165.3-166.2: DEC switches acc->blended (routine slow-down,
|
||||
# not FCW) and the raw blended output snaps -0.312 -> -1.109 in one 50ms tick, then continues to ~-1.33.
|
||||
g = _E2ETransitionGuard()
|
||||
raw_acc = [-0.312, -0.312] # still acc mode
|
||||
raw_blended = [-1.109, -1.112, -1.121, -1.110, -1.118, -1.137, -1.140, -1.133, -1.145, -1.165,
|
||||
-1.199, -1.227, -1.255, -1.268, -1.287, -1.293, -1.326]
|
||||
for v in raw_acc:
|
||||
g.apply(v, False)
|
||||
guarded = [g.apply(v, i < 10) for i, v in enumerate(raw_blended)] # smoothing active for the first 10 frames
|
||||
assert guarded[0] == pytest.approx(-0.312 - TRANSITION_MAX_DROP_PER_CYCLE) # graded, not the -1.109 snap
|
||||
assert min(guarded[:3]) > -1.0 # nowhere near the raw value in the first few frames
|
||||
assert guarded[-1] == pytest.approx(raw_blended[-1]) # long since converged and tracking raw exactly
|
||||
|
||||
|
||||
def test_reset_drops_stale_baseline():
|
||||
g = _E2ETransitionGuard()
|
||||
g.apply(-0.3, False)
|
||||
g.reset()
|
||||
assert g.apply(-1.1, True) == pytest.approx(-1.1) # no stale baseline to limit against after reset
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
LongitudinalPlannerSP.is_e2e() decides whether the e2e model's raw action.desiredAcceleration blends into
|
||||
the MPC's solution via min(). A near/fast-closing radar lead must always route to pure MPC regardless of
|
||||
whether DEC itself is on -- that baseline previously lived entirely inside DEC's active()-gated branch, so
|
||||
turning DEC off silently dropped it (identical lead input, different is_e2e() answer). These tests pin the
|
||||
fix: the lead check now runs unconditionally, before DEC's own toggle is even consulted.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP
|
||||
|
||||
|
||||
class FakeDec:
|
||||
def __init__(self, active=False, mode="acc", has_radar_acc_lead=False):
|
||||
self._active = active
|
||||
self._mode = mode
|
||||
self._has_radar_acc_lead = has_radar_acc_lead
|
||||
|
||||
def active(self):
|
||||
return self._active
|
||||
|
||||
def mode(self):
|
||||
return self._mode
|
||||
|
||||
def has_radar_acc_lead(self):
|
||||
return self._has_radar_acc_lead
|
||||
|
||||
|
||||
def make_sm(experimental_mode=True):
|
||||
return {'selfdriveState': SimpleNamespace(experimentalMode=experimental_mode)}
|
||||
|
||||
|
||||
def is_e2e(dec):
|
||||
# is_e2e only reads self.dec -- no need to construct the full LongitudinalPlannerSP.
|
||||
return LongitudinalPlannerSP.is_e2e(SimpleNamespace(dec=dec), make_sm())
|
||||
|
||||
|
||||
def test_experimental_mode_off_never_e2e():
|
||||
sm_off = make_sm(experimental_mode=False)
|
||||
assert not LongitudinalPlannerSP.is_e2e(SimpleNamespace(dec=FakeDec(active=True, mode="blended")), sm_off)
|
||||
assert not LongitudinalPlannerSP.is_e2e(SimpleNamespace(dec=FakeDec(has_radar_acc_lead=True)), sm_off)
|
||||
|
||||
|
||||
def test_lead_present_blocks_e2e_regardless_of_dec_active():
|
||||
# the bug this fixes: identical lead, DEC on vs off must agree.
|
||||
assert not is_e2e(FakeDec(active=True, mode="acc", has_radar_acc_lead=True))
|
||||
assert not is_e2e(FakeDec(active=False, mode="acc", has_radar_acc_lead=True))
|
||||
|
||||
|
||||
def test_no_lead_dec_off_falls_back_to_experimental_mode():
|
||||
assert is_e2e(FakeDec(active=False, has_radar_acc_lead=False))
|
||||
|
||||
|
||||
def test_no_lead_dec_on_follows_dec_mode():
|
||||
assert is_e2e(FakeDec(active=True, mode="blended", has_radar_acc_lead=False))
|
||||
assert not is_e2e(FakeDec(active=True, mode="acc", has_radar_acc_lead=False))
|
||||
@@ -5,6 +5,8 @@ from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeMode
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
|
||||
|
||||
TurnDirection = custom.ModelDataV2SP.TurnDirection
|
||||
|
||||
@@ -107,7 +109,11 @@ def set_lane_turn_params():
|
||||
])
|
||||
def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params):
|
||||
dh = DesireHelper()
|
||||
relc = RoadEdgeLaneChangeController(dh)
|
||||
relc.enabled = True
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
||||
for _ in range(10):
|
||||
dh.update(carstate, lateral_active, lane_change_prob)
|
||||
dh.update(carstate, lateral_active, lane_change_prob,
|
||||
left_edge_detected=relc.left_edge_detected, right_edge_detected=relc.right_edge_detected)
|
||||
assert dh.desire == expected_desire # The first four tests were unit tests to test the controller, where this tests the integration in desire helpers
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Copyright (c) 2021-, rav4kumar, 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 pytest
|
||||
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import (
|
||||
RoadEdgeLaneChangeController, EDGE_REACTION_TIME, EDGE_CLEAR_TIME, MIN_SPEED,
|
||||
)
|
||||
|
||||
V_HIGH = MIN_SPEED + 2.0
|
||||
V_LOW = MIN_SPEED - 1.0
|
||||
|
||||
|
||||
class DummyRoadEdge:
|
||||
def __init__(self, y):
|
||||
self.y = [y]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def relc(mocker):
|
||||
mock_params = mocker.patch("openpilot.sunnypilot.selfdrive.controls.lib.relc.Params")
|
||||
mock_params.return_value.get_bool.return_value = True
|
||||
controller = RoadEdgeLaneChangeController(DesireHelper())
|
||||
controller.enabled = True
|
||||
return controller
|
||||
|
||||
|
||||
def make_road_edges(left_y=-3.0, right_y=3.0):
|
||||
return [DummyRoadEdge(left_y), DummyRoadEdge(right_y)]
|
||||
|
||||
|
||||
def drive(controller, road_edge_stds, lane_line_probs, seconds, v_ego=V_HIGH, road_edges=None):
|
||||
for _ in range(int(seconds / DT_MDL) + 1):
|
||||
controller.update(road_edge_stds, lane_line_probs, v_ego, road_edges)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("road_edge_stds,lane_line_probs,attr", [
|
||||
([0.0, 0.9], [0.8, 0.0, 0.8, 0.8], "left_edge_detected"),
|
||||
([0.9, 0.0], [0.8, 0.8, 0.0, 0.8], "right_edge_detected"),
|
||||
])
|
||||
def test_edge_detection(relc, road_edge_stds, lane_line_probs, attr):
|
||||
drive(relc, road_edge_stds, lane_line_probs, EDGE_REACTION_TIME + 0.1)
|
||||
assert getattr(relc, attr)
|
||||
|
||||
|
||||
def test_edge_detection_requires_time(relc):
|
||||
drive(relc, [0.0, 0.9], [0.8, 0.0, 0.8, 0.8], EDGE_REACTION_TIME - 0.05)
|
||||
assert not relc.left_edge_detected
|
||||
|
||||
|
||||
def test_both_edges_detected(relc):
|
||||
drive(relc, [0.0, 0.0], [0.8, 0.0, 0.0, 0.8], EDGE_REACTION_TIME + 0.1)
|
||||
assert relc.left_edge_detected
|
||||
assert relc.right_edge_detected
|
||||
|
||||
|
||||
def test_noise_doesnt_clear(relc):
|
||||
edge = ([0.0, 0.9], [0.8, 0.0, 0.8, 0.8])
|
||||
clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8])
|
||||
|
||||
drive(relc, *edge, EDGE_REACTION_TIME + 0.1)
|
||||
assert relc.left_edge_detected
|
||||
|
||||
relc.update(*clear, V_HIGH)
|
||||
relc.update(*edge, V_HIGH)
|
||||
assert relc.left_edge_detected
|
||||
|
||||
|
||||
def test_clears_after_window(relc):
|
||||
edge = ([0.0, 0.9], [0.8, 0.0, 0.8, 0.8])
|
||||
clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8])
|
||||
|
||||
drive(relc, *edge, EDGE_REACTION_TIME + 0.1)
|
||||
assert relc.left_edge_detected
|
||||
|
||||
drive(relc, *clear, EDGE_CLEAR_TIME + 0.05)
|
||||
assert not relc.left_edge_detected
|
||||
assert relc.left_edge_timer == 0.0
|
||||
|
||||
|
||||
def test_low_speed_skips(relc):
|
||||
drive(relc, [0.0, 0.9], [0.8, 0.0, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, v_ego=V_LOW)
|
||||
assert not relc.left_edge_detected
|
||||
assert relc.left_edge_timer == 0.0
|
||||
|
||||
|
||||
def test_speed_drop_resets(relc):
|
||||
drive(relc, [0.0, 0.9], [0.8, 0.0, 0.8, 0.8], EDGE_REACTION_TIME + 0.1)
|
||||
assert relc.left_edge_detected
|
||||
|
||||
relc.update([0.0, 0.9], [0.8, 0.0, 0.8, 0.8], V_LOW)
|
||||
assert not relc.left_edge_detected
|
||||
|
||||
|
||||
def test_param_off_resets(relc):
|
||||
drive(relc, [0.0, 0.9], [0.8, 0.0, 0.8, 0.8], EDGE_REACTION_TIME + 0.1)
|
||||
assert relc.left_edge_detected
|
||||
|
||||
relc.params.get_bool.return_value = False
|
||||
relc.read_params()
|
||||
relc.update([0.0, 0.9], [0.8, 0.0, 0.8, 0.8], V_HIGH)
|
||||
assert not relc.left_edge_detected
|
||||
assert not relc.right_edge_detected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lane_line_probs", [
|
||||
[0.0, 0.8, 0.8, 0.8],
|
||||
[0.8, 0.8, 0.8, 0.0],
|
||||
])
|
||||
def test_outer_lane_lines_do_not_drive_edge_detection(relc, lane_line_probs):
|
||||
drive(relc, [0.0, 0.0], lane_line_probs, EDGE_REACTION_TIME + 0.1)
|
||||
assert not relc.left_edge_detected
|
||||
assert not relc.right_edge_detected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("road_edge_stds,road_edges,attr", [
|
||||
([0.0, 0.9], make_road_edges(left_y=-3.0, right_y=8.0), "left_edge_detected"),
|
||||
([0.9, 0.0], make_road_edges(left_y=-8.0, right_y=3.0), "right_edge_detected"),
|
||||
])
|
||||
def test_near_road_edge_geometry_blocks_with_visible_lane_lines(relc, road_edge_stds, road_edges, attr):
|
||||
drive(relc, road_edge_stds, [0.8, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, road_edges=road_edges)
|
||||
assert getattr(relc, attr)
|
||||
|
||||
|
||||
def test_far_road_edge_geometry_does_not_block(relc):
|
||||
drive(relc, [0.0, 0.0], [0.8, 0.0, 0.0, 0.8], EDGE_REACTION_TIME + 0.1, road_edges=make_road_edges(left_y=-8.0, right_y=8.0))
|
||||
assert not relc.left_edge_detected
|
||||
assert not relc.right_edge_detected
|
||||
@@ -243,4 +243,12 @@ EVENTS_SP: dict[int, dict[str, Alert | AlertCallbackType]] = {
|
||||
AlertStatus.normal, AlertSize.none,
|
||||
Priority.MID, VisualAlert.none, AudibleAlert.prompt, 3.),
|
||||
},
|
||||
|
||||
EventNameSP.laneChangeRoadEdge: {
|
||||
ET.WARNING: Alert(
|
||||
"Lane Change Unavailable: Road Edge",
|
||||
"",
|
||||
AlertStatus.userPrompt, AlertSize.small,
|
||||
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.1),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"AccelPersonality": {
|
||||
"title": "Acceleration Profile",
|
||||
"description": "Eco takes off gently and keeps the roomiest, smoothest following gap; Sport takes off briskly and follows a touch tighter. Hard-braking authority is always preserved."
|
||||
},
|
||||
"AccelPersonalityEnabled": {
|
||||
"title": "Enable Acceleration Profiles",
|
||||
"description": "Enables Eco/Normal/Sport profiles -- a tier-scaled take-off from a stop and a slightly wider, smoother following gap. Braking is never reduced below stock."
|
||||
},
|
||||
"AccessToken": {
|
||||
"title": "AccessTokenIsNice",
|
||||
"description": ""
|
||||
@@ -1098,6 +1106,10 @@
|
||||
"title": "Quiet Mode",
|
||||
"description": ""
|
||||
},
|
||||
"RadarDistance": {
|
||||
"title": "Radar Distance",
|
||||
"description": "De-noises the lead sunnypilot follows -- holds it through brief radar flicker/dropout and smooths a jittery (track-flipping) lead -- so it does not lose and re-grab the lead or hunt the gap. Braking is never reduced below stock."
|
||||
},
|
||||
"RainbowMode": {
|
||||
"title": "Rainbow Mode",
|
||||
"description": ""
|
||||
@@ -1118,6 +1130,10 @@
|
||||
"title": "Record Front Lock",
|
||||
"description": ""
|
||||
},
|
||||
"RoadEdgeLaneChangeEnabled": {
|
||||
"title": "Block Lane Change: Road Edge Detection",
|
||||
"description": "Blocks lane changes when the model sees a road edge on the signaled side."
|
||||
},
|
||||
"RoadName": {
|
||||
"title": "Road Name",
|
||||
"description": ""
|
||||
@@ -1324,14 +1340,30 @@
|
||||
"step": 0.1,
|
||||
"unit": "m/s\u00b2"
|
||||
},
|
||||
"ToyotaAutoHold": {
|
||||
"title": "Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS",
|
||||
"description": ""
|
||||
},
|
||||
"ToyotaDriveMode": {
|
||||
"title": "Enable drive mode btn link",
|
||||
"description": ""
|
||||
},
|
||||
"ToyotaEnforceStockLongitudinal": {
|
||||
"title": "Toyota: Enforce Factory Longitudinal Control",
|
||||
"description": "When enabled, sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used."
|
||||
},
|
||||
"ToyotaEnhancedBsm": {
|
||||
"title": "Toyota: Prius TSS2 BSM and some tssp",
|
||||
"description": ""
|
||||
},
|
||||
"ToyotaStopAndGoHack": {
|
||||
"title": "Toyota: Stop and Go Hack (Alpha)",
|
||||
"description": "sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk."
|
||||
},
|
||||
"ToyotaTSS2Long": {
|
||||
"title": "Toyota: custom longitudinal for TSS2",
|
||||
"description": ""
|
||||
},
|
||||
"TrainingVersion": {
|
||||
"title": "Training Version",
|
||||
"description": ""
|
||||
|
||||
@@ -537,6 +537,12 @@
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "RoadEdgeLaneChangeEnabled",
|
||||
"widget": "toggle",
|
||||
"title": "Block Lane Change: Road Edge Detection",
|
||||
"description": "Blocks the lane change if the model sees a road edge on your signaled side."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -587,6 +593,26 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "RadarDistance",
|
||||
"widget": "toggle",
|
||||
"title": "Radar Distance",
|
||||
"description": "De-noises the lead sunnypilot follows -- holds it through brief radar flicker/dropout and smooths a jittery (track-flipping) lead -- so it does not lose and re-grab the lead or hunt the gap. Braking is never reduced below stock.",
|
||||
"visibility": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "DisengageOnAccelerator",
|
||||
"widget": "toggle",
|
||||
@@ -620,6 +646,58 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"widget": "toggle",
|
||||
"title": "Enable Acceleration Profiles",
|
||||
"description": "Enables Eco/Normal/Sport profiles -- a tier-scaled take-off from a stop and a slightly wider, smoother following gap. Braking is never reduced below stock.",
|
||||
"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 takes off gently and keeps the roomiest, smoothest following gap; Sport takes off briskly and follows a touch tighter. Hard-braking authority is always preserved.",
|
||||
"options": [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "Eco"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Normal"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Sport"
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
},
|
||||
{
|
||||
"type": "param",
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "IntelligentCruiseButtonManagement",
|
||||
"widget": "toggle",
|
||||
@@ -2001,6 +2079,22 @@
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "PlanplusControl",
|
||||
"widget": "option",
|
||||
"title": "Plan Plus Controls",
|
||||
"description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lane center; too high and it will ping-pong.",
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
"enablement": [
|
||||
{
|
||||
"type": "param",
|
||||
"key": "ShowAdvancedControls",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2168,6 +2262,50 @@
|
||||
"title": "Toyota / Lexus Settings",
|
||||
"description": "",
|
||||
"items": [
|
||||
{
|
||||
"key": "ToyotaAutoHold",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaEnhancedBsm",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: Prius TSS2 BSM and some tssp",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaTSS2Long",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: custom longitudinal for TSS2",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaDriveMode",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Enable drive mode btn link",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaEnforceStockLongitudinal",
|
||||
"widget": "toggle",
|
||||
|
||||
@@ -24,6 +24,16 @@ sections:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: RadarDistance
|
||||
widget: toggle
|
||||
title: Radar Distance
|
||||
description: De-noises the lead sunnypilot follows -- holds it through brief radar flicker/dropout and
|
||||
smooths a jittery (track-flipping) lead -- so it does not lose and re-grab the lead or hunt the gap.
|
||||
Braking is never reduced below stock.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: DisengageOnAccelerator
|
||||
widget: toggle
|
||||
title: Disengage Cruise on Accelerator Pedal
|
||||
@@ -43,6 +53,32 @@ sections:
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonalityEnabled
|
||||
widget: toggle
|
||||
title: Enable Acceleration Profiles
|
||||
description: Enables Eco/Normal/Sport profiles -- a tier-scaled take-off from a stop and a slightly wider,
|
||||
smoother following gap. Braking is never reduced below stock.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonality
|
||||
widget: multiple_button
|
||||
title: Acceleration Profile
|
||||
description: Eco takes off gently and keeps the roomiest, smoothest following gap; Sport takes off briskly
|
||||
and follows a touch tighter. Hard-braking authority is always preserved.
|
||||
options:
|
||||
- value: 0
|
||||
label: Eco
|
||||
- value: 1
|
||||
label: Normal
|
||||
- value: 2
|
||||
label: Sport
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: AccelPersonalityEnabled
|
||||
equals: true
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
|
||||
@@ -51,6 +51,16 @@ sections:
|
||||
key: LagdToggle
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: PlanplusControl
|
||||
widget: option
|
||||
title: Plan Plus Controls
|
||||
description: Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover
|
||||
to lane center; too high and it will ping-pong.
|
||||
min: 0.0
|
||||
max: 2.0
|
||||
step: 0.1
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- id: lateral_control
|
||||
title: Lateral Control
|
||||
description: Neural network lateral control for supported models
|
||||
|
||||
@@ -255,3 +255,7 @@ sections:
|
||||
key: AutoLaneChangeTimer
|
||||
op: '>'
|
||||
value: 0
|
||||
- key: RoadEdgeLaneChangeEnabled
|
||||
widget: toggle
|
||||
title: 'Block Lane Change: Road Edge Detection'
|
||||
description: Blocks the lane change if the model sees a road edge on your signaled side.
|
||||
|
||||
@@ -60,6 +60,30 @@ sections:
|
||||
title: Toyota / Lexus Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ToyotaAutoHold
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaEnhancedBsm
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: Prius TSS2 BSM and some tssp'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaTSS2Long
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: custom longitudinal for TSS2'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaDriveMode
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enable drive mode btn link
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaEnforceStockLongitudinal
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
|
||||
@@ -45,8 +45,9 @@ class ScrollState(Enum):
|
||||
|
||||
|
||||
class GuiScrollPanel2:
|
||||
def __init__(self, horizontal: bool = True) -> None:
|
||||
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
|
||||
self._horizontal = horizontal
|
||||
self._handle_out_of_bounds = handle_out_of_bounds
|
||||
self._state = ScrollState.STEADY
|
||||
self._offset: rl.Vector2 = rl.Vector2(0, 0)
|
||||
self._initial_click_event: MouseEvent | None = None
|
||||
@@ -98,7 +99,7 @@ class GuiScrollPanel2:
|
||||
# simple exponential return if out of bounds
|
||||
# out of bounds is handled by snapping, so skip if set
|
||||
out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset
|
||||
if out_of_bounds and snap_target is None:
|
||||
if out_of_bounds and snap_target is None and self._handle_out_of_bounds:
|
||||
target = max_offset if self.get_offset() > max_offset else min_offset
|
||||
|
||||
dt = rl.get_frame_time() or 1e-6
|
||||
|
||||
@@ -75,7 +75,6 @@ class _Scroller(Widget):
|
||||
self._items: list[Widget] = []
|
||||
self._horizontal = horizontal
|
||||
self._snap_items = snap_items
|
||||
assert not self._snap_items or self._horizontal, "Snapping is only supported for horizontal scrolling"
|
||||
self._spacing = spacing
|
||||
self._pad = pad
|
||||
|
||||
@@ -191,8 +190,12 @@ class _Scroller(Widget):
|
||||
snap_target: float | None = None
|
||||
if self._snap_items and visible_items and self._scrolling_to[0] is None:
|
||||
# TODO: this doesn't handle two small buttons at the edges well
|
||||
center_pos = self._rect.x + self._rect.width / 2
|
||||
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
|
||||
if self._horizontal:
|
||||
center_pos = self._rect.x + self._rect.width / 2
|
||||
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
|
||||
else:
|
||||
center_pos = self._rect.y + self._rect.height / 2
|
||||
closest_delta_pos = min((((item.rect.y + item.rect.height / 2) - center_pos) for item in visible_items), key=abs)
|
||||
snap_target = self.scroll_panel.get_offset() - closest_delta_pos
|
||||
|
||||
return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target)
|
||||
|
||||
Reference in New Issue
Block a user