From 5c85bd6efa6bb1fd611db2a7c7046529e9f635a3 Mon Sep 17 00:00:00 2001 From: Segran Date: Tue, 20 Oct 2026 13:58:29 +0200 Subject: [PATCH 1/5] test(control): reproduce lost EV reserve behind unrealized stable offers Signed-off-by: Segran --- .../control/ev_unrealized_offer_test.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 go/internal/control/ev_unrealized_offer_test.go diff --git a/go/internal/control/ev_unrealized_offer_test.go b/go/internal/control/ev_unrealized_offer_test.go new file mode 100644 index 000000000..c880c785f --- /dev/null +++ b/go/internal/control/ev_unrealized_offer_test.go @@ -0,0 +1,46 @@ +package control + +import ( + "math" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A stable current offer is not proof that its power is already inside the +// site meter. These incident replays specify the missing EV-priority behavior; +// they intentionally fail until the reservation accounts for unrealized offers. +// This is a battery-allocation regression, not an Easee hardware simulator. +func TestEVBatteryReservesUnrealizedStableOffer(t *testing.T) { + for _, tc := range []struct { + name string + gridW, batteryW, demandW, actualW float64 + phaseA []float64 + }{ + {"15A offered but only 4.155kW drawn", 13843, 9383, 10350, 4155, []float64{19.65, 20.52, 19.46}}, + {"6A offered but no draw", 10347, 8261, 4140, 0, []float64{18.84, 13.29, 12.09}}, + {"realized offer preserves battery allocation", 13843, 9383, 4140, 4140, []float64{19.65, 20.52, 19.46}}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := evSplitFixture(t, tc.gridW, tc.phaseA, tc.demandW, "3p") + state.SiteFuseSafetyA = .5 + sent(state, "ev", tc.demandW, tc.demandW) + sent(state, "first", tc.batteryW, tc.batteryW) + store.Update("car", telemetry.DerEV, tc.actualW, nil, nil) + store.RecordDriverSuccess("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: tc.batteryW}}, store, state, 17250), "first") + // Conditional balanced three-phase projection at configured 230 V, + // holding all other loads constant. Release battery charge before EV rise. + worst := math.Max(tc.phaseA[0], math.Max(tc.phaseA[1], tc.phaseA[2])) + projectedA := worst + (tc.demandW-tc.actualW)/690 + requiredCutW := math.Max(0, (projectedA-24.5)*690) + ceilingW := math.Max(0, tc.batteryW-requiredCutW) + if got > ceilingW+1 { + t.Fatalf("stable EV offer %.0f W, actual %.0f W: battery allocation %.0f W exceeds EV-priority ceiling %.0f W; projected busiest phase %.2f A", tc.demandW, tc.actualW, got, ceilingW, projectedA) + } + if tc.actualW == tc.demandW && math.Abs(got-tc.batteryW) > 1 { + t.Fatalf("realized EV offer unnecessarily reduced battery: got %.0f W want %.0f W", got, tc.batteryW) + } + }) + } +} From 349a2ac0a221fe01b9974c96cc5e7e6848918a68 Mon Sep 17 00:00:00 2001 From: Segran Date: Tue, 20 Oct 2026 14:09:18 +0200 Subject: [PATCH 2/5] fix(control): retain EV reserve until accepted power is realized Signed-off-by: Segran --- .changeset/ev-unrealized-offer-reserve.md | 5 ++ go/internal/control/ev_battery_headroom.go | 40 +++++++-- .../control/ev_battery_headroom_test.go | 30 +++---- .../control/ev_realized_reserve_test.go | 81 +++++++++++++++++++ .../control/ev_unrealized_offer_test.go | 4 +- 5 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 .changeset/ev-unrealized-offer-reserve.md create mode 100644 go/internal/control/ev_realized_reserve_test.go diff --git a/.changeset/ev-unrealized-offer-reserve.md b/.changeset/ev-unrealized-offer-reserve.md new file mode 100644 index 000000000..8a13126ea --- /dev/null +++ b/.changeset/ev-unrealized-offer-reserve.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep optional battery charging out of the power still needed by an EV whose accepted current offer is not fully delivered, including Charge now. Missing or unaligned EV measurements retain the full reservation; EV starts and increases still wait for measured site headroom. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 12a867c61..6d4f046b7 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -2,20 +2,22 @@ package control import ( "math" + "time" "github.com/srcfl/ftw/go/internal/telemetry" ) // clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands; no charger -// or battery reading takes part. The EV still needs its demand less the -// lowest offer of the last evSettle, which the meter surely shows. +// still needs. A stable offer is not proof of delivery. Fresh, aligned EV +// power may reduce the reservation, but never beyond the lowest recent offer. +// This only caps optional battery charge; it grants no EV rise or restart. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets } now := state.now() - // This tick's new offer is not in the meter yet; the lowest logged one is. + // A recent offer rise cannot establish load already in the meter. The + // lowest logged offer is an upper bound on credited measured EV power. offeredW, _, _, ok := state.evOffers.span(now) if !ok { offeredW = 0 @@ -31,11 +33,12 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if len(state.evFuseDemands) == 0 { return targets } - needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } + creditedW := realizedEVForBatteryW(store, state, meter, now) + needW := math.Max(0, demandW-math.Min(offeredW, creditedW)) latest := make(map[string]float64, len(targets)) targeted := make(map[string]bool, len(targets)) for _, target := range targets { @@ -142,3 +145,30 @@ func floorPositiveEVBatteryTargets(targets []DispatchTarget) []DispatchTarget { } return out } + +// Missing, unhealthy or unaligned EV observations reserve the full demand. +// The meter-only EV fuse gate still decides every rise/start; battery relief +// from this clamp cannot fund a rise before it appears in the site meter. +func realizedEVForBatteryW(store *telemetry.Store, state *State, meter *telemetry.DerReading, now time.Time) float64 { + const maxAge = 15 * time.Second + const maxSkew = 10 * time.Second + if meter == nil || meter.UpdatedAt.After(now) || now.Sub(meter.UpdatedAt) > maxAge { + return 0 + } + var total float64 + for _, demand := range state.evFuseDemands { + rd := store.Get(demand.driverName, telemetry.DerEV) + health := store.DriverHealth(demand.driverName) + if rd == nil || health == nil || !health.TelemetryLive() || health.DeviceFault || rd.UpdatedAt.After(now) || now.Sub(rd.UpdatedAt) > maxAge { + continue + } + observation, known := telemetry.ControlPowerObservation(rd.RawW, rd.Data, rd.UpdatedAt) + at := observation.Seen() + age, skew := now.Sub(at), meter.UpdatedAt.Sub(at) + if !known || age < 0 || age > maxAge || age > telemetry.ControlPowerMaxAge(rd.Data) || skew < -maxSkew || skew > maxSkew || !finiteFuseValue(observation.PowerW) { + continue + } + total += math.Min(demand.powerW, math.Max(0, observation.PowerW)) + } + return total +} diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index 9fafb4872..bcd491eeb 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/ev_battery_headroom_test.go @@ -10,8 +10,8 @@ import ( ) // evSplitFixture is a 25 A site with a 1 A margin (16,560 W). It holds the -// meter reading, our recent commands and the EV demand; device readings are -// left out on purpose, since the split must not read them. +// meter reading, our recent commands and the EV demand. EV readings report +// realized demand unless a test overrides delivery. func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float64, demandPhase string) (*telemetry.Store, *State) { t.Helper() store := telemetry.NewStore() @@ -30,6 +30,8 @@ func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float state.batteryCommands = map[string]commandLog{} if demandW > 0 { state.evFuseDemands = []evFuseDemand{{"car", demandW, demandPhase}} + store.Update("car", telemetry.DerEV, demandW, nil, nil) + store.RecordDriverSuccess("car") } return store, state } @@ -168,28 +170,16 @@ func TestEVBatteryKeepsChargeNeededForMeasuredExportRelief(t *testing.T) { } } -// The split reads our commands and the meter only. A lone, missing, late -// or too high reading from the charger or battery changes nothing. -func TestEVBatterySplitIgnoresDeviceReadings(t *testing.T) { - run := func(readings func(*telemetry.Store)) float64 { +// Battery device readings never finance optional charge or EV headroom. +func TestEVBatterySplitIgnoresBatteryReadings(t *testing.T) { + run := func(power float64) float64 { store, state := evSplitFixture(t, 7140, nil, 10350, "3p") sent(state, "ev", 4140, 4140) sent(state, "first", 0, 0) - readings(store) + store.Update("first", telemetry.DerBattery, power, nil, nil) return targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250), "first") } - want := run(func(*telemetry.Store) {}) - for name, readings := range map[string]func(*telemetry.Store){ - "ev_lone_zero": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 0, nil, nil) }, - "ev_above_offer": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 11000, nil, nil) }, - "ev_unavailable": func(s *telemetry.Store) { - s.Update("car", telemetry.DerEV, 0, nil, []byte(`{"control_power_available":false}`)) - }, - "battery_discharging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, -5000, nil, nil) }, - "battery_charging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, 6000, nil, nil) }, - } { - if got := run(readings); got != want { - t.Fatalf("%s moved the battery target %.1f -> %.1f W", name, want, got) - } + if run(-5000) != run(6000) { + t.Fatal("battery telemetry funded charge") } } diff --git a/go/internal/control/ev_realized_reserve_test.go b/go/internal/control/ev_realized_reserve_test.go new file mode 100644 index 000000000..6fdd929d7 --- /dev/null +++ b/go/internal/control/ev_realized_reserve_test.go @@ -0,0 +1,81 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVBatteryRealizedCreditRequiresFreshAlignedHealthyPower(t *testing.T) { + for _, name := range []string{"fresh", "missing", "stale", "future", "skew", "fault", "offline", "unavailable", "cached source", "negative", "above demand"} { + t.Run(name, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, nil, 10350, "3p") + now := time.Now() + state.clock = func() time.Time { return now } + meter := store.Get("meter", telemetry.DerMeter) + meter.UpdatedAt = now + ev := store.Get("car", telemetry.DerEV) + ev.UpdatedAt = now + ev.RawW = 4155 + want := 4155.0 + switch name { + case "missing": + store.Remove("car") + want = 0 + case "stale": + ev.UpdatedAt = now.Add(-16 * time.Second) + want = 0 + case "future": + ev.UpdatedAt = now.Add(time.Second) + want = 0 + case "skew": + ev.UpdatedAt = now.Add(-11 * time.Second) + want = 0 + case "fault": + store.DriverHealthMut("car").SetDeviceFault(true, "test") + want = 0 + case "offline": + store.DriverHealthMut("car").SetOffline() + want = 0 + case "unavailable": + ev.Data = []byte(`{"control_power_available":false}`) + want = 0 + case "cached source": + ev.Data, _ = json.Marshal(map[string]any{"control_power_observed_at": now.Add(-time.Minute).Format(time.RFC3339Nano)}) + want = 0 + case "negative": + ev.RawW = -1000 + want = 0 + case "above demand": + ev.RawW = 20000 + want = 10350 + } + if got := realizedEVForBatteryW(store, state, meter, now); got != want { + t.Fatalf("credited %.0f W want %.0f W", got, want) + } + }) + } +} + +func TestEVBatteryUnknownDeliveryKeepsFullReservation(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{22, 21, 20}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 8000, 8000) + store.Remove("car") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + if got := targetW(out, "first"); got != 0 { + t.Fatalf("unknown EV delivery released battery room: %.0f W", got) + } +} + +func TestEVBatteryFreshSiblingCannotCreditMissingCharger(t *testing.T) { + store, state := evSplitFixture(t, 12000, nil, 4140, "3p") + state.evFuseDemands = append(state.evFuseDemands, evFuseDemand{"missing", 6210, "3p"}) + now := time.Now() + state.clock = func() time.Time { return now } + if got := realizedEVForBatteryW(store, state, store.Get("meter", telemetry.DerMeter), now); got != 4140 { + t.Fatalf("credited %.0f W", got) + } +} diff --git a/go/internal/control/ev_unrealized_offer_test.go b/go/internal/control/ev_unrealized_offer_test.go index c880c785f..442cc1e38 100644 --- a/go/internal/control/ev_unrealized_offer_test.go +++ b/go/internal/control/ev_unrealized_offer_test.go @@ -8,8 +8,8 @@ import ( ) // A stable current offer is not proof that its power is already inside the -// site meter. These incident replays specify the missing EV-priority behavior; -// they intentionally fail until the reservation accounts for unrealized offers. +// site meter. These incident replays require the battery to leave room for +// the unrealized part of a stable EV offer. // This is a battery-allocation regression, not an Easee hardware simulator. func TestEVBatteryReservesUnrealizedStableOffer(t *testing.T) { for _, tc := range []struct { From 387b7d7f9c6d2b351afe27377b50d510d6220c20 Mon Sep 17 00:00:00 2001 From: Segran Date: Tue, 20 Oct 2026 14:28:50 +0200 Subject: [PATCH 3/5] fix(telemetry): qualify EV power through a health-gated accessor Signed-off-by: Segran --- go/internal/control/ev_battery_headroom.go | 12 ++---- go/internal/telemetry/ev_control_power.go | 22 +++++++++++ .../telemetry/ev_control_power_test.go | 38 +++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 go/internal/telemetry/ev_control_power.go create mode 100644 go/internal/telemetry/ev_control_power_test.go diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 6d4f046b7..a233e4dd2 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -157,15 +157,9 @@ func realizedEVForBatteryW(store *telemetry.Store, state *State, meter *telemetr } var total float64 for _, demand := range state.evFuseDemands { - rd := store.Get(demand.driverName, telemetry.DerEV) - health := store.DriverHealth(demand.driverName) - if rd == nil || health == nil || !health.TelemetryLive() || health.DeviceFault || rd.UpdatedAt.After(now) || now.Sub(rd.UpdatedAt) > maxAge { - continue - } - observation, known := telemetry.ControlPowerObservation(rd.RawW, rd.Data, rd.UpdatedAt) - at := observation.Seen() - age, skew := now.Sub(at), meter.UpdatedAt.Sub(at) - if !known || age < 0 || age > maxAge || age > telemetry.ControlPowerMaxAge(rd.Data) || skew < -maxSkew || skew > maxSkew || !finiteFuseValue(observation.PowerW) { + observation, known := store.OnlineEVControlPower(demand.driverName, now, maxAge) + skew := meter.UpdatedAt.Sub(observation.Seen()) + if !known || skew < -maxSkew || skew > maxSkew { continue } total += math.Min(demand.powerW, math.Max(0, observation.PowerW)) diff --git a/go/internal/telemetry/ev_control_power.go b/go/internal/telemetry/ev_control_power.go new file mode 100644 index 000000000..9488f04b2 --- /dev/null +++ b/go/internal/telemetry/ev_control_power.go @@ -0,0 +1,22 @@ +package telemetry + +import "time" + +// OnlineEVControlPower qualifies raw EV delivery for optional battery-load +// reservation. A faulted charger earns no credit here (unlike total site-load +// accounting), since retaining the full reservation is the conservative choice. +func (s *Store) OnlineEVControlPower(driver string, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r := s.readings[key(driver, DerEV)] + h := s.health[driver] + if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || maxAge <= 0 || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > maxAge { + return ControlObservation{}, false + } + observation, known := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + age := now.Sub(observation.Seen()) + if !known || age < 0 || age > maxAge || age > ControlPowerMaxAge(r.Data) { + return ControlObservation{}, false + } + return observation, true +} diff --git a/go/internal/telemetry/ev_control_power_test.go b/go/internal/telemetry/ev_control_power_test.go new file mode 100644 index 000000000..02928b03e --- /dev/null +++ b/go/internal/telemetry/ev_control_power_test.go @@ -0,0 +1,38 @@ +package telemetry + +import ( + "testing" + "time" +) + +func TestOnlineEVControlPowerRejectsUnqualifiedCache(t *testing.T) { + for _, tc := range []string{"fresh", "offline", "fault", "stale receipt", "future receipt", "unavailable", "missing health"} { + t.Run(tc, func(t *testing.T) { + s := NewStore() + s.Update("ev", DerEV, 4140, nil, nil) + s.RecordDriverSuccess("ev") + now := time.Now() + r := s.Get("ev", DerEV) + r.UpdatedAt = now + want := tc == "fresh" + switch tc { + case "offline": + s.DriverHealthMut("ev").SetOffline() + case "fault": + s.DriverHealthMut("ev").SetDeviceFault(true, "test") + case "stale receipt": + r.UpdatedAt = now.Add(-16 * time.Second) + case "future receipt": + r.UpdatedAt = now.Add(time.Second) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "missing health": + delete(s.health, "ev") + } + p, ok := s.OnlineEVControlPower("ev", now, 15*time.Second) + if ok != want || (ok && p.PowerW != 4140) { + t.Fatalf("observation=%+v known=%v want %v", p, ok, want) + } + }) + } +} From 4372354480671b1d22df1c80a6f5c5d045ac4e30 Mon Sep 17 00:00:00 2001 From: Segran Date: Fri, 23 Oct 2026 14:24:59 +0200 Subject: [PATCH 4/5] fix(control): retain EV headroom through battery relief transitions --- .changeset/ev-unrealized-offer-reserve.md | 2 + go/internal/control/ev_battery_headroom.go | 88 ++++++++++- go/internal/control/ev_battery_relief_test.go | 148 ++++++++++++++++++ .../ev_field_reservation_regression_test.go | 46 ++++++ go/internal/control/sitesim_test.go | 2 +- .../control/testdata/sitesim_field_known.txt | 6 - .../telemetry/battery_control_power.go | 9 ++ .../telemetry/battery_control_power_test.go | 51 ++++++ go/internal/telemetry/ev_control_power.go | 14 +- go/internal/telemetry/online_control_power.go | 22 +++ 10 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 go/internal/control/ev_battery_relief_test.go create mode 100644 go/internal/control/ev_field_reservation_regression_test.go create mode 100644 go/internal/telemetry/battery_control_power.go create mode 100644 go/internal/telemetry/battery_control_power_test.go create mode 100644 go/internal/telemetry/online_control_power.go diff --git a/.changeset/ev-unrealized-offer-reserve.md b/.changeset/ev-unrealized-offer-reserve.md index 8a13126ea..37978c85c 100644 --- a/.changeset/ev-unrealized-offer-reserve.md +++ b/.changeset/ev-unrealized-offer-reserve.md @@ -3,3 +3,5 @@ --- Keep optional battery charging out of the power still needed by an EV whose accepted current offer is not fully delivered, including Charge now. Missing or unaligned EV measurements retain the full reservation; EV starts and increases still wait for measured site headroom. + +Keep the measured phase reservation active while a managed EV pause propagates, and do not release permitted battery relief twice before a prior import increase is observed. Existing fuse protection waits are unchanged. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index a233e4dd2..4c69147d8 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -10,7 +10,8 @@ import ( // clampEVBatteryHeadroom keeps optional battery charge out of room an EV // still needs. A stable offer is not proof of delivery. Fresh, aligned EV // power may reduce the reservation, but never beyond the lowest recent offer. -// This only caps optional battery charge; it grants no EV rise or restart. +// This caps optional charge and the removal of permitted fuse relief; it +// grants no EV rise or restart. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets @@ -30,11 +31,15 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st phases = 1 } } - if len(state.evFuseDemands) == 0 { + if len(state.evFuseDemands) == 0 && len(state.evCommanded) == 0 { return targets } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { + if meter == nil { + return floorPositiveEVBatteryTargets(targets) + } + meterW := meter.RawW + if !finiteFuseValue(meterW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } creditedW := realizedEVForBatteryW(store, state, meter, now) @@ -54,16 +59,27 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st } } // Battery charge stays under the level the EV may rise to. - spare := state.importRiseCeilingW(fuseMaxW) - meter.RawW - needW + spare := state.importRiseCeilingW(fuseMaxW) - meterW - needW // During measured export overflow, the existing fuse pipeline may // require charge to reduce export. Magnitudes alone cannot label that // worst phase as import; leave this direction to the existing guard. - if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { + if meterW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known { spare = math.Min(spare, phaseSpare) } } out := capBatteryCharge(targets, latest, state.pendingBatteryCommandsW(now), spare) + // Retaining discharge protects only an offer already sent, not demand + // which the EV admission guard has not allowed. Otherwise an observed EV + // cut could never release temporary battery relief. + pendingEVW := math.Max(0, state.EVCommandedW-creditedW) + reliefSpare := math.Max(0, fuseMaxW-state.fuseSafetyMarginW()) - meterW - pendingEVW + if meterW >= 0 && state.SiteFuseVoltage > 0 { + if phaseSpare, known := evBatteryPhaseSpareW(meter, state, pendingEVW/(state.SiteFuseVoltage*phases)); known { + reliefSpare = math.Min(reliefSpare, phaseSpare) + } + } + out = retainEVBatteryRelief(out, latest, reliefSpare-pendingEVBatteryImportW(store, state, meter, latest), store, state) // Charge needed to bring measured export inside its hard ceiling is // not optional. Preserve that part of the already validated offer; // this stage never invents or increases an original battery command. @@ -72,7 +88,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st offeredCharge += math.Max(0, targets[i].TargetW) finalCharge += math.Max(0, out[i].TargetW) } - exportFloor := math.Min(offeredCharge, math.Max(0, -meter.RawW-state.effectiveExportCeilingW(fuseMaxW))) + exportFloor := math.Min(offeredCharge, math.Max(0, -meterW-state.effectiveExportCeilingW(fuseMaxW))) if exportFloor > finalCharge && offeredCharge > finalCharge { scale := (exportFloor - finalCharge) / (offeredCharge - finalCharge) for i := range out { @@ -166,3 +182,63 @@ func realizedEVForBatteryW(store *telemetry.Store, state *State, meter *telemetr } return total } + +// Ending discharge raises import too. Retain only an already sent, currently +// permitted discharge while the measured phase room cannot absorb its removal. +// Never invent discharge for a missing, unhealthy or direction-blocked battery. +func retainEVBatteryRelief(targets []DispatchTarget, latest map[string]float64, spare float64, store *telemetry.Store, state *State) []DispatchTarget { + now := state.now() + available := make([]float64, len(targets)) + lost, total := 0.0, 0.0 + for i, t := range targets { + lost += math.Max(0, math.Min(0, t.TargetW)-math.Min(0, latest[t.Driver])) + if t.TargetW > 0 || latest[t.Driver] >= t.TargetW { + continue + } + if _, _, _, sent := state.batteryCommands[t.Driver].span(now); !sent { + continue + } + r, h := store.Get(t.Driver, telemetry.DerBattery), store.DriverHealth(t.Driver) + if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > 15*time.Second { + continue + } + if r.SoC == nil || !finiteFuseValue(*r.SoC) || *r.SoC < 0.05 || *r.SoC > 1 { + continue + } + lower, _ := fuseTargetBounds(r, state.DriverLimits[t.Driver]) + available[i] = math.Max(0, t.TargetW-math.Max(latest[t.Driver], lower)) + total += available[i] + } + if total <= 0 { + return targets + } + retain := math.Min(total, math.Max(0, lost-spare)) + if retain <= 0 { + return targets + } + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if available[i] > 0 { + out[i].TargetW -= available[i] * retain / total + out[i].Clamped = true + } + } + return out +} + +// A qualified battery reading aligned with the site meter may prove a prior +// import increase has arrived. Otherwise retain the existing command window. +func pendingEVBatteryImportW(store *telemetry.Store, state *State, meter *telemetry.DerReading, latest map[string]float64) float64 { + now := state.now() + pending := state.pendingBatteryCommandsW(now) + observedPending := 0.0 + for driver, last := range latest { + observation, known := store.OnlineBatteryControlPower(driver, now, 15*time.Second) + skew := meter.UpdatedAt.Sub(observation.Seen()) + if !known || skew < -10*time.Second || skew > 10*time.Second { + return pending + } + observedPending += math.Max(0, last-observation.PowerW) + } + return math.Min(pending, observedPending) +} diff --git a/go/internal/control/ev_battery_relief_test.go b/go/internal/control/ev_battery_relief_test.go new file mode 100644 index 000000000..ed3b7e19e --- /dev/null +++ b/go/internal/control/ev_battery_relief_test.go @@ -0,0 +1,148 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func TestEVBatteryReliefRetentionRespectsEligibility(t *testing.T) { + for _, mode := range []string{"healthy", "missing_soc", "empty", "blocked", "fault", "offline", "stale", "future", "no_order", "disabled"} { + t.Run(mode, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "first", -1745, -1745) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + r := store.Get("first", telemetry.DerBattery) + switch mode { + case "missing_soc": + r.SoC = nil + case "empty": + x := .04 + r.SoC = &x + case "blocked": + r.Data = []byte(`{"discharge_capable":false}`) + case "fault": + store.DriverHealthMut("first").SetDeviceFault(true, "test") + case "offline": + store.DriverHealthMut("first").SetOffline() + case "stale": + r.UpdatedAt = r.UpdatedAt.Add(-time.Minute) + case "future": + r.UpdatedAt = state.now().Add(time.Minute) + case "disabled": + state.DriverLimits["first"] = PowerLimits{MaxDischargeW: 0, MaxDischargeWSet: true} + case "no_order": + delete(state.batteryCommands, "first") + } + out := retainEVBatteryRelief([]DispatchTarget{{Driver: "first", TargetW: 0}}, map[string]float64{"first": -1745}, 1725, store, state) + want := 0.0 + if mode == "healthy" { + want = -20 + } + if got := targetW(out, "first"); got != want { + t.Fatalf("got %.1f want %.1f", got, want) + } + }) + } +} +func TestEVBatteryPausedManagedLoadStillCapsCharge(t *testing.T) { + store, state := evSplitFixture(t, 11000, []float64{21, 17, 17}, 0, "3p") + state.evCommanded = map[string]float64{"car": 0} + sent(state, "first", 2070, 2070) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 5070}}, store, state, 17250) + if got := targetW(out, "first"); got > 4140.01 { + t.Fatalf("paused car released unmeasured room: %.1f", got) + } +} + +func TestEVBatteryPendingReliefRequiresAlignedFreshProof(t *testing.T) { + for _, mode := range []string{"pending", "arrived", "stale", "future", "unavailable", "skew"} { + t.Run(mode, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + now := time.Now() + state.clock = func() time.Time { return now } + sent(state, "first", -1745, -690) + soc := .3 + actual := -690.0 + if mode == "pending" { + actual = -1745 + } + store.Update("first", telemetry.DerBattery, actual, &soc, nil) + store.RecordDriverSuccess("first") + r := store.Get("first", telemetry.DerBattery) + r.UpdatedAt = now + meter := store.Get("meter", telemetry.DerMeter) + meter.UpdatedAt = now + switch mode { + case "stale": + r.UpdatedAt = now.Add(-time.Minute) + case "future": + r.UpdatedAt = now.Add(time.Second) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "skew": + r.UpdatedAt = now.Add(-11 * time.Second) + } + want := 1055.0 + if mode == "arrived" { + want = 0 + } + if got := pendingEVBatteryImportW(store, state, meter, map[string]float64{"first": -690}); got != want { + t.Fatalf("pending %.1f want %.1f", got, want) + } + }) + } +} + +// Releasing existing discharge is itself an import rise. Test the complete +// clamp, so a qualified helper alone cannot hide a missing pipeline call. +func TestEVBatteryClampRetainsReliefUntilImportFits(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{23.5, 13.5, 13.5}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", -1745, -1745) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + if got := targetW(out, "first"); got != -1400 { + t.Fatalf("ending relief would exceed phase room: target %.1f want -1400", got) + } +} + +func TestEVBatteryClampDoesNotSpendUnseenImportTwice(t *testing.T) { + for _, arrived := range []bool{false, true} { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", -1745, -690) + soc, actual := .3, -1745.0 + if arrived { + actual = -690 + } + store.Update("first", telemetry.DerBattery, actual, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + want := -20.0 + if arrived { + want = 0 + } + if got := targetW(out, "first"); got != want { + t.Fatalf("arrived=%v target %.1f want %.1f", arrived, got, want) + } + } +} + +func TestEVBatteryReliefDoesNotReserveUnadmittedDemand(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "ev", 10350, 8280) + sent(state, "first", -1745, -1745) + store.Update("car", telemetry.DerEV, 8280, nil, nil) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + if got := targetW(out, "first"); got != -20 { + t.Fatalf("unadmitted EV demand retained unnecessary discharge: %.1f", got) + } +} diff --git a/go/internal/control/ev_field_reservation_regression_test.go b/go/internal/control/ev_field_reservation_regression_test.go new file mode 100644 index 000000000..e5b1be1c2 --- /dev/null +++ b/go/internal/control/ev_field_reservation_regression_test.go @@ -0,0 +1,46 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "testing" +) + +// Reconstructed clamp input. Actual internal hardware command logs were not captured. +func TestFieldReservationUnrealized15A(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Update("car", telemetry.DerEV, 4175, nil, nil) + store.RecordDriverSuccess("car") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250) + battery := targetW(out, "first") + projected := 23.34 + (10350.0-4175)/(3*230) - (6000-battery)/(3*230) + t.Logf("battery_target_w=%.2f projected_worst_phase_a=%.3f", battery, projected) + if math.IsNaN(battery) || projected > 24.0001 { + t.Fatalf("unrealized offer is not reserved: battery %.2f W, projected %.3f A", battery, projected) + } +} + +func TestFieldReservationRealizedOfferPreservesBattery(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Update("car", telemetry.DerEV, 10350, nil, nil) + store.RecordDriverSuccess("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250), "first") + if got != 6000 { + t.Fatalf("realized offer unnecessarily reduced battery: %.2f", got) + } +} + +func TestFieldReservationMissingDeliveryKeepsFullReserve(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Remove("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250), "first") + if got != 0 { + t.Fatalf("missing EV measurement credited delivery: battery %.2f", got) + } +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 61e0fba67..bc20a18e9 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -584,7 +584,7 @@ func simScenarios() []simScenario { expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { p := s.p offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) - if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { + if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || p.Battery.W > 5520+1 { return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) } return nil diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 71f1a29a3..b91cf1d4a 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -13,12 +13,6 @@ # last raise, then pauses when both land (surplus_reserve.go:128). # relief srcfl/ftw#1789, draft: the battery ends its relief before the # car's cut shows. -pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief -pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief -pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export diff --git a/go/internal/telemetry/battery_control_power.go b/go/internal/telemetry/battery_control_power.go new file mode 100644 index 000000000..be2659af3 --- /dev/null +++ b/go/internal/telemetry/battery_control_power.go @@ -0,0 +1,9 @@ +package telemetry + +import "time" + +// OnlineBatteryControlPower qualifies battery delivery before control may +// release the reservation for an import increase already commanded. +func (s *Store) OnlineBatteryControlPower(driver string, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + return s.onlineControlPower(driver, DerBattery, now, maxAge) +} diff --git a/go/internal/telemetry/battery_control_power_test.go b/go/internal/telemetry/battery_control_power_test.go new file mode 100644 index 000000000..19ad48bbe --- /dev/null +++ b/go/internal/telemetry/battery_control_power_test.go @@ -0,0 +1,51 @@ +package telemetry + +import ( + "math" + "testing" + "time" +) + +func TestOnlineBatteryControlPowerRejectsUnqualifiedCache(t *testing.T) { + for _, mode := range []string{"fresh", "missing", "offline", "fault", "stale receipt", "future receipt", "stale source", "future source", "source validity", "unavailable", "nonfinite", "missing health", "invalid age"} { + t.Run(mode, func(t *testing.T) { + s := NewStore() + now := time.Now().UTC() + s.Update("battery", DerBattery, -690, nil, nil) + s.RecordDriverSuccess("battery") + r := s.Get("battery", DerBattery) + r.UpdatedAt = now + age := 15 * time.Second + switch mode { + case "missing": + s.Remove("battery") + case "offline": + s.DriverHealthMut("battery").SetOffline() + case "fault": + s.DriverHealthMut("battery").SetDeviceFault(true, "test") + case "stale receipt": + r.UpdatedAt = now.Add(-16 * time.Second) + case "future receipt": + r.UpdatedAt = now.Add(time.Second) + case "stale source": + r.Data = []byte(`{"control_power_observed_at":"` + now.Add(-16*time.Second).Format(time.RFC3339Nano) + `"}`) + case "future source": + r.Data = []byte(`{"control_power_observed_at":"` + now.Add(time.Second).Format(time.RFC3339Nano) + `"}`) + case "source validity": + r.Data = []byte(`{"power_max_age_s":1,"control_power_observed_at":"` + now.Add(-2*time.Second).Format(time.RFC3339Nano) + `"}`) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "nonfinite": + r.RawW = math.NaN() + case "missing health": + delete(s.health, "battery") + case "invalid age": + age = 0 + } + p, ok := s.OnlineBatteryControlPower("battery", now, age) + if ok != (mode == "fresh") || (ok && p.PowerW != -690) { + t.Fatalf("observation=%+v known=%v mode=%s", p, ok, mode) + } + }) + } +} diff --git a/go/internal/telemetry/ev_control_power.go b/go/internal/telemetry/ev_control_power.go index 9488f04b2..03468cdfe 100644 --- a/go/internal/telemetry/ev_control_power.go +++ b/go/internal/telemetry/ev_control_power.go @@ -6,17 +6,5 @@ import "time" // reservation. A faulted charger earns no credit here (unlike total site-load // accounting), since retaining the full reservation is the conservative choice. func (s *Store) OnlineEVControlPower(driver string, now time.Time, maxAge time.Duration) (ControlObservation, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - r := s.readings[key(driver, DerEV)] - h := s.health[driver] - if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || maxAge <= 0 || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > maxAge { - return ControlObservation{}, false - } - observation, known := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) - age := now.Sub(observation.Seen()) - if !known || age < 0 || age > maxAge || age > ControlPowerMaxAge(r.Data) { - return ControlObservation{}, false - } - return observation, true + return s.onlineControlPower(driver, DerEV, now, maxAge) } diff --git a/go/internal/telemetry/online_control_power.go b/go/internal/telemetry/online_control_power.go new file mode 100644 index 000000000..a4477e88e --- /dev/null +++ b/go/internal/telemetry/online_control_power.go @@ -0,0 +1,22 @@ +package telemetry + +import "time" + +// onlineControlPower qualifies a device observation with receipt age, source +// age and driver health under one read lock. Cached power alone is not proof +// that a commanded import increase reached the site meter. +func (s *Store) onlineControlPower(driver string, kind DerType, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r := s.readings[key(driver, kind)] + h := s.health[driver] + if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || maxAge <= 0 || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > maxAge { + return ControlObservation{}, false + } + observation, known := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + age := now.Sub(observation.Seen()) + if !known || age < 0 || age > maxAge || age > ControlPowerMaxAge(r.Data) { + return ControlObservation{}, false + } + return observation, true +} From 7361f9d0818a106789b97e37fca09954afd105a7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 24 Oct 2026 12:30:51 +0200 Subject: [PATCH 5/5] fix(control): keep pending import from batteries without targets Signed-off-by: Fredrik Ahlgren --- .changeset/ev-pending-battery-targets.md | 5 + go/internal/control/ev_battery_headroom.go | 8 ++ .../ev_battery_pending_targets_test.go | 103 ++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 .changeset/ev-pending-battery-targets.md create mode 100644 go/internal/control/ev_battery_pending_targets_test.go diff --git a/.changeset/ev-pending-battery-targets.md b/.changeset/ev-pending-battery-targets.md new file mode 100644 index 000000000..e024aea77 --- /dev/null +++ b/.changeset/ev-pending-battery-targets.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep pending battery import reserved when that battery has no current dispatch target. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 4c69147d8..c81f817ef 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -231,6 +231,14 @@ func retainEVBatteryRelief(targets []DispatchTarget, latest map[string]float64, func pendingEVBatteryImportW(store *telemetry.Store, state *State, meter *telemetry.DerReading, latest map[string]float64) float64 { now := state.now() pending := state.pendingBatteryCommandsW(now) + // Proof from current targets cannot clear a pending rise on a battery + // that has no target in this dispatch. + for driver, log := range state.batteryCommands { + low, _, last, sent := log.span(now) + if _, present := latest[driver]; sent && last > low && !present { + return pending + } + } observedPending := 0.0 for driver, last := range latest { observation, known := store.OnlineBatteryControlPower(driver, now, 15*time.Second) diff --git a/go/internal/control/ev_battery_pending_targets_test.go b/go/internal/control/ev_battery_pending_targets_test.go new file mode 100644 index 000000000..793a9f4fa --- /dev/null +++ b/go/internal/control/ev_battery_pending_targets_test.go @@ -0,0 +1,103 @@ +package control + +import ( + "encoding/json" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "testing" + "time" +) + +func newEVBatteryTransitionTestState(t *testing.T, kind string) (*telemetry.Store, *State, map[string]float64, float64) { + t.Helper() + store := telemetry.NewStore() + soc := 0.6 + low := 0.04 + data, _ := json.Marshal(map[string]float64{"l1_a": 22.3, "l2_a": 12.3, "l3_a": 12.3, "l1_w": 22.3 * 230, "l2_w": 12.3 * 230, "l3_w": 12.3 * 230}) + store.Update("review_meter", telemetry.DerMeter, 10787, nil, data) + store.RecordDriverSuccess("review_meter") + a, b := -2000.0, 0.0 + if kind == "battery_at_soc_floor" { + a, b = -1000, -2000 + } + store.Update("review_a", telemetry.DerBattery, a, &soc, nil) + store.RecordDriverSuccess("review_a") + store.Update("review_ev", telemetry.DerEV, 10350, nil, nil) + store.RecordDriverSuccess("review_ev") + caps := map[string]float64{"review_a": 10000} + if kind == "battery_at_soc_floor" { + store.Update("review_a", telemetry.DerBattery, a, &low, nil) + store.Update("review_b", telemetry.DerBattery, b, &soc, nil) + store.RecordDriverSuccess("review_b") + caps["review_b"] = 10000 + } + now := time.Now().Add(time.Millisecond) + st := NewState(0, 0, "review_meter") + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + st.SiteFuseSafetyA = 1 + st.Mode = ModeIdle + st.SlewEnabled = false + st.MinDispatchIntervalS = 0 + st.DriverLimits = map[string]PowerLimits{"review_a": {MaxChargeW: 5000, MaxDischargeW: 5000}, "review_b": {MaxChargeW: 5000, MaxDischargeW: 5000}} + st.clock = func() time.Time { return now.Add(-time.Hour) } + st.SetEVCommanded([]loadpoint.State{{ID: "review_lp", DriverName: "review_ev", PluggedIn: true, CommandedKnown: true, CommandedW: 10350, CommandedPhases: 3}}) + st.clock = func() time.Time { return now } + st.SetEVFuseDemands([]loadpoint.FuseDemand{{DriverName: "review_ev", PowerW: 10350, PhaseMode: "3p"}}) + st.evOffers = commandLog{}.add(now.Add(-time.Hour), 10350) + st.batteryCommands = map[string]commandLog{"review_a": commandLog{}.add(now.Add(-time.Hour), a)} + pending := 0.0 + if kind == "pending_battery_without_target" { + st.batteryCommands["review_b"] = commandLog{}.add(now.Add(-time.Hour), 0).add(now.Add(-2*time.Second), 1000) + pending = 1000 + } + if kind == "battery_at_soc_floor" { + st.batteryCommands["review_b"] = commandLog{}.add(now.Add(-time.Hour), b) + } + return store, st, caps, pending +} +func TestEVBatteryPendingImportAcrossTargets(t *testing.T) { + for _, kind := range []string{"single_control", "pending_battery_without_target", "battery_at_soc_floor"} { + for _, path := range []string{"allocation", "idle", "manual"} { + t.Run(kind+"/"+path, func(t *testing.T) { + store, st, caps, pending := newEVBatteryTransitionTestState(t, kind) + targets := []DispatchTarget{{Driver: "review_a", TargetW: 0}} + initial := -2000.0 + if kind == "battery_at_soc_floor" { + targets = append(targets, DispatchTarget{Driver: "review_b", TargetW: 0}) + initial = -3000 + } + var out []DispatchTarget + if path == "allocation" { + out = clampEVBatteryHeadroom(targets, store, st, 17250) + } else { + if path == "manual" { + st.SetBatteryManualHold(BatteryManualHold{PowerW: 0, ExpiresAt: st.now().Add(time.Hour)}) + } + out = ComputeDispatch(store, st, caps, 17250) + } + total := 0.0 + for _, v := range out { + total += v.TargetW + } + projected := 22.3 + (pending+total-initial)/690 + t.Logf("review result target_w=%.3f projected_a=%.6f", total, projected) + if len(out) != len(targets) { + t.Fatalf("missing battery targets") + } + if projected > 24.001 || math.Abs(projected-24) > 0.01 { + t.Fatalf("unsafe or excessive relief: projected phase %.6f A; target %.3f W", projected, total) + } + if kind == "battery_at_soc_floor" { + for _, v := range out { + if v.Driver == "review_a" && v.TargetW < 0 { + t.Fatalf("battery below floor was retained") + } + } + } + }) + } + } +}