Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reactive-pv-export-cap.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 37 additions & 2 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-
Expand Down Expand Up @@ -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:
Expand Down
165 changes: 165 additions & 0 deletions go/internal/control/pv_curtail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
126 changes: 126 additions & 0 deletions go/internal/control/pv_export_guard.go
Original file line number Diff line number Diff line change
@@ -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
}