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