Motivation
Today `MaxCommandW = 5000` is hard-coded in `go/internal/control/dispatch.go` and applied to every battery command regardless of hardware. Real batteries vary wildly:
- Ferroamp EnergyHub can push 10-15 kW depending on configuration.
- Sungrow hybrid inverters are typically 5-10 kW.
- Pixii EssLi is 5-7 kW.
A 5 kW ceiling across the board means Ferroamp + Sungrow on a 16 A / 11 kW site loses 3-5 kW of headroom on charging slots where the operator would benefit. On bigger fuses (32 A / 22 kW) the loss is even larger.
Operator feedback (2026-04-20): "vi började med 5 kW för att börja försiktigt, men bättre om vi utgår från riktiga gränser nu."
Phased plan
Phase A — per-driver config (unblocks operators immediately)
- `config.Driver` gains `max_charge_w` and `max_discharge_w` (both optional, 0 = fall through to `MaxCommandW = 5000` default for backcompat).
- `control.batteryInfo` + `clampWithSoC` become per-battery aware: each battery clamps against its own limit, not a global constant.
- Fuse-guard is made bidirectional: the charge path gets the same "site boundary must not exceed fuse" protection the discharge path already has. Operator feedback: "FuseGuard förstås alltid vara överordnad spelar ju ingen roll om det är discharge eller charge."
- UI surfaces the limits in the Devices / Settings view.
Phase B — observed maxima (passive insight)
- `control` or `telemetry` tracks a rolling `max_observed_charge_w` and `max_observed_discharge_w` per driver over a 30-day window.
- Surfaced via `/api/devices` and `/api/battery_models` so the UI can show "you configured 5 kW, we've seen 6.2 kW actually flow".
Phase C — self-tune discovers limits (active insight)
- Extend `selftune` step-response to probe max amplitude: ramp to 3 kW → 5 kW → 7 kW until the battery plateaus before reaching the command. That plateau is the observed ceiling.
- Gated on safe conditions (good PV, high-ish SoC, grid stable). Runs at operator command, not automatically.
- Writes a learned value to `state.db` per device.
Phase D — UI suggests raise, operator approves
- Dashboard shows side-by-side: Configured | Observed | Self-tune measured. One-click to raise the configured cap toward the learned value.
- Never auto-raises. Human-in-loop.
Safety invariants (all phases)
- Site fuse-guard is the non-negotiable ceiling above any per-driver cap.
- SoC clamps (don't discharge when < 5 %) stay.
- Slew limits stay.
- A per-driver cap above the fuse budget gets clamped down to the fuse per-dispatch-cycle — config can suggest 15 kW but if the fuse is 11 kW the dispatcher still respects 11 kW at the site boundary.
PR sequence
- `feat(control): per-driver power limits + bidirectional fuse-guard` (Phase A + safety invariant).
- `feat(telemetry): rolling observed max per battery` (Phase B).
- `feat(selftune): probe battery max power during step response` (Phase C).
- `feat(ui): suggest raising per-driver caps from observed/learned data` (Phase D).
Each PR links back here.
Motivation
Today `MaxCommandW = 5000` is hard-coded in `go/internal/control/dispatch.go` and applied to every battery command regardless of hardware. Real batteries vary wildly:
A 5 kW ceiling across the board means Ferroamp + Sungrow on a 16 A / 11 kW site loses 3-5 kW of headroom on charging slots where the operator would benefit. On bigger fuses (32 A / 22 kW) the loss is even larger.
Operator feedback (2026-04-20): "vi började med 5 kW för att börja försiktigt, men bättre om vi utgår från riktiga gränser nu."
Phased plan
Phase A — per-driver config (unblocks operators immediately)
Phase B — observed maxima (passive insight)
Phase C — self-tune discovers limits (active insight)
Phase D — UI suggests raise, operator approves
Safety invariants (all phases)
PR sequence
Each PR links back here.