From 51fb9a99f9b939a0f87aec4cd7ad8cbd4396ff5e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 2 Oct 2026 10:59:49 +0200 Subject: [PATCH 1/3] fix(mpc): aim an EV deadline past the horizon at its last slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a departure passed while the car was still plugged in, the next one lay past the published prices. Core sent that slot index to Energyplan unbounded, and the worker rejected the request ("invalid EV identity, energy, target or steps"), so the box fell back to Core DP until prices arrived or the car was unplugged. The home box did so on 2 Oct, 07:06–07:13. Core DP already clamps such a deadline to the horizon's last slot, and the fleet contract check already reads it that way. Move the rule into LoadpointSpec.deadlineSlot and use it for the DP, the Energyplan request and the plan check, so both planners aim at the same slot. Fixes srcfl/ftw#1497 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-deadline-past-horizon.md | 5 ++ go/cmd/ftw/main.go | 6 +- go/internal/mpc/ev_deadline_horizon_test.go | 72 +++++++++++++++++++++ go/internal/mpc/external_optimizer.go | 4 +- go/internal/mpc/loadpoint_spec.go | 10 +++ go/internal/mpc/mpc.go | 15 ++--- 6 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 .changeset/ev-deadline-past-horizon.md create mode 100644 go/internal/mpc/ev_deadline_horizon_test.go diff --git a/.changeset/ev-deadline-past-horizon.md b/.changeset/ev-deadline-past-horizon.md new file mode 100644 index 000000000..c8040f5ee --- /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. Both planners now aim for the end of the horizon, as Core DP already did; 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..140664b3a --- /dev/null +++ b/go/internal/mpc/ev_deadline_horizon_test.go @@ -0,0 +1,72 @@ +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) + } + // Two hours of surplus cannot add the 18 kWh the target needs; the plan + // says how much is left at the end of the horizon. + if plan.LoadpointShortfallWh["car"] <= 0 { + t.Fatalf("shortfall at the horizon's end not reported: %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) + } + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 95e548417..bc0269cf2 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -327,7 +327,7 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest InitialEnergyWh: lp.CapacityWh * initialSoC, MaxEnergyWh: lp.CapacityWh * maxSoC, TargetEnergyWh: lp.CapacityWh * targetSoC, - TargetSlot: lp.TargetSlotIdx, ChargeEfficiency: efficiency, + TargetSlot: lp.deadlineSlot(len(slots)), ChargeEfficiency: efficiency, MaxChargeW: lp.MaxChargeW, AllowedStepsW: steps, SurplusOnly: lp.SurplusOnly, NoStorageToLoad: lp.blocksBatteryToEV(), }) @@ -551,7 +551,7 @@ 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]) } - if lp.TargetSoC > 0 && i == lp.TargetSlotIdx { + if i == lp.deadlineSlot(len(slots)) { 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..b50b00b9d 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -131,6 +131,16 @@ 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. +// 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 From 7f82ce8e6fbc0e01b42481b3679164b1b8e18677 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 2 Oct 2026 11:19:26 +0200 Subject: [PATCH 2/3] fix(mpc): a departure inside the horizon goes before a later one Two findings from a local Codex review: - With two cars, a departure past the horizon clamped to the last slot competed equally with a car leaving in that slot. With real charger steps, the worker gave one hour of surplus to tomorrow's three-phase car and left today's single-phase car 3 kWh short. A departure past the horizon now gets no deadline when another car leaves inside it. - The plan check reported a shortfall at the horizon's end for a later departure. The reserve plan's alert would then say the car is short at departure, although the plan does not yet cover the departure night. Only a departure inside the horizon is checked, as before. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-deadline-past-horizon.md | 2 +- go/internal/mpc/ev_deadline_horizon_test.go | 67 +++++++++++++++++++-- go/internal/mpc/external_optimizer.go | 20 +++++- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.changeset/ev-deadline-past-horizon.md b/.changeset/ev-deadline-past-horizon.md index c8040f5ee..2cb9bc602 100644 --- a/.changeset/ev-deadline-past-horizon.md +++ b/.changeset/ev-deadline-past-horizon.md @@ -2,4 +2,4 @@ "ftw": patch --- -A plugged-in car whose next departure lies past the published prices no longer makes Energyplan reject the plan. Both planners now aim for the end of the horizon, as Core DP already did; before, FTW fell back to Core DP until prices for the departure day arrived or the car was unplugged. +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/internal/mpc/ev_deadline_horizon_test.go b/go/internal/mpc/ev_deadline_horizon_test.go index 140664b3a..722108484 100644 --- a/go/internal/mpc/ev_deadline_horizon_test.go +++ b/go/internal/mpc/ev_deadline_horizon_test.go @@ -45,10 +45,69 @@ func TestNativeEVDeadlinePastHorizonPlans(t *testing.T) { 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) } - // Two hours of surplus cannot add the 18 kWh the target needs; the plan - // says how much is left at the end of the horizon. - if plan.LoadpointShortfallWh["car"] <= 0 { - t.Fatalf("shortfall at the horizon's end not reported: %v", plan.LoadpointShortfallWh) + // 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) } } diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index bc0269cf2..bbaa17982 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -310,7 +310,19 @@ 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 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 { + return lp.deadlineSlot(len(slots)) >= 0 && !pastHorizon(lp) + }) + for _, lp := range loadpoints { + deadline := lp.deadlineSlot(len(slots)) + if departsInside && pastHorizon(lp) { + deadline = -1 + } steps := lp.normalizedSteps() efficiency := lp.ChargeEfficiency if efficiency <= 0 { @@ -327,7 +339,7 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest InitialEnergyWh: lp.CapacityWh * initialSoC, MaxEnergyWh: lp.CapacityWh * maxSoC, TargetEnergyWh: lp.CapacityWh * targetSoC, - TargetSlot: lp.deadlineSlot(len(slots)), ChargeEfficiency: efficiency, + TargetSlot: deadline, ChargeEfficiency: efficiency, MaxChargeW: lp.MaxChargeW, AllowedStepsW: steps, SurplusOnly: lp.SurplusOnly, NoStorageToLoad: lp.blocksBatteryToEV(), }) @@ -551,7 +563,9 @@ 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]) } - if i == lp.deadlineSlot(len(slots)) { + // 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 } From 209522bc338faddba30056381b9338c1106b9c1b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 2 Oct 2026 11:32:03 +0200 Subject: [PATCH 3/3] fix(mpc): only a car that still needs energy goes first A third local Codex review: a plugged-in car stays active after it reaches its target, so a satisfied departure inside the horizon still removed tomorrow's deadline, and the surplus went to the grid instead of tomorrow's car. Only a car whose target is above its current SoC now takes priority. The SoC bounds sent to the optimizer move into LoadpointSpec.requestSoC so the rule and the request read the same values. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/mpc/ev_deadline_horizon_test.go | 32 +++++++++++++++++++++ go/internal/mpc/external_optimizer.go | 14 ++++----- go/internal/mpc/loadpoint_spec.go | 16 ++++++++++- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/go/internal/mpc/ev_deadline_horizon_test.go b/go/internal/mpc/ev_deadline_horizon_test.go index 722108484..b189471a6 100644 --- a/go/internal/mpc/ev_deadline_horizon_test.go +++ b/go/internal/mpc/ev_deadline_horizon_test.go @@ -129,3 +129,35 @@ func TestEVDeadlineSlot(t *testing.T) { } } } + +// 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 bbaa17982..f89a64017 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -311,12 +311,13 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest }} } // A departure past the horizon becomes a goal at its last slot, as in Core - // DP. It must not compete with a car that leaves inside the horizon, so - // then it gets no deadline. + // 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 { - return lp.deadlineSlot(len(slots)) >= 0 && !pastHorizon(lp) + initial, target, _ := lp.requestSoC() + return lp.deadlineSlot(len(slots)) >= 0 && !pastHorizon(lp) && target > initial }) for _, lp := range loadpoints { deadline := lp.deadlineSlot(len(slots)) @@ -328,12 +329,7 @@ func (o *ExternalOptimizer) buildRequest(slots []Slot, p Params) externalRequest 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, diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index b50b00b9d..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,16 @@ 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.