Skip to content

Multi-inverter rework: tracking issue #4856

Description

@chalfontchubby

Tracking issue for a rework of multi-inverter support. Filing this as the umbrella so the discussion happens in one place; the intent is to spin off separate sub-issues per workstream once we've agreed the shape, not to fix everything here.

Prompted by the #4842 thread, where @gcoan and I ended up agreeing that "multi-inverter support was a bit of an edge case" and worth a proper look rather than another point fix.

Full write-up: https://claude.ai/code/artifact/e91af2a5-3440-459e-8daa-a3a31d7f32d5

Everything below is verified against v8.54.2 / main 4b42b7a1 by reading the code; the numbers are computed from the formulae as written, not measured on hardware.

The shape of the problem

Predbat has three separate models of a multi-inverter system and no shared one:

  1. Planner — optimises a single virtual battery made by summing the fleet. soc_kw, soc_max, rates and limits are plain sums; battery temperature is an unweighted mean; the charge curve is measured from inverter 0 and applied to everything.
  2. Executor — splits that fleet-wide answer back out per inverter.
  3. Balancer — an independent 60-second reactive loop that builds its own Inverter objects, knows nothing about the plan, and can only write rate 0 or rate max.

Almost every reported multi-inverter fault sits in one of the two seams. It all works correctly when every inverter is identical — which is what the test suite covers, and what real fleets grow out of.

The headline finding

Not any of the existing bug reports: the executor's target split actively manufactures imbalance from a balanced start.

adjust_battery_target_multi() (execute.py:807-808) apportions the fleet SoC delta by each inverter's share of total charge rate and never reads soc_max. On @gcoan's 9.5 + 5.2 kWh fleet, both on 2.6 kW, starting perfectly balanced at 50/50:

Fleet target 9.5 kWh ends 5.2 kWh ends Spread Fleet actually reaches
60% 58% 64% 6 pts 60%
80% 73% 92% 19 pts 80%
90% 81% 100% 19 pts 88%
95% 85% 100% 15 pts 90%

Rate-proportional and capacity-proportional splits optimise different things — the first makes inverters finish together, the second makes them end balanced. We currently pick the first and then run a separate subsystem to undo the consequences. Above ~90% the small battery saturates, the surplus isn't redistributed, and the fleet silently lands short of the target the plan was costed against. A capacity-proportional split hits every one of those targets exactly, with zero spread.

This is, I think, the actual mechanism behind #830.

Twelve findings

Finding Refs
F1 Target split is by charge rate, not capacity — creates imbalance from a balanced start #4842, #830
F2 Once the smallest battery saturates the fleet silently undershoots its own plan #830
F3 Planner can't model a fleet whose aggregate rate falls as members fill — under-books charge windows by ~22 min on the #830 fleet #830
F4 Balancer's only actuator is full-off/full-on, so correcting imbalance under load forces peak-rate import #3172
F5 Balancer's unconditional reset can overwrite a deliberate hold, breaking freeze charge on 22 of 24 inverter types #829
F6 can_power_house is structurally unreachable during a force export #4842
F7 Partner selection is a fixed (i+1) % n ring — arbitrary for 3+ inverters #4842
F8 Fleet capability derived inconsistently: first-inverter-wins for freeze/reserve, fleet-AND for feed-in-first #4690
F9 Battery temperature is an unweighted mean; charge curve measured from inverter 0 only
F10 Every multi-inverter test uses two identical inverters, so none of the above is covered
F11 Planner already chooses a fleet export power, but execution applies it uniformly instead of allocating it #4842
F12 Imbalance has no cost anywhere in the plan, so a cheaper-but-divergent option always wins

F5 is worth separate attention as a live correctness bug of the same family as #4690. The balancer's reset loop is ungated by state: if an inverter's discharge rate reads zero and any other inverter is discharging, it's reset to max. But rate 0 is exactly how we express a freeze charge and how set_discharge_during_charge = off is enforced on any inverter without timed pause — and only GE and GEC declare has_timed_pause. On the other 22 types the balancer can undo a hold the executor just established and keep it undone until the next plan cycle. That matches the symptom in #829 (discharge rate observed at 2600 where it should have been 0), open since Jan 2025.

The thing that makes this tractable

F11 is the encouraging one. With set_export_low_power on, export power is already encoded in the fractional part of the export limit (export_rate_adjust = 1 - frac), and the planner already scores a ladder of 0.0 / 0.3 / 0.5 / 0.7 = 100/70/50/30% of max (plan.py:2294-2299). The lever exists and is already optimised.

What's missing is allocation: execute.py:493 multiplies every inverter's rate by the same factor. Pick 50% on a two-inverter fleet and both run at half, rather than the fullest at max and the other off. So merit-order dispatch is an execution-layer allocation of a number we already compute, not a new planning concept.

Import is asymmetric — find_charge_rate() is already per-inverter and capacity-aware, so the charge side needs no allocator, just F1 fixed.

A dispatch rule that covers the whole range

Expressing output as C-rate (C = P/E, per hour) makes the invariant exact: every inverter at the same C-rate loses SoC% at the same speed, whatever their capacities. Power proportional to capacity is equal C.

That gives a fleet property we have no concept of today — the highest power deliverable while staying balanced, set by the member with the lowest C ceiling:

P_balance = min(P_i / E_i) × ΣE_j
Fleet P_balance P_max ceiling
#830 — 9.5 + 5.2 kWh 4.02 kW 5.20 kW 77%
#4842 — 12.4 + 9.52 + 8.19 6.31 kW 8.20 kW 77%
#3172 — 19.0 + 9.5 4.50 kW 6.00 kW 75%
matched 9.5 + 9.5 5.20 kW 5.20 kW 100%

Every real mixed fleet lands at 75–77% — which falls between the existing ladder's 70% and 100% rungs, so the most useful point on the curve is currently unreachable. On a matched fleet it lands at 100% and collapses harmlessly, which is also why none of this surfaces in testing.

Balanced and unbalanced don't need separate logic. Water-fill to a common projected end-of-interval SoC, clamped by each inverter's rate limit. On the #830 fleet over 15 minutes:

Fleet state Required Allocated 9.5 / 5.2 Spread before → after
balanced 50/50 3.0 kW 1.94 / 1.06 kW 0.0 → 0.0 pts
balanced 50/50 5.2 kW 2.60 / 2.60 0.0 → 5.7 pts
out of balance 45/75 3.0 kW 0.40 / 2.60 30.0 → 18.6 pts
out of balance 45/75 5.2 kW 2.60 / 2.60 30.0 → 24.3 pts

One formula: capacity-proportional when balanced, merit-order draw from the fullest when not, graceful degradation to today's behaviour at full power. No modes, no thresholds.

Super power, and what it costs

P_balance is an envelope, not a cap — when the house wants 6 kW the fleet must deliver 6 kW. #3172 is the fault of treating it as a limit: that fleet holds balance to 4.5 kW of a 6.0 kW max, and our only response to exceeding it today is to zero an inverter and import the difference at peak.

Exceeding it should be freely available and simply accounted for, and the accounting has two tiers:

How the imbalance is undone Marginal cost
Ride on planned throughput — skew allocation of load, a planned charge, or a planned export. Same energy at the same price; only which battery does the work changes. ≈ 0
Cross-charge — A into B with no grid purpose 13.3% of everything moved, for nothing

So the cost of super power isn't the imbalance. It's the risk of being left with no planned throughput to fix it on. And that risk is small because recovery is fast: 15 min at full 5.2 kW opens a 5.7-point spread, and 25 min of ordinary 1 kW demand closes it at zero cost. From a full 30-point spread — 90 min at 1 kW, 45 min at 2 kW, 295 min at 0.3 kW.

The load-bearing detail: rebalancing needs throughput, not time. An idle or frozen fleet can't rebalance at all. That's the real hazard, and the only condition under which cross-charging ever looks tempting.

Which leaves us with an inversion worth naming: cross-charging, the only rebalancing path that costs anything, ships today as a user-facing feature (balance_inverters_crosscharge). Free rebalancing on planned throughput doesn't exist at all.

Proposed workstreams

Ordered by dependency, not severity. Suggest these become the sub-issues.

  • 0 — Heterogeneous fleet test rig (F10). Nothing else is safe without it. Parameterise capacity/rate/count/starting SoC; fixtures for the Predbat takes account of dissimilar battery capacities in the plan #830 pair and the Multi-inverter balancing cannot act during a force export, and the target split worsens imbalance #4842 three-way; a force-export scenario to make F6 reachable; an assertion that a balanced fleet stays balanced across a full charge.
  • 1 — Capacity-proportional target split with rate feasibility and saturation redistribution (F1, F2). Highest value-to-risk change available, ~40 lines.
  • 2 — Merit-order power allocator (F4, F11). One water-filling function serving both the export throttle and the balancer. Hard invariant currently held by nothing: allocated fleet power must still meet the requirement in total.
  • 3 — Plan-aware balancer (F5, F6, F7). Never overwrite a deliberate zero; use the right predicate per state; pick the best partner, not the next index.
  • 4 — Fleet capability resolution (F8, F9). Apply the inv_support_feedin_first reasoning consistently. Probably where the INVERTER_DEF freeze-mechanism declaration parked on Freeze hold isn't always guaranteed by execution, even when the plan assumes it (multiple instances) #4690 belongs.
  • 4b — Fleet-derived ladder rung + balance tie-break (F12). Add a rung at P_balance / P_max; break ties on balance, weighted by remaining throughput.
  • 5 — Per-inverter feasibility in the planner (F3). The only one touching the optimiser. Two shapes: a post-plan deliverability check (cheap, contained) or a piecewise fleet rate (faithful, but reaches into the per-minute loop and the C++ kernel). Full per-inverter planning is not recommended.
  • Ongoing — observability. Publish SoC spread and cumulative cross-charged energy as entities, so any of the above can be shown to have helped.

Open questions for discussion

  1. Is equal SoC% the right balance target? It makes equal C the invariant. For batteries of different age or chemistry, equal headroom or equal DoD might serve the hardware better. Water-filling supports either, but the choice propagates into the target split.
  2. Should the planner deliberately choose a slower rate to preserve balance? Recommend a tie-break among options already within metric_min_improvement_export rather than a real imbalance term — the latter needs F3's model change first.
  3. How much extra register traffic is acceptable? A proportional actuator writes more often than on/off. Needs an explicit deadband and minimum write interval, not just a threshold — the flash-wear caution in the docs already applies to balancing.
  4. Should cross-charging remain an offered strategy? Once workstream 2 lands it's the only path that costs anything. Demoting or removing it is a visible behaviour change and wants a decision rather than a quiet deprecation.
  5. Should balancing still be off by default and marked Beta? The users who most need it are turning it off because of F4.

Related: #4842, #830, #829, #3172, #4844, #3303, #4690, #2514.

@springfall2008 @gcoan — nothing here is implemented, and the phasing and the five questions are proposals rather than conclusions. Happy to start on workstream 0 whenever, since it's inert on its own.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions