From 5c85bd6efa6bb1fd611db2a7c7046529e9f635a3 Mon Sep 17 00:00:00 2001 From: Segran Date: Tue, 20 Oct 2026 13:58:29 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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) + } + }) + } +}