diff --git a/.changeset/preserve-zero-battery-limits.md b/.changeset/preserve-zero-battery-limits.md new file mode 100644 index 00000000..b0553490 --- /dev/null +++ b/.changeset/preserve-zero-battery-limits.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep explicit zero battery charge and discharge limits disabled through dispatch. diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 65bdbc1b..235c2791 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -6,7 +6,9 @@ import ( "time" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" ) func TestControlStateFromConfigAppliesSiteGain(t *testing.T) { @@ -46,3 +48,101 @@ func TestControlSlotDirectiveFromMPCPreservesDecisionIdentity(t *testing.T) { t.Fatalf("loadpoint allocation changed across adapter: %+v", got.LoadpointEnergyWh) } } + +func parseBatteryLimitConfig(t *testing.T, driverLimits, batteryLimits string) *config.Config { + t.Helper() + yaml := ` +site: + name: Limit test +fuse: + max_amps: 63 + phases: 3 + voltage: 230 +api: + port: 8080 +drivers: + - name: battery + lua: battery.lua + is_site_meter: true + battery_capacity_wh: 10000 + capabilities: + standalone: true +` + driverLimits + ` +batteries: + battery: +` + batteryLimits + cfg, err := config.Parse([]byte(yaml), t.TempDir()) + if err != nil { + t.Fatalf("parse battery limit config: %v", err) + } + return cfg +} + +func batteryLimitStore(gridW float64) *telemetry.Store { + store := telemetry.NewStore() + store.Update("battery", telemetry.DerMeter, gridW, nil, nil) + soc := 0.5 + store.Update("battery", telemetry.DerBattery, 0, &soc, nil) + store.DriverHealthMut("battery").RecordSuccess() + return store +} + +func TestBatteryLimitConfigExplicitZeroChargeReachesControl(t *testing.T) { + cfg := parseBatteryLimitConfig(t, + " max_charge_w: 7000\n max_discharge_w: 6000\n", + " max_charge_w: 0\n max_discharge_w: 4000\n") + ctrl := newControlStateFromConfig(cfg) + lim := ctrl.DriverLimits["battery"] + if !lim.MaxChargeWSet || lim.MaxChargeW != 0 { + t.Fatalf("charge limit lost config presence: %+v", lim) + } + ctrl.Mode = control.ModeCharge + targets := control.ComputeDispatch(batteryLimitStore(-6000), ctrl, map[string]float64{"battery": 10000}, 40000) + if len(targets) != 1 || targets[0].TargetW != 0 { + t.Fatalf("batteries.battery.max_charge_w=0 produced %+v, want one 0 W target", targets) + } +} + +func TestBatteryLimitConfigExplicitZeroDischargeReachesControl(t *testing.T) { + cfg := parseBatteryLimitConfig(t, + " max_charge_w: 7000\n max_discharge_w: 6000\n", + " max_charge_w: 4000\n max_discharge_w: 0\n") + ctrl := newControlStateFromConfig(cfg) + lim := ctrl.DriverLimits["battery"] + if !lim.MaxDischargeWSet || lim.MaxDischargeW != 0 { + t.Fatalf("discharge limit lost config presence: %+v", lim) + } + ctrl.Mode = control.ModeSelfConsumption + ctrl.SlewRateW = 100000 + ctrl.MinDispatchIntervalS = 0 + targets := control.ComputeDispatch(batteryLimitStore(12000), ctrl, map[string]float64{"battery": 10000}, 40000) + if len(targets) != 1 || targets[0].TargetW != 0 { + t.Fatalf("batteries.battery.max_discharge_w=0 produced %+v, want one 0 W target", targets) + } +} + +func TestBatteryLimitConfigUnsetUsesDriverValue(t *testing.T) { + cfg := parseBatteryLimitConfig(t, + " max_charge_w: 7000\n max_discharge_w: 6000\n", + " weight: 1\n") + ctrl := newControlStateFromConfig(cfg) + ctrl.Mode = control.ModeCharge + targets := control.ComputeDispatch(batteryLimitStore(0), ctrl, map[string]float64{"battery": 10000}, 40000) + if len(targets) != 1 || targets[0].TargetW != 7000 { + t.Fatalf("unset battery charge limit produced %+v, want configured driver limit 7000 W", targets) + } +} + +func TestBatteryLimitConfigBothZeroRetainsControlDefault(t *testing.T) { + cfg := parseBatteryLimitConfig(t, "", + " max_charge_w: 0\n max_discharge_w: 0\n") + ctrl := newControlStateFromConfig(cfg) + if _, ok := ctrl.DriverLimits["battery"]; ok { + t.Fatalf("both-zero config error became hard-disabled limits: %+v", ctrl.DriverLimits["battery"]) + } + ctrl.Mode = control.ModeCharge + targets := control.ComputeDispatch(batteryLimitStore(0), ctrl, map[string]float64{"battery": 10000}, 40000) + if len(targets) != 1 || targets[0].TargetW != control.MaxCommandW { + t.Fatalf("both-zero config error produced %+v, want control default %d W", targets, control.MaxCommandW) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index f95958f6..f02c5566 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -3282,11 +3282,14 @@ func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint // driverLimitsFrom builds the driver-name → per-battery PowerLimits map // used by control.State for per-battery charge/discharge caps (#145). -// Reads the drivers section first, then falls back to the batteries -// section for the same key — operators commonly set per-battery limits +// Reads the drivers section first, then applies any batteries-section +// override for the same key — operators commonly set per-battery limits // only under `batteries:` (the MPC reads them from there), and without -// this fallback the dispatcher silently uses the 5 kW MaxCommandW +// this path the dispatcher silently uses the 5 kW MaxCommandW // default while the planner schedules against the configured 9 kW. +// Battery limit pointers preserve omitted versus explicit zero. As in the +// MPC builder below, exact both-zero battery overrides are a config error and +// retain defaults rather than disabling the battery in both directions. // Drivers without limits in either place are omitted from the map. func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits { out := map[string]control.PowerLimits{} @@ -3295,20 +3298,29 @@ func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Batte continue } chg, dis := d.MaxChargeW, d.MaxDischargeW + chgSet, disSet := chg > 0, dis > 0 if b, ok := batteries[d.Name]; ok { - if chg == 0 && b.MaxChargeW != nil && *b.MaxChargeW > 0 { - chg = *b.MaxChargeW - } - if dis == 0 && b.MaxDischargeW != nil && *b.MaxDischargeW > 0 { - dis = *b.MaxDischargeW + bothZero := b.MaxChargeW != nil && *b.MaxChargeW == 0 && + b.MaxDischargeW != nil && *b.MaxDischargeW == 0 + if !bothZero { + if b.MaxChargeW != nil && *b.MaxChargeW >= 0 { + chg = *b.MaxChargeW + chgSet = true + } + if b.MaxDischargeW != nil && *b.MaxDischargeW >= 0 { + dis = *b.MaxDischargeW + disSet = true + } } } - if chg == 0 && dis == 0 { + if chg == 0 && dis == 0 && !chgSet && !disSet { continue } out[d.Name] = control.PowerLimits{ - MaxChargeW: chg, - MaxDischargeW: dis, + MaxChargeW: chg, + MaxDischargeW: dis, + MaxChargeWSet: chgSet, + MaxDischargeWSet: disSet, } } return out diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index 7695e9f4..0c5c6a27 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -646,6 +646,49 @@ func TestWeightedDistribution(t *testing.T) { } } +func TestWeightedDistributionReallocatesBlockedDirection(t *testing.T) { + tests := []struct { + name string + correction float64 + bats []batteryInfo + wantB float64 + }{ + { + name: "charge", + correction: 1000, + bats: []batteryInfo{ + {driver: "blocked", capacityWh: 10000, soc: 0.5, online: true, chargeBlocked: true}, + {driver: "capable", capacityWh: 10000, soc: 0.5, online: true}, + }, + wantB: 1000, + }, + { + name: "discharge", + correction: -1000, + bats: []batteryInfo{ + {driver: "blocked", capacityWh: 10000, soc: 0.5, online: true, dischargeBlocked: true}, + {driver: "capable", capacityWh: 10000, soc: 0.5, online: true}, + }, + wantB: -1000, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targets := distributeWeighted(tt.bats, tt.correction, map[string]float64{ + "blocked": 1, + "capable": 1, + }) + got := targetsByDriver(targets) + if got["blocked"].TargetW != 0 || !got["blocked"].Clamped { + t.Errorf("blocked target = %+v, want 0 W clamped", got["blocked"]) + } + if got["capable"].TargetW != tt.wantB { + t.Errorf("capable TargetW = %.1f W, want %.1f W", got["capable"].TargetW, tt.wantB) + } + }) + } +} + // ---- Clamps ---- func TestClampWithSoCBlocksDischargeWhenEmpty(t *testing.T) { @@ -691,6 +734,96 @@ func TestClampWithSoCUsesPerBatteryLimits(t *testing.T) { } } +func TestClampWithSoCPreservesExplicitZeroDirectionLimits(t *testing.T) { + b := batteryInfo{ + soc: 0.5, + maxChargeWSet: true, + maxDischargeW: 8000, + maxDischargeWSet: true, + } + if v, was := clampWithSoC(1000, b); v != 0 || !was { + t.Errorf("explicit zero charge limit: got %f clamped=%v, want 0 true", v, was) + } + if v, was := clampWithSoC(-1000, b); v != -1000 || was { + t.Errorf("enabled discharge direction changed: got %f clamped=%v, want -1000 false", v, was) + } + + b = batteryInfo{ + soc: 0.5, + maxChargeW: 8000, + maxChargeWSet: true, + maxDischargeWSet: true, + } + if v, was := clampWithSoC(-1000, b); v != 0 || !was { + t.Errorf("explicit zero discharge limit: got %f clamped=%v, want 0 true", v, was) + } + if v, was := clampWithSoC(1000, b); v != 1000 || was { + t.Errorf("enabled charge direction changed: got %f clamped=%v, want 1000 false", v, was) + } +} + +func TestPowerLimitsPositiveValuesRemainEffectiveWithoutSetFlags(t *testing.T) { + limits := map[string]PowerLimits{ + "battery": {MaxChargeW: 7000, MaxDischargeW: 6000}, + } + targets := clampTargetsToPowerLimits([]DispatchTarget{ + {Driver: "battery", TargetW: 9000}, + {Driver: "battery", TargetW: -9000}, + }, limits) + if targets[0].TargetW != 7000 || !targets[0].Clamped { + t.Errorf("legacy positive charge limit: got %+v, want +7000 clamped", targets[0]) + } + if targets[1].TargetW != -6000 || !targets[1].Clamped { + t.Errorf("legacy positive discharge limit: got %+v, want -6000 clamped", targets[1]) + } +} + +func TestComputeDispatchPreservesExplicitZeroDirectionLimits(t *testing.T) { + t.Run("charge", func(t *testing.T) { + store := seedStore(-6000, []struct { + name string + currentW, soc float64 + }{{"battery", 0, 0.5}}) + st := NewState(0, 0, "ferroamp") + st.Mode = ModeCharge + st.DriverLimits = map[string]PowerLimits{ + "battery": { + MaxChargeWSet: true, + MaxDischargeW: 6000, + MaxDischargeWSet: true, + }, + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 50000) + if len(targets) != 1 || targets[0].TargetW != 0 { + t.Fatalf("explicit zero charge limit produced targets %+v, want one 0 W target", targets) + } + }) + + t.Run("discharge", func(t *testing.T) { + store := seedStore(12000, []struct { + name string + currentW, soc float64 + }{{"battery", 0, 0.5}}) + st := NewState(0, 0, "ferroamp") + st.Mode = ModeSelfConsumption + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + st.DriverLimits = map[string]PowerLimits{ + "battery": { + MaxChargeW: 6000, + MaxChargeWSet: true, + MaxDischargeWSet: true, + }, + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 50000) + if len(targets) != 1 || targets[0].TargetW != 0 { + t.Fatalf("explicit zero discharge limit produced targets %+v, want one 0 W target", targets) + } + }) +} + // ---- Fuse guard ---- // Old-world test updated for the bidirectional predicted-grid guard diff --git a/go/internal/control/deadband_charge_block_test.go b/go/internal/control/deadband_charge_block_test.go index 4437b1fc..6573b49c 100644 --- a/go/internal/control/deadband_charge_block_test.go +++ b/go/internal/control/deadband_charge_block_test.go @@ -142,6 +142,76 @@ func TestDeadbandExitMayNotStrandChargeBlockedDriver(t *testing.T) { } } +func TestDeadbandExitEnforcesExplicitZeroDirection(t *testing.T) { + tests := []struct { + name string + liveW float64 + limits PowerLimits + wantText string + }{ + { + name: "discharge", + liveW: -2000, + limits: PowerLimits{MaxDischargeWSet: true}, + wantText: "max_discharge_w: 0", + }, + { + name: "charge", + liveW: 2000, + limits: PowerLimits{MaxChargeWSet: true}, + wantText: "max_charge_w: 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := seedDeadbandSite(0, []deadbandBattery{{"blocked", tt.liveW, 0.55, ""}}) + st := NewState(0, 60, "meter") + st.Mode = ModeSelfConsumption + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + st.DriverLimits = map[string]PowerLimits{"blocked": tt.limits} + + targets := ComputeDispatch(store, st, caps(map[string]float64{"blocked": 10000}), 11040) + if len(targets) == 0 { + t.Fatalf("no target issued — %s left the battery at %.0f W", tt.wantText, tt.liveW) + } + got := targetsByDriver(targets) + if math.Abs(got["blocked"].TargetW) > 0.01 { + t.Errorf("blocked TargetW = %.1f W, want 0 W for %s", got["blocked"].TargetW, tt.wantText) + } + }) + } +} + +func TestDeadbandExitReallocatesExplicitZeroDischarge(t *testing.T) { + store := seedDeadbandSite(0, []deadbandBattery{ + {"blocked", -2000, 0.55, ""}, + {"capable", 0, 0.55, ""}, + }) + st := NewState(0, 60, "meter") + st.Mode = ModeSelfConsumption + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + st.DriverLimits = map[string]PowerLimits{ + "blocked": {MaxDischargeWSet: true}, + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{ + "blocked": 10000, + "capable": 10000, + }), 11040) + if len(targets) == 0 { + t.Fatal("no target issued — the blocked battery kept the fleet inside deadband") + } + got := targetsByDriver(targets) + if math.Abs(got["blocked"].TargetW) > 0.01 { + t.Errorf("blocked TargetW = %.1f W, want 0 W", got["blocked"].TargetW) + } + if math.Abs(got["capable"].TargetW+2000) > 0.01 { + t.Errorf("capable TargetW = %.1f W, want -2000 W reallocated discharge", got["capable"].TargetW) + } +} + // ---- The quiet ticks the fix must leave quiet ---- // The guard that bounds the whole change: a deadband tick with nothing @@ -163,6 +233,20 @@ func TestDeadbandExitStaysQuietWhenNothingIsBlocked(t *testing.T) { } } +func TestDeadbandExitStaysQuietWithOmittedPowerLimits(t *testing.T) { + store := seedDeadbandSite(0, []deadbandBattery{{"ferroamp", -2000, 0.50, ""}}) + st := NewState(0, 60, "meter") + st.Mode = ModeSelfConsumption + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + if len(targets) != 0 { + t.Errorf("deadband tick issued %d target(s) %v — omitted limits must keep the default quiet behavior", + len(targets), targets) + } +} + // The other half of the guard: the block is armed, but no battery is charging // against it, so there is nothing to withdraw and the tick stays quiet. This // is what keeps the predicate "a blocked battery is charging" rather than the diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 98521fa7..464758a6 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -255,17 +255,37 @@ func (s *State) SetManualEVCharging(w float64, active bool) { s.EVChargingW = s.ManualEVChargingW + s.liveEVChargingW } -// PowerLimits holds the per-driver charge/discharge ceiling. Zero on -// either field means "use the global MaxCommandW default" — the value -// an unset config key carries through the YAML → Driver struct → -// dispatch map pipeline. A non-zero value overrides the default at -// every clamp point (clampWithSoC and the post-slew re-clamp). +// PowerLimits holds the per-driver charge/discharge ceiling. The Set fields +// preserve the difference between an omitted limit and an explicit zero: an +// omitted limit uses MaxCommandW, while an explicit zero closes that direction. +// A positive value remains effective without its Set field so existing callers +// that construct PowerLimits directly keep their old behavior. // // A per-driver cap higher than the site fuse doesn't buy extra throughput: // the fuse-guard still scales at the site boundary (#145 safety invariant). type PowerLimits struct { - MaxChargeW float64 - MaxDischargeW float64 + MaxChargeW float64 + MaxDischargeW float64 + MaxChargeWSet bool + MaxDischargeWSet bool +} + +func effectivePowerLimit(value float64, set bool) float64 { + if set && value >= 0 { + return value + } + if value > 0 { + return value + } + return MaxCommandW +} + +func (l PowerLimits) chargeCap() float64 { + return effectivePowerLimit(l.MaxChargeW, l.MaxChargeWSet) +} + +func (l PowerLimits) dischargeCap() float64 { + return effectivePowerLimit(l.MaxDischargeW, l.MaxDischargeWSet) } // DispatchTarget is one command to issue to a single battery driver. @@ -614,8 +634,9 @@ type State struct { FuseSaturated bool // DriverLimits maps driver name → per-battery charge/discharge cap. - // Missing entries (or zero fields) fall through to the global - // MaxCommandW default. Consulted in every clamp step — per-battery + // Missing entries and unset fields fall through to the global + // MaxCommandW default; a set zero blocks that direction. Consulted in every + // clamp step — per-battery // clampWithSoC, post-slew re-clamp, and fuse-guard's reference to // total headroom. Hot-swappable via the config-reload watcher. // Issue #145. @@ -1211,14 +1232,16 @@ func (s *State) siteFuseMaxW() float64 { // batteryInfo is internal state read from telemetry per dispatch cycle. type batteryInfo struct { - driver string - capacityWh float64 - currentW float64 - soc float64 - online bool - group string // inverter-affinity tag; empty = untagged (#143) - maxChargeW float64 // per-driver cap; 0 = use MaxCommandW default (#145) - maxDischargeW float64 // per-driver cap; 0 = use MaxCommandW default (#145) + driver string + capacityWh float64 + currentW float64 + soc float64 + online bool + group string // inverter-affinity tag; empty = untagged (#143) + maxChargeW float64 // per-driver cap; see maxChargeWSet (#145) + maxDischargeW float64 // per-driver cap; see maxDischargeWSet (#145) + maxChargeWSet bool // true preserves an explicit zero (charge disabled) + maxDischargeWSet bool // true preserves an explicit zero (discharge disabled) // Per-direction blocks the driver reports this cycle. A battery that // can't move in the demanded direction (e.g. a Ferroamp ESO floored at @@ -1236,20 +1259,14 @@ type batteryInfo struct { // back to MaxCommandW when the driver didn't set an explicit limit. // Kept a method so every clamp point queries the same fallback rule. func (b batteryInfo) chargeCap() float64 { - if b.maxChargeW > 0 { - return b.maxChargeW - } - return MaxCommandW + return effectivePowerLimit(b.maxChargeW, b.maxChargeWSet) } // dischargeCap is the symmetric version of chargeCap for discharge // targets. Returned as a positive magnitude; callers apply the minus // sign at the comparison site. func (b batteryInfo) dischargeCap() float64 { - if b.maxDischargeW > 0 { - return b.maxDischargeW - } - return MaxCommandW + return effectivePowerLimit(b.maxDischargeW, b.maxDischargeWSet) } // batteryDirectionBlocks reads the optional discharge_capable / charge_capable @@ -1645,6 +1662,11 @@ func ComputeDispatch( } 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. + dischargeBlocked = dischargeBlocked || lim.dischargeCap() == 0 + chargeBlocked = chargeBlocked || lim.chargeCap() == 0 batteries = append(batteries, batteryInfo{ driver: name, capacityWh: cap, @@ -1654,6 +1676,8 @@ func ComputeDispatch( group: state.InverterGroups[name], maxChargeW: lim.MaxChargeW, maxDischargeW: lim.MaxDischargeW, + maxChargeWSet: lim.MaxChargeWSet, + maxDischargeWSet: lim.MaxDischargeWSet, dischargeBlocked: dischargeBlocked, chargeBlocked: chargeBlocked, }) @@ -2085,6 +2109,7 @@ func ComputeDispatch( surplusActive := state.EVSurplusOnlyReserveW > 0 && effectiveMode == ModeSelfConsumption if !surplusActive && math.Abs(errW) < state.GridToleranceW && !(noSelfDischarge && anyBatteryDischarging(onlineBats)) && + !anyExplicitZeroDischargeViolation(onlineBats) && !anyBlockedBatteryCharging(onlineBats, noSelfCharge) { // A small grid error is not a statement that the site is // safe. errW compares one aggregate number against one @@ -2464,25 +2489,7 @@ func ComputeDispatch( // reading), the slewed target inherits the overshoot. Re-apply the // per-driver cap (DriverLimits, falling back to MaxCommandW) so we // never issue a command outside safe bounds. - for i := range raw { - maxC := float64(MaxCommandW) - maxD := float64(MaxCommandW) - if lim, ok := state.DriverLimits[raw[i].Driver]; ok { - if lim.MaxChargeW > 0 { - maxC = lim.MaxChargeW - } - if lim.MaxDischargeW > 0 { - maxD = lim.MaxDischargeW - } - } - if raw[i].TargetW > maxC { - raw[i].TargetW = maxC - raw[i].Clamped = true - } else if raw[i].TargetW < -maxD { - raw[i].TargetW = -maxD - raw[i].Clamped = true - } - } + raw = clampTargetsToPowerLimits(raw, state.DriverLimits) // ---- Fuse guard (bidirectional, #145) ---- return applyDispatchSafetyPipeline(raw, store, state, driverCapacities, fuseMaxW, dispatchSafetyOptions{ @@ -2561,10 +2568,16 @@ func applyDispatchSafetyPipeline( // emergency remains superior and may still discharge to prevent a trip. targets = applyBatteryBoostReserve(targets, store, state, driverCapacities) - // forceFuseDischarge runs LAST. A fuse overflow can demand a battery - // target far beyond what slew would allow in one tick; slew-limiting that - // response would leave the fuse violated for multiple ticks. + // forceFuseDischarge runs after the normal policy rails. A fuse overflow can + // demand a battery target far beyond what slew would allow in one tick; + // slew-limiting that response would leave the fuse violated for multiple ticks. targets = forceFuseDischarge(targets, store, state, driverCapacities, fuseMaxW) + // Keep the hardware direction contract last. forceFuseDischarge already + // allocates within the discharge cap, but this final check also covers any + // future safety stage and callers that enter the pipeline directly. + if state != nil { + targets = clampTargetsToPowerLimits(targets, state.DriverLimits) + } republishFuseEVCapAfterFuseDischarge(targets, store, state, fuseMaxW) recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) return targets @@ -3032,9 +3045,9 @@ const curtailMinPerDriverW = 1.0 // is treated as having no curtail-absorption headroom. Below it, the // battery's MaxChargeW (or MaxCommandW default) is added to the live // curtail limit so PV stays uncapped while the battery can still take -// the energy. Hard-coded conservatively — the goal is to err on the -// side of preserving PV generation when there's anywhere meaningful -// to put it. +// the energy. An explicit zero adds no headroom. Hard-coded conservatively: +// the goal is to err on the side of preserving PV generation when there's +// anywhere meaningful to put it. const pvCurtailBatterySoCMax = 0.99 // liveCurtailLimitW computes the cap PV may produce *right now* given @@ -3130,11 +3143,7 @@ func liveCurtailLimitW(state *State, store *telemetry.Store) (float64, bool) { if r.SoC == nil || *r.SoC >= pvCurtailBatterySoCMax { continue } - capW := float64(MaxCommandW) - if lim, ok := state.DriverLimits[r.Driver]; ok && lim.MaxChargeW > 0 { - capW = lim.MaxChargeW - } - batHeadroomW += capW + batHeadroomW += state.DriverLimits[r.Driver].chargeCap() } // EV reserve: prefer the curtail-specific value (counts plugged- @@ -3293,6 +3302,20 @@ func anyBatteryDischarging(bats []batteryInfo) bool { return false } +// anyExplicitZeroDischargeViolation reports a live discharge that conflicts +// with a configured hard zero. This is narrower than dischargeBlocked: a +// driver's transient discharge_capable=false report does not bypass the +// reactive deadband. The config limit does, because returning no target would +// leave the driver's previous negative setpoint active on every quiet tick. +func anyExplicitZeroDischargeViolation(bats []batteryInfo) bool { + for _, b := range bats { + if b.maxDischargeWSet && b.maxDischargeW == 0 && b.currentW < -1 { + return true + } + } + return false +} + // anyBlockedBatteryCharging reports whether a battery whose charge direction // this tick has closed is measured charging right now. It is the charge-side // twin of anyBatteryDischarging, and it exists for the deadband exit: an @@ -3710,25 +3733,42 @@ func distributePriority(bats []batteryInfo, totalCorrection float64, order []str // distributeWeighted splits by custom weights. Missing batteries default to weight=1. func distributeWeighted(bats []batteryInfo, totalCorrection float64, weights map[string]float64) []DispatchTarget { + var currentTotal float64 + for _, b := range bats { + currentTotal += b.currentW + } + desiredTotal := currentTotal + totalCorrection + blockedForDirection := func(b batteryInfo) bool { + if desiredTotal > 0 { + return b.chargeBlocked + } + if desiredTotal < 0 { + return b.dischargeBlocked + } + return false + } + var totalW float64 for _, b := range bats { + if blockedForDirection(b) { + continue + } w, ok := weights[b.driver] if !ok { w = 1.0 } totalW += w } - if totalW <= 0 { - return nil - } - var currentTotal float64 - for _, b := range bats { - currentTotal += b.currentW - } - desiredTotal := currentTotal + totalCorrection out := make([]DispatchTarget, 0, len(bats)) for _, b := range bats { + if blockedForDirection(b) { + out = append(out, DispatchTarget{Driver: b.driver, TargetW: 0, Clamped: true}) + continue + } + if totalW <= 0 { + continue + } w, ok := weights[b.driver] if !ok { w = 1.0 @@ -3751,16 +3791,32 @@ func chargeAll(store *telemetry.Store, capacities map[string]float64, limits map if h == nil || !h.IsOnline() { continue } - target := float64(MaxCommandW) - if lim, ok := limits[name]; ok && lim.MaxChargeW > 0 { - target = lim.MaxChargeW - } + target := limits[name].chargeCap() // Site convention: + = charge. out = append(out, DispatchTarget{Driver: name, TargetW: target}) } return out } +// clampTargetsToPowerLimits is the common last-mile clamp for command slices. +// It treats a missing limit as MaxCommandW and an explicit zero as a closed +// direction. Mutating in place matches the other safety floors in this file. +func clampTargetsToPowerLimits(targets []DispatchTarget, limits map[string]PowerLimits) []DispatchTarget { + for i := range targets { + lim := limits[targets[i].Driver] + maxC := lim.chargeCap() + maxD := lim.dischargeCap() + if targets[i].TargetW > maxC { + targets[i].TargetW = maxC + targets[i].Clamped = true + } else if targets[i].TargetW < -maxD { + targets[i].TargetW = -maxD + targets[i].Clamped = true + } + } + return targets +} + // clampWithSoC applies the hard safety clamps for one battery command: // - don't discharge below SoC 5 % (site: don't make target < 0 when SoC < 0.05); // BMS handles fine-grained SoC but we never ask it to pull an empty pack. @@ -4339,17 +4395,8 @@ func holdFleetAtZero(store *telemetry.Store, capacities map[string]float64) []Di // A battery that is empty or reports a blocked direction may still be moving // in that direction, but core must not send a command that asks it to continue. func fuseTargetBounds(r *telemetry.DerReading, lim PowerLimits) (lower, upper float64) { - maxChargeW := float64(MaxCommandW) - maxDischargeW := float64(MaxCommandW) - if lim.MaxChargeW > 0 { - maxChargeW = lim.MaxChargeW - } - if lim.MaxDischargeW > 0 { - maxDischargeW = lim.MaxDischargeW - } - - lower = -maxDischargeW - upper = maxChargeW + lower = -lim.dischargeCap() + upper = lim.chargeCap() soc := 0.1 if r.SoC != nil { soc = *r.SoC diff --git a/go/internal/control/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index 7015161f..0e2f4847 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -135,6 +135,42 @@ func TestFuseSaverRespectsMaxDischarge(t *testing.T) { } } +func TestFuseSaverRespectsExplicitZeroDischargeLimit(t *testing.T) { + store, state, caps := setupFuseSaver(20000, 0, 0.6, 10000) + state.DriverLimits["bat"] = PowerLimits{ + MaxChargeW: 10000, + MaxDischargeWSet: true, + } + targets := []DispatchTarget{{Driver: "bat", TargetW: 0}} + out := forceFuseDischarge(targets, store, state, caps, 11040) + if out[0].TargetW != 0 || out[0].Clamped { + t.Errorf("explicit zero discharge limit must close fuse-saver headroom: got %+v", out[0]) + } +} + +func TestDeadbandFuseSaverRespectsExplicitZeroDischargeLimit(t *testing.T) { + store, state, caps := setupFuseSaver(14000, 0, 0.6, 10000) + state.DriverLimits["bat"] = PowerLimits{ + MaxChargeW: 10000, + MaxDischargeWSet: true, + } + state.SetGridTarget(14000) + state.MinDispatchIntervalS = 0 + + if out := ComputeDispatch(store, state, caps, 11040); out != nil { + t.Fatalf("deadband fuse saver crossed explicit zero discharge limit: %+v", out) + } +} + +func TestFuseSaverLegacyZeroWithoutSetFlagStillUsesDefault(t *testing.T) { + store, state, caps := setupFuseSaver(20000, 0, 0.6, 0) + targets := []DispatchTarget{{Driver: "bat", TargetW: 0}} + out := forceFuseDischarge(targets, store, state, caps, 11040) + if out[0].TargetW != -MaxCommandW || !out[0].Clamped { + t.Errorf("legacy zero without validity flag: got %+v, want -%d W clamped", out[0], MaxCommandW) + } +} + // Within fuse → no-op. The fuse-saver doesn't touch dispatch when // predicted gridW is already safe. func TestFuseSaverNoOpWhenWithinFuse(t *testing.T) { diff --git a/go/internal/control/pv_curtail_test.go b/go/internal/control/pv_curtail_test.go index 2b453bf8..4fbfa2fb 100644 --- a/go/internal/control/pv_curtail_test.go +++ b/go/internal/control/pv_curtail_test.go @@ -339,6 +339,28 @@ func TestComputePVCurtail_BatteryHeadroomLiftsCap(t *testing.T) { } } +func TestComputePVCurtail_ExplicitZeroChargeLimitAddsNoBatteryHeadroom(t *testing.T) { + st := NewState(0, 100, "meter") + st.SlotDirective = stubSlotDirective(SlotDirective{PVLimitW: 500}) + st.SupportsPVCurtail = map[string]bool{"solaredge": true} + st.DriverLimits = map[string]PowerLimits{ + "pixii": { + MaxChargeWSet: true, + MaxDischargeW: 5000, + MaxDischargeWSet: true, + }, + } + store := telemetry.NewStore() + emitPV(t, store, "solaredge", -3000) + emitBattery(t, store, "pixii", 0, 0.60) + emitMeter(t, store, "meter", -2500) + + got := findCurtail(ComputePVCurtail(st, store)) + if abs(got["solaredge"]-500) > 1e-3 { + t.Errorf("zero charge limit invented battery headroom: want 500 W PV limit, got %.2f", got["solaredge"]) + } +} + // Battery essentially full (SoC >= ceiling) and no EV reserve → // planner-warranted curtail goes through, capped at live load only. func TestComputePVCurtail_FullBatteryNoHeadroomCurtails(t *testing.T) {