Skip to content

fix(control): a down battery's PV no longer takes part of the charge target - #1815

Merged
frahlg merged 1 commit into
masterfrom
fix/down-group-pv-share
Oct 23, 2026
Merged

frahlg merged 1 commit into
masterfrom
fix/down-group-pv-share

Conversation

@frahlg

@frahlg frahlg commented Oct 23, 2026

Copy link
Copy Markdown
Member

Problem

On a site with inverter groups (#143), splitByCapacityAndPV sends charge to the battery on the same inverter as live PV. It summed the PV of every group, including a group whose battery was not in the split: offline, faulted, or unable to charge (chargeBlocked, parked by distributeProportional). That group's share of the local pool went to no battery, and the overflow had already been reduced by it. The fleet total came out short by up to that group's PV.

Golden seeded_siblings/039_device_fault: Ferroamp faulted, 3,316 W of PV on its inverter, planner asks for 9,600 W of charge. Sungrow and Pixii got 3,142 W each, 6,284 W in total. 3,316 W went to the grid.

When the target was smaller than that PV, the whole target went to no battery. In 003, 027 and 033 the site exported 3.1 to 4.4 kW in self-consumption while both healthy batteries got 0 W.

Found while building #1814, not fixed there.

Fix

PV in a group with no battery in the split is not DC-local to any battery that can take charge. It reaches them over the AC bus, so it now stays out of the local pool. Physically it still flows out of its own inverter; only the split's accounting changes. The other batteries share the whole target. Where one hits its power limit, the rest goes to the grid as before, the same as for a site with no groups. No new setting.

The change sums groupPV only over groups with capacity in bats. capByGroup moves up to the first loop so the sum can use it.

Evidence

  • TestInverterAffinity_PVWithoutItsBatteryKeepsTheTotal fails on master and passes here, 3,316 W of PV on the down battery's inverter:

    • battery absent from the split (offline or faulted), 6,000 W target: master 1,342 + 1,342 W, now 3,000 + 3,000 W;
    • battery present but chargeBlocked, same target: the same numbers;
    • 2,000 W target, below that PV: master 0 + 0 W, now 1,000 + 1,000 W.
  • Golden corpus: 8 of 551 records move, all seeded_siblings/*_device_fault with groups and a charge target. Only that family is re-recorded.

    record battery sum before after
    003 0.0 2,607.8
    009 6,634.0 8,716.1 (Sungrow at its 5,000 W limit)
    015 1,207.8 4,379.8
    021 2,899.2 7,604.2
    027 0.0 795.2
    033 0.0 2,947.2
    039 6,284.0 9,600.0
    045 3,340.6 7,993.6

    051 and 057, the same variant with a discharge target, do not move. A scratch check (not committed) ran all ten device_fault records with groups twice, with and without InverterGroups. The answers are now identical, as they should be when the only PV sits on the down battery's inverter. Five other records in the family changed by under 10 µW when re-recorded (old clock noise, inside the replay's 0.01 W tolerance).

  • TestSiteSimFieldMatrix: sitesim never sets InverterGroups, so the matrix cannot move. It passes with sitesim_field_known.txt unchanged.

  • go vet ./internal/... ./cmd/ftw/... and go test ./internal/... ./cmd/ftw/... pass.

  • Independent review (Sonnet subagent): approve, no findings. It regenerated all nine families with master's and this dispatch.go; only these 8 records differ. A 300k-case random check of splitByCapacityAndPV (1–4 batteries, groups, zero capacity, charge caps, PV, targets −3 to 9.6 kW) never put the sum above the target, kept it equal to the target whenever no battery clamped (master broke that 102,292 times), and matched master exactly whenever every PV group had a battery, and for every target ≤ 0. The third test case came from this review.

Not changed

  • When a battery is clamped at its power limit, the capacity split does not give its excess to a sibling with room (009: Pixii at 3,716 W could take 884 W more). That holds with or without groups, so it is separate from this bug.
  • Draft fix(control): fuse protection as one function #1789 re-records the whole golden corpus. Whichever of the two lands second re-records seeded_siblings.json. The code does not overlap.

🤖 Generated with Claude Code

…target

splitByCapacityAndPV summed the PV of every inverter group, including a
group with no battery in the split (offline, faulted or charge-blocked).
That group's local share went to no battery, so the fleet charged up to
that PV short. Golden seeded_siblings/039 charged 6,284 W of 9,600 W.

Count only groups with a battery in the split. Their PV reaches the
other batteries over the AC bus, which split the whole target by
capacity. Re-record seeded_siblings: 8 device_fault records move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T11:43:04.877987Z 00e97e5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@frahlg
frahlg merged commit 7b99353 into master Oct 23, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant