diff --git a/.changeset/ev-deadline-past-horizon.md b/.changeset/ev-deadline-past-horizon.md new file mode 100644 index 000000000..2cb9bc602 --- /dev/null +++ b/.changeset/ev-deadline-past-horizon.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A plugged-in car whose next departure lies past the published prices no longer makes Energyplan reject the plan. The planner puts reachable solar surplus toward that car, as Core DP already did, but a car that leaves before the end of the plan goes first. Before, FTW fell back to Core DP until prices for the departure day arrived or the car was unplugged. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 4d74b2a43..c991564f9 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1651,9 +1651,9 @@ func main() { } // Map target time → slot index using the DP's // actual slot length (hour-of-prices vs. 15-min - // quarters vary by market). Anything past horizon - // gets clamped by the DP itself; negative means - // "no deadline". + // quarters vary by market). Both planners clamp a + // deadline past the horizon to its last slot; + // negative means "no deadline". if slotLenMin <= 0 { slotLenMin = 60 } diff --git a/go/internal/mpc/ev_deadline_horizon_test.go b/go/internal/mpc/ev_deadline_horizon_test.go new file mode 100644 index 000000000..b189471a6 --- /dev/null +++ b/go/internal/mpc/ev_deadline_horizon_test.go @@ -0,0 +1,163 @@ +package mpc + +import ( + "context" + "testing" + "time" +) + +// #1497: after a departure passes while the car is still plugged in, the next +// one lies past the published prices. Both planners work toward it at the +// horizon's last slot; Energyplan used to reject the request instead. +func deadlinePastHorizon() ([]Slot, Params) { + slots := []Slot{ + {StartMs: 0, LenMin: 60, PVW: -6000, LoadW: 500, PriceOre: 100, SpotOre: 30}, + {StartMs: 3600000, LenMin: 60, PVW: -6000, LoadW: 500, PriceOre: 100, SpotOre: 30}, + {StartMs: 7200000, LenMin: 60, LoadW: 500, PriceOre: 200, SpotOre: 60}, + } + p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoC: .5, + SoCMin: .1, SoCMax: .9, ChargeEfficiency: 1, DischargeEfficiency: 1, + Loadpoint: &LoadpointSpec{ID: "car", CapacityWh: 60000, Levels: 61, + SoCMax: 1, InitialSoC: .5, TargetSoC: .8, TargetSlotIdx: 20, PluggedIn: true, + SurplusOnly: true, ChargeEfficiency: 1, MaxChargeW: 11000, AllowedStepsW: []float64{0, 5500, 11000}}} + return slots, p +} + +func TestEVDeadlinePastHorizonRequestUsesLastSlot(t *testing.T) { + slots, p := deadlinePastHorizon() + req := (&ExternalOptimizer{}).buildRequest(slots, p) + if got := req.FlexLoads[0].TargetSlot; got != len(slots)-1 { + t.Fatalf("request deadline = %d, want the last slot %d", got, len(slots)-1) + } +} + +func TestNativeEVDeadlinePastHorizonPlans(t *testing.T) { + slots, p := deadlinePastHorizon() + worker := nativeWorker(t, 500*time.Millisecond) + t.Cleanup(func() { _ = worker.Close() }) + plan, err := worker.Optimize(context.Background(), slots, p) + if err != nil { + t.Fatalf("Energyplan rejected a deadline past the horizon: %v", err) + } + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + if plan.Solver.Fallback || plan.Actions[0].LoadpointW <= 0 || plan.Actions[1].LoadpointW <= 0 { + t.Fatalf("surplus did not go toward the departure: %+v", plan.Actions) + } + // The departure is planned when its prices arrive; until then nothing + // can be missed, so the plan reports no shortfall. + if len(plan.LoadpointShortfallWh) != 0 { + t.Fatalf("a departure past the horizon was reported as missed: %v", plan.LoadpointShortfallWh) + } +} + +// chargerSteps are a charger's 6–16 A steps at the given line voltage sum. +func chargerSteps(volts float64) []float64 { + steps := []float64{0} + for amps := 6.0; amps <= 16; amps++ { + steps = append(steps, amps*volts) + } + return steps +} + +// A car that leaves inside the horizon goes first: a later departure must not +// take the surplus it needs. With the same deadline, the worker gave one hour +// of 5 kW surplus to tomorrow's three-phase car and left today's single-phase +// car 3 kWh short. +func departureAndNextDay() ([]Slot, Params) { + slots := make([]Slot, 64) + for i := range slots { + slots[i] = Slot{StartMs: int64(i) * 900000, LenMin: 15, LoadW: 500, PriceOre: 150, SpotOre: 40} + if i < 4 { + slots[i].PVW = -5500 + } + } + p := Params{Mode: ModeArbitrage, CapacityWh: 10000, InitialSoC: .5, + SoCMin: .1, SoCMax: .9, ChargeEfficiency: 1, DischargeEfficiency: 1, + Loadpoints: []*LoadpointSpec{ + {ID: "today", CapacityWh: 60000, Levels: 61, SoCMax: 1, InitialSoC: .5, TargetSoC: .55, + TargetSlotIdx: 63, PluggedIn: true, SurplusOnly: true, ChargeEfficiency: 1, + MaxChargeW: 3680, AllowedStepsW: chargerSteps(230)}, + {ID: "tomorrow", CapacityWh: 60000, Levels: 61, SoCMax: 1, InitialSoC: .5, TargetSoC: .8, + TargetSlotIdx: 160, PluggedIn: true, SurplusOnly: true, ChargeEfficiency: 1, + MaxChargeW: 11040, AllowedStepsW: chargerSteps(690)}, + }} + return slots, p +} + +func TestEVDeadlineInsideHorizonGoesFirst(t *testing.T) { + slots, p := departureAndNextDay() + req := (&ExternalOptimizer{}).buildRequest(slots, p) + if req.FlexLoads[0].TargetSlot != 63 || req.FlexLoads[1].TargetSlot != -1 { + t.Fatalf("deadlines = %d and %d, want 63 for today's car and none for tomorrow's", + req.FlexLoads[0].TargetSlot, req.FlexLoads[1].TargetSlot) + } +} + +func TestNativeEVDeadlineInsideHorizonGoesFirst(t *testing.T) { + slots, p := departureAndNextDay() + worker := nativeWorker(t, 500*time.Millisecond) + t.Cleanup(func() { _ = worker.Close() }) + plan, err := worker.Optimize(context.Background(), slots, p) + if err != nil { + t.Fatal(err) + } + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + if plan.LoadpointShortfallWh["today"] > 0 { + t.Fatalf("today's departure lost its surplus to tomorrow's car: %v", plan.LoadpointShortfallWh) + } +} + +func TestEVDeadlineSlot(t *testing.T) { + for _, tc := range []struct { + name string + target float64 + slot int + want int + }{ + {"inside", .8, 1, 1}, + {"past the horizon", .8, 20, 2}, + {"no target", 0, 20, -1}, + {"no deadline", .8, -1, -1}, + } { + lp := &LoadpointSpec{TargetSoC: tc.target, TargetSlotIdx: tc.slot} + if got := lp.deadlineSlot(3); got != tc.want { + t.Errorf("%s: deadlineSlot = %d, want %d", tc.name, got, tc.want) + } + } +} + +// A car that already holds its target needs nothing before it leaves, so +// tomorrow's car keeps its goal and takes the surplus instead of the grid. +func TestEVDeadlineSatisfiedDepartureDoesNotBlock(t *testing.T) { + slots, p := departureAndNextDay() + p.Loadpoints[0].InitialSoC = .55 + req := (&ExternalOptimizer{}).buildRequest(slots, p) + if req.FlexLoads[1].TargetSlot != len(slots)-1 { + t.Fatalf("tomorrow's deadline = %d, want the last slot %d", req.FlexLoads[1].TargetSlot, len(slots)-1) + } +} + +func TestNativeEVDeadlineSatisfiedDepartureDoesNotBlock(t *testing.T) { + slots, p := departureAndNextDay() + p.Loadpoints[0].InitialSoC = .55 + worker := nativeWorker(t, 500*time.Millisecond) + t.Cleanup(func() { _ = worker.Close() }) + plan, err := worker.Optimize(context.Background(), slots, p) + if err != nil { + t.Fatal(err) + } + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatal(err) + } + var tomorrowWh float64 + for _, a := range plan.Actions { + tomorrowWh += a.LoadpointPowerW["tomorrow"] * a.DurationHours() + } + if tomorrowWh < 4000 { + t.Fatalf("surplus went to the grid instead of tomorrow's car: %.0f Wh", tomorrowWh) + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 95e548417..f89a64017 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -310,24 +310,32 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest TerminalPriceOreKWh: p.TerminalSoCPrice, }} } - for _, lp := range p.activeLoadpoints() { + // A departure past the horizon becomes a goal at its last slot, as in Core + // DP. It must not compete with a car that still needs energy before it + // leaves inside the horizon, so then it gets no deadline. + loadpoints := p.activeLoadpoints() + pastHorizon := func(lp *LoadpointSpec) bool { return lp.TargetSoC > 0 && lp.TargetSlotIdx >= len(slots) } + departsInside := slices.ContainsFunc(loadpoints, func(lp *LoadpointSpec) bool { + initial, target, _ := lp.requestSoC() + return lp.deadlineSlot(len(slots)) >= 0 && !pastHorizon(lp) && target > initial + }) + for _, lp := range loadpoints { + deadline := lp.deadlineSlot(len(slots)) + if departsInside && pastHorizon(lp) { + deadline = -1 + } steps := lp.normalizedSteps() efficiency := lp.ChargeEfficiency if efficiency <= 0 { efficiency = 0.9 } - minSoC, maxSoC := lp.SoCMin, lp.SoCMax - if maxSoC <= minSoC { - minSoC, maxSoC = 0, 1 - } - initialSoC := math.Max(minSoC, math.Min(maxSoC, lp.InitialSoC)) - targetSoC := math.Max(minSoC, math.Min(maxSoC, lp.TargetSoC)) + initialSoC, targetSoC, maxSoC := lp.requestSoC() req.FlexLoads = append(req.FlexLoads, externalFlexLoad{ ID: lp.ID, CapacityWh: lp.CapacityWh, InitialEnergyWh: lp.CapacityWh * initialSoC, MaxEnergyWh: lp.CapacityWh * maxSoC, TargetEnergyWh: lp.CapacityWh * targetSoC, - TargetSlot: lp.TargetSlotIdx, ChargeEfficiency: efficiency, + TargetSlot: deadline, ChargeEfficiency: efficiency, MaxChargeW: lp.MaxChargeW, AllowedStepsW: steps, SurplusOnly: lp.SurplusOnly, NoStorageToLoad: lp.blocksBatteryToEV(), }) @@ -551,6 +559,8 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if math.Abs(reportedSoC-evSoC[lp.ID]) > 0.0002 { return fmt.Errorf("slot %d loadpoint %s SoC %.4f inconsistent with replay %.4f", i, lp.ID, reportedSoC, evSoC[lp.ID]) } + // 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 { deadlineShortfall[lp.ID] = missing diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index 9ac5146bf..63f54fd4a 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -1,6 +1,10 @@ package mpc -import "github.com/srcfl/ftw/go/internal/loadpoint" +import ( + "math" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) // LoadpointSpec tells the DP how to extend its state space with an EV // loadpoint. Set `Params.Loadpoint` to a non-nil spec to have the @@ -131,6 +135,26 @@ func (l *LoadpointSpec) normalizedSteps() []float64 { // active reports whether the DP should include EV dimensions for // this spec. Nil or un-plugged = inactive; treat as pure battery. +// requestSoC is the initial and target SoC sent to the optimizer, inside the +// loadpoint's SoC bounds, and the upper bound itself. +func (l *LoadpointSpec) requestSoC() (initial, target, maxSoC float64) { + minSoC, maxSoC := l.SoCMin, l.SoCMax + if maxSoC <= minSoC { + minSoC, maxSoC = 0, 1 + } + return math.Max(minSoC, math.Min(maxSoC, l.InitialSoC)), math.Max(minSoC, math.Min(maxSoC, l.TargetSoC)), maxSoC +} + +// deadlineSlot is the slot by whose end the target must be met in a horizon +// of n slots: -1 without a target or deadline, and the last slot for a +// deadline past the horizon, so a planner still works toward it. +func (l *LoadpointSpec) deadlineSlot(n int) int { + if l.TargetSoC <= 0 || l.TargetSlotIdx < 0 || n <= 0 { + return -1 + } + return min(l.TargetSlotIdx, n-1) +} + func (l *LoadpointSpec) active() bool { return l != nil && l.PluggedIn && l.CapacityWh > 0 && l.Levels >= 2 } diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index 9aa7e4b6a..309538653 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -727,18 +727,11 @@ func OptimizeContext(ctx context.Context, slots []Slot, p Params) (Plan, error) } } - // Deadline slot index for the mid-horizon EV target penalty. A - // target beyond horizon end gets clamped to the last slot so - // the DP still "sees" it (rather than silently ignoring). A - // target of -1 means no deadline — opportunistic charging only. + // Deadline slot for the EV target penalty; -1 means opportunistic + // charging only. Energyplan receives the same slot. deadlineSlot := -1 - if evActive && lp.TargetSoC > 0 { - deadlineSlot = lp.TargetSlotIdx - if deadlineSlot < 0 { - deadlineSlot = -1 - } else if deadlineSlot >= N { - deadlineSlot = N - 1 - } + if evActive { + deadlineSlot = lp.deadlineSlot(N) } // Terminal values. Battery SoC credits stored energy. EV SoC