You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
Executor — splits that fleet-wide answer back out per inverter.
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
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:
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.
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.
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
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.
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.
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.
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.
Should balancing still be off by default and marked Beta? The users who most need it are turning it off because of F4.
@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.
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/ main4b42b7a1by 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:
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.Inverterobjects, 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 readssoc_max. On @gcoan's 9.5 + 5.2 kWh fleet, both on 2.6 kW, starting perfectly balanced at 50/50: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
can_power_houseis structurally unreachable during a force export(i+1) % nring — arbitrary for 3+ invertersF5 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 = offis enforced on any inverter without timed pause — and onlyGEandGECdeclarehas_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_poweron, 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 of0.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:493multiplies 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:
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:
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_balanceis 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:
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.
inv_support_feedin_firstreasoning consistently. Probably where theINVERTER_DEFfreeze-mechanism declaration parked on Freeze hold isn't always guaranteed by execution, even when the plan assumes it (multiple instances) #4690 belongs.P_balance / P_max; break ties on balance, weighted by remaining throughput.Open questions for discussion
metric_min_improvement_exportrather than a real imbalance term — the latter needs F3's model change first.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.