From 0741506de05dea8ab76a894aee99698ebee06fb7 Mon Sep 17 00:00:00 2001 From: Olaf Villadsen Date: Sat, 12 Sep 2026 21:58:33 -0700 Subject: [PATCH] GM Bolt CC: drive stock set speed from planned speed, not PID accel The CC-only Bolt button spam projected a set speed from the longitudinal PID acceleration output, and the CC-only tune (kp=5, 0.9 deadzone) was written for the retired speed-error PID. On the current accel-error PID the deadzone never applies, so aEgo noise was amplified five-fold into constant +/- taps, with and without a lead. Because the stock cruise is itself a slow speed servo, wrapping it in 1 mph steps driven by a noisy accel signal limit-cycled. Scoped to CHEVROLET_BOLT_CC_2017 / 2018_2021 / 2022_2023. Other CC-only cars keep their existing tune and set speed selector. - gmcan: _bolt_cc_setpoint_button targets the filtered planned speed 2.5 s ahead (actuators.speed) capped at the cruise set speed, with 0.8 mph hysteresis and a 1.5 s dwell in steady state. A planned speed drop > 1 m/s or a decel request <= -0.5 m/s^2 bypasses the filter and taps down every 0.25 s. No taps below the stock 25 mph minimum. The reverse-confirm helper is removed. - interface: Bolt CC kp 5 -> 0.5 so the accel command tracks the planner. - carcontroller: pass the hud set speed into the spam logic and clear the target filter when spam is inactive. - starpilot_following: +0.4 s follow time on cruise-button Bolts, which can only slow by coasting once the set speed is lowered. - tests: cover the new set speed logic, the Bolt tune, and that an Equinox CC keeps the existing tune and selector. Tested on a 2023 Chevrolet Bolt EUV without ACC or pedal interceptor, comma 4, highway driving: set speed holds still with no lead, taps every several seconds behind a steady lead, drops fast when a lead brakes. Co-Authored-By: Claude Fable 5.1 --- opendbc_repo/opendbc/car/gm/carcontroller.py | 13 +- opendbc_repo/opendbc/car/gm/gmcan.py | 120 +++++++++----- opendbc_repo/opendbc/car/gm/interface.py | 7 + opendbc_repo/opendbc/car/gm/tests/test_gm.py | 149 ++++++++++++++---- starpilot/controls/lib/starpilot_following.py | 9 ++ 5 files changed, 228 insertions(+), 70 deletions(-) diff --git a/opendbc_repo/opendbc/car/gm/carcontroller.py b/opendbc_repo/opendbc/car/gm/carcontroller.py index ec188298389cb5..1ef29f6f2a4476 100644 --- a/opendbc_repo/opendbc/car/gm/carcontroller.py +++ b/opendbc_repo/opendbc/car/gm/carcontroller.py @@ -519,10 +519,9 @@ def __init__(self, dbc_names, CP): self.xt4_cc_button_burst_last_counter = -1 self.xt4_cc_button_observed_counter = -1 self.xt4_cc_button_counter_frame = 0 - self.gm_cc_last_direction_button = CruiseButtons.INIT - self.gm_cc_last_direction_frame = 0 - self.gm_cc_pending_reverse_button = CruiseButtons.INIT - self.gm_cc_pending_reverse_frame = 0 + # Bolt CC-only set speed target filter state, see gmcan._bolt_cc_setpoint_button + self.gm_cc_target_speed = None + self.gm_cc_target_frame = None self.lka_steering_cmd_counter = 0 self.lka_icon_status_last = (False, False) @@ -980,7 +979,8 @@ def update(self, CC, CS, now_nanos, starpilot_toggles): should_send_cc_button_spam(self.CP, CC, CS) ) if xt4_cc_button_spam: - can_sends.extend(gmcan.create_gm_cc_spam_command(self.packer_pt, self, CS, actuators, starpilot_toggles)) + can_sends.extend(gmcan.create_gm_cc_spam_command(self.packer_pt, self, CS, actuators, starpilot_toggles, + v_cruise=hud_v_cruise)) elif self.CP.carFingerprint == CAR.CADILLAC_XT4_CC: self.xt4_cc_button_burst_remaining = 0 self.xt4_cc_button_burst_button = CruiseButtons.INIT @@ -1165,8 +1165,11 @@ def update(self, CC, CS, now_nanos, starpilot_toggles): can_sends.extend(gmcan.create_gm_cc_spam_command( self.packer_pt, self, CS, actuators, starpilot_toggles, longitudinal_adjustment_active=longitudinal_adjustment_active, + v_cruise=hud_v_cruise, )) else: + self.gm_cc_target_speed = None + self.gm_cc_target_frame = None if (CS.out.cruiseState.enabled and CC.enabled and self.frame % 52 == 0 and CS.cruise_buttons == CruiseButtons.UNPRESS and CS.out.gasPressed and CS.out.cruiseState.speed < CS.out.vEgo < hud_v_cruise): if self.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC: diff --git a/opendbc_repo/opendbc/car/gm/gmcan.py b/opendbc_repo/opendbc/car/gm/gmcan.py index 670180cbd5e1f8..9dd60102a282d6 100644 --- a/opendbc_repo/opendbc/car/gm/gmcan.py +++ b/opendbc_repo/opendbc/car/gm/gmcan.py @@ -1,3 +1,5 @@ +import numpy as np + from opendbc.car import DT_CTRL, structs from opendbc.car.can_definitions import CanData from opendbc.car.common.conversions import Conversions as CV @@ -25,9 +27,22 @@ CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023, } -BOLT_CC_TARGET_DEADBAND_MPH = 0.75 -BOLT_CC_REVERSE_CONFIRM_S = 0.6 -BOLT_CC_DIRECTION_MEMORY_S = 1.5 +# Bolt CC-only (no ACC, no pedal interceptor) longitudinal control taps the stock +# cruise +/- buttons. The stock cruise is itself a slow speed servo, so the set +# speed is driven from the planner's speed target instead of the acceleration +# command, which limit-cycles when fed through 1 mph steps. +BOLT_CC_SPEEDO_RATIO = 1.01 # stock cruise holds vEgo about 1% under the displayed set speed +BOLT_CC_PLAN_HORIZON_S = 2.5 # actuators.speed is the planned speed this far ahead +BOLT_CC_TAP_HYSTERESIS_MPH = 0.8 # |target - set speed| needed before a 1 mph tap +BOLT_CC_TARGET_TAU_UP_S = 3.0 # target filter time constant while the target rises +BOLT_CC_TARGET_TAU_DOWN_S = 1.5 # target filter time constant while the target falls +BOLT_CC_URGENT_DROP_MS = 1.0 # planned speed drop below the filtered target that bypasses the filter +BOLT_CC_URGENT_DECEL = -0.5 # accel command that bypasses the filter and shortens the tap interval +BOLT_CC_TAP_INTERVAL_BP_MPH = [1.0, 3.0, 6.0] +BOLT_CC_TAP_INTERVAL_V_S = [1.5, 0.6, 0.25] +BOLT_CC_URGENT_TAP_INTERVAL_S = 0.25 +BOLT_CC_MIN_SET_SPEED_MPH = 25.0 # stock cruise will not set below this, taps are wasted +BOLT_CC_CANCEL_MARGIN_MS = 3.25 # cancel when the target falls this far below the engage speed VOLT_CC_CARS = { CAR.CHEVROLET_VOLT_CC, } @@ -311,34 +326,67 @@ def create_lka_icon_command(bus, active, critical, steer): return CanData(0x104c006c, dat, bus) -def stabilize_bolt_cc_button(controller, CP, requested_button): - if CP.carFingerprint not in BOLT_CC_BUTTON_CARS: - return requested_button +def _bolt_cc_setpoint_button(controller, CS, actuators, v_cruise, ms_convert, is_metric): + """Pick the stock cruise button that moves the set speed toward the planner's speed target. - direction_buttons = (CruiseButtons.RES_ACCEL, CruiseButtons.DECEL_SET) - if requested_button not in direction_buttons: - controller.gm_cc_pending_reverse_button = CruiseButtons.INIT - return requested_button + Returns (button, min_seconds_since_last_tap). Filter state lives on the controller and is + cleared by the controller whenever button spam is not active. + """ + v_ego = float(CS.out.vEgo) + accel = float(actuators.accel) + stock_speed = float(CS.out.cruiseState.speed) + speed_setpoint = int(round(stock_speed * ms_convert)) + + # controlsd publishes the planned speed ~2.5 s ahead in actuators.speed. With no lead + # this equals the cruise speed, so the set speed simply parks there. + v_plan = float(getattr(actuators, "speed", 0.0) or 0.0) + if v_plan <= 0.0: + v_plan = v_ego + BOLT_CC_PLAN_HORIZON_S * accel + raw_target = max(v_plan, 0.0) + has_cruise_cap = v_cruise is not None and float(v_cruise) > 0.0 + if has_cruise_cap: + raw_target = min(raw_target, float(v_cruise)) + + filtered = getattr(controller, "gm_cc_target_speed", None) + last_frame = getattr(controller, "gm_cc_target_frame", None) + if filtered is None or last_frame is None: + # Start from what the stock cruise already holds so engaging is tap-free. + filtered = stock_speed / BOLT_CC_SPEEDO_RATIO if stock_speed > 0.0 else v_ego + dt = DT_CTRL + else: + dt = min(max((controller.frame - last_frame) * DT_CTRL, DT_CTRL), 0.5) + controller.gm_cc_target_frame = controller.frame + + # A sudden drop in the planned speed (lead braking) must not wait on the filter. + urgent = accel <= BOLT_CC_URGENT_DECEL or raw_target < filtered - BOLT_CC_URGENT_DROP_MS + if urgent and raw_target < filtered: + filtered = raw_target + else: + tau = BOLT_CC_TARGET_TAU_UP_S if raw_target > filtered else BOLT_CC_TARGET_TAU_DOWN_S + filtered += (raw_target - filtered) * min(dt / tau, 1.0) + controller.gm_cc_target_speed = filtered - last_button = getattr(controller, "gm_cc_last_direction_button", CruiseButtons.INIT) - last_frame = getattr(controller, "gm_cc_last_direction_frame", -int(BOLT_CC_DIRECTION_MEMORY_S / DT_CTRL) - 1) - recently_sent = (controller.frame - last_frame) * DT_CTRL <= BOLT_CC_DIRECTION_MEMORY_S - reversing_to_accel = (last_button == CruiseButtons.DECEL_SET and - requested_button == CruiseButtons.RES_ACCEL and recently_sent) + if CS.CP.minEnableSpeed - filtered > BOLT_CC_CANCEL_MARGIN_MS: + return CruiseButtons.CANCEL, 0.0 - if reversing_to_accel: - pending_button = getattr(controller, "gm_cc_pending_reverse_button", CruiseButtons.INIT) - if pending_button != requested_button: - controller.gm_cc_pending_reverse_button = requested_button - controller.gm_cc_pending_reverse_frame = controller.frame - return CruiseButtons.INIT + # The speedo ratio applies to the speed we want to travel at. The cruise speed is + # already in displayed units, so it caps the set speed directly. + target_setpoint = filtered * BOLT_CC_SPEEDO_RATIO * ms_convert + if has_cruise_cap: + target_setpoint = min(target_setpoint, float(round(float(v_cruise) * ms_convert))) + diff = target_setpoint - speed_setpoint - pending_frame = getattr(controller, "gm_cc_pending_reverse_frame", controller.frame) - if (controller.frame - pending_frame) * DT_CTRL < BOLT_CC_REVERSE_CONFIRM_S: - return CruiseButtons.INIT + min_setpoint = BOLT_CC_MIN_SET_SPEED_MPH * (CV.MPH_TO_KPH if is_metric else 1.0) + button = CruiseButtons.INIT + if diff < -BOLT_CC_TAP_HYSTERESIS_MPH and speed_setpoint > min_setpoint: + button = CruiseButtons.DECEL_SET + elif diff > BOLT_CC_TAP_HYSTERESIS_MPH: + button = CruiseButtons.RES_ACCEL - controller.gm_cc_pending_reverse_button = CruiseButtons.INIT - return requested_button + interval = float(np.interp(abs(diff), BOLT_CC_TAP_INTERVAL_BP_MPH, BOLT_CC_TAP_INTERVAL_V_S)) + if button == CruiseButtons.DECEL_SET and urgent: + interval = min(interval, BOLT_CC_URGENT_TAP_INTERVAL_S) + return button, interval def _create_volt_cc_spam_command(CS, actuators, ms_convert, longitudinal_adjustment_active): @@ -388,7 +436,8 @@ def _create_volt_cc_spam_command(CS, actuators, ms_convert, longitudinal_adjustm return CruiseButtons.RES_ACCEL, rate -def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggles, longitudinal_adjustment_active=False): +def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggles, longitudinal_adjustment_active=False, + v_cruise=None): accel = actuators.accel v_ego = CS.out.vEgo cruise_btn = CruiseButtons.INIT @@ -396,23 +445,21 @@ def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggl is_metric = getattr(starpilot_toggles, "is_metric", False) ms_convert = CV.MS_TO_KPH if is_metric else CV.MS_TO_MPH speed_setpoint = int(round(CS.out.cruiseState.speed * ms_convert)) - projected_setpoint = (v_ego * 1.01 + 3 * accel) * ms_convert - desired_setpoint = int(round(projected_setpoint)) bolt_cc = CS.CP.carFingerprint in BOLT_CC_BUTTON_CARS - target_deadband = BOLT_CC_TARGET_DEADBAND_MPH * (CV.MPH_TO_KPH if is_metric else 1.0) if bolt_cc else 0.0 - comparison_setpoint = projected_setpoint if bolt_cc else desired_setpoint - if CS.CP.carFingerprint in VOLT_CC_CARS: + if bolt_cc: + cruise_btn, rate = _bolt_cc_setpoint_button(controller, CS, actuators, v_cruise, ms_convert, is_metric) + elif CS.CP.carFingerprint in VOLT_CC_CARS: cruise_btn, rate = _create_volt_cc_spam_command(CS, actuators, ms_convert, longitudinal_adjustment_active) else: + desired_setpoint = int(round((v_ego * 1.01 + 3 * accel) * ms_convert)) if CS.CP.minEnableSpeed - (desired_setpoint / ms_convert) > 3.25: cruise_btn = CruiseButtons.CANCEL - elif comparison_setpoint < speed_setpoint - target_deadband and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1: + elif desired_setpoint < speed_setpoint and speed_setpoint > CS.CP.minEnableSpeed * ms_convert + 1: cruise_btn = CruiseButtons.DECEL_SET - elif comparison_setpoint > speed_setpoint + target_deadband: + elif desired_setpoint > speed_setpoint: cruise_btn = CruiseButtons.RES_ACCEL - cruise_btn = stabilize_bolt_cc_button(controller, CS.CP, cruise_btn) if cruise_btn == CruiseButtons.CANCEL: controller.apply_speed = 0 elif cruise_btn == CruiseButtons.DECEL_SET: @@ -462,9 +509,6 @@ def create_gm_cc_spam_command(packer, controller, CS, actuators, starpilot_toggl # Or bus 2, since we're forwarding... but I think it does if (cruise_btn != CruiseButtons.INIT) and ((controller.frame - controller.last_button_frame) * DT_CTRL > rate): controller.last_button_frame = controller.frame - if bolt_cc and cruise_btn in (CruiseButtons.RES_ACCEL, CruiseButtons.DECEL_SET): - controller.gm_cc_last_direction_button = cruise_btn - controller.gm_cc_last_direction_frame = controller.frame if CS.CP.carFingerprint == CAR.CHEVROLET_MALIBU_HYBRID_CC: phase_map = malibu_phase_map_for_button(cruise_btn) if phase_map: diff --git a/opendbc_repo/opendbc/car/gm/interface.py b/opendbc_repo/opendbc/car/gm/interface.py index 63df6aa86406fe..0f9d03947ed611 100755 --- a/opendbc_repo/opendbc/car/gm/interface.py +++ b/opendbc_repo/opendbc/car/gm/interface.py @@ -678,6 +678,13 @@ def _get_params(ret: structs.CarParams, candidate, fingerprint, car_fw, alpha_lo ret.longitudinalActuatorDelay = 1. if candidate == CAR.CHEVROLET_MALIBU_CC: ret.longitudinalTuning.kpV = [0., 20., 20.] + elif candidate in (CAR.CHEVROLET_BOLT_CC_2017, CAR.CHEVROLET_BOLT_CC_2018_2021, CAR.CHEVROLET_BOLT_CC_2022_2023): + # LongControl runs a PID on acceleration error with the planner accel as feedforward. + # The kp=5 / 0.9 deadzone tune above was written for the retired speed-error loop; on + # the accel loop the deadzone never applies and kp=5 amplified aEgo noise five-fold + # into the Bolt's cruise button taps. Keep the command close to the planner accel so + # the set speed logic in gmcan sees a clean signal. + ret.longitudinalTuning.kpV = [0., 0.5, 0.5] ret.longitudinalTuning.kiBP = [0.] ret.longitudinalTuning.kiV = [0.1] ret.stoppingDecelRate = 11.18 # == 25 mph/s (.04 rate) diff --git a/opendbc_repo/opendbc/car/gm/tests/test_gm.py b/opendbc_repo/opendbc/car/gm/tests/test_gm.py index f338d78c100c2a..e52096e146fc3a 100644 --- a/opendbc_repo/opendbc/car/gm/tests/test_gm.py +++ b/opendbc_repo/opendbc/car/gm/tests/test_gm.py @@ -428,6 +428,25 @@ def test_bolt_acc_pedal_pid_accel_limits_keep_full_negative_authority(self): assert accel_max == pytest.approx(np.interp(4.73, [0.0, 1.5, 4.0, 8.0, 15.0], [0.54, 0.74, 1.03, 1.46, CarControllerParams.ACCEL_MAX])) + @parameterized.expand([ + CAR.CHEVROLET_BOLT_CC_2017, + CAR.CHEVROLET_BOLT_CC_2018_2021, + CAR.CHEVROLET_BOLT_CC_2022_2023, + ]) + def test_bolt_cc_long_tune_tracks_planner_accel(self, car_model): + car_params = interfaces[car_model].get_params(car_model, _empty_fingerprint(), [], alpha_long=False, is_release=False, + docs=False, starpilot_toggles=_test_starpilot_toggles()) + + assert car_params.flags & GMFlags.CC_LONG.value + assert list(car_params.longitudinalTuning.kpV) == [0., 0.5, 0.5] + + def test_other_cc_only_cars_keep_existing_long_tune(self): + car_params = interfaces[CAR.CHEVROLET_EQUINOX_CC].get_params(CAR.CHEVROLET_EQUINOX_CC, _empty_fingerprint(), [], alpha_long=False, + is_release=False, docs=False, starpilot_toggles=_test_starpilot_toggles()) + + assert car_params.flags & GMFlags.CC_LONG.value + assert list(car_params.longitudinalTuning.kpV) == [0., 5., 2.] + def test_bolt_cc_pedal_pid_accel_limits_remain_regen_limited(self): cp = SimpleNamespace( enableGasInterceptorDEPRECATED=True, @@ -1266,7 +1285,9 @@ def test_volt_cc_no_camera_redneck_spam_stays_on_powertrain_bus(self): def test_non_volt_cc_redneck_spam_stays_on_powertrain_bus(self): packer = CANPacker(DBC[CAR.CHEVROLET_BOLT_CC_2018_2021][Bus.pt]) - controller = SimpleNamespace(frame=int(0.3 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0) + # Seed the Bolt set speed target filter so a tap is due on this frame. + controller = SimpleNamespace(frame=int(0.3 / DT_CTRL), last_button_frame=0, apply_speed=0, malibu_button_phase=0, + gm_cc_target_speed=27.5, gm_cc_target_frame=int(0.3 / DT_CTRL) - 4) cs = SimpleNamespace( CP=SimpleNamespace( carFingerprint=CAR.CHEVROLET_BOLT_CC_2018_2021, @@ -1331,33 +1352,107 @@ def test_non_bolt_cc_redneck_keeps_existing_setpoint_selector(self): assert len(msgs) == 1 assert controller.apply_speed == 61 - def test_bolt_cc_redneck_requires_persistent_acceleration_after_deceleration(self): - cp = SimpleNamespace(carFingerprint=CAR.CHEVROLET_BOLT_CC_2018_2021) - controller = SimpleNamespace( - frame=100, - gm_cc_last_direction_button=CruiseButtons.DECEL_SET, - gm_cc_last_direction_frame=100, - gm_cc_pending_reverse_button=CruiseButtons.INIT, - gm_cc_pending_reverse_frame=0, - ) - - assert gmcan.stabilize_bolt_cc_button(controller, cp, CruiseButtons.RES_ACCEL) == CruiseButtons.INIT - controller.frame += int(0.5 / DT_CTRL) - assert gmcan.stabilize_bolt_cc_button(controller, cp, CruiseButtons.RES_ACCEL) == CruiseButtons.INIT - controller.frame += int(0.11 / DT_CTRL) - assert gmcan.stabilize_bolt_cc_button(controller, cp, CruiseButtons.RES_ACCEL) == CruiseButtons.RES_ACCEL + @staticmethod + def _bolt_cc_controller(frame=0): + return SimpleNamespace(frame=frame, last_button_frame=0, apply_speed=0, + gm_cc_target_speed=None, gm_cc_target_frame=None) + + @staticmethod + def _bolt_cc_state(v_ego_mph, set_mph, car=CAR.CHEVROLET_BOLT_CC_2022_2023): + CP = SimpleNamespace(carFingerprint=car, minEnableSpeed=24 * CV.MPH_TO_MS, networkLocation=None, flags=0) + out = SimpleNamespace(vEgo=v_ego_mph * CV.MPH_TO_MS, + cruiseState=SimpleNamespace(speed=set_mph * CV.MPH_TO_MS)) + return SimpleNamespace(out=out, CP=CP, buttons_counter=0) + + @staticmethod + def _bolt_cc_button(controller, CS, plan_mph, accel=0.0, v_cruise_mph=65.0): + actuators = SimpleNamespace(accel=accel, speed=plan_mph * CV.MPH_TO_MS) + return gmcan._bolt_cc_setpoint_button(controller, CS, actuators, v_cruise_mph * CV.MPH_TO_MS, CV.MS_TO_MPH, False) + + def test_bolt_cc_holds_set_speed_with_no_lead(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=64.4, set_mph=65) + for _ in range(50): + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=65.0, accel=0.05) + assert button == CruiseButtons.INIT + + def test_bolt_cc_cruise_speed_caps_set_speed(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=65.4, set_mph=66) + for _ in range(100): + controller.frame += 4 + button, interval = self._bolt_cc_button(controller, cs, plan_mph=70.0, accel=0.3, v_cruise_mph=65.0) + assert button == CruiseButtons.DECEL_SET + assert interval == gmcan.BOLT_CC_TAP_INTERVAL_V_S[0] + + def test_bolt_cc_small_target_changes_are_filtered_before_tapping(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=64.4, set_mph=65) + # A 1 mph planned speed drop is about a 1 mph set speed drop once the speedo ratio is applied. + button, _ = self._bolt_cc_button(controller, cs, plan_mph=63.4, accel=-0.2) + assert button == CruiseButtons.INIT + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=63.4, accel=-0.2) + assert button == CruiseButtons.INIT + for _ in range(150): + controller.frame += 4 + button, interval = self._bolt_cc_button(controller, cs, plan_mph=63.4, accel=-0.2) + assert button == CruiseButtons.DECEL_SET + assert interval == gmcan.BOLT_CC_TAP_INTERVAL_V_S[0] + + def test_bolt_cc_sudden_planned_speed_drop_bypasses_filter_and_taps_fast(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=64.4, set_mph=65) + self._bolt_cc_button(controller, cs, plan_mph=65.0) + controller.frame += 4 + button, interval = self._bolt_cc_button(controller, cs, plan_mph=55.0, accel=-1.5) + assert button == CruiseButtons.DECEL_SET + assert interval == gmcan.BOLT_CC_URGENT_TAP_INTERVAL_S + + def test_bolt_cc_raising_target_is_slower_than_lowering(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=59.4, set_mph=60) + self._bolt_cc_button(controller, cs, plan_mph=60.0) + for _ in range(5): + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=63.0, accel=0.5) + assert button == CruiseButtons.INIT + for _ in range(100): + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=63.0, accel=0.5) + assert button == CruiseButtons.RES_ACCEL + + def test_bolt_cc_does_not_tap_below_stock_minimum_set_speed(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=25.0, set_mph=25) + for _ in range(100): + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=22.0, accel=-0.3) + assert button == CruiseButtons.INIT + + def test_bolt_cc_cancels_when_target_falls_well_below_engage_speed(self): + controller = self._bolt_cc_controller() + cs = self._bolt_cc_state(v_ego_mph=30.0, set_mph=30) + self._bolt_cc_button(controller, cs, plan_mph=30.0) + controller.frame += 4 + button, _ = self._bolt_cc_button(controller, cs, plan_mph=10.0, accel=-2.0) + assert button == CruiseButtons.CANCEL + + def test_bolt_cc_spam_command_updates_apply_speed_and_respects_interval(self): + packer = CANPacker(DBC[CAR.CHEVROLET_BOLT_CC_2022_2023][Bus.pt]) + controller = self._bolt_cc_controller(frame=1000) + cs = self._bolt_cc_state(v_ego_mph=65.4, set_mph=66) + actuators = SimpleNamespace(accel=-0.1, speed=66.0 * CV.MPH_TO_MS) + toggles = SimpleNamespace(is_metric=False) + v_cruise = 65.0 * CV.MPH_TO_MS + + msgs = gmcan.create_gm_cc_spam_command(packer, controller, cs, actuators, toggles, v_cruise=v_cruise) - def test_bolt_cc_redneck_deceleration_is_not_debounced(self): - cp = SimpleNamespace(carFingerprint=CAR.CHEVROLET_BOLT_CC_2018_2021) - controller = SimpleNamespace( - frame=100, - gm_cc_last_direction_button=CruiseButtons.RES_ACCEL, - gm_cc_last_direction_frame=100, - gm_cc_pending_reverse_button=CruiseButtons.INIT, - gm_cc_pending_reverse_frame=0, - ) - - assert gmcan.stabilize_bolt_cc_button(controller, cp, CruiseButtons.DECEL_SET) == CruiseButtons.DECEL_SET + assert len(msgs) == 1 + assert controller.apply_speed == 65 + controller.frame += 4 + assert gmcan.create_gm_cc_spam_command(packer, controller, cs, actuators, toggles, v_cruise=v_cruise) == [] def test_xt4_cc_redneck_spam_matches_physical_button_burst(self): packer = CANPacker(DBC[CAR.CADILLAC_XT4_CC][Bus.pt]) diff --git a/starpilot/controls/lib/starpilot_following.py b/starpilot/controls/lib/starpilot_following.py index f682c91a9a50cc..614d9185d02dcf 100644 --- a/starpilot/controls/lib/starpilot_following.py +++ b/starpilot/controls/lib/starpilot_following.py @@ -2,6 +2,7 @@ import numpy as np from cereal import log +from opendbc.car.gm.values import CAR as GM_CAR from openpilot.common.constants import CV from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.lead_behavior import should_disable_far_lead_throttle @@ -21,6 +22,8 @@ # through the maneuver instead of braking behind a lead it is about to leave. # Ramps in gradually, snaps back to the normal gap when the safety gate trips. LANE_CHANGE_MIN_T_FOLLOW = 0.25 # hard floor on the reduced gap (s) +BOLT_CC_EXTRA_T_FOLLOW = 0.4 # extra headway for cruise-button Bolts (s) +BOLT_CC_CARS = {GM_CAR.CHEVROLET_BOLT_CC_2017, GM_CAR.CHEVROLET_BOLT_CC_2018_2021, GM_CAR.CHEVROLET_BOLT_CC_2022_2023} LANE_CHANGE_GAP_RAMP_IN_RATE = 0.6 # seconds of headway per second, toward the shorter gap LANE_CHANGE_GAP_RAMP_OUT_RATE = 4.0 # seconds of headway per second, back to the normal gap LANE_CHANGE_ABORT_LEAD_BRAKE = 0.8 # lead decel that aborts the reduction (m/s^2) @@ -126,6 +129,12 @@ def update(self, long_control_active, v_ego, sm, starpilot_toggles): if self.starpilot_planner.starpilot_weather.weather_id != 0: self.t_follow = min(self.t_follow + self.starpilot_planner.starpilot_weather.increase_following_distance, MAX_T_FOLLOW) + if (long_control_active and getattr(starpilot_toggles, "has_cc_long", False) and + getattr(starpilot_toggles, "car_model", None) in BOLT_CC_CARS): + # Cruise-button Bolts can only slow by lowering the stock set speed, which coasts and + # regens at the stock cruise's own pace. Leave more room for that lag. + self.t_follow = min(self.t_follow + BOLT_CC_EXTRA_T_FOLLOW, MAX_T_FOLLOW) + self.update_lane_change_gap(long_control_active, v_ego, sm, starpilot_toggles) self.following_lead = self.starpilot_planner.tracking_lead and self.starpilot_planner.lead_one.dRel < (self.t_follow * 2) * v_ego