← back to Charge And Explore
backend/test/charge-estimate.test.ts
93 lines
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
estimateChargeMinutes,
estimateChargeRange,
blendActiveEstimate,
defaultChargeCurve,
} from "../src/core/charge-estimate.ts";
import type { ChargeEstimateInput } from "../src/core/types.ts";
const baseInput = (arrival: number, target: number): ChargeEstimateInput => ({
arrivalSocPercent: arrival,
targetSocPercent: target,
usableBatteryKWh: 75,
vehicleMaxDcKw: 250,
stationMaxKw: 250,
chargingCurve: defaultChargeCurve,
environmentFactor: 1,
conversionEfficiency: 0.92,
connectionOverheadMinutes: 2,
});
describe("estimateChargeMinutes", () => {
it("returns zero when target is not above arrival SOC", () => {
assert.equal(estimateChargeMinutes(baseInput(80, 70)), 0);
});
it("takes longer to charge 70–90 than 50–70 (curve tapers)", () => {
const lower = estimateChargeMinutes(baseInput(50, 70));
const upper = estimateChargeMinutes(baseInput(70, 90));
assert.ok(upper > lower, `expected 70-90 (${upper}) > 50-70 (${lower})`);
});
it("respects the lower station power cap", () => {
const fast = estimateChargeMinutes({ ...baseInput(20, 70), stationMaxKw: 250 });
const slow = estimateChargeMinutes({ ...baseInput(20, 70), stationMaxKw: 100 });
assert.ok(slow > fast, `expected 100 kW (${slow}) slower than 250 kW (${fast})`);
});
it("throws when usable battery is not positive", () => {
assert.throws(() => estimateChargeMinutes({ ...baseInput(20, 80), usableBatteryKWh: 0 }));
});
it("cold weather increases charge time", () => {
const warm = estimateChargeMinutes({ ...baseInput(20, 80), environmentFactor: 1.0 });
const cold = estimateChargeMinutes({ ...baseInput(20, 80), environmentFactor: 0.6 });
assert.ok(cold > warm);
});
it("is monotonic in target SOC", () => {
let prev = 0;
for (let target = 25; target <= 100; target += 5) {
const m = estimateChargeMinutes(baseInput(20, target));
assert.ok(m >= prev, `non-monotonic at target ${target}: ${m} < ${prev}`);
prev = m;
}
});
it("handles a curve gap without producing Infinity/NaN", () => {
const withGap = estimateChargeMinutes({
...baseInput(0, 100),
chargingCurve: [
{ minSoc: 0, maxSoc: 30, powerMultiplier: 1 },
// deliberate gap 30–60 (no band)
{ minSoc: 60, maxSoc: 100, powerMultiplier: 0.4 },
],
});
assert.ok(Number.isFinite(withGap) && withGap > 0);
});
});
describe("estimateChargeRange", () => {
it("brackets the midpoint low <= mid <= high", () => {
const r = estimateChargeRange(baseInput(20, 80));
assert.ok(r.low <= r.mid && r.mid <= r.high);
});
});
describe("blendActiveEstimate", () => {
it("falls back to the model with too few telemetry samples", () => {
assert.equal(blendActiveEstimate(30, 12, 1), 30);
});
it("ignores negative telemetry", () => {
assert.equal(blendActiveEstimate(30, -5, 5), 30);
});
it("weights telemetry 75% once stable", () => {
// 0.75*20 + 0.25*40 = 25
assert.equal(blendActiveEstimate(40, 20, 3), 25);
});
});