diff --git a/opendbc_repo/opendbc/car/gm/carcontroller.py b/opendbc_repo/opendbc/car/gm/carcontroller.py index c7721b835f..6bf6714ffa 100644 --- a/opendbc_repo/opendbc/car/gm/carcontroller.py +++ b/opendbc_repo/opendbc/car/gm/carcontroller.py @@ -40,13 +40,15 @@ AUTO_HOLD_MAX_BRAKE = 240 AUTO_HOLD_MIN_DRIVE_TIME_S = 3.0 VOLT_ONE_PEDAL_DECEL_BP = [0.5 * CV.MPH_TO_MS, 6.0 * CV.MPH_TO_MS] VOLT_ONE_PEDAL_DECEL_V = [-1.0, -1.1] -VOLT_ONE_PEDAL_MAX_DECEL = -1.6 +VOLT_ONE_PEDAL_REGEN_PADDLE_DECEL_V = [-1.5, -1.6] +VOLT_ONE_PEDAL_MAX_DECEL = min((*VOLT_ONE_PEDAL_DECEL_V, *VOLT_ONE_PEDAL_REGEN_PADDLE_DECEL_V)) - 0.5 +VOLT_ONE_PEDAL_PID_NEG_LIMIT = -3.5 VOLT_ONE_PEDAL_SPEED_ERROR_FACTOR_BP = [1.5, 20.0] VOLT_ONE_PEDAL_SPEED_ERROR_FACTOR_V = [0.4, 0.2] VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_SPEED_FACTOR_BP = [0.0, 10.0 * CV.MPH_TO_MS] -VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_SPEED_FACTOR_V = [0.25, 1.0] +VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_SPEED_FACTOR_V = [0.2, 1.0] VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_STEER_FACTOR_BP = [20.0, 120.0] -VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_STEER_FACTOR_V = [1.0, 0.25] +VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_STEER_FACTOR_V = [1.0, 0.2] VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_UP = 0.8 * DT_CTRL * 4 VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_DOWN = 0.8 * DT_CTRL * 4 VOLT_ONE_PEDAL_ACCEL_PITCH_FACTOR_BP = [4.0, 8.0] @@ -189,6 +191,10 @@ def estimate_auto_hold_brake(driver_brake: float, op_brake: float) -> int: return int(round(np.clip(hold_brake, AUTO_HOLD_MIN_BRAKE, AUTO_HOLD_MAX_BRAKE))) +def get_volt_one_pedal_target_decel(v_ego: float) -> float: + return float(np.interp(v_ego, VOLT_ONE_PEDAL_DECEL_BP, VOLT_ONE_PEDAL_DECEL_V)) + + def should_activate_volt_one_pedal(one_pedal_ready: bool, cruise_main: bool, long_active: bool, gas_pressed: bool, brake_pressed: bool, regen_braking: bool, single_pedal_mode: bool, gear_shifter, moving_backward: bool) -> bool: @@ -294,7 +300,7 @@ class CarController(CarControllerBase): (CP.longitudinalTuning.kiBP, CP.longitudinalTuning.kiV), rate=1 / (DT_CTRL * 4), pos_limit=0.0, - neg_limit=VOLT_ONE_PEDAL_MAX_DECEL, + neg_limit=VOLT_ONE_PEDAL_PID_NEG_LIMIT, ) self.volt_one_pedal_decel = 0.0 self.volt_one_pedal_brake = 0 @@ -309,17 +315,13 @@ class CarController(CarControllerBase): self.volt_one_pedal_brake = 0 def _update_volt_one_pedal_brake(self, CC, CS): - if CS.out.vEgo > VOLT_ONE_PEDAL_DECEL_BP[-1]: - self._reset_volt_one_pedal() - return - pitch_accel = 0.0 if len(CC.orientationNED) == 3 and CS.out.vEgo > self.CP.vEgoStopping: pitch_accel = math.sin(CC.orientationNED[1]) * ACCELERATION_DUE_TO_GRAVITY pitch_factor_values = VOLT_ONE_PEDAL_ACCEL_PITCH_FACTOR_V if pitch_accel <= 0.0 else VOLT_ONE_PEDAL_ACCEL_PITCH_FACTOR_INCLINE_V pitch_accel *= float(np.interp(CS.out.vEgo, VOLT_ONE_PEDAL_ACCEL_PITCH_FACTOR_BP, pitch_factor_values)) - target_decel = float(np.interp(CS.out.vEgo, VOLT_ONE_PEDAL_DECEL_BP, VOLT_ONE_PEDAL_DECEL_V)) + target_decel = get_volt_one_pedal_target_decel(CS.out.vEgo) measured_decel = min(0.0, CS.out.aEgo + pitch_accel) error_factor = float(np.interp(CS.out.vEgo, VOLT_ONE_PEDAL_SPEED_ERROR_FACTOR_BP, VOLT_ONE_PEDAL_SPEED_ERROR_FACTOR_V)) error = (target_decel - measured_decel) * error_factor @@ -330,7 +332,7 @@ class CarController(CarControllerBase): float(np.interp(abs(CS.out.steeringAngleDeg), VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_STEER_FACTOR_BP, VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_STEER_FACTOR_V)), ) lower = min(self.volt_one_pedal_decel, measured_decel) - VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_UP * rate_limit_factor - upper = max(self.volt_one_pedal_decel, measured_decel) + VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_DOWN * rate_limit_factor + upper = max(self.volt_one_pedal_decel, measured_decel) + VOLT_ONE_PEDAL_DECEL_RATE_LIMIT_DOWN + rate_limit_factor self.volt_one_pedal_decel = float(np.clip(raw_decel, lower, upper)) self.volt_one_pedal_decel = max(self.volt_one_pedal_decel, VOLT_ONE_PEDAL_MAX_DECEL) self.volt_one_pedal_brake = int(round(np.clip( diff --git a/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py b/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py index 284c0b623c..20b4d4eaec 100644 --- a/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py +++ b/opendbc_repo/opendbc/car/gm/tests/test_carcontroller.py @@ -40,6 +40,7 @@ from opendbc.car.gm.carcontroller import ( estimate_auto_hold_brake, get_adas_keepalive_step, get_lka_steering_cmd_counter, + get_volt_one_pedal_target_decel, get_testing_ground_1_brake_switch_bias, get_stock_cc_active_for_cancel, should_activate_auto_hold, @@ -54,6 +55,7 @@ from opendbc.car.gm.carcontroller import ( from opendbc.car.gm.gmcan import get_friction_brake_mode from opendbc.car.gm.values import AccState, CAR, GMFlags from opendbc.car.structs import CarParams +from opendbc.car.common.conversions import Conversions as CV def _cs(enabled, pcm_acc_status): @@ -427,6 +429,12 @@ def test_volt_one_pedal_activation_requires_main_l_mode_and_no_driver_input(): ) +def test_volt_one_pedal_target_decel_stays_active_above_low_speed_band(): + assert get_volt_one_pedal_target_decel(0.5 * CV.MPH_TO_MS) == -1.0 + assert get_volt_one_pedal_target_decel(6.0 * CV.MPH_TO_MS) == -1.1 + assert get_volt_one_pedal_target_decel(20.0 * CV.MPH_TO_MS) == -1.1 + + def test_friction_brake_mode_keeps_near_stop_disabled_for_regular_long_braking(): CP = SimpleNamespace(carFingerprint=CAR.CHEVROLET_VOLT_ASCM) diff --git a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py index be8ef3acb6..0cde32b452 100755 --- a/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py +++ b/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @@ -86,12 +86,14 @@ STABLE_FOLLOW_CRUISE_HEADWAY_BELOW_TARGET = 0.35 STABLE_FOLLOW_CRUISE_HEADWAY_ABOVE_TARGET = 0.90 STABLE_FOLLOW_CRUISE_MAX_LEAD_BRAKE = 0.35 NEAR_DUPLICATE_LEAD_SOURCE_MIN_SPEED = 20.0 +NEAR_DUPLICATE_IDENTICAL_RADAR_SOURCE_MIN_SPEED = 10.0 NEAR_DUPLICATE_LEAD_SOURCE_MIN_MODEL_PROB = 0.9 NEAR_DUPLICATE_LEAD_SOURCE_MAX_LEAD_BRAKE = 0.35 NEAR_DUPLICATE_LEAD_SOURCE_MAX_DREL_DIFF = 1.5 NEAR_DUPLICATE_LEAD_SOURCE_MAX_VREL_DIFF = 0.35 NEAR_DUPLICATE_LEAD_SOURCE_HYSTERESIS_MIN = 1.25 NEAR_DUPLICATE_LEAD_SOURCE_HYSTERESIS_MAX = 2.25 +NEAR_DUPLICATE_IDENTICAL_RADAR_SOURCE_KEEP_MARGIN = 0.35 # Function to get parameter value based on current speed def get_speed_based_param(speed_mph, param_array): @@ -615,23 +617,33 @@ class LongitudinalMpc: return max(STABLE_FOLLOW_CRUISE_HYSTERESIS_MIN, STABLE_FOLLOW_CRUISE_HYSTERESIS_GAIN * float(v_ego)) + @staticmethod + def leads_share_identical_radar_track(lead_one, lead_two): + if lead_one is None or lead_two is None or not lead_one.status or not lead_two.status: + return False + if not (bool(getattr(lead_one, "radar", False)) and bool(getattr(lead_two, "radar", False))): + return False + track_one = int(getattr(lead_one, "radarTrackId", -1)) + track_two = int(getattr(lead_two, "radarTrackId", -1)) + return track_one >= 0 and track_one == track_two + @staticmethod def leads_are_near_duplicates(lead_one, lead_two, v_ego): if lead_one is None or lead_two is None or not lead_one.status or not lead_two.status: return False + if LongitudinalMpc.leads_share_identical_radar_track(lead_one, lead_two): + if float(v_ego) < NEAR_DUPLICATE_IDENTICAL_RADAR_SOURCE_MIN_SPEED: + return False + return ( + abs(float(lead_one.dRel) - float(lead_two.dRel)) <= NEAR_DUPLICATE_LEAD_SOURCE_MAX_DREL_DIFF and + abs(float(lead_one.vRel) - float(lead_two.vRel)) <= max(1.0, NEAR_DUPLICATE_LEAD_SOURCE_MAX_VREL_DIFF) + ) if float(v_ego) < NEAR_DUPLICATE_LEAD_SOURCE_MIN_SPEED: return False lead_one_radar = bool(getattr(lead_one, "radar", False)) lead_two_radar = bool(getattr(lead_two, "radar", False)) if lead_one_radar or lead_two_radar: - track_one = int(getattr(lead_one, "radarTrackId", -1)) - track_two = int(getattr(lead_two, "radarTrackId", -1)) - return ( - lead_one_radar and lead_two_radar and - track_one >= 0 and track_one == track_two and - abs(float(lead_one.dRel) - float(lead_two.dRel)) <= NEAR_DUPLICATE_LEAD_SOURCE_MAX_DREL_DIFF and - abs(float(lead_one.vRel) - float(lead_two.vRel)) <= max(1.0, NEAR_DUPLICATE_LEAD_SOURCE_MAX_VREL_DIFF) - ) + return False if float(getattr(lead_one, "modelProb", 0.0)) < NEAR_DUPLICATE_LEAD_SOURCE_MIN_MODEL_PROB: return False if float(getattr(lead_two, "modelProb", 0.0)) < NEAR_DUPLICATE_LEAD_SOURCE_MIN_MODEL_PROB: @@ -661,6 +673,15 @@ class LongitudinalMpc: return 0.0, hysteresis return hysteresis, 0.0 + def get_identical_radar_duplicate_source_hold(self, prev_source, lead_one, lead_two, lead_0_obstacle, lead_1_obstacle): + if prev_source not in ("lead0", "lead1"): + return None + if not self.leads_share_identical_radar_track(lead_one, lead_two): + return None + if abs(float(lead_0_obstacle) - float(lead_1_obstacle)) > NEAR_DUPLICATE_IDENTICAL_RADAR_SOURCE_KEEP_MARGIN: + return None + return prev_source + def set_accel_limits(self, min_a, max_a): # TODO this sets a max accel limit, but the minimum limit is only for cruise decel # needs refactor @@ -714,7 +735,17 @@ class LongitudinalMpc: lead_0_obstacle = lead_0_obstacle + lead_0_bias lead_1_obstacle = lead_1_obstacle + lead_1_bias x_obstacles = np.column_stack([lead_0_obstacle, lead_1_obstacle, cruise_obstacle]) - self.source = SOURCES[np.argmin(x_obstacles[0])] + candidate_source = SOURCES[np.argmin(x_obstacles[0])] + sticky_source = None + if optional_far_lead_comfort and candidate_source in ("lead0", "lead1"): + sticky_source = self.get_identical_radar_duplicate_source_hold( + prev_source, + lead_one, + lead_two, + lead_0_obstacle[0], + lead_1_obstacle[0], + ) + self.source = sticky_source or candidate_source # These are not used in ACC mode x[:], v[:], a[:], j[:] = 0.0, 0.0, 0.0, 0.0 diff --git a/selfdrive/controls/lib/longitudinal_planner.py b/selfdrive/controls/lib/longitudinal_planner.py index 3b7fcb797a..3607873aeb 100755 --- a/selfdrive/controls/lib/longitudinal_planner.py +++ b/selfdrive/controls/lib/longitudinal_planner.py @@ -353,6 +353,7 @@ NEAR_DUPLICATE_LEAD_TRANSITION_MAX_CLOSING_SPEED = 3.5 NEAR_DUPLICATE_LEAD_TRANSITION_MIN_TTC = 8.0 NEAR_DUPLICATE_LEAD_TRANSITION_MIN_HEADWAY_BELOW_TARGET = 0.45 NEAR_DUPLICATE_LEAD_TRANSITION_MAX_HEADWAY_ABOVE_TARGET = 0.85 +LOW_SPEED_IDENTICAL_RADAR_DUPLICATE_TRANSITION_EXTRA_HEADWAY = 0.15 NEAR_DUPLICATE_LEAD_TRANSITION_MIN_DELTA_A = 0.35 NEAR_DUPLICATE_LEAD_TRANSITION_POSITIVE_STEP = 0.22 NEAR_DUPLICATE_LEAD_TRANSITION_NEGATIVE_STEP = 0.32 @@ -1813,10 +1814,13 @@ class LongitudinalPlanner: current_source, tracking_lead_active): if lead is None or not lead.status: return None - if current_source not in ("cruise", "lead0", "lead1") and not tracking_lead_active: + if current_source not in ("cruise", "lead0", "lead1"): + return None + if current_source == "cruise" and not tracking_lead_active: return None if not (self.lead_one.status and self.lead_two.status): return None + identical_radar_duplicates = self.mpc.leads_share_identical_radar_track(self.lead_one, self.lead_two) if not self.mpc.leads_are_near_duplicates(self.lead_one, self.lead_two, v_ego): return None low_speed_extension_active = bool( @@ -1850,7 +1854,10 @@ class LongitudinalPlanner: actual_headway = float(lead.dRel) / max(float(v_ego), 1e-3) if actual_headway < max(0.0, float(base_t_follow) - NEAR_DUPLICATE_LEAD_TRANSITION_MIN_HEADWAY_BELOW_TARGET): return None - if actual_headway > float(base_t_follow) + NEAR_DUPLICATE_LEAD_TRANSITION_MAX_HEADWAY_ABOVE_TARGET: + max_headway_above_target = NEAR_DUPLICATE_LEAD_TRANSITION_MAX_HEADWAY_ABOVE_TARGET + if low_speed_extension_active and identical_radar_duplicates: + max_headway_above_target += LOW_SPEED_IDENTICAL_RADAR_DUPLICATE_TRANSITION_EXTRA_HEADWAY + if actual_headway > float(base_t_follow) + max_headway_above_target: return None target_delta = float(output_a_target) - float(prev_output_a_target) diff --git a/selfdrive/controls/tests/test_longitudinal_planner.py b/selfdrive/controls/tests/test_longitudinal_planner.py index bb4010925e..242253c22c 100644 --- a/selfdrive/controls/tests/test_longitudinal_planner.py +++ b/selfdrive/controls/tests/test_longitudinal_planner.py @@ -2667,6 +2667,34 @@ def test_near_duplicate_lead_source_hysteresis_prefers_previous_source_for_ident assert lead_1_bias > 0.0 +def test_near_duplicate_leads_detect_identical_radar_track_below_45_mph(): + v_ego = 14.31 + CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + planner = LongitudinalPlanner(CP, init_v=v_ego) + lead_one = make_lead(status=True, d_rel=25.7, v_lead=14.61, a_lead=0.0, radar=True, model_prob=1.0) + lead_two = make_lead(status=True, d_rel=25.7, v_lead=14.61, a_lead=0.0, radar=True, model_prob=1.0) + lead_one.vRel = lead_one.vLead - v_ego + lead_two.vRel = lead_two.vLead - v_ego + lead_one.radarTrackId = 2493 + lead_two.radarTrackId = 2493 + + assert planner.mpc.leads_are_near_duplicates(lead_one, lead_two, v_ego) + + +def test_identical_radar_duplicate_source_hold_keeps_previous_label(): + v_ego = 21.6 + CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) + planner = LongitudinalPlanner(CP, init_v=v_ego) + lead_one = make_lead(status=True, d_rel=33.5, v_lead=20.7, a_lead=-0.03, radar=True, model_prob=1.0) + lead_two = make_lead(status=True, d_rel=33.5, v_lead=20.7, a_lead=-0.03, radar=True, model_prob=1.0) + lead_one.radarTrackId = 2493 + lead_two.radarTrackId = 2493 + + sticky = planner.mpc.get_identical_radar_duplicate_source_hold("lead1", lead_one, lead_two, 33.52, 33.50) + + assert sticky == "lead1" + + def test_near_duplicate_lead_source_hysteresis_skips_distinct_leads(): v_ego = 27.0 CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) @@ -2731,13 +2759,15 @@ def test_near_duplicate_lead_transition_target_damps_tracking_cruise_sign_flip() def test_near_duplicate_lead_transition_target_damps_low_speed_duplicate_radar_handoff(): - v_ego = 14.31 + v_ego = 17.61 CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC) planner = LongitudinalPlanner(CP, init_v=v_ego) - lead_one = make_lead(status=True, d_rel=25.7, v_lead=14.61, a_lead=0.0, radar=True, model_prob=1.0) - lead_two = make_lead(status=True, d_rel=25.7, v_lead=14.61, a_lead=0.0, radar=True, model_prob=1.0) + lead_one = make_lead(status=True, d_rel=41.9, v_lead=16.85, a_lead=0.0, radar=True, model_prob=1.0) + lead_two = make_lead(status=True, d_rel=41.9, v_lead=16.85, a_lead=0.0, radar=True, model_prob=1.0) lead_one.vRel = lead_one.vLead - v_ego lead_two.vRel = lead_two.vLead - v_ego + lead_one.radarTrackId = 2493 + lead_two.radarTrackId = 2493 planner.lead_one = lead_one planner.lead_two = lead_two @@ -2745,14 +2775,14 @@ def test_near_duplicate_lead_transition_target_damps_low_speed_duplicate_radar_h lead_one, v_ego, 1.45, - prev_output_a_target=0.68, - output_a_target=0.03, - current_source="lead0", + prev_output_a_target=0.89, + output_a_target=0.05, + current_source="cruise", tracking_lead_active=True, ) assert smoothed is not None - assert smoothed == pytest.approx(0.36, abs=1e-6) + assert smoothed == pytest.approx(0.57, abs=1e-6) def test_duplicate_slow_lead_brake_hold_prevents_zero_cross_from_duplicate_voacc_leads(): diff --git a/starpilot/system/the_pond/assets/components/home/home.css b/starpilot/system/the_pond/assets/components/home/home.css index 7dbe9fc552..32eecbef48 100644 --- a/starpilot/system/the_pond/assets/components/home/home.css +++ b/starpilot/system/the_pond/assets/components/home/home.css @@ -120,6 +120,32 @@ border-color: rgba(139, 108, 197, 0.42); } +.dashboard-analysis-status { + align-items: center; + background: rgba(139, 108, 197, 0.12); + border: 1px solid var(--dashboard-border); + border-radius: 8px; + color: var(--dashboard-muted); + display: inline-flex; + font-size: 0.88rem; + font-weight: var(--font-weight-bold); + gap: 0.5rem; + justify-self: start; + max-width: 100%; + min-width: 0; + padding: 0.55rem 0.75rem; +} + +.dashboard-analysis-status i { + color: var(--dashboard-accent-2); + flex: 0 0 auto; +} + +.dashboard-analysis-status span { + min-width: 0; + overflow-wrap: anywhere; +} + .dashboard-last-drive { display: grid; gap: 0.9rem; diff --git a/starpilot/system/the_pond/assets/components/home/home.js b/starpilot/system/the_pond/assets/components/home/home.js index f74efd23cc..da5aca344f 100644 --- a/starpilot/system/the_pond/assets/components/home/home.js +++ b/starpilot/system/the_pond/assets/components/home/home.js @@ -6,6 +6,7 @@ const HOME_STATE = { unit: "miles", error: "", initialized: false, + refreshTimer: null, }; const FAVORITE_COLORS = ["#5ec8c8", "#8b6cc5", "#d4a060", "#e05577", "#6cc56e", "#8aa3ff"]; @@ -167,6 +168,51 @@ function driveStatsReady(drive) { return drive?.attentionKnown !== false; } +function dashboardPendingDriveCount(dashboard) { + const recent = Array.isArray(dashboard?.recentDrives) ? dashboard.recentDrives : []; + return recent.filter(drive => !driveStatsReady(drive)).length; +} + +function dashboardShouldAutoRefresh(dashboard) { + const analysis = dashboard?.analysis || {}; + return Boolean(analysis.running) + || numberValue(analysis.pendingRoutes) > 0 + || dashboardPendingDriveCount(dashboard) > 0; +} + +function clearDashboardRefreshTimer() { + if (HOME_STATE.refreshTimer) { + clearTimeout(HOME_STATE.refreshTimer); + HOME_STATE.refreshTimer = null; + } +} + +function scheduleDashboardRefresh(dashboard) { + clearDashboardRefreshTimer(); + if (!dashboardShouldAutoRefresh(dashboard)) return; + HOME_STATE.refreshTimer = setTimeout(() => initializeHome(false), 3500); +} + +function renderAnalysisStatus(dashboard) { + const analysis = dashboard?.analysis || {}; + const pendingRoutes = Math.max(0, Math.round(numberValue(analysis.pendingRoutes))); + const pendingDrives = dashboardPendingDriveCount(dashboard); + const count = Math.max(pendingRoutes, pendingDrives); + if (!analysis.running && count <= 0) return ""; + + const runningCount = count || Math.max(1, Math.round(numberValue(analysis.batchSize))); + const label = analysis.running + ? `Analyzing ${runningCount} ${runningCount === 1 ? "drive" : "drives"}` + : `${count} ${count === 1 ? "drive" : "drives"} queued`; + + return ` +
+ + ${escapeHtml(label)} +
+ `; +} + function renderLastDrive(drive) { const ready = driveStatsReady(drive); return ` @@ -416,6 +462,7 @@ function renderDashboard(state) { if (!shell) return; if (state.status === "error") { + clearDashboardRefreshTimer(); shell.innerHTML = `
Failed to load dashboard: ${escapeHtml(state.error)}
@@ -453,6 +500,7 @@ function renderDashboard(state) { ${renderLastDrive(dashboard.lastDrive || fallbackDashboard(data, state.unit).lastDrive)} + ${renderAnalysisStatus(dashboard)}
Your driving
@@ -482,10 +530,12 @@ function renderDashboard(state) { `; bindDashboardActions(); + scheduleDashboardRefresh(dashboard); } async function initializeHome(force = false) { if (force) { + clearDashboardRefreshTimer(); HOME_STATE.status = "loading"; renderDashboard(HOME_STATE); } diff --git a/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json b/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json index 9ca5426fd2..1d8ae1752b 100644 --- a/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json +++ b/starpilot/system/the_pond/assets/components/tools/device_settings_layout.json @@ -2511,7 +2511,7 @@ { "key": "VoltOnePedalMode", "label": "Volt One Pedal Mode", - "description": "On supported Chevy Volts in L / single-pedal mode, blend light friction braking at low speed so the car can come to a stop and hold without using the brake pedal.", + "description": "On supported Chevy Volts in L / single-pedal mode, blend friction braking so the car can slow to a stop and hold without using the brake pedal.", "data_type": "bool", "ui_type": "toggle" }, diff --git a/starpilot/system/the_pond/tests/test_dashboard_stats.py b/starpilot/system/the_pond/tests/test_dashboard_stats.py index 367d39b805..ae3239582e 100644 --- a/starpilot/system/the_pond/tests/test_dashboard_stats.py +++ b/starpilot/system/the_pond/tests/test_dashboard_stats.py @@ -541,6 +541,8 @@ def test_lightweight_routes_surface_recent_drives_without_log_analysis(monkeypat assert dashboard["week"]["drives"] == 2 assert dashboard["favoriteModels"][0]["name"] == "Orion" assert dashboard["favoriteModels"][0]["drives"] == 2 + assert dashboard["analysis"]["pendingRoutes"] == 2 + assert dashboard["analysis"]["batchSize"] == 2 utilities._invalidate_dashboard_cache() @@ -676,6 +678,112 @@ def test_shell_update_preserves_old_analysis_version_for_reparse(): assert utilities._analysis_candidates([{"name": "route-1", "modifiedAt": 100}], stats) +def test_week_summary_ignores_stale_premigration_route_rows(monkeypatch): + utilities._invalidate_dashboard_cache() + route_infos = [ + { + "name": "route-stale", + "segments": [], + "segmentCount": 1, + "startedAt": utilities.datetime(2026, 6, 15, 8, 0, 0), + "modifiedAt": 100, + }, + { + "name": "route-current", + "segments": [], + "segmentCount": 1, + "startedAt": utilities.datetime(2026, 6, 17, 8, 0, 0), + "modifiedAt": 200, + }, + ] + params = FakeParams({ + utilities.DASHBOARD_PERSISTENT_STATS_PARAM: { + "routes": { + "route-stale": { + "date": "2026-06-15T08:00:00", + "endDate": "2026-06-15T08:20:00", + "distanceMeters": 160934.4, + "duration": 1200, + "engagedSeconds": 600.0, + "model": "Orion", + "modifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + "analysisVersion": utilities.DASHBOARD_ROUTE_ANALYSIS_VERSION - 1, + }, + "route-current": { + "date": "2026-06-17T08:00:00", + "endDate": "2026-06-17T08:20:00", + "distanceMeters": 32186.88, + "duration": 1200, + "engagedSeconds": 900.0, + "model": "Orion", + "modifiedAt": 200, + "attentionKnown": True, + "analysisComplete": True, + "analysisVersion": utilities.DASHBOARD_ROUTE_ANALYSIS_VERSION, + }, + }, + }, + }) + monkeypatch.setattr(utilities, "_list_dashboard_routes", lambda paths: route_infos) + monkeypatch.setattr(utilities, "_start_dashboard_background_analysis", lambda *args: False) + monkeypatch.setattr(utilities, "_build_storage_summary", lambda paths: {"freeBytes": 0, "usedBytes": 0, "totalBytes": 0, "usedPercent": 0, "segmentCounts": {}}) + + dashboard = utilities.get_dashboard_stats(["/tmp/missing"], params, now=utilities.datetime(2026, 6, 18, 12, 0, 0)) + + assert dashboard["week"]["distance"] == 20.0 + assert dashboard["week"]["dailyDistance"][0]["distance"] == 0.0 + assert dashboard["week"]["dailyDistance"][2]["distance"] == 20.0 + assert dashboard["analysis"]["pendingRoutes"] == 1 + utilities._invalidate_dashboard_cache() + + +def test_week_summary_keeps_persisted_rows_when_raw_route_is_gone(monkeypatch): + utilities._invalidate_dashboard_cache() + params = FakeParams({ + utilities.DASHBOARD_PERSISTENT_STATS_PARAM: { + "routes": { + "route-pruned": { + "date": "2026-06-15T08:00:00", + "endDate": "2026-06-15T08:20:00", + "distanceMeters": 16093.44, + "duration": 1200, + "engagedSeconds": 600.0, + "model": "Orion", + "modifiedAt": 100, + "attentionKnown": True, + "analysisComplete": True, + "analysisVersion": utilities.DASHBOARD_ROUTE_ANALYSIS_VERSION - 1, + }, + }, + }, + }) + monkeypatch.setattr(utilities, "_list_dashboard_routes", lambda paths: []) + monkeypatch.setattr(utilities, "_start_dashboard_background_analysis", lambda *args: False) + monkeypatch.setattr(utilities, "_build_storage_summary", lambda paths: {"freeBytes": 0, "usedBytes": 0, "totalBytes": 0, "usedPercent": 0, "segmentCounts": {}}) + + dashboard = utilities.get_dashboard_stats(["/tmp/missing"], params, now=utilities.datetime(2026, 6, 18, 12, 0, 0)) + + assert dashboard["week"]["distance"] == 10.0 + assert dashboard["week"]["dailyDistance"][0]["distance"] == 10.0 + assert dashboard["analysis"]["pendingRoutes"] == 0 + utilities._invalidate_dashboard_cache() + + +def test_week_summary_resets_at_monday_midnight(): + drives = [ + {"date": "2026-06-21T23:59:00", "distance": 30.0, "duration": 1800, "engagedSeconds": 900}, + {"date": "2026-06-22T00:00:00", "distance": 4.0, "duration": 600, "engagedSeconds": 300}, + ] + + week = utilities._build_week_summary(drives, utilities.datetime(2026, 6, 22, 0, 1, 0), is_metric=False) + + assert week["distance"] == 4.0 + assert week["drives"] == 1 + assert week["dailyDistance"][0]["distance"] == 4.0 + + def test_unknown_attention_rows_do_not_reset_persisted_clean_records(): params = FakeParams() known_drive = { diff --git a/starpilot/system/the_pond/utilities.py b/starpilot/system/the_pond/utilities.py index 8f4112d197..1d62205f7d 100644 --- a/starpilot/system/the_pond/utilities.py +++ b/starpilot/system/the_pond/utilities.py @@ -58,7 +58,7 @@ METER_TO_KILOMETER = 0.001 METER_PER_SECOND_TO_MPH = CV.MS_TO_KPH * CV.KPH_TO_MPH DASHBOARD_CACHE_TTL_SECONDS = 5.0 -DASHBOARD_ROUTE_SCAN_LIMIT = 24 +DASHBOARD_ROUTE_SCAN_LIMIT = 96 DASHBOARD_ROUTE_ANALYSIS_LIMIT = 0 DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS = 0.0 DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT = 5 @@ -1313,6 +1313,20 @@ def _coalesce_display_drives(drives, is_metric): return sorted(coalesced, key=_drive_sort_time, reverse=True) +def _drive_has_stale_analysis(drive): + if not bool(drive.get("attentionKnown", True)) or not bool(drive.get("analysisComplete", False)): + return False + return _safe_int(drive.get("analysisVersion", 0), 0) < DASHBOARD_ROUTE_ANALYSIS_VERSION + + +def _week_summary_drives(drives, pending_route_names=None): + pending_route_names = pending_route_names or set() + return [ + drive for drive in drives or [] + if not (_drive_has_stale_analysis(drive) and str(drive.get("name", "")).strip() in pending_route_names) + ] + + def _analysis_candidates(route_infos, persistent_stats): routes = persistent_stats.get("routes", {}) if isinstance(persistent_stats, dict) else {} routes = routes if isinstance(routes, dict) else {} @@ -1383,15 +1397,30 @@ def _dashboard_worker_env(repo_root): return env -def _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats): +def _dashboard_analyzer_running(): + process = _DASHBOARD_ANALYZER_PROCESS + return process is not None and process.poll() is None + + +def _dashboard_analysis_status(candidates): + pending_count = len(candidates or []) + return { + "pendingRoutes": pending_count, + "running": _dashboard_analyzer_running(), + "batchSize": min(DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT, pending_count), + } + + +def _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats, candidates=None): global _DASHBOARD_ANALYZER_PROCESS - if not route_infos or not _analysis_candidates(route_infos, persistent_stats): - return + candidates = candidates if candidates is not None else _analysis_candidates(route_infos, persistent_stats) + if not route_infos or not candidates: + return False with _DASHBOARD_ANALYZER_LOCK: - if _DASHBOARD_ANALYZER_PROCESS is not None and _DASHBOARD_ANALYZER_PROCESS.poll() is None: - return + if _dashboard_analyzer_running(): + return True repo_root = Path(__file__).resolve().parents[3] worker_code = ( "import json, sys;" @@ -1424,6 +1453,8 @@ def _start_dashboard_background_analysis(footage_paths, route_infos, persistent_ if log_file is not None: log_file.close() + return _dashboard_analyzer_running() + def _start_of_week(now): return datetime(now.year, now.month, now.day) - timedelta(days=now.weekday()) @@ -2143,7 +2174,12 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None): and _DASHBOARD_CACHE["value"] is not None and cache_now - _DASHBOARD_CACHE["updated_at"] < DASHBOARD_CACHE_TTL_SECONDS ): - return copy.deepcopy(_DASHBOARD_CACHE["value"]) + cached_dashboard = copy.deepcopy(_DASHBOARD_CACHE["value"]) + cached_analysis = cached_dashboard.get("analysis", {}) if isinstance(cached_dashboard, dict) else {} + if isinstance(cached_analysis, dict): + cached_analysis["running"] = _dashboard_analyzer_running() + cached_dashboard["analysis"] = cached_analysis + return cached_dashboard is_metric = _params_get_bool(params_obj, "IsMetric") model_names = _model_lookup(params_obj) @@ -2171,6 +2207,11 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None): persisted_drives = _persistent_drives(persistent_stats, is_metric) combined_drives = _merge_dashboard_drives(shell_drives, persisted_drives, analyzed_drives) display_drives = _coalesce_display_drives(combined_drives, is_metric) + pending_candidates = _analysis_candidates(route_infos, persistent_stats) + pending_route_names = {str(route.get("name", "")).strip() for route in pending_candidates} + week_drives = _week_summary_drives(combined_drives, pending_route_names) + _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats, pending_candidates) + analysis_status = _dashboard_analysis_status(pending_candidates) if not display_drives: dashboard = _dashboard_empty(is_metric, now, footage_paths, params_obj, persistent_stats) @@ -2179,19 +2220,19 @@ def get_dashboard_stats(footage_paths, params_obj=None, now=None): dashboard = { "lastDrive": _public_drive(display_drives[0], is_metric), "recentDrives": [_public_drive(drive, is_metric) for drive in display_drives[:DASHBOARD_RECENT_DRIVE_LIMIT]], - "week": _build_week_summary(display_drives, now, is_metric), + "week": _build_week_summary(week_drives, now, is_metric), "records": records, "device": _build_device_summary(params_obj), "storage": _build_storage_summary(footage_paths), "favoriteModels": _build_favorite_models(params_obj, persistent_stats), } + dashboard["analysis"] = analysis_status _DASHBOARD_CACHE.update({ "key": cache_key, "updated_at": cache_now, "value": copy.deepcopy(dashboard), }) - _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats) return dashboard