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