From b75c0f9e95adaca141705a431cdcc869eaf42404 Mon Sep 17 00:00:00 2001 From: Jake Jurek Date: Sat, 20 Jun 2026 10:28:37 -0400 Subject: [PATCH] feat(dashboard): add battery endurance tracker widget Lap-by-lap SOC, min cell voltage, and max cell temperature with boundary projections (laps until >60C, <20% SOC, 0% SOC), per-lap deltas/% change, event statistics, and a 0-35 min voltage/temperature graph with regression projection lines. Manual lap capture persists to localStorage so the table survives page refresh and Sentinel token expiry. Registered as a full-width gr26 BCU widget. --- .../gr26/live/BatteryEnduranceWidget.tsx | 991 ++++++++++++++++++ dashboard/src/components/widgets/registry.tsx | 12 + .../lib/__tests__/batteryEndurance.test.ts | 98 ++ dashboard/src/lib/batteryEndurance.ts | 175 ++++ 4 files changed, 1276 insertions(+) create mode 100644 dashboard/src/components/widgets/gr26/live/BatteryEnduranceWidget.tsx create mode 100644 dashboard/src/lib/__tests__/batteryEndurance.test.ts create mode 100644 dashboard/src/lib/batteryEndurance.ts diff --git a/dashboard/src/components/widgets/gr26/live/BatteryEnduranceWidget.tsx b/dashboard/src/components/widgets/gr26/live/BatteryEnduranceWidget.tsx new file mode 100644 index 00000000..b6c24a2a --- /dev/null +++ b/dashboard/src/components/widgets/gr26/live/BatteryEnduranceWidget.tsx @@ -0,0 +1,991 @@ +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, +} from "react"; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; +import { ReadyState } from "react-use-websocket"; +import { + BatteryWarning, + Play, + Plus, + Thermometer, + Trash2, + Undo2, + Wifi, + WifiOff, + Zap, +} from "lucide-react"; + +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + useLiveSignals, + type LiveSignalState, + type SeriesPoint, +} from "@/lib/useLiveSignals"; +import { fetchPairs, type PairRow } from "@/lib/pairs"; +import { + crossX, + enduranceKey, + formatElapsed, + initEnduranceState, + linearRegression, + loadEndurance, + meanPerLapDelta, + projectLapsUntil, + saveEndurance, + summarize, + type EnduranceState, + type LapRow, + type Regression, + type Thresholds, +} from "@/lib/batteryEndurance"; + +const SOC_SIGNAL = "bcu_accumulator_soc"; +const MINV_SIGNAL = "bcu_min_cell_voltage"; +const MAXT_SIGNAL = "bcu_max_cell_temp"; +const SIGNALS_CSV = `${SOC_SIGNAL},${MINV_SIGNAL},${MAXT_SIGNAL}`; + +const ENDURANCE_MIN = 35; // fixed graph window: 0–35 minutes from event start +const STALE_MS = 5_000; // live sample older than this ⇒ flag the snapshot +const PAIRS_REFRESH_MS = 20_000; + +const VOLTAGE_COLOR = "#3b82f6"; +const TEMP_COLOR = "#ef4444"; +const PROJECTION_OPACITY = 0.8; + +interface BatteryEnduranceWidgetProps { + vehicle_id: string; + showDeltaBanner?: boolean; +} + +// Runs the live subscription into caller-owned refs and renders nothing, so +// signal-rate churn never re-renders the table/chart. Memo'd on its inputs. +const LiveSignalsRunner = memo(function LiveSignalsRunner({ + vehicleId, + latestRef, + seriesRef, + totalRef, + backfillRef, + onReadyChange, +}: { + vehicleId: string; + latestRef: MutableRefObject>; + seriesRef: MutableRefObject>; + totalRef: MutableRefObject; + backfillRef: MutableRefObject; + onReadyChange: (state: ReadyState) => void; +}) { + useLiveSignals({ + vehicleId, + transport: "ws", + signals: SIGNALS_CSV, + backfillSec: 30, + rateHz: 5, + seriesWindowMs: 60_000, + latestRef, + seriesRef, + totalRef, + backfillRef, + onReadyChange, + }); + return null; +}); + +function fmt(value: number | null | undefined, decimals: number): string { + return value == null || !Number.isFinite(value) + ? "—" + : value.toFixed(decimals); +} + +// Per-lap delta cell: shows absolute change and % change, colored by whether +// the move is toward the danger boundary (red) or away from it (green). +function DeltaCell({ + abs, + pct, + decimals, + dangerOnRise, +}: { + abs: number | null; + pct: number | null; + decimals: number; + dangerOnRise: boolean; +}) { + if (abs == null || !Number.isFinite(abs)) { + return ; + } + const rising = abs > 0; + const neutral = abs === 0; + const danger = dangerOnRise ? rising : !rising; + const color = neutral + ? "text-muted-foreground" + : danger + ? "text-red-500" + : "text-green-500"; + return ( + + {rising ? "+" : ""} + {abs.toFixed(decimals)} + {pct != null && Number.isFinite(pct) && ( + + ({rising ? "+" : ""} + {pct.toFixed(1)}%) + + )} + + ); +} + +function StatBox({ + label, + value, + unit, + hint, +}: { + label: string; + value: string; + unit?: string; + hint?: string; +}) { + return ( +
+
{label}
+
+ {value} + {unit && ( + + {unit} + + )} +
+ {hint &&
{hint}
} +
+ ); +} + +function ProjectionCard({ + icon, + label, + laps, + rate, +}: { + icon: React.ReactNode; + label: string; + laps: number | null; + rate: string; +}) { + const reached = laps === 0; + const tone = + laps == null + ? "border-border" + : reached || laps <= 2 + ? "border-red-500/60 bg-red-500/10" + : laps <= 5 + ? "border-yellow-500/60 bg-yellow-500/10" + : "border-green-500/50 bg-green-500/10"; + return ( +
+
+ {icon} + {label} +
+
+ {laps == null ? "—" : reached ? "now" : `${laps.toFixed(1)}`} + {laps != null && !reached && ( + + laps + + )} +
+
{rate}
+
+ ); +} + +// Endpoints for a dashed forward projection: from the regression value at the +// last sample to where it crosses the boundary, clamped to the graph window. +function projectionSegment( + reg: Regression | null, + lastX: number, + boundaryY: number, + xMax: number, +): [{ x: number; y: number }, { x: number; y: number }] | null { + if (!reg) return null; + const yAtLast = reg.slope * lastX + reg.intercept; + const cross = crossX(reg, boundaryY); + let endX = cross == null || !Number.isFinite(cross) ? xMax : cross; + endX = Math.min(Math.max(endX, lastX), xMax); + const yAtEnd = reg.slope * endX + reg.intercept; + return [ + { x: lastX, y: yAtLast }, + { x: endX, y: yAtEnd }, + ]; +} + +export default function BatteryEnduranceWidget({ + vehicle_id, +}: BatteryEnduranceWidgetProps) { + const latestRef = useRef>(new Map()); + const seriesRef = useRef>(new Map()); + const totalRef = useRef(0); + const backfillRef = useRef(0); + const [ready, setReady] = useState(ReadyState.UNINSTANTIATED); + const onReadyChange = useCallback((s: ReadyState) => setReady(s), []); + + const [state, setState] = useState(initEnduranceState); + const [pairsRows, setPairsRows] = useState([]); + const [confirmReset, setConfirmReset] = useState(false); + + // Load persisted state on mount / vehicle change. localStorage is the source + // of truth so the table survives refresh and token expiry. + useEffect(() => { + setState(loadEndurance(vehicle_id)); + }, [vehicle_id]); + + // Sync across tabs editing the same vehicle's table. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === enduranceKey(vehicle_id)) { + setState(loadEndurance(vehicle_id)); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [vehicle_id]); + + // 1 Hz tick to surface live readouts + elapsed without per-signal re-renders. + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 1_000); + return () => clearInterval(id); + }, []); + + const persist = useCallback( + (next: EnduranceState) => { + setState(next); + saveEndurance(vehicle_id, next); + }, + [vehicle_id], + ); + + // Pull dense voltage+temp samples over the event window for the chart. + useEffect(() => { + if (state.eventStartMs == null) { + setPairsRows([]); + return; + } + const startMs = state.eventStartMs; + let cancelled = false; + const load = async () => { + try { + const res = await fetchPairs({ + vehicleId: vehicle_id, + signals: [MINV_SIGNAL, MAXT_SIGNAL], + startIso: new Date(startMs).toISOString(), + endIso: new Date(Date.now()).toISOString(), + maxPoints: 2000, + }); + if (!cancelled) setPairsRows(res.rows); + } catch { + // Keep last good samples; the persisted table stays visible regardless. + } + }; + load(); + const id = setInterval(load, PAIRS_REFRESH_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [vehicle_id, state.eventStartMs, state.laps.length]); + + const connected = ready === ReadyState.OPEN; + const now = Date.now(); + + const liveSoc = latestRef.current.get(SOC_SIGNAL); + const liveV = latestRef.current.get(MINV_SIGNAL); + const liveT = latestRef.current.get(MAXT_SIGNAL); + + const startEvent = () => { + persist({ ...state, eventStartMs: Date.now() }); + }; + + const addLap = () => { + const t = Date.now(); + const soc = latestRef.current.get(SOC_SIGNAL); + const v = latestRef.current.get(MINV_SIGNAL); + const temp = latestRef.current.get(MAXT_SIGNAL); + const present = soc && v && temp; + const oldest = present + ? Math.min(soc.producedAt, v.producedAt, temp.producedAt) + : 0; + const eventStartMs = state.eventStartMs ?? t; // auto-anchor on first lap + const lap: LapRow = { + lap: state.laps.length + 1, + tMs: t, + elapsedMs: t - eventStartMs, + soc: soc?.value ?? NaN, + minV: v?.value ?? NaN, + maxT: temp?.value ?? NaN, + stale: !present || t - oldest > STALE_MS, + }; + persist({ ...state, eventStartMs, laps: [...state.laps, lap] }); + }; + + const undoLap = () => { + if (state.laps.length === 0) return; + persist({ ...state, laps: state.laps.slice(0, -1) }); + }; + + const deleteLap = (index: number) => { + const laps = state.laps + .filter((_, i) => i !== index) + .map((l, i) => ({ ...l, lap: i + 1 })); + persist({ ...state, laps }); + }; + + const reset = () => { + persist(initEnduranceState()); + setConfirmReset(false); + }; + + const setThreshold = (key: keyof Thresholds, raw: string) => { + const value = parseFloat(raw); + if (!Number.isFinite(value)) return; + persist({ ...state, thresholds: { ...state.thresholds, [key]: value } }); + }; + + const { thresholds, laps } = state; + + const socValues = useMemo(() => laps.map((l) => l.soc), [laps]); + const vValues = useMemo(() => laps.map((l) => l.minV), [laps]); + const tValues = useMemo(() => laps.map((l) => l.maxT), [laps]); + + const lapsUntilTemp = projectLapsUntil(tValues, thresholds.tempMaxC, "up"); + const lapsUntilLowSoc = projectLapsUntil( + socValues, + thresholds.socLowPct, + "down", + ); + const lapsUntilEmptySoc = projectLapsUntil( + socValues, + thresholds.socEmptyPct, + "down", + ); + + const socRate = meanPerLapDelta(socValues); + const tRate = meanPerLapDelta(tValues); + + const socStats = summarize(socValues); + const vStats = summarize(vValues); + const tStats = summarize(tValues); + + const avgLapMs = useMemo(() => { + if (laps.length < 2) return null; + const deltas: number[] = []; + for (let i = 1; i < laps.length; i++) { + deltas.push(laps[i].tMs - laps[i - 1].tMs); + } + return deltas.reduce((a, b) => a + b, 0) / deltas.length; + }, [laps]); + + const elapsedMs = + state.eventStartMs != null ? now - state.eventStartMs : null; + + // Chart data: minutes since event start, clipped to the 0–35 window. + const chartData = useMemo(() => { + if (state.eventStartMs == null) return []; + const startMs = state.eventStartMs; + return pairsRows + .map((r) => { + const raw = r.produced_at; + const t = + typeof raw === "string" + ? new Date(raw).getTime() + : typeof raw === "number" + ? raw + : NaN; + const v = r[MINV_SIGNAL]; + const temp = r[MAXT_SIGNAL]; + return { + minute: (t - startMs) / 60_000, + voltage: typeof v === "number" && Number.isFinite(v) ? v : null, + temp: typeof temp === "number" && Number.isFinite(temp) ? temp : null, + }; + }) + .filter((d) => d.minute >= 0 && d.minute <= ENDURANCE_MIN); + }, [pairsRows, state.eventStartMs]); + + const vReg = useMemo( + () => + linearRegression( + chartData + .filter((d) => d.voltage != null) + .map((d) => ({ x: d.minute, y: d.voltage as number })), + ), + [chartData], + ); + const tReg = useMemo( + () => + linearRegression( + chartData + .filter((d) => d.temp != null) + .map((d) => ({ x: d.minute, y: d.temp as number })), + ), + [chartData], + ); + + const lastX = chartData.length ? chartData[chartData.length - 1].minute : 0; + const vSeg = projectionSegment( + vReg, + lastX, + thresholds.minCellVFloor, + ENDURANCE_MIN, + ); + const tProjection = projectionSegment( + tReg, + lastX, + thresholds.tempMaxC, + ENDURANCE_MIN, + ); + + const voltages = chartData + .map((d) => d.voltage) + .filter((v): v is number => v != null); + const temps = chartData + .map((d) => d.temp) + .filter((v): v is number => v != null); + + const vDomain: [number, number] = voltages.length + ? [ + Math.min(thresholds.minCellVFloor, ...voltages) - 0.1, + Math.max(...voltages) + 0.1, + ] + : [thresholds.minCellVFloor - 0.5, thresholds.minCellVFloor + 1.5]; + const tDomain: [number, number] = temps.length + ? [Math.min(...temps) - 2, Math.max(thresholds.tempMaxC + 5, ...temps) + 2] + : [20, thresholds.tempMaxC + 5]; + + const vAt35 = vReg ? vReg.slope * 35 + vReg.intercept : null; + const tAt35 = tReg ? tReg.slope * 35 + tReg.intercept : null; + const socTimeReg = useMemo( + () => + linearRegression( + laps.map((l) => ({ x: l.elapsedMs / 60_000, y: l.soc })), + ), + [laps], + ); + const socAt35 = socTimeReg + ? socTimeReg.slope * 35 + socTimeReg.intercept + : null; + + const pct = (curr: number, prev: number): number | null => + Number.isFinite(curr) && Number.isFinite(prev) && prev !== 0 + ? ((curr - prev) / Math.abs(prev)) * 100 + : null; + + return ( + + + + {/* Header: title, connection, live readout, controls */} +
+
+ +

Battery Endurance Tracker

+ + {connected ? ( + + ) : ( + + )} + {connected ? "Live" : "Offline"} + + {elapsedMs != null && ( + + {formatElapsed(elapsedMs)} / {ENDURANCE_MIN}:00 + + )} +
+ +
+ {state.eventStartMs == null && ( + + )} + + + {confirmReset ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* Live readout + projections */} +
+ + + + } + label={`to ${thresholds.tempMaxC}°C`} + laps={lapsUntilTemp} + rate={ + tRate == null + ? "—" + : `${tRate >= 0 ? "+" : ""}${tRate.toFixed(2)}°C/lap` + } + /> + } + label={`to ${thresholds.socLowPct}% SOC`} + laps={lapsUntilLowSoc} + rate={socRate == null ? "—" : `${socRate.toFixed(2)}%/lap`} + /> + } + label={`to ${thresholds.socEmptyPct}% SOC`} + laps={lapsUntilEmptySoc} + rate={socRate == null ? "—" : `${socRate.toFixed(2)}%/lap`} + /> +
+ + {/* Graph */} +
+ {state.eventStartMs == null ? ( +
+ Press “Start Event” to begin plotting voltage & temperature. +
+ ) : ( +
+ + + + `${v}m`} + height={24} + /> + v.toFixed(2)} + stroke={VOLTAGE_COLOR} + /> + v.toFixed(0)} + stroke={TEMP_COLOR} + /> + + typeof value === "number" + ? `${value.toFixed(1)} min` + : String(value) + } + formatter={(value, name) => { + const num = + typeof value === "number" ? value : Number(value); + const unit = name === "temp" ? "°C" : "V"; + return [ + `${num.toFixed(name === "temp" ? 1 : 3)} ${unit}`, + name === "temp" ? "Max cell T" : "Min cell V", + ]; + }} + /> + } + /> + } /> + + + + + + {vSeg && ( + + )} + {tProjection && ( + + )} + + + + + +
+ )} +
+ + {/* Statistics + thresholds */} +
+ + + + + + + +
+ +
+ setThreshold("tempMaxC", v)} + /> + setThreshold("socLowPct", v)} + /> + setThreshold("minCellVFloor", v)} + /> +
+ + {/* Lap table */} +
+ + + + Lap + Time + SOC % + ΔSOC + Min V + ΔV + Max °C + ΔT + + + + + {laps.length === 0 ? ( + + + No laps yet — press “Lap +1” to capture the current values. + + + ) : ( + laps.map((lap, i) => { + const prev = i > 0 ? laps[i - 1] : null; + const dSoc = prev ? lap.soc - prev.soc : null; + const dV = prev ? lap.minV - prev.minV : null; + const dT = prev ? lap.maxT - prev.maxT : null; + return ( + + + {lap.lap} + {lap.stale && ( + + * + + )} + + + {formatElapsed(lap.elapsedMs)} + + + {fmt(lap.soc, 1)} + + + + + + {fmt(lap.minV, 3)} + + + + + + {fmt(lap.maxT, 1)} + + + + + + + + + ); + }) + )} + +
+
+
+ ); +} + +// Local threshold field: edits a draft string, commits the parsed number on +// blur / Enter so typing intermediate values doesn't thrash persisted state. +function ThresholdInput({ + label, + value, + step, + onCommit, +}: { + label: string; + value: number; + step: string; + onCommit: (raw: string) => void; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => { + setDraft(String(value)); + }, [value]); + return ( + + ); +} diff --git a/dashboard/src/components/widgets/registry.tsx b/dashboard/src/components/widgets/registry.tsx index 6729a6a0..d722611c 100644 --- a/dashboard/src/components/widgets/registry.tsx +++ b/dashboard/src/components/widgets/registry.tsx @@ -3,6 +3,7 @@ import AccelerometerWidget from "@/components/widgets/gr24/AccelerometerWidget"; import { Activity, BatteryFull, + BatteryWarning, Bug, CirclePlus, Cpu, @@ -18,6 +19,7 @@ import Gr26PedalsWidget from "@/components/widgets/gr26/live/PedalsWidget"; import Gr26PedalBarWidget from "@/components/widgets/gr26/live/PedalBarWidget"; import Gr26EcuDebugWidget from "@/components/widgets/gr26/live/EcuDebugWidget"; import Gr26BcuDebugWidget from "@/components/widgets/gr26/live/BcuDebugWidget"; +import BatteryEnduranceWidget from "@/components/widgets/gr26/live/BatteryEnduranceWidget"; import Gr26DtiDebugWidget from "@/components/widgets/gr26/live/DtiDebugWidget"; import Gr26InverterDebugWidget from "@/components/widgets/gr26/live/InverterDebugWidget"; import DgpsDebugWidget from "@/components/widgets/gr26/live/DgpsDebugWidget"; @@ -153,6 +155,16 @@ export const gr26_registry = { }, ], BCU: [ + { + id: "battery-endurance", + name: "Battery Endurance Tracker", + description: + "Lap-by-lap SOC, min cell voltage & max cell temp with boundary projections and a 0–35 min voltage/temperature graph. Persists across refresh and token expiry.", + component: BatteryEnduranceWidget, + icon: BatteryWarning, + span: 12, + preview: "/widgets/gr26/battery-endurance.png", + }, { id: "bms-cells", name: "BMS Cells", diff --git a/dashboard/src/lib/__tests__/batteryEndurance.test.ts b/dashboard/src/lib/__tests__/batteryEndurance.test.ts new file mode 100644 index 00000000..3a29925b --- /dev/null +++ b/dashboard/src/lib/__tests__/batteryEndurance.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { + crossX, + linearRegression, + meanPerLapDelta, + projectLapsUntil, + summarize, +} from "@/lib/batteryEndurance"; + +describe("linearRegression", () => { + it("returns null with fewer than two points", () => { + expect(linearRegression([])).toBeNull(); + expect(linearRegression([{ x: 1, y: 1 }])).toBeNull(); + }); + + it("fits a perfect line exactly", () => { + const reg = linearRegression([ + { x: 0, y: 2 }, + { x: 1, y: 4 }, + { x: 2, y: 6 }, + ]); + expect(reg).not.toBeNull(); + expect(reg!.slope).toBeCloseTo(2, 10); + expect(reg!.intercept).toBeCloseTo(2, 10); + }); + + it("returns null when x has zero variance", () => { + expect( + linearRegression([ + { x: 5, y: 1 }, + { x: 5, y: 2 }, + ]), + ).toBeNull(); + }); +}); + +describe("crossX", () => { + it("solves for x at a boundary", () => { + const reg = { slope: 2, intercept: 2, n: 3 }; + expect(crossX(reg, 10)).toBeCloseTo(4, 10); // 2x + 2 = 10 → x = 4 + }); + + it("returns null for a flat line", () => { + expect(crossX({ slope: 0, intercept: 5, n: 3 }, 10)).toBeNull(); + }); +}); + +describe("meanPerLapDelta", () => { + it("averages the trailing window of consecutive deltas", () => { + // deltas: -2, -3, -5 ; last 2 → (-3 + -5)/2 = -4 + expect(meanPerLapDelta([100, 98, 95, 90], 2)).toBeCloseTo(-4, 10); + }); + + it("returns null with insufficient data", () => { + expect(meanPerLapDelta([42])).toBeNull(); + }); +}); + +describe("projectLapsUntil", () => { + it("projects laps to a falling SOC boundary", () => { + // SOC dropping 5%/lap, currently 40, boundary 20 → 4 laps + const laps = [55, 50, 45, 40]; + expect(projectLapsUntil(laps, 20, "down")).toBeCloseTo(4, 6); + }); + + it("projects laps to a rising temperature boundary", () => { + // temp rising 4°C/lap, currently 48, boundary 60 → 3 laps + const laps = [40, 44, 48]; + expect(projectLapsUntil(laps, 60, "up")).toBeCloseTo(3, 6); + }); + + it("returns 0 once the boundary is already reached", () => { + expect(projectLapsUntil([30, 25, 18], 20, "down")).toBe(0); + expect(projectLapsUntil([55, 60, 62], 60, "up")).toBe(0); + }); + + it("returns null when the trend moves away from the boundary", () => { + expect(projectLapsUntil([30, 35, 40], 20, "down")).toBeNull(); // SOC rising + expect(projectLapsUntil([60, 55, 50], 60, "up")).toBeNull(); // temp falling + }); + + it("returns null without enough laps", () => { + expect(projectLapsUntil([40], 20, "down")).toBeNull(); + }); +}); + +describe("summarize", () => { + it("computes min/max/avg over finite values", () => { + const s = summarize([10, 20, 30]); + expect(s).toEqual({ min: 10, max: 30, avg: 20 }); + }); + + it("ignores non-finite values and returns null when empty", () => { + expect(summarize([NaN, Infinity])).toBeNull(); + const s = summarize([NaN, 4, 6]); + expect(s).toEqual({ min: 4, max: 6, avg: 5 }); + }); +}); diff --git a/dashboard/src/lib/batteryEndurance.ts b/dashboard/src/lib/batteryEndurance.ts new file mode 100644 index 00000000..0b4c5ca3 --- /dev/null +++ b/dashboard/src/lib/batteryEndurance.ts @@ -0,0 +1,175 @@ +// State, persistence, and projection math for the Battery Endurance Tracker +// widget. Kept free of React so the math is unit-testable in isolation. + +export interface LapRow { + lap: number; // 1-based lap number + tMs: number; // wall-clock time of the snapshot (Date.now()) + elapsedMs: number; // tMs - eventStartMs + soc: number; // bcu_accumulator_soc value at snapshot (%) + minV: number; // bcu_min_cell_voltage value at snapshot (V) + maxT: number; // bcu_max_cell_temp value at snapshot (°C) + stale: boolean; // live sample was missing/old when captured +} + +export interface Thresholds { + tempMaxC: number; // upper temperature boundary + socLowPct: number; // low-SOC warning boundary + socEmptyPct: number; // empty-SOC boundary + minCellVFloor: number; // lower cell-voltage boundary (graph projection) +} + +export const DEFAULT_THRESHOLDS: Thresholds = { + tempMaxC: 60, + socLowPct: 20, + socEmptyPct: 0, + minCellVFloor: 3.0, +}; + +export interface EnduranceState { + eventStartMs: number | null; + laps: LapRow[]; + thresholds: Thresholds; +} + +export const initEnduranceState = (): EnduranceState => ({ + eventStartMs: null, + laps: [], + thresholds: { ...DEFAULT_THRESHOLDS }, +}); + +const KEY_PREFIX = "battery_endurance_v1_"; + +export const enduranceKey = (vehicleId: string): string => + `${KEY_PREFIX}${vehicleId}`; + +export function loadEndurance(vehicleId: string): EnduranceState { + try { + const saved = localStorage.getItem(enduranceKey(vehicleId)); + if (!saved) return initEnduranceState(); + const parsed = JSON.parse(saved) as Partial; + return { + eventStartMs: + typeof parsed.eventStartMs === "number" ? parsed.eventStartMs : null, + laps: Array.isArray(parsed.laps) ? parsed.laps : [], + thresholds: { ...DEFAULT_THRESHOLDS, ...(parsed.thresholds ?? {}) }, + }; + } catch { + return initEnduranceState(); + } +} + +export function saveEndurance(vehicleId: string, state: EnduranceState): void { + try { + localStorage.setItem(enduranceKey(vehicleId), JSON.stringify(state)); + } catch { + // Quota or serialization failure — non-fatal, in-memory state stands. + } +} + +export interface Regression { + slope: number; + intercept: number; + n: number; +} + +// Ordinary least-squares fit of y = slope·x + intercept. Returns null when +// there are fewer than two points or x has zero variance (vertical line). +export function linearRegression( + points: { x: number; y: number }[], +): Regression | null { + const pts = points.filter( + (p) => Number.isFinite(p.x) && Number.isFinite(p.y), + ); + const n = pts.length; + if (n < 2) return null; + let sx = 0; + let sy = 0; + let sxx = 0; + let sxy = 0; + for (const p of pts) { + sx += p.x; + sy += p.y; + sxx += p.x * p.x; + sxy += p.x * p.y; + } + const denom = n * sxx - sx * sx; + if (denom === 0) return null; + const slope = (n * sxy - sx * sy) / denom; + const intercept = (sy - slope * sx) / n; + return { slope, intercept, n }; +} + +// x at which the regression line reaches boundaryY. null when the line is flat. +export function crossX(reg: Regression, boundaryY: number): number | null { + if (reg.slope === 0) return null; + return (boundaryY - reg.intercept) / reg.slope; +} + +export type TrendDirection = "up" | "down"; + +// Mean per-lap delta over the trailing window (last k consecutive deltas). +export function meanPerLapDelta(values: number[], k = 3): number | null { + if (values.length < 2) return null; + const deltas: number[] = []; + for (let i = 1; i < values.length; i++) { + const d = values[i] - values[i - 1]; + if (Number.isFinite(d)) deltas.push(d); + } + const recent = deltas.slice(-k); + if (recent.length === 0) return null; + return recent.reduce((a, b) => a + b, 0) / recent.length; +} + +// Laps until `current` reaches `boundary`, from the trailing per-lap rate. +// Returns null when there isn't enough data or the trend moves away from the +// boundary; 0 when the boundary is already reached/crossed. +export function projectLapsUntil( + values: number[], + boundary: number, + direction: TrendDirection, + k = 3, +): number | null { + if (values.length < 2) return null; + const current = values[values.length - 1]; + if (!Number.isFinite(current)) return null; + const rate = meanPerLapDelta(values, k); + if (rate == null || !Number.isFinite(rate)) return null; + + if (direction === "down") { + if (current <= boundary) return 0; + if (rate >= 0) return null; // not falling — never reaches a lower boundary + return (current - boundary) / -rate; + } + if (current >= boundary) return 0; + if (rate <= 0) return null; // not rising — never reaches an upper boundary + return (boundary - current) / rate; +} + +export interface Stats { + min: number; + max: number; + avg: number; +} + +export function summarize(values: number[]): Stats | null { + const finite = values.filter((v) => Number.isFinite(v)); + if (finite.length === 0) return null; + let min = Infinity; + let max = -Infinity; + let sum = 0; + for (const v of finite) { + if (v < min) min = v; + if (v > max) max = v; + sum += v; + } + return { min, max, avg: sum / finite.length }; +} + +// "m:ss" from a duration in ms. Negative/NaN clamp to 0:00. +export function formatElapsed(ms: number): string { + const safe = Number.isFinite(ms) && ms > 0 ? ms : 0; + const totalSec = Math.floor(safe / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +}