← back to Charge And Explore
backend/src/core/charging-sessions.ts
104 lines
// Charging-session detection over the recorded SOC history (Dust2026:
// "charging session view — graph each session's kW curve and $/kWh estimate").
// Pure functions — no I/O — so the grouping/energy math is unit-testable.
export interface SessionPoint {
t: number; // epoch ms
soc: number; // battery %
charging: boolean;
kw?: number | null; // charger_power at read time (recorded while charging)
ea?: number | null; // charge_energy_added kWh (Tesla resets it per session)
}
export interface CurvePoint { t: number; kw: number }
export interface ChargingSession {
startT: number;
endT: number;
durationMin: number;
socFrom: number;
socTo: number;
energyKWh: number | null;
// "reported" = from charge_energy_added; "soc-estimate" = ΔSOC × battery size
energySource: "reported" | "soc-estimate" | null;
peakKw: number | null;
avgKw: number | null;
curve: CurvePoint[];
}
export interface DetectOptions {
// Battery capacity for the ΔSOC fallback when charge_energy_added wasn't sampled.
batteryKWh?: number | null;
// Two charging points further apart than this are separate sessions.
maxGapMs?: number;
}
const round1 = (x: number) => Math.round(x * 10) / 10;
// Session energy from charge_energy_added samples. Tesla resets the counter when
// a new physical session starts, so if a detected run spans a reset (counter
// drops), summing only the positive deltas — seeded with the first sample, which
// covers charging that happened before we first read the car — stays correct.
function energyFromEa(pts: SessionPoint[]): number | null {
const eas = pts.filter((p) => typeof p.ea === "number" && p.ea >= 0).map((p) => p.ea as number);
if (!eas.length) return null;
let total = eas[0] as number;
for (let i = 1; i < eas.length; i++) total += Math.max(0, (eas[i] as number) - (eas[i - 1] as number));
return round1(total);
}
// Instantaneous kW per point: prefer the recorded charger_power; otherwise derive
// segment power from the energy (or SOC) climbed since the previous point.
function buildCurve(pts: SessionPoint[], batteryKWh: number | null | undefined): CurvePoint[] {
const curve: CurvePoint[] = [];
for (let i = 0; i < pts.length; i++) {
const p = pts[i]!;
if (typeof p.kw === "number" && p.kw >= 0) { curve.push({ t: p.t, kw: round1(p.kw) }); continue; }
const prev = pts[i - 1];
if (!prev) continue;
const hours = (p.t - prev.t) / 3_600_000;
if (hours <= 0) continue;
let addedKWh: number | null = null;
if (typeof p.ea === "number" && typeof prev.ea === "number" && p.ea >= prev.ea) addedKWh = p.ea - prev.ea;
else if (batteryKWh && p.soc > prev.soc) addedKWh = ((p.soc - prev.soc) / 100) * batteryKWh;
if (addedKWh != null) curve.push({ t: p.t, kw: round1(addedKWh / hours) });
}
return curve;
}
function finalizeSession(pts: SessionPoint[], opts: DetectOptions): ChargingSession {
const first = pts[0]!, last = pts[pts.length - 1]!;
const startT = first.t, endT = last.t;
const durationMin = Math.round((endT - startT) / 60_000);
const socFrom = first.soc, socTo = last.soc;
let energyKWh = energyFromEa(pts);
let energySource: ChargingSession["energySource"] = energyKWh != null ? "reported" : null;
if (energyKWh == null && opts.batteryKWh && socTo > socFrom) {
energyKWh = round1(((socTo - socFrom) / 100) * opts.batteryKWh);
energySource = "soc-estimate";
}
const curve = buildCurve(pts, opts.batteryKWh);
const peakKw = curve.length ? round1(Math.max(...curve.map((c) => c.kw))) : null;
const hours = (endT - startT) / 3_600_000;
const avgKw = energyKWh != null && hours >= 0.05 ? round1(energyKWh / hours) : null;
return { startT, endT, durationMin, socFrom, socTo, energyKWh, energySource, peakKw, avgKw, curve };
}
// Group the history's charging points into sessions: a run of consecutive
// charging points is one session; any non-charging point in between, or a gap
// wider than maxGapMs (default 3h — sampler cadence is ≤1h), splits the run.
export function detectChargingSessions(points: SessionPoint[], opts: DetectOptions = {}): ChargingSession[] {
const maxGap = opts.maxGapMs ?? 3 * 3_600_000;
const sorted = [...points].sort((a, b) => a.t - b.t);
const sessions: ChargingSession[] = [];
let run: SessionPoint[] = [];
for (const p of sorted) {
if (p.charging) {
if (run.length && p.t - run[run.length - 1]!.t > maxGap) { sessions.push(finalizeSession(run, opts)); run = []; }
run.push(p);
} else if (run.length) { sessions.push(finalizeSession(run, opts)); run = []; }
}
if (run.length) sessions.push(finalizeSession(run, opts));
return sessions.reverse(); // newest first
}