This commit is contained in:
firestar5683
2026-06-18 13:36:40 -05:00
parent 64bb95e3cf
commit 5eb6fe2dd9
10 changed files with 341 additions and 38 deletions
+12 -10
View File
@@ -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(
@@ -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)
@@ -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
@@ -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)
@@ -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():
@@ -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;
@@ -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 `
<div class="dashboard-analysis-status">
<i class="bi bi-hourglass-split"></i>
<span>${escapeHtml(label)}</span>
</div>
`;
}
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 = `
<div class="dashboard dashboard-narrow">
<div class="dashboard-error">Failed to load dashboard: ${escapeHtml(state.error)}</div>
@@ -453,6 +500,7 @@ function renderDashboard(state) {
</header>
${renderLastDrive(dashboard.lastDrive || fallbackDashboard(data, state.unit).lastDrive)}
${renderAnalysisStatus(dashboard)}
<div class="dashboard-section-label"><span></span>Your driving</div>
<div class="dashboard-summary-grid">
@@ -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);
}
@@ -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"
},
@@ -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 = {
+50 -9
View File
@@ -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