← back to Charge And Explore

backend/src/core/charge-estimate.ts

98 lines

// Charging-time estimator.
//
// DC charging is nonlinear: delivered power tapers as the pack fills, so a
// naive `energy / maxKw` systematically UNDER-estimates high-SOC sessions.
// We integrate the charging curve band-by-band instead, and always surface a
// confidence RANGE rather than false precision.

import type { ChargeEstimateInput } from "./types.ts";

export const clamp = (value: number, min: number, max: number): number =>
  Math.min(max, Math.max(min, value));

/**
 * Pre-arrival / fallback model estimate, in whole minutes.
 * Integrates each charge-curve band that overlaps [arrival, target] SOC.
 */
export function estimateChargeMinutes(input: ChargeEstimateInput): number {
  const start = clamp(input.arrivalSocPercent, 0, 100);
  const target = clamp(input.targetSocPercent, 0, 100);
  if (target <= start) return 0;
  if (input.usableBatteryKWh <= 0) {
    throw new Error("usableBatteryKWh must be positive");
  }

  const basePowerKw = Math.min(input.vehicleMaxDcKw, input.stationMaxKw);
  const environment = clamp(input.environmentFactor, 0.55, 1.05);
  const efficiency = clamp(input.conversionEfficiency, 0.85, 0.98);

  let totalMinutes = 0;
  for (const band of input.chargingCurve) {
    const lower = Math.max(start, band.minSoc);
    const upper = Math.min(target, band.maxSoc);
    if (upper <= lower) continue;

    const socFraction = (upper - lower) / 100;
    const batteryEnergyKWh = input.usableBatteryKWh * socFraction;
    // Floor effective power at 10 kW so a degenerate/empty band can't
    // blow the estimate up to infinity.
    const effectivePowerKw = Math.max(
      10,
      basePowerKw * clamp(band.powerMultiplier, 0.05, 1) * environment * efficiency,
    );
    totalMinutes += (batteryEnergyKWh / effectivePowerKw) * 60;
  }

  return Math.ceil(totalMinutes + Math.max(0, input.connectionOverheadMinutes));
}

/**
 * A ± confidence band around a model estimate. Callers should show the range
 * ("about 28–36 minutes"), never a single deceptive number.
 */
export function estimateChargeRange(
  input: ChargeEstimateInput,
  spread = 0.15,
): { low: number; mid: number; high: number } {
  const mid = estimateChargeMinutes(input);
  return {
    low: Math.floor(mid * (1 - spread)),
    mid,
    high: Math.ceil(mid * (1 + spread)),
  };
}

/**
 * During an active session, trust Tesla `TimeToFullCharge` telemetry once it
 * is stable, but keep the model as fallback + anomaly detector. Blends 75%
 * telemetry / 25% model once we have >= 2 stable, valid samples.
 */
export function blendActiveEstimate(
  modelMinutes: number,
  telemetryMinutes: number | null,
  stableTelemetrySamples: number,
): number {
  if (
    telemetryMinutes === null ||
    stableTelemetrySamples < 2 ||
    telemetryMinutes < 0
  ) {
    return modelMinutes;
  }
  return Math.round(0.75 * telemetryMinutes + 0.25 * modelMinutes);
}

/**
 * A reasonable default taper curve for a modern ~250 kW-capable pack.
 * MUST be replaced with per-model/per-trim calibrated curves before launch —
 * and never presented as an official manufacturer spec.
 */
export const defaultChargeCurve = [
  { minSoc: 0, maxSoc: 20, powerMultiplier: 1.0 },
  { minSoc: 20, maxSoc: 40, powerMultiplier: 0.92 },
  { minSoc: 40, maxSoc: 55, powerMultiplier: 0.78 },
  { minSoc: 55, maxSoc: 70, powerMultiplier: 0.6 },
  { minSoc: 70, maxSoc: 85, powerMultiplier: 0.4 },
  { minSoc: 85, maxSoc: 100, powerMultiplier: 0.22 },
];