Skip to content

Slew anchored on actual output + MaxCommandW default pins the controller into commanding more charge than it wants, indefinitely, when a device stops following #816

Description

@Leitet

Symptom

A site imported 4.4 kW from the grid for 12+ hours while its plan said battery_w 0 ("idle — export PV surplus") and the live controller was actively trying to reduce charging. dispatch: meter clamp reduced battery target logged 856 times over 12.2 h. Live sample:

bat_w 6551   target 5000  clamped true   grid 4494   pv -2771   soc 0.79   mode=self_consumption (planner idle slot)

The controller's own computed ideal was currentTotal − errW = 6548 − 5616 = 932 W. It issued +5000 W charge.

Mechanism (all three needed)

  1. Slew anchors on actual output. raw[i].TargetW = anchor ± SlewRateW with anchor = battery SmoothedW. With actual 6551 W and slew 500 W, the furthest the target can move in one cycle is 6051 W — regardless of the controller wanting 932 W.
  2. MaxCommandW = 5000 applies silently. driverLimitsFrom (go/cmd/ftw/main.go) does continue when a battery has max_charge_w: 0 and max_discharge_w: 0, so the driver gets no DriverLimits entry and the re-clamp falls back to the ±5 kW default. Note the inconsistency: mpcBatteryFleetFromConfig treats the same both-zero config as an explicit config error, logs a warning, and substitutes 0.5C — the control path neither warns nor defaults, it just omits the limits.
  3. The device doesn't follow. Actual stays put, so the anchor stays put, so next cycle recomputes the same target. Observed as target == actual − 500 cycle after cycle, for hours, at two different power levels (6551/5000 and 3734/3234).

The fixed point is stable and self-sustaining: the controller emits a large charge command while its intent is to cut charging, which is exactly what keeps the battery charging.

What makes it recoverable in theory and not in practice

If the device followed, actual would descend 500 W/cycle and converge in ~7 cycles. The code comments already anticipate the overshoot case ("If the battery was already beyond its per-command cap … re-apply the per-driver cap") — but the re-clamp bounds magnitude, not intent: capping 6051 → 5000 still emits a 5 kW charge.

Suggested directions

  • Stall detection: if |actual − target| fails to shrink over N cycles, stop anchoring on actual — anchor on the last commanded value, or bypass slew — so a non-responsive device cannot hold the controller hostage. Log it; a device that ignores commands is a fault worth surfacing, not absorbing silently.
  • Never emit a command that overshoots the computed ideal in the wrong direction: when idealTarget < currentTotal (reduce charging), the issued command should never exceed idealTarget, whatever the slew anchor says. Slew should limit how fast you approach a target, not force you past it.
  • Make both-zero handling consistent: driverLimitsFrom should warn and default like the MPC path rather than silently dropping the limits.
  • Consider surfacing "device is not following dispatch" in the help report — the existing report said "the battery is not following the plan" but attributed it to safety clamps.

Site context: FoxESS 1K5 under a local control-tier driver; charge direction is under investigation separately (the device follows discharge commands but not charge). That device fault is what exposed this, but the trap is device-agnostic — any driver that stops responding while charging above MaxCommandW reproduces it.

🤖 Diagnosed with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions