mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-28 18:33:45 +08:00
Kachow
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user