Skip to content

Commit a217d77

Browse files
frahlgclaude
andauthored
fix(control): act only on new site-meter samples in battery dispatch (#1879)
* fix(control): act only on new site-meter samples in battery dispatch A failed meter poll keeps the old reading, and the dispatch gate passes it until the 60 s watchdog expires. ComputeDispatch re-applied the same held error every tick and wound the battery to full power in the wrong direction. Track the meter sample the last dispatch used. Without a newer one, the PI path holds its targets and skips integration, and every other path may only move a battery toward 0 W. Item A1 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): keep safety cuts on held meter ticks The held-meter exit in the PI path returned before the cuts the deadband exit keeps: a battery discharging under max_discharge_w=0, charging while blocked, or discharging against a no-discharge plan. While the meter was held, those cuts were not sent for up to 60 s. Share one condition with the deadband exit. When it fires on a held tick, skip the PI with no correction and let the clamps, holdTowardZero and the safety pipeline cut the battery. Also drop the startup clause that treated a failed latest read as held before the first dispatch. A poll that emits and then errors leaves that flag set on every tick, so a fresh start never dispatched although each tick brought a new sample. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent edaf041 commit a217d77

6 files changed

Lines changed: 265 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
Stop a frozen site meter from driving the home battery. When the meter has no reading newer than the one the last battery order used, for example while its reads fail inside the watchdog window, the battery keeps its last order and may only move toward 0 W. Before, a held +2 kW import reading wound the battery to 10 kW discharge in ten ticks while the site exported 8 kW.

‎go/internal/control/battery_full_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func TestFullBatteryManualStopDischargeAndSibling(t *testing.T) {
6060
}
6161
}
6262
st.SetBatteryManualHold(BatteryManualHold{PowerW: -2000, ExpiresAt: time.Now().Add(time.Hour)})
63+
tel.Update("ferroamp", telemetry.DerMeter, -1000, nil, nil) // a new sample allows the reversal
6364
got = ComputeDispatch(tel, st, capacities, 13800)
6465
for _, target := range got {
6566
if target.TargetW >= 0 {

‎go/internal/control/battery_soc_max_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func TestSoCMaxManualHoldAndSibling(t *testing.T) {
4949
t.Fatalf("charge must go to the battery below its own ceiling: %+v", got)
5050
}
5151
st.SetBatteryManualHold(BatteryManualHold{PowerW: -2000, ExpiresAt: time.Now().Add(time.Hour)})
52+
tel.Update("ferroamp", telemetry.DerMeter, -1000, nil, nil) // a new sample allows the reversal
5253
got = ComputeDispatch(tel, st, capacities, 13800)
5354
if targetFor(t, got, "battery").TargetW >= 0 {
5455
t.Fatalf("charge ceiling blocked discharge: %+v", got)

‎go/internal/control/control_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ func TestDispatchWrongDirectionWindupDecaysPIIntegral(t *testing.T) {
143143
// every subsequent cycle (that's the 2026-05-25 sunrise regression).
144144
var lastTarget float64
145145
for i := 0; i < 5; i++ {
146+
store.Update("ferroamp", telemetry.DerMeter, -1000, nil, nil) // the PI acts once per new sample
146147
targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
147148
if len(targets) == 1 {
148149
lastTarget = targets[0].TargetW

‎go/internal/control/dispatch.go‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,9 @@ type State struct {
405405
// meterClampLogged records that the live-meter clamp was already
406406
// reported, so it is logged when it engages rather than every tick.
407407
meterClampLogged bool
408+
// dispatchMeterAt is the UpdatedAt of the last site-meter sample the
409+
// battery dispatch acted on. See siteMeterHeld.
410+
dispatchMeterAt time.Time
408411

409412
// Slew + holdoff
410413
SlewRateW float64
@@ -1456,6 +1459,7 @@ func ComputeDispatch(
14561459
if siteMeter != nil {
14571460
rawGridW = siteMeter.SmoothedW
14581461
}
1462+
meterHeld := state.siteMeterHeld(siteMeter)
14591463
// Live EV/V2X charger readings override the manual slider on each tick —
14601464
// hardware truth beats guesses. Positive V2X is a vehicle charging load;
14611465
// negative V2X is kept separate so the default controller does not
@@ -1775,6 +1779,24 @@ func ComputeDispatch(
17751779
// already handled above)
17761780
// - planner_cheap / planner_arbitrage when UseEnergyDispatch=false
17771781

1782+
// A battery doing what a limit forbids. The deadband exit below
1783+
// and the held-meter exit both fall through to cut it.
1784+
batteryBreaksLimit := (noSelfDischarge && anyBatteryDischarging(onlineBats)) ||
1785+
anyExplicitZeroDischargeViolation(onlineBats) ||
1786+
anyBlockedBatteryCharging(onlineBats, noSelfCharge)
1787+
1788+
// The PI acts on the meter alone. A held sample carries no new
1789+
// error, so hold the targets and do not integrate it again. When
1790+
// a battery breaks a limit, skip the PI with no correction; the
1791+
// clamps, holdTowardZero and the safety pipeline then cut it.
1792+
if meterHeld {
1793+
if !batteryBreaksLimit {
1794+
return fuseSaverEarlyExit(store, state, driverCapacities, fuseMaxW)
1795+
}
1796+
totalCorrection = 0
1797+
break
1798+
}
1799+
17781800
// Surplus-only EV reserve (legacy/reactive path): when an EV
17791801
// under a surplus_only loadpoint is connected, bias the grid
17801802
// signal so the PI leaves `reserveRemaining` of export untouched
@@ -1863,10 +1885,7 @@ func ComputeDispatch(
18631885
// full pipeline ends in the same forceFuseDischarge the
18641886
// early exit's fuse-saver would have run.
18651887
surplusActive := state.EVSurplusOnlyReserveW > 0 && effectiveMode == ModeSelfConsumption
1866-
if !surplusActive && math.Abs(errW) < state.GridToleranceW &&
1867-
!(noSelfDischarge && anyBatteryDischarging(onlineBats)) &&
1868-
!anyExplicitZeroDischargeViolation(onlineBats) &&
1869-
!anyBlockedBatteryCharging(onlineBats, noSelfCharge) {
1888+
if !surplusActive && math.Abs(errW) < state.GridToleranceW && !batteryBreaksLimit {
18701889
// A small grid error is not a statement that the site is
18711890
// safe. errW compares one aggregate number against one
18721891
// aggregate target; two protections bind on numbers this
@@ -2236,6 +2255,9 @@ func ComputeDispatch(
22362255
// per-driver cap (DriverLimits, falling back to MaxCommandW) so we
22372256
// never issue a command outside safe bounds.
22382257
raw = clampTargetsToPowerLimits(raw, state.DriverLimits)
2258+
if meterHeld {
2259+
raw = holdTowardZero(raw, state.PrevTargets)
2260+
}
22392261

22402262
// ---- Fuse guard (bidirectional, #145) ----
22412263
return applyDispatchSafetyPipeline(raw, store, state, driverCapacities, fuseMaxW, dispatchSafetyOptions{
@@ -4083,6 +4105,40 @@ func fuseSaverEarlyExit(
40834105
return out
40844106
}
40854107

4108+
// siteMeterHeld reports whether the site meter has no sample newer than the
4109+
// one the last dispatch acted on. A failed poll keeps the old reading and its
4110+
// UpdatedAt, and the dispatch gate passes it until the watchdog expires. Acting
4111+
// on it again moves the battery one more held error each tick: a held +2 kW
4112+
// import wound a battery to -10 kW in ten ticks while the site exported 8 kW.
4113+
// A failed read does not count by itself: a poll that emits and then errors
4114+
// still delivers a new sample.
4115+
// The EV fuse limit follows the same rule (ev_fuse_budget.go).
4116+
func (s *State) siteMeterHeld(meter *telemetry.DerReading) bool {
4117+
if meter == nil {
4118+
return false
4119+
}
4120+
if !meter.UpdatedAt.After(s.dispatchMeterAt) {
4121+
return true
4122+
}
4123+
s.dispatchMeterAt = meter.UpdatedAt
4124+
return false
4125+
}
4126+
4127+
// holdTowardZero keeps each target between 0 and the last command sent to
4128+
// that battery. Without a new meter sample, dispatch may stop or reduce a
4129+
// battery but must not raise one. A battery with no earlier command gets 0.
4130+
func holdTowardZero(targets []DispatchTarget, prev map[string]float64) []DispatchTarget {
4131+
for i := range targets {
4132+
p := prev[targets[i].Driver]
4133+
w := math.Max(math.Min(targets[i].TargetW, math.Max(p, 0)), math.Min(p, 0))
4134+
if w != targets[i].TargetW {
4135+
targets[i].TargetW = w
4136+
targets[i].Clamped = true
4137+
}
4138+
}
4139+
return targets
4140+
}
4141+
40864142
// holdFleetAtZero builds the forced-idle command set: 0 W for every battery
40874143
// this dispatcher is allowed to command. Eligibility is the same predicate the
40884144
// main path's "gather online batteries" applies — a health record that reports
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package control
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
"time"
7+
8+
"github.com/srcfl/ftw/go/internal/telemetry"
9+
)
10+
11+
// The site meter's reads fail (held reading, no new UpdatedAt) while the
12+
// battery keeps reporting fresh telemetry. Within the 60 s watchdog window
13+
// dispatch must not act on the held meter value again and again.
14+
func runFrozenMeter(t *testing.T, heldGridW, realHouseW float64) (lastCmd, realGrid float64) {
15+
t.Helper()
16+
store := telemetry.NewStore()
17+
store.Update("meter", telemetry.DerMeter, heldGridW, nil, nil)
18+
store.DriverHealthMut("meter").RecordSuccess()
19+
soc := 0.5
20+
store.Update("bat", telemetry.DerBattery, 0, &soc, nil)
21+
store.DriverHealthMut("bat").RecordSuccess()
22+
23+
st := NewState(0, 50, "meter")
24+
st.Mode = ModeSelfConsumption
25+
clk := time.Now()
26+
st.clock = func() time.Time { return clk }
27+
capsMap := caps(map[string]float64{"bat": 10000})
28+
st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}}
29+
frozenAt := store.Get("meter", telemetry.DerMeter).UpdatedAt
30+
31+
batW := 0.0
32+
for tick := 0; tick < 10; tick++ { // 10 ticks x 5 s = 50 s < 60 s watchdog
33+
// Meter poll fails: RecordError, reading held.
34+
store.DriverHealthMut("meter").RecordError("modbus timeout")
35+
targets := ComputeDispatch(store, st, capsMap, 3*25*230)
36+
for _, tg := range targets {
37+
if tg.Driver == "bat" {
38+
batW = tg.TargetW
39+
}
40+
}
41+
// Battery follows its command and reports it (fresh).
42+
for i := 0; i < 30; i++ {
43+
store.Update("bat", telemetry.DerBattery, batW, &soc, nil)
44+
}
45+
store.DriverHealthMut("bat").RecordSuccess()
46+
t.Logf("tick %d: bat cmd %.0f W, real grid %.0f W, meter says %.0f W",
47+
tick, batW, realHouseW+batW, store.Get("meter", telemetry.DerMeter).SmoothedW)
48+
clk = clk.Add(5 * time.Second)
49+
}
50+
if !store.Get("meter", telemetry.DerMeter).UpdatedAt.Equal(frozenAt) {
51+
t.Fatal("meter reading moved")
52+
}
53+
return batW, realHouseW + batW
54+
}
55+
56+
func TestFrozenImportMeterDoesNotDischargeToExport(t *testing.T) {
57+
cmd, grid := runFrozenMeter(t, 2000, 2000)
58+
if grid < -500 {
59+
t.Fatalf("frozen +2000 W meter drove battery to %.0f W; real grid %.0f W (export)", cmd, grid)
60+
}
61+
}
62+
63+
func TestFrozenExportMeterDoesNotChargeFromGrid(t *testing.T) {
64+
cmd, grid := runFrozenMeter(t, -3000, 1500)
65+
if grid > 3000 {
66+
t.Fatalf("frozen -3000 W meter drove battery to %.0f W; real grid %.0f W (import)", cmd, grid)
67+
}
68+
}
69+
70+
// A meter that stops updating after a good sample holds the battery at its
71+
// last order and lets a manual hold only cut toward 0. The next new sample
72+
// releases the hold.
73+
func TestHeldMeterOnlyCutsTowardZero(t *testing.T) {
74+
store := seedStore(2000, []struct {
75+
name string
76+
currentW, soc float64
77+
}{{"bat", 0, 0.5}})
78+
st := NewState(0, 50, "ferroamp")
79+
st.Mode = ModeSelfConsumption
80+
st.MinDispatchIntervalS = 0
81+
st.SlewEnabled = false
82+
capsMap := caps(map[string]float64{"bat": 10000})
83+
st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}}
84+
85+
first := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW
86+
if first >= 0 {
87+
t.Fatalf("fresh +2000 W import did not discharge: %.0f W", first)
88+
}
89+
store.Update("bat", telemetry.DerBattery, first, ptrF64(0.5), nil)
90+
if got := ComputeDispatch(store, st, capsMap, 3*25*230); len(got) != 0 {
91+
t.Fatalf("held meter sample produced new orders: %+v", got)
92+
}
93+
94+
st.SetBatteryManualHold(BatteryManualHold{PowerW: first / 2, ExpiresAt: time.Now().Add(time.Hour)})
95+
if got := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW; got != first/2 {
96+
t.Fatalf("cut toward 0 on a held sample: got %.0f W, want %.0f W", got, first/2)
97+
}
98+
st.SetBatteryManualHold(BatteryManualHold{PowerW: 3000, ExpiresAt: time.Now().Add(time.Hour)})
99+
if got := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW; got != 0 {
100+
t.Fatalf("held sample allowed a reversal to charge: %.0f W", got)
101+
}
102+
103+
store.Update("ferroamp", telemetry.DerMeter, 2000, nil, nil)
104+
if got := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW; got != 3000 {
105+
t.Fatalf("new sample did not release the hold: %.0f W", got)
106+
}
107+
}
108+
109+
func heldMeterState() (*State, map[string]float64) {
110+
st := NewState(0, 50, "ferroamp")
111+
st.Mode = ModeSelfConsumption
112+
st.MinDispatchIntervalS = 0
113+
st.SlewEnabled = false
114+
st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}}
115+
return st, caps(map[string]float64{"bat": 10000})
116+
}
117+
118+
func reportBattery(store *telemetry.Store, w float64, data json.RawMessage) {
119+
soc := 0.5
120+
for i := 0; i < 40; i++ {
121+
store.Update("bat", telemetry.DerBattery, w, &soc, data)
122+
}
123+
store.DriverHealthMut("bat").RecordSuccess()
124+
}
125+
126+
func sentTo(targets []DispatchTarget, driver string) *DispatchTarget {
127+
for i := range targets {
128+
if targets[i].Driver == driver {
129+
return &targets[i]
130+
}
131+
}
132+
return nil
133+
}
134+
135+
// A held meter must not keep a battery discharging after max_discharge_w
136+
// becomes an explicit 0. The deadband exit falls through for this; so must
137+
// the held exit.
138+
func TestHeldMeterStillSendsExplicitZeroDischarge(t *testing.T) {
139+
store := seedStore(2000, []struct {
140+
name string
141+
currentW, soc float64
142+
}{{"bat", 0, 0.5}})
143+
st, capsMap := heldMeterState()
144+
first := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW
145+
if first >= 0 {
146+
t.Fatalf("fresh +2000 W import did not discharge: %.0f W", first)
147+
}
148+
reportBattery(store, first, nil)
149+
st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 0, MaxDischargeWSet: true}}
150+
sent := sentTo(ComputeDispatch(store, st, capsMap, 3*25*230), "bat")
151+
if sent == nil || sent.TargetW < 0 {
152+
t.Fatalf("held meter left battery discharging at %.0f W under max_discharge_w=0: %+v", first, sent)
153+
}
154+
}
155+
156+
// A held meter must not keep a battery charging after its driver reports
157+
// charge_capable=false.
158+
func TestHeldMeterStillStopsBlockedCharge(t *testing.T) {
159+
store := seedStore(-2000, []struct {
160+
name string
161+
currentW, soc float64
162+
}{{"bat", 0, 0.5}})
163+
st, capsMap := heldMeterState()
164+
first := targetFor(t, ComputeDispatch(store, st, capsMap, 3*25*230), "bat").TargetW
165+
if first <= 0 {
166+
t.Fatalf("fresh -2000 W export did not charge: %.0f W", first)
167+
}
168+
reportBattery(store, first, json.RawMessage(`{"charge_capable":false}`))
169+
sent := sentTo(ComputeDispatch(store, st, capsMap, 3*25*230), "bat")
170+
if sent == nil || sent.TargetW > 0 {
171+
t.Fatalf("held meter left battery charging at %.0f W after charge_capable=false: %+v", first, sent)
172+
}
173+
}
174+
175+
// A meter poll that emits and then returns an error leaves the latest read
176+
// marked failed on every tick, but each tick still brings a new sample.
177+
// Dispatch must act on it from a fresh start.
178+
func TestEmitThenErrorMeterIsNotHeld(t *testing.T) {
179+
store := seedStore(2000, []struct {
180+
name string
181+
currentW, soc float64
182+
}{{"bat", 0, 0.5}})
183+
st, capsMap := heldMeterState()
184+
acted := false
185+
for tick := 0; tick < 10; tick++ {
186+
time.Sleep(time.Millisecond) // UpdatedAt is wall-clock time
187+
store.Update("ferroamp", telemetry.DerMeter, 2000, nil, nil)
188+
store.DriverHealthMut("ferroamp").RecordSuccess()
189+
store.DriverHealthMut("ferroamp").RecordError("secondary register timeout")
190+
if sent := sentTo(ComputeDispatch(store, st, capsMap, 3*25*230), "bat"); sent != nil && sent.TargetW < 0 {
191+
acted = true
192+
}
193+
}
194+
if !acted {
195+
t.Fatal("ten new +2000 W meter samples, battery never dispatched")
196+
}
197+
}

0 commit comments

Comments
 (0)