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/slew-cannot-reopen-charge-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The slew limiter can no longer re-open a charge block another stage closed. Because the limiter anchors each target on the battery's measured output rather than on the previous command, a battery physically charging pulled its own command back up after the tick had already pinned the fleet to 0 W — so a passive-arbitrage idle slot with the meter exporting 2000 W and the battery at +2000 W commanded 1500 W of charging on a tick that forbids charging, and a battery reporting `charge_capable=false` was commanded to charge anyway while a capable sibling absorbed the same share. A charge floor now runs after the limiter, mirroring the discharge-side floor already there: the site-wide charge block (planner_self's export-surplus gate, planner_self's stale plan, the arbitrage-family idle live-export gate) and a driver's own charge-capability report both survive to the hardware.
77 changes: 77 additions & 0 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2221,14 +2221,36 @@ func ComputeDispatch(
return applyDispatchSafetyPipeline(raw, store, state, driverCapacities, fuseMaxW, dispatchSafetyOptions{
manualHoldActive: manualHoldActive,
noSelfDischarge: noSelfDischarge,
noSelfCharge: noSelfCharge,
chargeBlocked: chargeBlockedDrivers(onlineBats),
updatePrevTargets: true,
recordDispatch: true,
})
}

// chargeBlockedDrivers is the set of online batteries that told us this tick
// they cannot charge. distributeProportional already parks them at 0; this
// carries the same fact past the slew limiter, which anchors on measured
// power and knows nothing about capability.
func chargeBlockedDrivers(bats []batteryInfo) map[string]bool {
var out map[string]bool
for _, b := range bats {
if !b.chargeBlocked {
continue
}
if out == nil {
out = make(map[string]bool, len(bats))
}
out[b.driver] = true
}
return out
}

type dispatchSafetyOptions struct {
manualHoldActive bool
noSelfDischarge bool
noSelfCharge bool
chargeBlocked map[string]bool
updatePrevTargets bool
recordDispatch bool
}
Expand Down Expand Up @@ -2264,6 +2286,7 @@ func applyDispatchSafetyPipeline(
if opts.noSelfDischarge {
targets = floorNegativeTargets(targets)
}
targets = floorBlockedCharge(targets, opts.noSelfCharge, opts.chargeBlocked)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the charge floor before deadband exits

When the grid error is already inside GridToleranceW, the legacy path returns through fuseSaverEarlyExit before this newly added floor runs. In an arbitrage idle/live-export or stale-plan tick where the battery is already charging enough to keep the meter near zero (for example meter -50 W, battery +2000 W, tolerance 60 W), noSelfCharge is true but ComputeDispatch emits no zero target, and main.go only sends commands for returned targets, so the previous charging command can continue despite the charge block. The same bypass affects a charge_capable=false battery if the site is in the deadband; the floor needs to be reached (or the early-exit condition suppressed) whenever a charge block must actively stop existing charge.

Useful? React with 👍 / 👎.


// A battery-boost lease may never draw the stationary fleet below its
// explicit reserve. Apply this immediately before forceFuseDischarge:
Expand Down Expand Up @@ -3175,6 +3198,60 @@ func floorNegativeTargets(targets []DispatchTarget) []DispatchTarget {
return targets
}

// floorBlockedCharge is the charge-side mirror of floorNegativeTargets: a
// target may not command charge into a direction this tick already closed.
//
// It exists because the slew limiter anchors every target on the battery's
// MEASURED output rather than on the command, so a battery physically
// charging at +2000 W is pulled back toward +2000 W from whatever the stages
// above decided — including the 0 W that noSelfCharge pinned two hundred
// lines earlier. Nothing below used to undo that: applyFuseGuard only shrinks
// toward zero, floorNegativeTargets covers the discharge side only, and
// planSignIntent reports "idle, no opinion" for an idle slot. A
// passive-arbitrage idle slot with the meter at -2000 W, the battery live at
// +2000 W and SlewRateW=500 therefore commanded +1500 W of charging on the
// tick whose entire purpose was to let that surplus reach the meter.
//
// Two authorities close the charge direction, both decided before slew runs:
//
// - noSelfCharge — the site-wide block: planner_self's export-surplus gate,
// planner_self's stale plan, and the arbitrage-family idle live-export
// gate. It pins the fleet TOTAL to zero; this bounds each command.
// - chargeBlocked — the driver's own capability report. Its share was
// already handed to capable siblings, so re-opening it double-counts the
// charge as well as ignoring the hardware.
//
// This is a bound on the output, not a list of the reasons the output was
// closed. The snap-to-zero carve-out inside the slew loop is the list
// version, and it names only plannerSelfExportSurplusGate and manual hold;
// the two charge gates added to ComputeDispatch after it were never added to
// it. That is how this bug was born, and a floor cannot be born that way. It
// only ever moves a target toward zero, so it can neither create motion nor
// widen a clamp applied above it — and nothing after it raises charge:
// applyBatteryBoostReserve touches negative targets only, and
// forceFuseDischarge only forces discharge.
//
// The discharge-side twin of the per-driver half is deliberately NOT here: a
// dischargeBlocked battery re-opened by slew is the same shape, but flooring
// it has to answer whether the fuse emergency in forceFuseDischarge outranks
// a driver's "I cannot discharge". Charge has no such override, so it is the
// half that can be fixed without deciding that.
func floorBlockedCharge(targets []DispatchTarget, noSelfCharge bool, chargeBlocked map[string]bool) []DispatchTarget {
if !noSelfCharge && len(chargeBlocked) == 0 {
return targets
}
for i := range targets {
if targets[i].TargetW <= 0 {
continue
}
if noSelfCharge || chargeBlocked[targets[i].Driver] {
targets[i].TargetW = 0
targets[i].Clamped = true
}
}
return targets
}

// coverLoadChargeSlot reports whether the current plan slot is a charge-from-
// PV-surplus slot: the DP meant to soak surplus (PlannedGridW below the
// grid-charge import band), NOT buy from the grid. Such a slot carries no hard
Expand Down
260 changes: 260 additions & 0 deletions go/internal/control/slew_charge_floor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package control

// The slew limiter may not re-open a charge direction another stage closed.
//
// Every test here runs at a slew rate a site actually runs (250-1500 W, next
// to NewState's 500 W default) with the battery MEASURED mid-charge. That
// combination is the whole bug: the limiter anchors on measured power, so a
// battery physically charging drags its own command back up regardless of
// what the tick decided. Pinning these at SlewRateW = 100000, the way most of
// the older dispatch tests do, would make all of them pass against the broken
// code.

import (
"encoding/json"
"math"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/telemetry"
)

// chargingBattery seeds one battery reporting live charge power, optionally
// with a capability block, plus the site meter.
func seedChargeFloorSite(t *testing.T, gridW float64, bats []struct {
name string
currentW, soc float64
capability string
}) *telemetry.Store {
t.Helper()
s := telemetry.NewStore()
s.Update("meter", telemetry.DerMeter, gridW, nil, nil)
s.DriverHealthMut("meter").RecordSuccess()
for _, b := range bats {
soc := b.soc
var data json.RawMessage
if b.capability != "" {
data = json.RawMessage(b.capability)
}
s.Update(b.name, telemetry.DerBattery, b.currentW, &soc, data)
s.DriverHealthMut(b.name).RecordSuccess()
}
return s
}

func idleArbitrageSlot(now time.Time, strategy string) SlotDirective {
return SlotDirective{
SlotStart: now.Add(-7 * time.Minute),
SlotEnd: now.Add(8 * time.Minute),
BatteryEnergyWh: 0,
Strategy: strategy,
}
}

// The reproduction from the harvest, watt for watt: a passive-arbitrage idle
// slot with the meter exporting 2000 W and the battery measured at +2000 W.
// The slot's charge block pins the fleet total to 0; before the post-slew
// floor the limiter walked that back to +1500 W, so the site charged on a
// tick whose whole purpose was to let the surplus reach the meter.
func TestSlewMayNotReopenArbitrageIdleChargeBlock(t *testing.T) {
now := time.Now()
store := seedChargeFloorSite(t, -2000, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", 2000, 0.55, ""},
})
st := NewState(0, 60, "meter")
st.Mode = ModePlannerPassiveArbitrage
st.UseEnergyDispatch = true
st.SlewRateW = 500
st.MinDispatchIntervalS = 0
st.SlotDirective = func(time.Time) (SlotDirective, bool) {
return idleArbitrageSlot(now, "passive_arbitrage"), true
}

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
got := targetsByDriver(targets)
if got["ferroamp"].TargetW > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W — an idle arbitrage slot over an exporting meter forbids charge; slew re-opened it",
got["ferroamp"].TargetW)
}
}

// Same law, planner_arbitrage rather than passive: the gate covers the whole
// arbitrage family.
func TestSlewMayNotReopenPlannerArbitrageIdleChargeBlock(t *testing.T) {
now := time.Now()
store := seedChargeFloorSite(t, -2000, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", 2000, 0.55, ""},
})
st := NewState(0, 60, "meter")
st.Mode = ModePlannerArbitrage
st.UseEnergyDispatch = true
st.SlewRateW = 500
st.MinDispatchIntervalS = 0
st.SlotDirective = func(time.Time) (SlotDirective, bool) {
return idleArbitrageSlot(now, "arbitrage"), true
}

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
got := targetsByDriver(targets)
if got["ferroamp"].TargetW > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W — idle arbitrage slot over an exporting meter must not charge", got["ferroamp"].TargetW)
}
}

// planner_self with no fresh plan is discharge-only: it may cover live import
// but may not buy or absorb into the pack until a plan arrives. The stale-plan
// block is the second of the two charge gates the slew loop's snap-to-zero
// carve-out never learned about.
func TestSlewMayNotReopenStalePlanChargeBlock(t *testing.T) {
store := seedChargeFloorSite(t, -1500, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", 2500, 0.60, ""},
})
st := NewState(0, 60, "meter")
st.Mode = ModePlannerSelf
st.UseEnergyDispatch = true
st.SlewRateW = 500
st.MinDispatchIntervalS = 0
st.SlotDirective = func(time.Time) (SlotDirective, bool) { return SlotDirective{}, false }
st.PlanTarget = func(time.Time) (string, float64, bool) { return "", 0, false }

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
got := targetsByDriver(targets)
if got["ferroamp"].TargetW > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W — planner_self on a stale plan is discharge-only; slew re-opened the charge block",
got["ferroamp"].TargetW)
}
if !st.PlanStale {
t.Errorf("scenario did not arm the stale-plan gate — the test proves nothing")
}
}

// The per-driver half. A battery that reports charge_capable=false is parked
// at 0 W by the distributor and its share handed to a capable sibling; the
// limiter then anchored it on its own live charge and commanded +1500 W into
// hardware that just said it cannot take it — while the sibling was already
// absorbing that same share.
func TestSlewMayNotReopenChargeBlockedBatterysParkedTarget(t *testing.T) {
store := seedChargeFloorSite(t, -2500, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", 2000, 0.50, `{"discharge_capable":true,"charge_capable":false}`},
{"sungrow", 0, 0.40, ""},
})
st := NewState(0, 50, "meter")
st.Mode = ModeSelfConsumption
st.SlewRateW = 500
st.MinDispatchIntervalS = 0

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200, "sungrow": 9600}), 11040)
got := targetsByDriver(targets)
if got["ferroamp"].TargetW > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W — the driver reported it cannot charge; slew re-opened its parked target",
got["ferroamp"].TargetW)
}
// The capable sibling keeps its legitimate one-step ramp: it was measured
// at 0 W and the limiter allows one rate's worth of charge per tick.
if math.Abs(got["sungrow"].TargetW-500) > 0.01 {
t.Errorf("sungrow TargetW = %.1f W, want 500 W — the capable sibling's ramp must survive the floor",
got["sungrow"].TargetW)
}
}

// The floor must not swallow charge nobody blocked. Same slew shape, plain
// self-consumption: the ramp toward the surplus is the correct answer and
// stays.
func TestChargeFloorLeavesUnblockedChargeAlone(t *testing.T) {
store := seedChargeFloorSite(t, -2500, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", 2000, 0.50, ""},
})
st := NewState(0, 50, "meter")
st.Mode = ModeSelfConsumption
st.SlewRateW = 500
st.MinDispatchIntervalS = 0

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
got := targetsByDriver(targets)
if math.Abs(got["ferroamp"].TargetW-2500) > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W, want 2500 W — no gate closed charge here, the ramp must survive",
got["ferroamp"].TargetW)
}
}

// The floor is one-sided. On a tick that forbids charging, a battery measured
// mid-DISCHARGE still ramps back toward 0 at the slew rate rather than being
// snapped to it — the charge block says nothing about discharge, and covering
// live load must not become collateral damage.
func TestChargeFloorLeavesDischargeAlone(t *testing.T) {
now := time.Now()
store := seedChargeFloorSite(t, -2000, []struct {
name string
currentW, soc float64
capability string
}{
{"ferroamp", -2000, 0.55, ""},
})
st := NewState(0, 60, "meter")
st.Mode = ModePlannerPassiveArbitrage
st.UseEnergyDispatch = true
st.SlewRateW = 500
st.MinDispatchIntervalS = 0
st.SlotDirective = func(time.Time) (SlotDirective, bool) {
return idleArbitrageSlot(now, "passive_arbitrage"), true
}

targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040)
got := targetsByDriver(targets)
if math.Abs(got["ferroamp"].TargetW-(-1500)) > 0.01 {
t.Errorf("ferroamp TargetW = %.1f W, want -1500 W — the charge floor must not touch a discharging battery's ramp",
got["ferroamp"].TargetW)
}
}

// A direct unit test of the floor's contract, so the shape is pinned
// independently of everything ComputeDispatch does above it: it moves only
// positive targets, only for blocked drivers, and never away from zero.
func TestFloorBlockedChargeContract(t *testing.T) {
in := []DispatchTarget{
{Driver: "a", TargetW: 1500},
{Driver: "b", TargetW: -1500},
{Driver: "c", TargetW: 0},
}
out := floorBlockedCharge(append([]DispatchTarget(nil), in...), false, map[string]bool{"a": true})
got := targetsByDriver(out)
if got["a"].TargetW != 0 || !got["a"].Clamped {
t.Errorf("blocked driver a = %.1f W (clamped=%v), want 0 W clamped", got["a"].TargetW, got["a"].Clamped)
}
if got["b"].TargetW != -1500 || got["b"].Clamped {
t.Errorf("driver b = %.1f W (clamped=%v), want -1500 W untouched", got["b"].TargetW, got["b"].Clamped)
}
if got["c"].TargetW != 0 || got["c"].Clamped {
t.Errorf("driver c = %.1f W (clamped=%v), want 0 W unclamped", got["c"].TargetW, got["c"].Clamped)
}

// The site-wide flag covers every driver, blocked list or not.
all := floorBlockedCharge(append([]DispatchTarget(nil), in...), true, nil)
gotAll := targetsByDriver(all)
if gotAll["a"].TargetW != 0 || gotAll["c"].TargetW != 0 {
t.Errorf("noSelfCharge left charge standing: a=%.1f c=%.1f", gotAll["a"].TargetW, gotAll["c"].TargetW)
}
if gotAll["b"].TargetW != -1500 {
t.Errorf("noSelfCharge moved a discharge target: b=%.1f", gotAll["b"].TargetW)
}
}
Loading