fix(execute): resolve multi-inverter status from all inverters, not just the last one processed - #4466
Conversation
…ust the last one processed execute_plan()'s headline status was a single variable overwritten once per inverter in the loop, so the dashboard silently showed whichever inverter happened to be processed last - hiding real disagreement between inverters, including genuine cross-charging (one inverter charging while another discharges at the same time), which gcoan confirmed is a real, known phenomenon on multi-inverter Sigenergy/GivEnergy-style systems. Track each inverter's own final core state in status_per_inverter (keyed by id, so an inverter passing through multiple assignments in its own processing still just keeps its own last value). After the loop, resolve one headline via resolve_multi_inverter_status(): if inverters disagree across the charge/export divide, surface it as "Cross-charging" rather than picking one side arbitrarily; if they only disagree on sub-state within the same side (e.g. one still Charging, another already Hold charging), show the most active one, since that's what the fleet is actually still doing overall. Extracted as a small pure function for direct unit testing rather than needing to drive execute_plan()'s full branching to construct genuine multi-inverter disagreement scenarios. Three existing execute.py tests (charge_imbalance2, charge_freeze_imb1, charge_freeze_imb4) asserted the old "last inverter wins" artifact as if it were correct (e.g. "Hold charging" when one inverter was actually still charging toward target) - updated to assert the corrected, more informative aggregate instead. Fixes #4446 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-on to the #4446 headline fix. The status_extra text (the "detail" attribute on predbat.status, shown as the tooltip/more-info text) already concatenated each inverter's SoC->target numerically for multi-inverter setups ("target 80%-40% / 60%-40%") but didn't say which state each entry belonged to. Prefix each entry with that inverter's own state ("target Charging 80%-40% / Hold charging 60%-40%") - reuses the existing append pattern and status_per_inverter/status values already computed for the #4446 fix, no new machinery. Single-inverter setups are unaffected. Added assert_status_extra to the shared execute test harness and locked in real values for charge_imbalance2 and charge_freeze_imb1, which already exercise genuine multi-inverter disagreement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a multi-inverter reporting bug in execute_plan() where the headline status could be overwritten once per inverter, causing the dashboard to reflect only the last-processed inverter and potentially hiding real fleet disagreement (including cross-charging). It introduces per-inverter status tracking and a resolver function to compute a single, informative headline status, plus adds targeted unit tests around the new resolution logic.
Changes:
- Track each inverter’s final core charge/export state during
execute_plan()and resolve a single fleet headline viaresolve_multi_inverter_status()(including explicit “Cross-charging” detection). - Enhance
status_extrain multi-inverter setups to prefix each inverter’s entry with that inverter’s own core state. - Add a focused unit test module for multi-inverter headline resolution, and update existing execute tests that previously encoded the “last inverter wins” artifact.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| apps/predbat/execute.py | Adds per-inverter status tracking and fleet headline resolution; updates status_extra formatting to include per-inverter state. |
| apps/predbat/tests/test_execute.py | Extends the execute test harness to assert status_extra; updates expectations for multi-inverter disagreement cases. |
| apps/predbat/tests/test_execute_multi_inverter_status.py | New unit tests covering the fleet headline status resolution rules (including cross-charging). |
| apps/predbat/unit_test.py | Registers the new multi-inverter status test in the unit test runner. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
if there is a new cross charging status then this should be added to the documentation of predbat statii https://springfall2008.github.io/batpred/what-does-predbat-do/#predbat-modes (and maybe I'll be writing an automation to detect it, be interesting to see if it ever happens) |
gcoan asked on #4466 for the new multi-inverter Cross-charging status (introduced by resolve_multi_inverter_status()) to be documented alongside the other predbat.status values.
|
Good call - added it: 3dbf4747 |
perfect thanks |
… iBoost hold status text status_hold_iboost was only ever set inside the same guard (status not in ["Exporting", "Charging"]) that gates the actual discharge-pause actions, so the annotation could never be recorded once any inverter's status reached Charging/Exporting. Split the guard so it still gates the pause/reserve actions but the status text update always runs, matching the pattern already used by the car-holding block just above it.
|
Dug into this. Copilot's suggested patch (guard at the There was a real (if narrower) version of this bug, just one guard earlier and specific to iBoost: the iBoost-hold block's entry condition included Pushed 993853 - split the iBoost guard so it still gates the discharge-pause/reserve actions, but the status text always updates, matching the car-holding pattern. Existing Caveat worth flagging: I couldn't construct a test that fails on the old code and passes on the new one. |
ReviewDug into why a test couldn't be written that fails without the final commit ( The fix splits that into an outer guard plus an inner execute.py ~536-554 — In a multi-inverter fleet, inverter B can show "Hold for iBoost" even when nothing was paused for it that cycle, because the shared A few more from tracing
Net: the core architectural direction (resolve status across all inverters instead of last-write-wins) is right and worth keeping. The hold-annotation variables ( |
…ne correct on multi-inverter fleets The final commit of this PR decoupled the "Hold for iBoost" status annotation from the pause action it describes - the annotation became a sibling of the inner discharge-hold guard instead of nested inside it, so it could now fire whenever the outer iBoost condition held, regardless of whether a pause actually happened this cycle. Re-nest it so the annotation stays coupled to boostHolding actually firing. Also fixes resolve_multi_inverter_status() so a Calibration break correctly overrides any stale core state an earlier-processed inverter left in status_per_inverter, matching what the function's own docstring already claimed but didn't implement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rday-plan reconstruction Cross-charging (#4466) contains "charging" but not "exporting" as a string, so calculate_yesterday()'s slot classification silently dropped the export half of a genuine cross-charging minute, showing it as plain charging only. Add yesterday_slot_is_exporting() and use it at both the search and the window-building call sites. Also clarifies (no behaviour change) that find_charge_curve()'s exact-match status checks deliberately exclude Cross-charging minutes from curve learning - another inverter is drawing/feeding power at the same time, so the sample isn't a clean single-inverter reading. And tightens the Cross-charging doc entry, which described it as both inverters "genuinely" charging/exporting when it also fires for two merely-holding sub-states (Hold charging + Hold exporting) with no current actually flowing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both fixes were previously only covered at the extracted-function level (resolve_multi_inverter_status / yesterday_slot_is_exporting), which proves the helper's logic but not that the surrounding code actually reaches it. Add the two integration cases and verify each genuinely fails when its fix is reverted: - execute: "calibration_after_charging_inverter" drives execute_plan() with a real two-inverter fleet where inverter 0 reaches Charging before inverter 1 enters calibration and breaks the loop. Without the fix the headline resolves back to the stale "Charging". Needed per-inverter escape hatches (in_calibration_array and the immediate-target/isCharging asserts) because the break leaves the fleet genuinely half-processed - the test now documents that real state rather than papering over it. - calculate_yesterday: a full "Cross-charging" status history must rebuild both charge AND export windows. Captured from inside a publish_html_plan mock, since the reconstructed windows only exist between the fake-window block and the restore at the end of the function. Without the fix the export side comes back empty. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Followed up on the coverage question, since "can you write a test that fails without it" is exactly what was in doubt on the original commit. Both fixes I pushed earlier were only covered at the extracted-function level, which proves the helper's logic but not that the surrounding code actually reaches it. Both now have integration coverage, and I verified each genuinely fails when its own fix is reverted:
This one needed new per-inverter escape hatches ( Worth noting one pre-existing quirk it documents: Cross-charging window reconstruction (test_calculate_yesterday.py) — feeds a full Test-only commit, no production code touched: |
Summary
execute_plan()'s headline status was a single variable overwritten once per inverter in the per-inverter loop - the dashboard silently showed whichever inverter happened to be processed last, hiding real disagreement between inverters.status_per_inverter, keyed by inverter id. After the loop,resolve_multi_inverter_status()resolves one headline:"Cross-charging", surfaced explicitly rather than picking one side arbitrarily.Charging, another alreadyHold charging) → show the most active one, since that's what the fleet is actually still doing overall.Demand,Read-Only,Calibration, or a Hold-for-car/iBoost annotation) → unchanged.resolve_multi_inverter_status) for direct, fast unit testing rather than needing to driveexecute_plan()'s full branching to construct genuine multi-inverter disagreement scenarios.charge_imbalance2,charge_freeze_imb1,charge_freeze_imb4) asserted the old "last inverter wins" artifact as correct (e.g. expecting"Hold charging"when one inverter was actually still charging toward target, per the test's own comment) - updated to assert the corrected, more informative aggregate.status_extra, thedetailattribute onpredbat.status) already concatenated each inverter's SoC→target numerically for multi-inverter setups ("target 80%-40% / 60%-40%") but didn't say which state each entry belonged to - now prefixes each entry with that inverter's own state ("target Charging 80%-40% / Hold charging 60%-40%"), reusing the same values already computed for the headline fix. Single-inverter setups unaffected.Fixes #4446
Test plan
test_execute_multi_inverter_status.pycovering: no-op fallthrough, single-inverter passthrough, full agreement, same-side precedence (both directions), cross-charging detection (including from the mildest sub-states on each side)assert_status_extraadded to the shared execute test harness, real values locked in forcharge_imbalance2andcharge_freeze_imb1(both already exercise genuine multi-inverter disagreement)./run_all --quicksuite passes./run_pre_commitpasses🤖 Generated with Claude Code