← back to Charge And Explore
backend/src/core/parked-periods.ts
112 lines
// Parked-period detection over the recorded SOC history (Dust2026: "stats when
// parked"). A parked period is a run of samples where the car is NOT charging
// and the odometer never moved — the odometer check is what separates real
// phantom drain from a drive between two sparse samples. Unlike charging
// sessions, parked runs are NOT split on time gaps: a sleeping car returns 408
// and produces no samples for hours, and that silent gap IS the parked time —
// bridging it is safe precisely because the odometer proves the car sat still.
// Pure functions — no I/O — so the grouping/drain math is unit-testable.
export interface ParkedPoint {
t: number; // epoch ms
soc: number; // battery %
range?: number | null; // rated range, miles
charging: boolean;
o?: number | null; // odometer, integer miles (absent on pre-feature points)
s?: boolean | null; // sentry mode at read time
}
export interface ParkedPeriod {
startT: number;
endT: number;
hours: number;
socFrom: number;
socTo: number;
socDrop: number; // ≥0; SOC regained while parked (sun/precondition) clamps to 0
rangeFrom: number | null;
rangeTo: number | null;
rangeLostMiles: number | null;
drainPctPerDay: number | null; // null when the period is too short to be meaningful
sentryOn: boolean; // sentry seen on at any sample in the period
odometerMiles: number;
samples: number;
}
export interface ParkedSummary {
periods: number;
totalHours: number;
totalSocDrop: number;
// Time-weighted: total drop ÷ total parked days (not a mean of per-period rates,
// which would let a short noisy period swamp a long overnight one).
avgDrainPctPerDay: number | null;
totalRangeLostMiles: number | null;
sentryHours: number;
}
export interface ParkedDetectOptions {
// Periods shorter than this are noise (a stoplight, a quick errand) — skip.
minDurationMs?: number;
}
const round1 = (x: number) => Math.round(x * 10) / 10;
function finalizePeriod(pts: ParkedPoint[]): ParkedPeriod {
const first = pts[0]!, last = pts[pts.length - 1]!;
const hours = (last.t - first.t) / 3_600_000;
const socDrop = Math.max(0, first.soc - last.soc);
const haveRange = typeof first.range === "number" && typeof last.range === "number";
const rangeLost = haveRange ? Math.max(0, (first.range as number) - (last.range as number)) : null;
// Below ~2h a 1% SOC step (Tesla reports integers) extrapolates to a silly
// per-day rate, so the rate needs a minimum observation window.
const drainPctPerDay = hours >= 2 ? round1(socDrop / (hours / 24)) : null;
return {
startT: first.t, endT: last.t, hours: round1(hours),
socFrom: first.soc, socTo: last.soc, socDrop,
rangeFrom: first.range ?? null, rangeTo: last.range ?? null,
rangeLostMiles: rangeLost,
drainPctPerDay,
sentryOn: pts.some((p) => p.s === true),
odometerMiles: first.o as number,
samples: pts.length,
};
}
// Group history points into parked periods: a run of consecutive non-charging
// points sharing one odometer reading. Points without an odometer (recorded
// before the field existed, or a read where vehicle_state was missing) can't
// prove the car sat still, so they end the current run and never start one.
// Odometer is integer-rounded upstream — a sub-half-mile drive between samples
// can slip through; that error is bounded and negligible for drain math.
export function detectParkedPeriods(points: ParkedPoint[], opts: ParkedDetectOptions = {}): ParkedPeriod[] {
const minDuration = opts.minDurationMs ?? 45 * 60_000;
const sorted = [...points].sort((a, b) => a.t - b.t);
const periods: ParkedPeriod[] = [];
let run: ParkedPoint[] = [];
const flush = () => {
if (run.length >= 2 && run[run.length - 1]!.t - run[0]!.t >= minDuration) periods.push(finalizePeriod(run));
run = [];
};
for (const p of sorted) {
const parkedCandidate = !p.charging && typeof p.o === "number";
if (!parkedCandidate) { flush(); continue; }
if (run.length && p.o !== run[0]!.o) flush();
run.push(p);
}
flush();
return periods.reverse(); // newest first
}
export function summarizeParked(periods: ParkedPeriod[]): ParkedSummary {
const totalHours = periods.reduce((a, p) => a + p.hours, 0);
const totalSocDrop = periods.reduce((a, p) => a + p.socDrop, 0);
const ranged = periods.filter((p) => p.rangeLostMiles != null);
return {
periods: periods.length,
totalHours: round1(totalHours),
totalSocDrop,
avgDrainPctPerDay: totalHours >= 2 ? round1(totalSocDrop / (totalHours / 24)) : null,
totalRangeLostMiles: ranged.length ? round1(ranged.reduce((a, p) => a + (p.rangeLostMiles as number), 0)) : null,
sentryHours: round1(periods.filter((p) => p.sentryOn).reduce((a, p) => a + p.hours, 0)),
};
}