From f26154f376cce45415568e688c564c286171b0bc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 7 Oct 2026 14:42:23 +0200 Subject: [PATCH] fix(planner): count a departure miss only above half a minimum run Energyplan now rounds an EV need below one minimum charging run to the nearest whole run (srcfl/energyplan#62). A need of at most half a run counts as met, so the plan starts no charger for it. ValidatePlan counted every miss above 1 Wh, so the plan view would show that need as a missed departure. departureMissWh computes the same threshold from what Core sends: half the energy of the smallest allowed step for MinChargeSeconds at the charge efficiency (0.9 when unset), and at least 1 Wh. For a 4140 W step and 300 s this is 155.25 Wh. A car that is charging at plan start keeps 1 Wh, since the worker then keeps its exact target. The threshold applies to every plan ValidatePlan replays, Core DP fallback included. docs/energyplan-contract.md states the rule. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-need-half-run.md | 5 +++ docs/energyplan-contract.md | 7 +++ go/internal/mpc/charging_periods.go | 22 +++++++++ go/internal/mpc/charging_periods_test.go | 57 ++++++++++++++++++++++++ go/internal/mpc/external_optimizer.go | 2 +- 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/ev-need-half-run.md diff --git a/.changeset/ev-need-half-run.md b/.changeset/ev-need-half-run.md new file mode 100644 index 000000000..67af29f1b --- /dev/null +++ b/.changeset/ev-need-half-run.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show an EV departure miss only when it is more than half of one minimum charging run, about 155 Wh for a 4.14 kW charger. Energyplan treats a smaller need as met instead of starting the charger for it. A car that is charging when the plan starts still counts a miss above 1 Wh. diff --git a/docs/energyplan-contract.md b/docs/energyplan-contract.md index 7de8b5ef9..e6dddfd4f 100644 --- a/docs/energyplan-contract.md +++ b/docs/energyplan-contract.md @@ -131,6 +131,13 @@ initial charging duration. These are preferences, not safety limits. A final top-up may be short. Stale or unknown connection evidence cannot establish an uninterrupted run; start preferences do not enter the electricity bill. +A need of at most 1 Wh counts as met. With `charging_periods`, a need below +one minimum run rounds to the nearest whole run. One run is the smallest +allowed step for `min_charge_seconds` at `charge_efficiency`. Up to half a run +counts as met; more becomes one full run, capped at `max_energy_wh`. A car +charging at plan start keeps its exact target. Core counts a departure miss +by the same rule. `flex_shortfall_wh` still measures the requested target. + ## Result, cost and failure `ok: true` means the worker returned a candidate. Core checks response identity, diff --git a/go/internal/mpc/charging_periods.go b/go/internal/mpc/charging_periods.go index 164c9aca9..92a840d75 100644 --- a/go/internal/mpc/charging_periods.go +++ b/go/internal/mpc/charging_periods.go @@ -19,6 +19,28 @@ func DefaultChargingPeriods(charging bool, seconds float64) ChargingPeriods { InitialCharging: charging, InitialChargeSeconds: min(300, max(0, seconds))} } +// departureMissWh is the least missing energy that counts as a missed +// departure. It follows Energyplan's need rounding (worker/src/fleet.rs, +// model_target): a need of at most 1 Wh, or of at most half of one minimum +// charging run, counts as met. One run stores the smallest allowed step for +// MinChargeSeconds at the charge efficiency Core sends. A car charging at +// plan start keeps the 1 Wh rule: the worker finishes its run exactly. +func (l *LoadpointSpec) departureMissWh() float64 { + if l.Charging.InitialCharging || l.Charging.MinChargeSeconds <= 0 { + return 1 + } + efficiency := l.ChargeEfficiency + if efficiency <= 0 { + efficiency = 0.9 + } + for _, step := range l.normalizedSteps() { + if step > 0 { + return max(1, step*l.Charging.MinChargeSeconds/3600*efficiency/2) + } + } + return 1 +} + func (o *EnergyplanOptimizer) prepareEnergyplanRequest(ctx context.Context, request *externalRequest, p Params) error { // Negotiate against the running worker on every request. Restart or a // binary rollback must not inherit a feature learned from another process. diff --git a/go/internal/mpc/charging_periods_test.go b/go/internal/mpc/charging_periods_test.go index 5a2dedcd0..17eda1789 100644 --- a/go/internal/mpc/charging_periods_test.go +++ b/go/internal/mpc/charging_periods_test.go @@ -93,3 +93,60 @@ func TestNativeChargingPeriodsContinueMeasuredRun(t *testing.T) { t.Fatalf("preference changed the bill: %g", plan.TotalCostOre) } } + +func TestDepartureMissFollowsEnergyplanNeedRounding(t *testing.T) { + // The home box: one 300 s run at 4140 W stores 310.5 Wh at 0.9. + lp := &LoadpointSpec{MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 6900, 11000}, ChargeEfficiency: .9, + Charging: DefaultChargingPeriods(false, 0)} + if got := lp.departureMissWh(); math.Abs(got-155.25) > 1e-9 { + t.Fatalf("half a run %.6f, want 155.25", got) + } + // Core sends 0.9 for an unset efficiency, and MinChargeW as the step. + lp.ChargeEfficiency, lp.AllowedStepsW, lp.MinChargeW = 0, nil, 4140 + if got := lp.departureMissWh(); math.Abs(got-155.25) > 1e-9 { + t.Fatalf("defaults give %.6f, want 155.25", got) + } + for name, lp := range map[string]*LoadpointSpec{ + "no minimum run": {MaxChargeW: 11000, Charging: ChargingPeriods{StartCostOre: 5}}, + "charging at plan start": {MaxChargeW: 11000, Charging: DefaultChargingPeriods(true, 120)}, + "no charging step": {Charging: DefaultChargingPeriods(false, 0)}, + "run below 2 Wh": {MaxChargeW: 10, ChargeEfficiency: 1, Charging: DefaultChargingPeriods(false, 0)}, + } { + if got := lp.departureMissWh(); got != 1 { + t.Fatalf("%s: %.6f, want the 1 Wh rule", name, got) + } + } +} + +func TestValidatePlanCountsADepartureMissOnlyAboveHalfARun(t *testing.T) { + // One run stores 3600 W * 300 s at efficiency 1 = 300 Wh. The car cannot + // charge, so it misses its whole need; 150 Wh is 1/512 of 76800 Wh. + for _, tc := range []struct { + name string + need float64 + charging ChargingPeriods + counted bool + }{ + {"below half a run", 149, DefaultChargingPeriods(false, 0), false}, + {"at half a run", 150, DefaultChargingPeriods(false, 0), false}, + {"above half a run", 151, DefaultChargingPeriods(false, 0), true}, + {"charging at plan start", 2, DefaultChargingPeriods(true, 120), true}, + {"no minimum run", 2, ChargingPeriods{}, true}, + {"no minimum run, 1 Wh", 1, ChargingPeriods{}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + slots, p := nearTargetReserveFixture() + p.Loadpoint = &LoadpointSpec{ID: "easee", CapacityWh: 76800, InitialSoC: .75 - tc.need/76800, SoCMax: .75, + Levels: 11, PluggedIn: true, TargetSoC: .75, TargetSlotIdx: 3, MaxChargeW: 11000, + AllowedStepsW: []float64{0, 3600, 11000}, ChargeEfficiency: 1, SurplusOnly: true, Charging: tc.charging} + plan := coreReservePlan(context.Background(), slots, p) + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + missing, counted := plan.LoadpointShortfallWh["easee"] + if counted != tc.counted || (counted && math.Abs(missing-tc.need) > 1e-6) { + t.Fatalf("need %.3f Wh: shortfall %v", tc.need, plan.LoadpointShortfallWh) + } + }) + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index f89a64017..cdd2e3e98 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -562,7 +562,7 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { // Only a departure inside the horizon can be missed; a later one // is planned when its prices arrive. if lp.TargetSoC > 0 && i == lp.TargetSlotIdx { - if missing := max(0, lp.TargetSoC-evSoC[lp.ID]) * lp.CapacityWh; missing > 1 { + if missing := max(0, lp.TargetSoC-evSoC[lp.ID]) * lp.CapacityWh; missing > lp.departureMissWh() { deadlineShortfall[lp.ID] = missing } }