From 7e77f0521f400fcf31d142b9c4a53f9fff0626b5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 11 Aug 2026 07:15:39 +0200 Subject: [PATCH] fix(control): mitigate PV-only export over limits --- .changeset/reactive-pv-export-cap.md | 5 + go/internal/control/dispatch.go | 39 +++++- go/internal/control/pv_curtail_test.go | 165 +++++++++++++++++++++++++ go/internal/control/pv_export_guard.go | 126 +++++++++++++++++++ 4 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 .changeset/reactive-pv-export-cap.md create mode 100644 go/internal/control/pv_export_guard.go diff --git a/.changeset/reactive-pv-export-cap.md b/.changeset/reactive-pv-export-cap.md new file mode 100644 index 000000000..cdc09cb64 --- /dev/null +++ b/.changeset/reactive-pv-export-cap.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Fresh site-meter data now makes Core trim curtail-capable solar when solar alone pushes export above the configured site limit. Core reports any overage it cannot remove without an unsafe zero-watt cap or control of unsupported solar. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index ca6258087..b34289fc8 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -566,6 +566,15 @@ type State struct { // leave the inverter capped after a slot rolls over. LastCurtailedDrivers map[string]bool + // PVExportGuardActive says fresh meter data showed solar-attributable + // export above the effective site ceiling on this tick. + // PVExportResidualW is the part of that overage which Core could not + // remove with safe, positive caps on online curtail-capable PV. It stays + // non-zero when PV is unsupported or when clearing the overage would + // require the still-ambiguous zero-watt curtail command. + PVExportGuardActive bool + PVExportResidualW float64 + // FuseEVMaxW is the joint allocator's verdict for the EV's allowed // wattage this tick. Only meaningful when FuseSaturated is true. // Read by the loadpoint controller (via a hook) to curtail the EV @@ -2748,13 +2757,16 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { } } + guard := computePVExportGuard(state, store) + // Decide which drivers should be curtailed this tick. A hold with // LimitW == 0 is still a valid cap (force PV off on the scoped // surface) — only release entirely when there is no active hold AND // the planner isn't asking for curtail. next := map[string]float64{} - wantCurtail := holdActive || limit > 0 - if wantCurtail && store != nil && len(state.SupportsPVCurtail) > 0 { + baseCurtailActive := holdActive || limit > 0 + wantCurtail := baseCurtailActive || guard.active + if baseCurtailActive && store != nil && len(state.SupportsPVCurtail) > 0 { if scopedDriver != "" { // Driver-scoped hold: cap that one driver only, regardless // of its live |PV| (operator may want to force a verified- @@ -2804,6 +2816,29 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget { } } + // The live export guard is a safety overlay, not another planner. Merge + // its per-driver caps after the economic/manual allocation and keep the + // tighter positive cap. An existing zero cannot win here: it is not an + // active zero cap in today's wire contract and would be filtered below. + for driver, limitW := range guard.caps { + current, ok := next[driver] + if !ok || current <= 0 || limitW < current { + next[driver] = limitW + } + } + guard.residualW = pvExportResidualAfterCaps(guard.overageW, state, store, next) + previousResidualW := state.PVExportResidualW + state.PVExportGuardActive = guard.active + state.PVExportResidualW = guard.residualW + if guard.residualW > 0 && previousResidualW <= 0 { + slog.Warn("pv export guard left an uncontrollable residual", + "pv_export_w", guard.exportW, + "export_ceiling_w", guard.ceilingW, + "residual_w", guard.residualW) + } else if guard.residualW <= 0 && previousResidualW > 0 { + slog.Info("pv export guard residual cleared") + } + // Release path. A previously-curtailed driver gets an explicit // `curtail_disable` (LimitW: 0) only when one of the following is // true: diff --git a/go/internal/control/pv_curtail_test.go b/go/internal/control/pv_curtail_test.go index 2b453bf8c..7b0fe20ed 100644 --- a/go/internal/control/pv_curtail_test.go +++ b/go/internal/control/pv_curtail_test.go @@ -79,6 +79,171 @@ func TestComputePVCurtail_LimitZero_DoesNothing(t *testing.T) { } } +func exportGuardState(maxExportW float64) *State { + st := NewState(0, 100, "meter") + st.SiteFuseAmps = 16 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + st.MaxExportW = maxExportW + return st +} + +func TestComputePVCurtail_ReactsToPVOnlyExportAboveCeiling(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -7000) + emitMeter(t, store, "meter", -6000) + + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["sungrow"]-6000) > 1e-3 { + t.Fatalf("want a 6000 W cap to remove the 1000 W overage, got %+v", got) + } + if !st.PVExportGuardActive { + t.Fatal("PVExportGuardActive = false while PV-only export is over the ceiling") + } + if st.PVExportResidualW != 0 { + t.Fatalf("fully controllable overage left %.2f W residual", st.PVExportResidualW) + } +} + +func TestComputePVCurtail_UsesFuseCeilingAndSafetyMargin(t *testing.T) { + st := exportGuardState(20000) // looser than the physical fuse + st.SiteFuseSafetyA = 0.5 + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -12000) + emitMeter(t, store, "meter", -12000) + + // 16 A * 230 V * 3 phases = 11040 W. The 0.5 A safety margin is + // 345 W, so the same effective ceiling as the battery guard is 10695 W. + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["sungrow"]-10695) > 1e-3 { + t.Fatalf("want fuse-minus-margin cap 10695 W, got %+v", got) + } +} + +func TestComputePVCurtail_DoesNotAttributeStorageExportToPV(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -7000) + emitBattery(t, store, "battery", -2000, 0.8) + emitMeter(t, store, "meter", -6000) + + // Of the 6 kW site export, 2 kW comes from storage. Solar-attributable + // export is therefore 4 kW, below the 5 kW ceiling. + if got := ComputePVCurtail(st, store); got != nil { + t.Fatalf("storage export must not trigger a PV cap, got %+v", got) + } + if st.PVExportGuardActive || st.PVExportResidualW != 0 { + t.Fatalf("guard state = active:%v residual:%.2f, want inactive and zero", + st.PVExportGuardActive, st.PVExportResidualW) + } +} + +func TestComputePVCurtail_ExportGuardNeverLoosensExistingCap(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + st.SetPVManualHold(PVManualHold{ + LimitW: 4000, + ExpiresAt: time.Now().Add(time.Minute), + }) + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -7000) + emitMeter(t, store, "meter", -6000) + + // The live guard would allow 6 kW. The existing 4 kW manual cap must win. + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["sungrow"]-4000) > 1e-3 { + t.Fatalf("export guard loosened the existing 4000 W cap: %+v", got) + } +} + +func TestComputePVCurtail_ResidualUsesMergedStricterCap(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + st.SetPVManualHold(PVManualHold{ + LimitW: 1.5, + ExpiresAt: time.Now().Add(time.Minute), + }) + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -2000) + emitPV(t, store, "unsupported", -6000) + emitMeter(t, store, "meter", -8000) + + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["sungrow"]-1.5) > 1e-3 { + t.Fatalf("want the existing 1.5 W positive cap to stay tighter, got %+v", got) + } + // The merged cap safely removes 1998.5 W of the 3000 W overage. + if abs(st.PVExportResidualW-1001.5) > 1e-3 { + t.Fatalf("residual = %.2f W, want 1001.5 W after the merged cap", + st.PVExportResidualW) + } +} + +func TestComputePVCurtail_FullReductionUsesSafeFloorAndReportsResidual(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + store := telemetry.NewStore() + emitPV(t, store, "sungrow", -2000) + emitPV(t, store, "unsupported", -6000) + emitMeter(t, store, "meter", -8000) + + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["sungrow"]-reactivePVCurtailFloorW) > 1e-3 { + t.Fatalf("want the safe positive floor %.0f W, got %+v", + reactivePVCurtailFloorW, got) + } + // Required reduction is 3000 W. The only controllable PV can safely + // shed 1998 W without using the ambiguous zero command. + if abs(st.PVExportResidualW-1002) > 1e-3 { + t.Fatalf("residual = %.2f W, want 1002 W", st.PVExportResidualW) + } +} + +func TestComputePVCurtail_UnsupportedPVOverageIsExposed(t *testing.T) { + st := exportGuardState(5000) + store := telemetry.NewStore() + emitPV(t, store, "unsupported", -6000) + emitMeter(t, store, "meter", -6000) + + if got := ComputePVCurtail(st, store); got != nil { + t.Fatalf("unsupported PV must not receive a cap, got %+v", got) + } + if !st.PVExportGuardActive || abs(st.PVExportResidualW-1000) > 1e-3 { + t.Fatalf("guard state = active:%v residual:%.2f, want active and 1000 W", + st.PVExportGuardActive, st.PVExportResidualW) + } +} + +func TestComputePVCurtail_ReleasesReactiveCapBelowCeiling(t *testing.T) { + st := exportGuardState(5000) + st.SupportsPVCurtail = map[string]bool{"sungrow": true} + first := telemetry.NewStore() + emitPV(t, first, "sungrow", -7000) + emitMeter(t, first, "meter", -6000) + if got := ComputePVCurtail(st, first); len(got) != 1 || got[0].LimitW <= 0 { + t.Fatalf("first tick did not install a reactive cap: %+v", got) + } + + // Use a fresh store so Kalman history from the violating sample does not + // blur the release condition. Live load now absorbs enough PV that export + // sits below the ceiling. + second := telemetry.NewStore() + emitPV(t, second, "sungrow", -7000) + emitMeter(t, second, "meter", -4000) + got := ComputePVCurtail(st, second) + if len(got) != 1 || got[0].Driver != "sungrow" || got[0].LimitW != 0 { + t.Fatalf("want one curtail release below the ceiling, got %+v", got) + } + if st.PVExportGuardActive || st.PVExportResidualW != 0 { + t.Fatalf("guard state was not cleared: active:%v residual:%.2f", + st.PVExportGuardActive, st.PVExportResidualW) + } +} + func TestComputePVCurtail_AllocatesLimitProportionally(t *testing.T) { // Two PV drivers: sungrow producing 6 kW, ferroamp producing 4 kW. // Total = 10 kW; plan caps at 1500 W. Expect: diff --git a/go/internal/control/pv_export_guard.go b/go/internal/control/pv_export_guard.go new file mode 100644 index 000000000..0a26ecd5b --- /dev/null +++ b/go/internal/control/pv_export_guard.go @@ -0,0 +1,126 @@ +package control + +import ( + "math" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// reactivePVCurtailFloorW is the smallest cap the live export guard may send. +// The current driver contract cannot distinguish an active 0 W cap from a +// release, and Ferroamp's MQTT path treats a literal zero as a sticky stop. +// Keep this strictly above curtailMinPerDriverW until the contract carries an +// explicit active bit and each driver declares its safe minimum. +const reactivePVCurtailFloorW = 2.0 + +type pvExportGuardDecision struct { + active bool + caps map[string]float64 + exportW float64 + ceilingW float64 + overageW float64 + residualW float64 +} + +// computePVExportGuard reduces online, curtail-capable PV when the fresh site +// meter shows solar-attributable export above the same effective ceiling used +// by the battery fuse guard. main.go calls ComputePVCurtail only after the +// shared site-meter freshness gate has passed. +// +// This is deliberately a partial guard. It never sends an ambiguous zero-watt +// command. Any reduction below the safe positive floor, or export from PV that +// cannot be curtailed, remains in residualW so the breach is not hidden. +func computePVExportGuard(state *State, store *telemetry.Store) pvExportGuardDecision { + var out pvExportGuardDecision + if state == nil || store == nil { + return out + } + + fuseMaxW := state.siteFuseMaxW() + if fuseMaxW <= 0 { + return out + } + out.ceilingW = state.effectiveExportCeilingW(fuseMaxW) + out.exportW = solarSurplusW(state, store) + if out.exportW <= out.ceilingW { + return out + } + out.active = true + out.overageW = out.exportW - out.ceilingW + + type candidate struct { + driver string + generationW float64 + reducibleW float64 + } + var candidates []candidate + var totalReducibleW float64 + for _, reading := range store.ReadingsByType(telemetry.DerPV) { + health := store.DriverHealth(reading.Driver) + if health == nil || !health.IsOnline() || !state.SupportsPVCurtail[reading.Driver] { + continue + } + generationW := -reading.SmoothedW + reducibleW := generationW - reactivePVCurtailFloorW + if reducibleW <= 0 { + continue + } + candidates = append(candidates, candidate{ + driver: reading.Driver, + generationW: generationW, + reducibleW: reducibleW, + }) + totalReducibleW += reducibleW + } + + reductionW := math.Min(out.overageW, totalReducibleW) + if reductionW <= 0 || totalReducibleW <= 0 { + return out + } + + out.caps = make(map[string]float64, len(candidates)) + for _, c := range candidates { + shareW := reductionW * (c.reducibleW / totalReducibleW) + limitW := c.generationW - shareW + if limitW < reactivePVCurtailFloorW { + limitW = reactivePVCurtailFloorW + } + out.caps[c.driver] = limitW + } + return out +} + +// pvExportResidualAfterCaps predicts the solar overage left after every safe +// positive cap selected for this tick, including a planner or manual cap that +// was already tighter than the live guard. It does not claim a driver applied +// the command; command refusal remains the actuation tracker's concern. +func pvExportResidualAfterCaps( + overageW float64, + state *State, + store *telemetry.Store, + caps map[string]float64, +) float64 { + if overageW <= 0 || state == nil || store == nil { + return 0 + } + var reductionW float64 + for _, reading := range store.ReadingsByType(telemetry.DerPV) { + health := store.DriverHealth(reading.Driver) + if health == nil || !health.IsOnline() || !state.SupportsPVCurtail[reading.Driver] { + continue + } + limitW, ok := caps[reading.Driver] + if !ok || limitW <= curtailMinPerDriverW { + continue + } + generationW := -reading.SmoothedW + if generationW > limitW { + reductionW += generationW - limitW + } + } + residualW := overageW - reductionW + if residualW < 0 { + return 0 + } + return residualW +}