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-deadline-past-horizon.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
163 changes: 163 additions & 0 deletions go/internal/mpc/ev_deadline_horizon_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 18 additions & 8 deletions go/internal/mpc/external_optimizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion go/internal/mpc/loadpoint_spec.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
15 changes: 4 additions & 11 deletions go/internal/mpc/mpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading