From 5eb6fe2dd9a663473be94667a40d4cb9a334778b Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:36:40 -0500 Subject: [PATCH] Kachow --- opendbc_repo/opendbc/car/gm/carcontroller.py | 22 ++-- .../car/gm/tests/test_carcontroller.py | 8 ++ .../lib/longitudinal_mpc_lib/long_mpc.py | 49 ++++++-- .../controls/lib/longitudinal_planner.py | 11 +- .../tests/test_longitudinal_planner.py | 44 +++++-- .../the_pond/assets/components/home/home.css | 26 +++++ .../the_pond/assets/components/home/home.js | 50 ++++++++ .../tools/device_settings_layout.json | 2 +- .../the_pond/tests/test_dashboard_stats.py | 108 ++++++++++++++++++ starpilot/system/the_pond/utilities.py | 59 ++++++++-- 10 files changed, 341 insertions(+), 38 deletions(-) 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 ` +