← 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
}