Skip to content
Merged
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/physical-planner-topologies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Plan sites with no home battery, several batteries or several EVs with the bundled Energyplan worker. Keep each asset's limits and energy target, validate worker identities and deadlines, and carry each battery's energy budget through Core control. Only count verified PV generation control, and stop using its plan when the driver or its health changes. Keep the previous plan for diagnosis when the fallback cannot represent the site.
12 changes: 12 additions & 0 deletions go/cmd/ftw/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,15 @@ func TestBuildMPCBetaStartsBundledEnergyplan(t *testing.T) {
}
t.Cleanup(func() { svc.Optimizer.Close() })
}

func TestBuildMPCWithoutHomeBattery(t *testing.T) {
cfg, _ := plannerEngineConfig(&config.Planner{Enabled: true, Engine: "energyplan"})
cfg.Drivers = nil
svc := buildMPC(cfg, nil, nil, nil)
if svc == nil || svc.Defaults.CapacityWh != 0 || svc.Defaults.InitialSoC != 0 || svc.Defaults.MaxChargeW != 0 || len(svc.BatteryFleet) != 0 {
t.Fatalf("batteryless site invented storage: %+v", svc)
}
if svc.Optimizer != nil {
defer svc.Optimizer.Close()
}
}
22 changes: 20 additions & 2 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,20 @@ func main() {
// ---- Start MPC planner (optional) ----
mpcSvc = buildMPC(cfg, st, tel, capacities)
if mpcSvc != nil {
pvProofLookup := func(name string) mpc.PVCurtailment {
proof := reg.PVGenerationLimit(name)
return mpc.PVCurtailment{Driver: name, Proof: proof.Token, MinW: proof.MinW, MaxW: proof.MaxW}
}
ctrl.PVGenerationLimit = pvProofLookup
mpcSvc.PVExecutionAllowed = func(proof mpc.PVCurtailment) bool {
return control.PVGenerationProofValid(tel, time.Now(), proof, pvProofLookup)
}
mpcSvc.PVCurtailmentProbe = func() mpc.PVCurtailment {
options := forecastSettings.Snapshot().Options
ctrlMu.Lock()
defer ctrlMu.Unlock()
return control.PlanningPVCurtailment(ctrl, tel, options)
}
// Plumb the site fuse so the DP joint-plans battery + EV under
// the fuse from the start (instead of producing plans that
// dispatch later has to scale via the joint allocator).
Expand Down Expand Up @@ -1708,7 +1722,8 @@ func main() {
}
// SlotDirectiveFromMPC lives in package control so tests
// and main share the plan→EMS field map.
return control.SlotDirectiveFromMPC(d), true
dir := control.SlotDirectiveFromMPC(d)
return dir, control.PlanningPVDirectiveValid(ctrl, tel, dir)
}
// Default to the energy-allocation path. The plan is a
// scheduler (decides WHEN each strategy applies); the EMS is
Expand Down Expand Up @@ -3679,7 +3694,7 @@ func aggregateBatteryFleetLimits(cfg *config.Config, fleet []mpc.BatteryFleetMem
}

// buildMPC constructs a planner from config. Returns nil if disabled,
// if prices aren't configured, or if there are no batteries with capacity.
// or if prices aren't configured. EV planning also works without home storage.
// The skip reason is the same vocabulary /api/mpc/diagnose exposes.
func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacities map[string]float64) *mpc.Service {
plannerOn := cfg.Planner != nil && cfg.Planner.Enabled
Expand Down Expand Up @@ -3765,6 +3780,9 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit
DischargeEfficiency: disEff,
ExportOrePerKWh: pl.ExportOrePerKWh,
}
if totalCap == 0 {
params.InitialSoC = 0
}
svc := mpc.New(st, tel, zone, params)
svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis)
// Release defaults select the beta worker. An explicit engine wins;
Expand Down
10 changes: 0 additions & 10 deletions go/internal/api/api_mpc_unavailable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,6 @@ func TestMPCDisabledEndpointsNameTheSkipReason(t *testing.T) {
},
want: mpc.ReasonNoPriceProvider,
},
{
name: "no battery capacity",
deps: &Deps{
Cfg: &config.Config{
Planner: &config.Planner{Enabled: true},
Price: &config.Price{Provider: "nordpool"},
},
},
want: mpc.ReasonNoBatteryCapacity,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
22 changes: 20 additions & 2 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,13 @@ type SlotDirective struct {
DecisionID string
SlotStart time.Time
SlotEnd time.Time
BatteryEnergyWh float64 // site-signed: + = charge, − = discharge
BatteryEnergyWh float64 // site-signed: + = charge, − = discharge
StorageEnergyWh map[string]float64 // validated per-storage AC energy budgets
SoCTarget float64
Strategy string // echoed for logging / API; mirrors mpc.Mode
PVLimitW float64 // 0 = no curtail; > 0 = cap aggregate PV output
PVCurtailActive bool
PVCurtailment mpc.PVCurtailment

// PlannedGridW is the plan's forecast of slot-average gridW given the
// planned battery / load / PV mix (site-signed: + = import). The
Expand Down Expand Up @@ -533,6 +536,7 @@ type State struct {
// accounting. Reset when the slot rolls over (by SlotStart equality).
// Zero-valued until UseEnergyDispatch fires its first cycle.
currentDirective SlotDirective
storageDelivery storageSlotDelivery
slotDelivered float64 // Wh delivered to batteries since slot start
lastTickTs time.Time // for ∫ battery_w dt
// controlSlotDecisionID is the accepted plan used to choose the last
Expand Down Expand Up @@ -592,6 +596,7 @@ type State struct {
// Populated from config.Driver.SupportsPVCurtail in main.go;
// hot-swappable via the config-reload watcher.
SupportsPVCurtail map[string]bool
PVGenerationLimit func(string) mpc.PVCurtailment

// SolarFeedDrivers flags drivers whose operator armed an opt-in
// `solar_pv` write path (e.g. the NIBE S-series Solar PV surplus
Expand Down Expand Up @@ -2431,6 +2436,8 @@ func ComputeDispatch(
var raw []DispatchTarget
if manualHoldActive && manualHold.Driver != "" {
raw = distributeScopedManualHold(onlineBats, manualHold.Driver, currentTotal+totalCorrection)
} else if allocated, ok := distributePlannedStorages(state, onlineBats, currentTotal+totalCorrection, manualHoldActive); ok {
raw = allocated
} else {
switch effectiveMode {
case ModeSelfConsumption, ModePeakShaving:
Expand Down Expand Up @@ -2959,6 +2966,7 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget {
}

now := state.now()
var plannedCap *SlotDirective

// Operator-installed manual hold takes precedence over the planner
// directive. Driver-scoped → cap only that driver. Site-aggregate
Expand All @@ -2982,7 +2990,7 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget {
// headroom, EVs on PV charging mode). When live absorbable W
// covers everything PV can produce, the curtail effectively
// suppresses itself.
if dir, ok := state.SlotDirective(now); ok {
if dir, ok := state.SlotDirective(now); ok && PlanningPVDirectiveValid(state, store, dir) {
if dir.PVLimitW > 0 {
if live, ok := liveCurtailLimitW(state, store); ok {
limit = live
Expand All @@ -2991,6 +2999,10 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget {
// back to the planner's static cap.
limit = dir.PVLimitW
}
if dir.PVCurtailActive {
limit = dir.PVLimitW
plannedCap = &dir
}
}
}
}
Expand Down Expand Up @@ -3070,6 +3082,12 @@ func ComputePVCurtail(state *State, store *telemetry.Store) []CurtailTarget {
}
}

if plannedCap != nil && !holdActive {
if caps, ok := plannedPVCaps(state, store, *plannedCap, limit); ok {
next = caps
}
}

// Release path. A previously-curtailed driver gets an explicit
// `curtail_disable` (LimitW: 0) only when one of the following is
// true:
Expand Down
80 changes: 80 additions & 0 deletions go/internal/control/pv_plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package control

import (
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/telemetry"
"math"
"time"
)

// Aggregate min(PV, cap) requires one verified control domain covering all PV.
// Independent inverter caps need per-source forecasts before they can qualify.
func PlanningPVCurtailment(state *State, store *telemetry.Store, options telemetry.ForecastOptions) mpc.PVCurtailment {
if state == nil || store == nil || state.PVGenerationLimit == nil {
return mpc.PVCurtailment{}
}
if !store.ForecastMeasurement(state.now(), state.SiteMeterDriver, options).PVValid {
return mpc.PVCurtailment{}
}
expected := map[string]bool{}
for _, f := range options.ExpectedFlows {
if f.DerType == telemetry.DerPV {
expected[f.Driver] = true
}
}
if len(expected) != 1 {
return mpc.PVCurtailment{}
}
for id := range expected {
proof := state.PVGenerationLimit(id)
if proof.Driver == id && proof.Valid() && planningPVProofValid(state, store, proof) {
return proof
}
}
return mpc.PVCurtailment{}
}

func planningPVProofValid(state *State, store *telemetry.Store, proof mpc.PVCurtailment) bool {
if state == nil || store == nil || !proof.Valid() || state.PVGenerationLimit == nil || !state.SupportsPVCurtail[proof.Driver] {
return false
}
return PVGenerationProofValid(store, state.now(), proof, state.PVGenerationLimit)
}

// PVGenerationProofValid takes a bounded registry lookup and no control-state
// lock. Service uses it after taking one plan/params snapshot, for all consumers.
func PVGenerationProofValid(store *telemetry.Store, now time.Time, proof mpc.PVCurtailment, lookup func(string) mpc.PVCurtailment) bool {
if store == nil || !proof.Valid() || lookup == nil || lookup(proof.Driver) != proof {
return false
}
sources := store.ReadingsByType(telemetry.DerPV)
if len(sources) != 1 || sources[0].Driver != proof.Driver {
return false
}
r := sources[0]
h := store.DriverHealth(proof.Driver)
return h != nil && h.IsOnline() && !r.UpdatedAt.After(now) && now.Sub(r.UpdatedAt) <= 90*time.Second && !math.IsNaN(r.RawW) && !math.IsInf(r.RawW, 0)
}

// Check the proof before exposing any energy directive from a plan that used
// PV control. Losing the driver/config/generation also revokes battery/EV use.
func PlanningPVDirectiveValid(state *State, store *telemetry.Store, dir SlotDirective) bool {
if dir.PVCurtailment.Proof == "" {
return !dir.PVCurtailActive
}
return planningPVProofValid(state, store, dir.PVCurtailment)
}

func plannedPVCaps(state *State, store *telemetry.Store, dir SlotDirective, limit float64) (map[string]float64, bool) {
p := dir.PVCurtailment
if !dir.PVCurtailActive || !planningPVProofValid(state, store, p) || math.IsNaN(limit) || math.IsInf(limit, 0) || limit < p.MinW || limit > p.MaxW {
return nil, false
}
// The worker proposes whole watts. A stronger manual/protective ceiling may
// be fractional; rounding down preserves that ceiling and the safe minimum.
limit = math.Floor(limit + 1e-7)
if limit < p.MinW {
return nil, false
}
return map[string]float64{p.Driver: limit}, true
}
68 changes: 68 additions & 0 deletions go/internal/control/pv_plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package control

import (
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/telemetry"
"testing"
"time"
)

func pvProofState(t *testing.T) (*State, *telemetry.Store, mpc.PVCurtailment, telemetry.ForecastOptions) {
t.Helper()
store := telemetry.NewStore()
emitPV(t, store, "pv", -6000)
emitMeter(t, store, "site", -5500)
s := NewState(0, 0, "site")
s.SupportsPVCurtail = map[string]bool{"pv": true}
proof := mpc.PVCurtailment{Driver: "pv", Proof: "loaded-generation-1", MinW: 2, MaxW: 15000}
s.PVGenerationLimit = func(string) mpc.PVCurtailment { return proof }
options := telemetry.ForecastOptions{ExpectedFlows: []telemetry.ForecastFlow{{Driver: "pv", DerType: telemetry.DerPV}}}
return s, store, proof, options
}

func TestPlanningPVRequiresLoadedGenerationAndCompleteDomain(t *testing.T) {
s, store, proof, options := pvProofState(t)
if got := PlanningPVCurtailment(s, store, options); got != proof {
t.Fatalf("capability=%+v", got)
}
dir := SlotDirective{PVLimitW: 100, PVCurtailActive: true, PVCurtailment: proof}
s.SlotDirective = stubSlotDirective(dir)
if got := findCurtail(ComputePVCurtail(s, store)); len(got) != 1 || got["pv"] != 100 {
t.Fatalf("cap raised by live headroom: %v", got)
}
s.PVGenerationLimit = nil
if PlanningPVCurtailment(s, store, options).Valid() || PlanningPVDirectiveValid(s, store, dir) {
t.Fatal("config opt-in was treated as command proof")
}
s.PVGenerationLimit = func(string) mpc.PVCurtailment { p := proof; p.Proof = "new-generation"; return p }
if PlanningPVDirectiveValid(s, store, dir) {
t.Fatal("old plan survived driver replacement")
}
s.PVGenerationLimit = func(string) mpc.PVCurtailment { return proof }
s.clock = func() time.Time { return time.Now().Add(2 * time.Minute) }
if PlanningPVDirectiveValid(s, store, dir) {
t.Fatal("stale telemetry kept plan credit")
}
s.clock = nil
emitPV(t, store, "second", -1)
options.ExpectedFlows = append(options.ExpectedFlows, telemetry.ForecastFlow{Driver: "second", DerType: telemetry.DerPV})
if PlanningPVCurtailment(s, store, options).Valid() || PlanningPVDirectiveValid(s, store, dir) {
t.Fatal("independent domains treated as one aggregate actuator")
}
}

func TestPlannedPVCapKeepsManualAndProtectiveCeilings(t *testing.T) {
s, store, proof, _ := pvProofState(t)
emitBattery(t, store, "battery", 0, .85)
s.SlotDirective = stubSlotDirective(SlotDirective{PVLimitW: 4000, PVCurtailActive: true, PVCurtailment: proof})
s.DCLinkProtectionEnabled = true
s.DCLinkProtectionSoCThreshold = .8
s.DCLinkProtectionMarginW = 1000
if got := findCurtail(ComputePVCurtail(s, store)); got["pv"] != 1500 {
t.Fatalf("protective cap relaxed: %v", got)
}
s.ManualPVHold = PVManualHold{LimitW: 50, ExpiresAt: time.Now().Add(time.Minute)}
if got := findCurtail(ComputePVCurtail(s, store)); got["pv"] != 50 {
t.Fatalf("manual cap relaxed: %v", got)
}
}
6 changes: 5 additions & 1 deletion go/internal/control/slot_directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package control

import (
"github.com/srcfl/ftw/go/internal/mpc"
"maps"
)

// SlotDirectiveFromMPC is the plan→EMS bridge. main.go and the site
Expand All @@ -13,12 +14,15 @@ func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective {
SlotStart: d.SlotStart,
SlotEnd: d.SlotEnd,
BatteryEnergyWh: d.BatteryEnergyWh,
StorageEnergyWh: maps.Clone(d.StorageEnergyWh),
SoCTarget: d.SoCTarget,
Strategy: string(d.Strategy),
PVLimitW: d.PVLimitW,
PVCurtailActive: d.PVCurtailActive,
PVCurtailment: d.PVCurtailment,
PlannedGridW: d.GridW,
HasPlannedGridW: true,
LivePVSurplusSoCCap: d.LivePVSurplusSoCCap,
LoadpointEnergyWh: d.LoadpointEnergyWh,
LoadpointEnergyWh: maps.Clone(d.LoadpointEnergyWh),
}
}
Loading
Loading