Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-need-half-run.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/energyplan-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions go/internal/mpc/charging_periods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions go/internal/mpc/charging_periods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion go/internal/mpc/external_optimizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the half-run tolerance only to initially small needs

When an EV starts with a normal multi-run need but the plan finishes slightly short鈥攆or example, a 10 kWh need with 100 Wh still missing鈥擡nergyplan never rounds that original need to zero, yet this comparison applies the half-run threshold to the final residual and removes the real departure shortfall. Determine whether the initial target - initial energy qualified for rounding; otherwise retain the 1 Wh threshold so an unmet requested target remains visible.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 馃憤聽/ 馃憥.

deadlineShortfall[lp.ID] = missing
}
}
Expand Down
Loading