diff --git a/.changeset/energyplan-ev-budget.md b/.changeset/energyplan-ev-budget.md new file mode 100644 index 00000000..82c6cc59 --- /dev/null +++ b/.changeset/energyplan-ev-budget.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Give the Energyplan planner 1.5 s instead of 0.5 s when a car is plugged in. On a Raspberry Pi 4, 0.5 s stopped just before the planner could improve on its first plan for the car, and the published plan could cost about 11% more than the best one. Battery-only plans keep their 0.5 s budget, and the first slot's remaining time still caps every budget. diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 1f29604d..fb30fcce 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -28,6 +28,10 @@ func NewEnergyplanOptimizer(binary string) (*EnergyplanOptimizer, error) { const ( energyplanSmallBudget = 500 * time.Millisecond + // With a car plugged in, a Raspberry Pi 4 needs about 0.6-0.7 s before + // the planner can improve on its first plan; at 0.5 s it stopped just + // short and published plans about 11% above the proven optimum. + energyplanEVBudget = 1500 * time.Millisecond energyplanFleetBudget = 5 * time.Second // Leave time for ValidatePlan and publication before firstSlotExpired. energyplanPublishMargin = 50 * time.Millisecond @@ -42,6 +46,9 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration { // Core already adjusts PV to one downside horizon. That margin does not // add worker scenarios or change this model's size. budget := energyplanSmallBudget + if len(p.activeLoadpoints()) > 0 { + budget = energyplanEVBudget + } // 1 battery + 1 EV is 5 "assets". A 193-slot villa with demand charges // routes to fleet MILP; 193*6 would leave it on the 500 ms budget. if len(slots)*assets >= 193*5 || p.PVCurtailment.MinW > 0 || len(p.DemandCharges) > 0 { diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 841f1e6d..73a8ef44 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -309,8 +309,8 @@ func TestBatterylessEVBudgetDoesNotInventStorage(t *testing.T) { for i := range horizon { horizon[i] = slots[0] } - if got := energyplanTimeBudget(horizon, p); got != 500*time.Millisecond { - t.Fatalf("batteryless EV budget=%v", got) + if got := energyplanTimeBudget(horizon, p); got != energyplanEVBudget { + t.Fatalf("batteryless EV budget=%v, want %v", got, energyplanEVBudget) } p.CapacityWh = 20000 if got := energyplanTimeBudget(horizon, p); got != 5*time.Second { @@ -488,3 +488,25 @@ func TestEnergyplanLateFleetReplanDoesNotStartDoomedSolve(t *testing.T) { t.Fatalf("wanted previous plan, got %+v", plan) } } + +// A car on a short published-price horizon gets more time than a battery +// alone. A Pi 4 at 0.5 s stopped just before the planner could improve on +// its first plan for the car. +func TestEVGetsLongerBudgetThanBatteryAlone(t *testing.T) { + slots, p := topologyFixture(1, 1) + horizon := make([]Slot, 112) + for i := range horizon { + horizon[i] = slots[0] + } + if got := energyplanTimeBudget(horizon, p); got != energyplanEVBudget { + t.Fatalf("1b+1EV 112-slot budget=%v, want %v", got, energyplanEVBudget) + } + battery, pb := topologyFixture(1, 0) + short := make([]Slot, 112) + for i := range short { + short[i] = battery[0] + } + if got := energyplanTimeBudget(short, pb); got != energyplanSmallBudget { + t.Fatalf("battery-only 112-slot budget=%v, want %v", got, energyplanSmallBudget) + } +}