← 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)),
  };
}