diff --git a/.changeset/control-stale-load-soc.md b/.changeset/control-stale-load-soc.md new file mode 100644 index 000000000..c4e698e35 --- /dev/null +++ b/.changeset/control-stale-load-soc.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Ignore offline battery and PV watts when computing household load, refuse to +discharge a battery that has never reported SoC, and require a fresh site +meter before live PV curtail uses live load. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 968fd2c0e..d627d5add 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -1674,15 +1674,16 @@ func ComputeDispatch( if r == nil || h == nil { continue } - // Default to near-empty SoC so dispatch errs on the side of - // caution (no discharge) if a battery never reports SoC. - // Using 0.5 would allow discharge of a potentially empty battery. - soc := 0.1 + soc := 0.0 + lim := state.DriverLimits[name] + dischargeBlocked, chargeBlocked := batteryDirectionBlocks(r.Data) if r.SoC != nil { soc = *r.SoC + } else { + // Never reported SoC: do not discharge a pack we cannot + // prove has energy. Charge-from-surplus stays allowed. + dischargeBlocked = true } - lim := state.DriverLimits[name] - dischargeBlocked, chargeBlocked := batteryDirectionBlocks(r.Data) // A configured zero is a hard direction block, not the legacy // "use MaxCommandW" sentinel. Feed it into the allocator as well as // the clamps so capable siblings receive the blocked battery's share. @@ -2719,11 +2720,32 @@ func applyDispatchSafetyPipeline( if state != nil { targets = clampTargetsToPowerLimits(targets, state.DriverLimits) } + targets = floorMissingSoCDischarge(targets, store) republishFuseEVCapAfterFuseDischarge(targets, store, state, fuseMaxW) recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) return targets } +// Unknown battery energy cannot support a discharge command, even during +// fuse relief. Check the final targets so slew cannot restore live discharge +// and EV headroom reflects only the battery commands we can actually send. +func floorMissingSoCDischarge(targets []DispatchTarget, store *telemetry.Store) []DispatchTarget { + for i := range targets { + if targets[i].TargetW >= 0 { + continue + } + if store != nil { + r := store.Get(targets[i].Driver, telemetry.DerBattery) + if r != nil && r.SoC != nil { + continue + } + } + targets[i].TargetW = 0 + targets[i].Clamped = true + } + return targets +} + func republishFuseEVCapAfterFuseDischarge(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) { // The joint allocator computes FuseEVMaxW assuming the battery target it // produced is what gets dispatched. forceFuseDischarge may flip that target @@ -2942,9 +2964,12 @@ func protectiveCurtailLimitW(state *State, store *telemetry.Store) (float64, boo // siteLoadW reads the household load (W) from the site meter when // available. Mirrors the formula main.go uses for status: load = -// gridW - battery - PV - EV - V2X (site convention). Falls back to 0 on -// missing telemetry, which makes protectiveCurtailLimitW degrade -// safely to "don't engage" rather than to a bogus tiny limit. +// gridW - battery - PV - EV - V2X (site convention). Battery and PV +// follow SumOnlineEVW: watchdog-offline last-known watts are ignored +// so a dead inverter cannot inflate load and skip export protection. +// Falls back to 0 on missing telemetry, which makes +// protectiveCurtailLimitW degrade safely to "don't engage" rather than +// to a bogus tiny limit. func siteLoadW(state *State, store *telemetry.Store) float64 { if state == nil || store == nil || state.SiteMeterDriver == "" { return 0 @@ -2954,20 +2979,33 @@ func siteLoadW(state *State, store *telemetry.Store) float64 { return 0 } gridW := mtr.SmoothedW - var batW, pvW float64 - for _, r := range store.ReadingsByType(telemetry.DerBattery) { - batW += r.SmoothedW - } - for _, r := range store.ReadingsByType(telemetry.DerPV) { - pvW += r.SmoothedW - } - load := gridW - batW - pvW - store.SumOnlineEVW() - store.SumOnlineV2XW() + load := gridW - + sumOnlineSignedW(store, telemetry.DerBattery) - + sumOnlineSignedW(store, telemetry.DerPV) - + store.SumOnlineEVW() - + store.SumOnlineV2XW() if load < 0 { return 0 } return load } +// sumOnlineSignedW mirrors telemetry.SumOnlineEVW for one DER type: +// last-known watts from a watchdog-offline driver do not enter load math. +func sumOnlineSignedW(store *telemetry.Store, t telemetry.DerType) float64 { + if store == nil { + return 0 + } + var sum float64 + for _, r := range store.ReadingsByType(t) { + if !store.DriverHealth(r.Driver).TelemetryLive() { + continue + } + sum += r.SmoothedW + } + return sum +} + func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { if state == nil { return nil @@ -3202,6 +3240,11 @@ const curtailMinPerDriverW = 1.0 // anywhere meaningful to put it. const pvCurtailBatterySoCMax = 0.99 +// liveMeterMaxAge is the inner freshness window for liveCurtailLimitW. +// Matches the PV generation proof window in pv_plan.go. The main-loop +// watchdog still gates dispatch; this catches callers that skipped it. +const liveMeterMaxAge = 90 * time.Second + // liveCurtailLimitW computes the cap PV may produce *right now* given // the planner's decision that curtail is economically warranted for // this slot. It rolls together three runtime quantities the planner @@ -3228,27 +3271,11 @@ const pvCurtailBatterySoCMax = 0.99 // curtail dispatch upstream skips curtail entirely (the cap doesn't // bind anything). func liveCurtailLimitW(state *State, store *telemetry.Store) (float64, bool) { - if state == nil || store == nil { - return 0, false - } - - // Require a fresh site-meter reading. Without it we can't compute - // live load and shouldn't be making live decisions — defer to the - // planner's static value instead. - var gridW float64 - if state.SiteMeterDriver == "" { - return 0, false - } - if m := store.Get(state.SiteMeterDriver, telemetry.DerMeter); m != nil { - gridW = m.RawW - } else if m := store.Get(state.SiteMeterDriver, telemetry.DerBattery); m != nil { - // Some site-meter drivers (e.g. ferroamp) emit grid flow on - // the battery channel because the same driver also owns the - // battery. Accept that as the meter reading. - gridW = m.RawW - } else { + m, ok := liveSiteMeter(state, store) + if !ok { return 0, false } + gridW := m.RawW // Live PV (positive watts of generation). var pvW float64 @@ -3313,6 +3340,33 @@ func liveCurtailLimitW(state *State, store *telemetry.Store) (float64, bool) { return liveLoadW + batHeadroomW + evReserveW, true } +// liveSiteMeter returns the configured site meter's live reading. +// Missing, watchdog-offline, or older-than-liveMeterMaxAge → ok=false. +// DerBattery is accepted only for that same configured driver when it +// has no DerMeter channel (Ferroamp combined owner). Any other battery +// is not the grid. +func liveSiteMeter(state *State, store *telemetry.Store) (*telemetry.DerReading, bool) { + if state == nil || store == nil || state.SiteMeterDriver == "" { + return nil, false + } + m := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if m == nil { + m = store.Get(state.SiteMeterDriver, telemetry.DerBattery) + } + if m == nil { + return nil, false + } + if !store.DriverHealth(state.SiteMeterDriver).TelemetryLive() { + return nil, false + } + now := state.now() + age := now.Sub(m.UpdatedAt) + if m.UpdatedAt.IsZero() || age < 0 || age > liveMeterMaxAge { + return nil, false + } + return m, true +} + func distributeScopedManualHold(bats []batteryInfo, driver string, powerW float64) []DispatchTarget { out := make([]DispatchTarget, 0, len(bats)) for _, b := range bats { @@ -4546,12 +4600,8 @@ func holdFleetAtZero(store *telemetry.Store, capacities map[string]float64) []Di func fuseTargetBounds(r *telemetry.DerReading, lim PowerLimits) (lower, upper float64) { lower = -lim.dischargeCap() upper = lim.chargeCap() - soc := 0.1 - if r.SoC != nil { - soc = *r.SoC - } dischargeBlocked, chargeBlocked := batteryDirectionBlocks(r.Data) - if soc < 0.05 || dischargeBlocked { + if r.SoC == nil || *r.SoC < 0.05 || dischargeBlocked { lower = 0 } if chargeBlocked { diff --git a/go/internal/control/stale_load_soc_test.go b/go/internal/control/stale_load_soc_test.go new file mode 100644 index 000000000..c74e61b6d --- /dev/null +++ b/go/internal/control/stale_load_soc_test.go @@ -0,0 +1,205 @@ +package control + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Last-known −4 kW PV from a watchdog-offline driver must not inflate +// household load. Mirrors TestSumOnlineEVWSkipsOfflineDrivers: live PV +// plus a corpse reading used to skip DC-link export protection. +func TestSiteLoadWIgnoresOfflinePV(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -5500) + emitPV(t, store, "live-pv", -6000) + emitPV(t, store, "dead-pv", -4000) + store.DriverHealthMut("dead-pv").SetOffline() + + got := siteLoadW(st, store) + if math.Abs(got-500) > 1 { + t.Errorf("siteLoadW = %.1f, want 500 (offline −4 kW PV ignored)", got) + } +} + +func TestSiteLoadWIgnoresOfflineBattery(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -5500) + emitPV(t, store, "pv", -6000) + emitBattery(t, store, "dead-bat", -5000, 0.5) + store.DriverHealthMut("dead-bat").SetOffline() + + got := siteLoadW(st, store) + if math.Abs(got-500) > 1 { + t.Errorf("siteLoadW = %.1f, want 500 (offline battery ignored)", got) + } +} + +func TestProtectivePVCurtailIgnoresOfflinePV(t *testing.T) { + store := telemetry.NewStore() + store.Update("ferroamp", telemetry.DerPV, -6000, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + store.Update("dead-pv", telemetry.DerPV, -4000, nil, nil) + store.DriverHealthMut("dead-pv").SetOffline() + soc := 0.85 + store.Update("ferroamp", telemetry.DerBattery, 0, &soc, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + store.Update("ferroamp", telemetry.DerMeter, -5500, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + + st := NewState(0, 0, "ferroamp") + st.SupportsPVCurtail = map[string]bool{"ferroamp": true} + st.DCLinkProtectionEnabled = true + st.DCLinkProtectionSoCThreshold = 0.80 + st.DCLinkProtectionMarginW = 1000 + + limit, ok := protectiveCurtailLimitW(st, store) + if !ok { + t.Fatal("protection must still engage; offline PV must not inflate load") + } + if math.Abs(limit-1500) > 50 { + t.Errorf("protective limit = %f, want ≈ 1500", limit) + } +} + +func TestMissingSoCDoesNotDischarge(t *testing.T) { + store := telemetry.NewStore() + store.Update("ferroamp", telemetry.DerMeter, 2000, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + store.Update("ferroamp", telemetry.DerBattery, 0, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + + st := NewState(0, 50, "ferroamp") + st.Mode = ModeSelfConsumption + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + for _, tg := range ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) { + if tg.TargetW < 0 { + t.Errorf("missing SoC must not discharge, got %+v", tg) + } + } +} + +func TestMissingSoCAllowsChargeFromSurplus(t *testing.T) { + store := telemetry.NewStore() + store.Update("ferroamp", telemetry.DerMeter, -2000, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + store.Update("ferroamp", telemetry.DerBattery, 0, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + + st := NewState(0, 50, "ferroamp") + st.Mode = ModeSelfConsumption + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + if len(targets) != 1 || targets[0].TargetW <= 0 { + t.Errorf("missing SoC should still allow charge-from-surplus, got %+v", targets) + } +} + +func TestMissingSoCDischargeStaysBlockedThroughSafety(t *testing.T) { + for _, tc := range []struct { + name string + gridW float64 + batteryW float64 + mode Mode + holdoff bool + }{ + {name: "slew from autonomous discharge", gridW: 2000, batteryW: -1000, mode: ModeSelfConsumption}, + {name: "fuse relief from idle", gridW: 15000, mode: ModeIdle}, + {name: "fuse relief cancels charge", gridW: 15000, batteryW: 1000, mode: ModeSelfConsumption}, + {name: "fuse relief during dispatch holdoff", gridW: 15000, mode: ModeSelfConsumption, holdoff: true}, + } { + t.Run(tc.name, func(t *testing.T) { + store := telemetry.NewStore() + emitMeter(t, store, "meter", tc.gridW) + store.Update("battery", telemetry.DerBattery, tc.batteryW, nil, nil) + store.DriverHealthMut("battery").RecordSuccess() + st := NewState(0, 50, "meter") + st.Mode = tc.mode + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + if tc.holdoff { + now := st.now() + st.LastDispatch = &now + st.MinDispatchIntervalS = 60 + } + targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 15200}), 11040) + if !tc.holdoff && len(targets) != 1 { + t.Fatalf("expected a battery command, got %+v", targets) + } + for _, target := range targets { + if target.TargetW < 0 { + t.Errorf("unknown SoC cannot authorize discharge: %+v", target) + } + } + for _, target := range st.LastTargets { + if target.TargetW < 0 { + t.Errorf("recorded a discharge command without SoC: %+v", target) + } + } + }) + } +} + +func TestMissingSoCFuseReliefUsesKnownBattery(t *testing.T) { + store := telemetry.NewStore() + emitMeter(t, store, "meter", 15000) + store.Update("unknown", telemetry.DerBattery, 0, nil, nil) + store.DriverHealthMut("unknown").RecordSuccess() + emitBattery(t, store, "known", 0, 0.5) + st := NewState(0, 50, "meter") + st.Mode = ModeIdle + st.MinDispatchIntervalS = 0 + targets := ComputeDispatch(store, st, caps(map[string]float64{"unknown": 15200, "known": 15200}), 11040) + knownDischarges := false + for _, target := range targets { + if target.Driver == "unknown" && target.TargetW < 0 { + t.Errorf("unknown battery received fuse discharge: %+v", target) + } + if target.Driver == "known" && target.TargetW < 0 { + knownDischarges = true + } + } + if !knownDischarges { + t.Fatalf("known battery should still provide fuse relief, got %+v", targets) + } +} + +func TestLiveCurtailLimitWStaleMeter(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -2000) + emitPV(t, store, "sungrow", -4000) + if _, ok := liveCurtailLimitW(st, store); !ok { + t.Fatal("fresh meter should yield a live cap") + } + store.Get("meter", telemetry.DerMeter).UpdatedAt = st.now().Add(-2 * time.Minute) + if _, ok := liveCurtailLimitW(st, store); ok { + t.Fatal("stale UpdatedAt on DerMeter must not yield a live cap") + } +} + +func TestLiveCurtailLimitWMissingMeterDoesNotUseBattery(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitBattery(t, store, "ferroamp", 5000, 0.5) + emitPV(t, store, "sungrow", -4000) + if limit, ok := liveCurtailLimitW(st, store); ok { + t.Fatalf("missing meter must not treat battery watts as grid (ok=true, limit=%.1f)", limit) + } +} + +func TestLiveCurtailLimitWOfflineMeter(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -2000) + store.DriverHealthMut("meter").SetOffline() + if _, ok := liveCurtailLimitW(st, store); ok { + t.Fatal("watchdog-offline meter must not yield a live cap") + } +}