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/preserve-zero-battery-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep explicit zero battery charge and discharge limits disabled through dispatch.
100 changes: 100 additions & 0 deletions go/cmd/ftw/control_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
34 changes: 23 additions & 11 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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
Expand Down
133 changes: 133 additions & 0 deletions go/internal/control/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading