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
8 changes: 8 additions & 0 deletions .changeset/shared-battery-power-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"ftw": patch
---

Use the same battery power limits for planning and control. When a power limit
is missing, both use the existing 5 kW control default instead of planning at
half the battery's energy capacity. Keep configured limits and one-sided zero
limits in both paths.
44 changes: 44 additions & 0 deletions go/cmd/ftw/battery_power_limits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package main

import (
"log/slog"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
)

// batteryPowerLimits resolves limits for both planning and dispatch, at boot
// and on config reload. Missing ratings use dispatch's existing 5 kW default:
// battery energy capacity alone does not establish a safe power rating.
func batteryPowerLimits(d config.Driver, b config.Battery) control.PowerLimits {
limits := control.PowerLimits{
MaxChargeW: control.MaxCommandW, MaxDischargeW: control.MaxCommandW,
MaxChargeWSet: true, MaxDischargeWSet: true,
}
if d.MaxChargeW > 0 {
limits.MaxChargeW = d.MaxChargeW
}
if d.MaxDischargeW > 0 {
limits.MaxDischargeW = d.MaxDischargeW
}
// Preserve the existing both-zero error policy and one-sided zero limits.
if b.MaxChargeW != nil && *b.MaxChargeW == 0 && b.MaxDischargeW != nil && *b.MaxDischargeW == 0 {
slog.Warn("battery: ignoring both-zero overrides; retaining driver limits or the dispatch default",
"driver", d.Name, "max_charge_w", limits.MaxChargeW, "max_discharge_w", limits.MaxDischargeW)
return limits
}
apply := func(override *float64, limit *float64, field string) {
if override == nil {
return
}
if *override >= 0 {
*limit = *override
} else {
slog.Warn("battery: ignoring negative power limit; retaining driver limit or the dispatch default",
"driver", d.Name, "field", field, "value", *override, "retained_w", *limit)
}
}
apply(b.MaxChargeW, &limits.MaxChargeW, "max_charge_w")
apply(b.MaxDischargeW, &limits.MaxDischargeW, "max_discharge_w")
return limits
}
51 changes: 51 additions & 0 deletions go/cmd/ftw/battery_power_limits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"testing"
"time"

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

// A capacity is not a power rating. A 20 kWh battery with no power limits
// used to get a 10 kW plan but only a 5 kW command, even with slew disabled.
func TestBatteryPowerLimitsAgreeWithDispatch(t *testing.T) {
for _, tc := range []struct {
name, driver, battery string
charge, discharge float64
}{
{"unset", "", " weight: 1\n", 5000, 5000},
{"driver charge only", " max_charge_w: 8000\n", " weight: 1\n", 8000, 5000},
{"driver discharge only", " max_discharge_w: 9000\n", " weight: 1\n", 5000, 9000},
{"battery override", " max_charge_w: 8000\n max_discharge_w: 9000\n", " max_charge_w: 6000\n max_discharge_w: 7000\n", 6000, 7000},
{"charge forbidden", "", " max_charge_w: 0\n", 0, 5000},
{"discharge forbidden", "", " max_discharge_w: 0\n", 5000, 0},
{"both zero ignored", "", " max_charge_w: 0\n max_discharge_w: 0\n", 5000, 5000},
{"both zero keeps known cap", " max_charge_w: 2000\n", " max_charge_w: 0\n max_discharge_w: 0\n", 2000, 5000},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := parseBatteryLimitConfig(t, tc.driver, tc.battery)
cfg.Drivers[0].BatteryCapacityWh = 20000
capacities := map[string]float64{"battery": 20000}
fleet := mpcBatteryFleetFromConfig(cfg, capacities)
if len(fleet) != 1 || fleet[0].MaxChargeW != tc.charge || fleet[0].MaxDischargeW != tc.discharge {
t.Fatalf("planner limits = %+v, want charge %.0f W, discharge %.0f W", fleet, tc.charge, tc.discharge)
}
for _, charge := range []bool{true, false} {
ctrl := newControlStateFromConfig(cfg)
ctrl.SlewEnabled = false
ctrl.Mode = control.ModeCharge
want := tc.charge
if !charge {
ctrl.Mode = control.ModeSelfConsumption
ctrl.SetBatteryManualHold(control.BatteryManualHold{PowerW: -20000, ExpiresAt: time.Now().Add(time.Minute)})
want = -tc.discharge
}
targets := control.ComputeDispatch(batteryLimitStore(0), ctrl, capacities, 40000)
if len(targets) != 1 || targets[0].TargetW != want {
t.Fatalf("dispatch = %+v, want %.0f W (charge=%v)", targets, want, charge)
}
}
})
}
}
6 changes: 3 additions & 3 deletions go/cmd/ftw/control_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,18 @@ func TestBatteryLimitConfigUnsetUsesDriverValue(t *testing.T) {
}
}

func TestBatteryLimitConfigBothZeroUsesHalfC(t *testing.T) {
func TestBatteryLimitConfigBothZeroUsesDefault(t *testing.T) {
cfg := parseBatteryLimitConfig(t, "",
" max_charge_w: 0\n max_discharge_w: 0\n")
ctrl := newControlStateFromConfig(cfg)
lim, ok := ctrl.DriverLimits["battery"]
if !ok || !lim.MaxChargeWSet || !lim.MaxDischargeWSet || lim.MaxChargeW != 5000 || lim.MaxDischargeW != 5000 {
t.Fatalf("both-zero config error did not share MPC 0.5C: %+v ok=%v", lim, ok)
t.Fatalf("both-zero config error did not share the MPC default: %+v ok=%v", lim, ok)
}
ctrl.Mode = control.ModeCharge
targets := control.ComputeDispatch(batteryLimitStore(0), ctrl, map[string]float64{"battery": 10000}, 40000)
if len(targets) != 1 || targets[0].TargetW != 5000 {
t.Fatalf("both-zero config error produced %+v, want 0.5C 5000 W", targets)
t.Fatalf("both-zero config error produced %+v, want the default 5000 W", targets)
}
}

Expand Down
97 changes: 6 additions & 91 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3504,58 +3504,12 @@ func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint
return out
}

// 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 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 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
// keep driver caps, using 0.5C only for missing limits, as the planner does.
// Drivers without limits in either place are omitted from the map.
// driverLimitsFrom gives dispatch the same per-driver limits as the planner.
func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits {
out := map[string]control.PowerLimits{}
for _, d := range drivers {
if d.ObserveOnly {
continue
}
chg, dis := d.MaxChargeW, d.MaxDischargeW
chgSet, disSet := chg > 0, dis > 0
if b, ok := batteries[d.Name]; ok {
bothZero := b.MaxChargeW != nil && *b.MaxChargeW == 0 &&
b.MaxDischargeW != nil && *b.MaxDischargeW == 0
if bothZero {
if defaultP := d.BatteryCapacityWh / 2; defaultP > 0 {
if !chgSet {
chg, chgSet = defaultP, true
}
if !disSet {
dis, disSet = defaultP, true
}
slog.Warn("control: ignoring both-zero battery overrides; retaining driver limits with 0.5C for missing limits",
"driver", d.Name, "max_charge_w", chg, "max_discharge_w", dis)
}
} else {
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 && !chgSet && !disSet {
continue
}
out[d.Name] = control.PowerLimits{
MaxChargeW: chg,
MaxDischargeW: dis,
MaxChargeWSet: chgSet,
MaxDischargeWSet: disSet,
if !d.ObserveOnly {
out[d.Name] = batteryPowerLimits(d, batteries[d.Name])
}
}
return out
Expand Down Expand Up @@ -3679,51 +3633,12 @@ func mpcBatteryFleetFromConfig(cfg *config.Config, capacities map[string]float64
if cap <= 0 {
continue
}
// Use configured driver limits, then 0.5C for missing limits. Zero is a
// legitimate one-sided constraint — `max_charge_w: 0` means
// "forbid charging, allow discharge only" and mpc.Optimize's
// action grid (`-MaxDischargeW…+MaxChargeW`) supports it.
// Negative is always a config mistake.
//
// Only the *both-zero* case is treated as a config error (and
// almost certainly is — it kills the planner's entire action
// space while leaving the service running). We fall back to
// driver limits in that case and log a warning.
defaultP := cap / 2
chg := defaultP
dis := defaultP
if d.MaxChargeW > 0 {
chg = d.MaxChargeW
}
if d.MaxDischargeW > 0 {
dis = d.MaxDischargeW
}
if b, ok := cfg.Batteries[d.Name]; ok {
bothZero := b.MaxChargeW != nil && *b.MaxChargeW == 0 &&
b.MaxDischargeW != nil && *b.MaxDischargeW == 0
if bothZero {
slog.Warn("mpc: ignoring both-zero battery overrides; retaining driver limits with 0.5C for missing limits",
"driver", d.Name, "max_charge_w", chg, "max_discharge_w", dis)
} else {
if b.MaxChargeW != nil && *b.MaxChargeW >= 0 {
chg = *b.MaxChargeW
} else if b.MaxChargeW != nil {
slog.Warn("mpc: ignoring negative batteries.max_charge_w; retaining charge limit",
"driver", d.Name, "value", *b.MaxChargeW, "max_charge_w", chg)
}
if b.MaxDischargeW != nil && *b.MaxDischargeW >= 0 {
dis = *b.MaxDischargeW
} else if b.MaxDischargeW != nil {
slog.Warn("mpc: ignoring negative batteries.max_discharge_w; retaining discharge limit",
"driver", d.Name, "value", *b.MaxDischargeW, "max_discharge_w", dis)
}
}
}
limits := batteryPowerLimits(d, cfg.Batteries[d.Name])
fleet = append(fleet, mpc.BatteryFleetMember{
Driver: d.Name,
CapacityWh: cap,
MaxChargeW: chg,
MaxDischargeW: dis,
MaxChargeW: limits.MaxChargeW,
MaxDischargeW: limits.MaxDischargeW,
})
}
return fleet
Expand Down
Loading