From fd534e9c0624e11ffa92c59a9cf2449d67ca7847 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 28 Sep 2026 20:24:08 +0200 Subject: [PATCH] fix(control): share battery power limits with the planner Signed-off-by: Fredrik Ahlgren --- .changeset/shared-battery-power-limits.md | 8 ++ go/cmd/ftw/battery_power_limits.go | 44 ++++++++++ go/cmd/ftw/battery_power_limits_test.go | 51 ++++++++++++ go/cmd/ftw/control_state_test.go | 6 +- go/cmd/ftw/main.go | 97 ++--------------------- 5 files changed, 112 insertions(+), 94 deletions(-) create mode 100644 .changeset/shared-battery-power-limits.md create mode 100644 go/cmd/ftw/battery_power_limits.go create mode 100644 go/cmd/ftw/battery_power_limits_test.go diff --git a/.changeset/shared-battery-power-limits.md b/.changeset/shared-battery-power-limits.md new file mode 100644 index 00000000..386a6fe0 --- /dev/null +++ b/.changeset/shared-battery-power-limits.md @@ -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. diff --git a/go/cmd/ftw/battery_power_limits.go b/go/cmd/ftw/battery_power_limits.go new file mode 100644 index 00000000..45a6a6a5 --- /dev/null +++ b/go/cmd/ftw/battery_power_limits.go @@ -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 +} diff --git a/go/cmd/ftw/battery_power_limits_test.go b/go/cmd/ftw/battery_power_limits_test.go new file mode 100644 index 00000000..d57687a8 --- /dev/null +++ b/go/cmd/ftw/battery_power_limits_test.go @@ -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) + } + } + }) + } +} diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 95999076..e0f65035 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -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) } } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 07715566..29f2db0f 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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 @@ -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