feat: external_solar_mode for AC-coupled PV setups - #167
Conversation
On AC-coupled installations the PV panels are wired to a separate inverter (e.g. SolarEdge, microinverters) and the battery inverter has no DC solar input. The only physical charging path is via the grid — surplus solar returns through the meter. With SOLAR_STORAGE hard-coded to grid_charge=False the battery sits idle the entire solar window. Adds an opt-in battery.external_solar_mode flag (default false, so DC-coupled users see no change). When enabled, the SOLAR_STORAGE intent maps to grid_charge=True in the inverter controller; all other intents keep their default mapping. Wired end-to-end: - BatterySettings dataclass + from_ha_config - InverterController helper applied in _map_intent_to_rates, get_period_settings, and get_detailed_period_groups - settings_store bootstrap defaults + schema migration - Settings → Battery tab toggle (PV coupling section) - Setup wizard load + complete payload Tests: 10 new behavioral tests covering the override in isolation and through get_period_settings / get_detailed_period_groups. Full unit suite (600) and backend suite (201) pass. Closes johanzander#162
Combines two pending upstream PRs into a local fork build so the AC-coupled installation can use them before they land on johanzander/main: - johanzander#164: extend Nordpool area hints to NL/BE/DE/FR/AT/PL - johanzander#167: external_solar_mode for AC-coupled PV setups
|
Thanks for this — the feature is well-structured and the end-to-end wiring (dataclass, store migration, API, wizard, Settings page) is clean. We'd be happy to accept it, but we need the fix to work correctly across all supported inverter types before it lands. The logic layer is fully unit-testable without real hardware, so no AC-coupled device is required to close the gaps. Required before merge1.
|
|
Great work Johan, and very nice to have the Modbus via solax nowadays. 🙏 I made it in a fork before but you made it nicer 😉 |
Upstream has merged PR johanzander#164 (Nordpool continental areas), so the fork now carries only the still-pending PR johanzander#167 (external_solar_mode). Fork build wiring (image:, workflow registry owner, workflow_dispatch) is reapplied on top of upstream 9.6.2.
Bundle all jvdd-fork changes accumulated since rebase on upstream 9.6.2
under one minor-version label. No code changes vs jvdd.7:
Features:
- external_solar_mode (upstream PR johanzander#167, pending)
Fixes:
- AI Analyst model IDs updated + persisted-config auto-migration (PR johanzander#180)
- SolaxModbus TOU begin/end write via time.* entity mirror (issue johanzander#181)
|
What do you need for this?, it's working at my end. Is it different for other inverters? I thought it's a manual override for the schedule and sets the ac charge switch on modbus or api. |
|
I would need the review comments addressed and all test to pass. |
On AC-coupled setups, switching grid_charge to True alone is not enough: with the TOU slot in Load First mode, the inverter's EMS does not actively initiate charging. The slot mode also needs to switch to Battery First, which makes the inverter actively pull power from the AC side during the planned solar window. This commit: - Adds _effective_mode_for_intent() mirroring _effective_grid_charge(): returns 'battery_first' for SOLAR_STORAGE when external_solar_mode is enabled, otherwise the default mode. - Applies it in inverter_controller.get_period_settings and get_detailed_period_groups (display paths), and in the three places the SolaxModbusGrowattController computes mode from intent. - Adds 6 tests covering the override on SOLAR_STORAGE, default behaviour when disabled, no leakage to other intents, and propagation through get_detailed_period_groups. Trade-off documented in the helper docstring: Battery First charges at the configured rate regardless of actual solar surplus, so in a SOLAR_STORAGE period with insufficient forecast accuracy the battery will draw from grid. BESS only plans SOLAR_STORAGE when surplus is expected, so the exposure is bounded by forecast quality. A future follow-up could rate-limit the EMS charging rate to match measured solar export, but that requires sensor data BESS does not currently track at this granularity. Live-verified on a Growatt MID 15KTL3-XH (SolaxModbus integration): without this change SOLAR_STORAGE periods produced no battery action; with this change battery charges actively during planned SOLAR_STORAGE hours.
|
Follow-up commit on this branch: also override the battery mode (not just Why this matters in practice: I went live-testing the previous version of this PR (jdungen fork build) on a Growatt MID 15KTL3-XH (SolaxModbus integration). Even with The change:
Documented trade-off in the helper docstring: Battery First charges at the configured rate regardless of actual solar surplus. If the forecast over-estimates solar in a SOLAR_STORAGE window, the inverter will pull from grid. The risk is bounded by forecast accuracy and the fact that BESS only plans SOLAR_STORAGE when surplus is expected. A future follow-up could rate-limit the EMS charging rate to match measured solar export, but that needs sensor data we don't currently track at this granularity. I'm running this on my fork build now and will report back on whether real-world battery behaviour matches the planned SOLAR_STORAGE periods over the next sunny day or two. |
Upstream merged our PR johanzander#180 (AI Analyst model IDs) in 9.6.3, so that patch is dropped from the fork diff. The fork now carries: - external_solar_mode (PR johanzander#167, still pending) — now with mode override in addition to grid_charge override - SolaxModbus TOU begin/end via time.* entity mirror (issue johanzander#181) Bumped to 9.6.4-jvdd.1 to stay above upstream's 9.6.3 release.
|
I am doing some fundamental changes to the algorithm and intent modes here: #187, that probably affects this PR. Lets follow up this one, after it has been merged and released. |
|
PR #187 has now merged, so this is ready to move forward. You'll need to rebase onto One thing the rebase needs to handle: #187 introduced a passive solar charging path. IDLE periods where the optimizer chose The intent classification and passive charging model are documented in Also curious to hear your real-world results — did battery behaviour match the planned SOLAR_STORAGE periods? |
…e-argued (#636) * feat: put PRs on the board so a decision about one is recorded, not re-argued The board held issues only, so every judgement about a PR had nowhere to live. "#167 and #354 are blocked", "#437 and #490 are lower priority, later" were real decisions the maintainer had already made, and the rhythm pass re-reported all four as due on every tick because nothing recorded them. The same conversation happened every 30 minutes. Projects v2 takes PRs as items with the identical field set, so the fix is membership rather than a parallel mechanism. A PR card carries the same `Priority` and `Awaiting` an issue card does; the digest emits them as `pr_board`, and `backlog-rhythm.sh` joins by number and suppresses on them: an `Awaiting` means parked on someone, `P4` means later-not-never. `content.type` is what separates PR cards from issue ones, confirmed against a real card rather than assumed — an added PR reports "PullRequest" with number/title/url/repository alongside it. Numbers are unique across issues and PRs in one repository, so this cannot collide with the existing issue lookup. Suppressed PRs are COUNTED AND LISTED, never dropped: the pass ends with `deferred: 4 (#490 priority P4; #167 awaiting discussion; ...)`. Silently vanishing would trade one failure for another — the goal is to stop re-asking about a settled decision, not to lose the item. CONTRACT CHANGE: an APPROVED, green, still-draft PR is now its own action, `mark_ready`, and it is the one thing no board decision can defer. It used to hand back to `implement-issue` like any other unfinished draft, on the principle that this pass must not grow a second review loop. That principle still holds, but it is what left #629 sitting approved, green and draft: the remedy on offer was a whole `implement-issue` session, and nobody spends one of those to run a single command. `gh pr ready` is a terminal action, not a loop, so naming it here duplicates nothing. `awaiting_maintainer` is deliberately NOT carved out the same way. An approved PR waiting on a merge is not broken; it is the maintainers call when to take it, and P4 is exactly how they say later. #490 sat approved for a day and was reported every tick as though that were news. Live effect: 31 actions -> 27, with one `deferred: 4` line in place of four recurring items, and #631 correctly still reported as merge-ready. No CHANGELOG entry: agent tooling, no user-visible effect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR * fix: mark_ready needs green checks, not just a clean merge Found by running the rule against the live fleet on its first pass, which is the only reason it was caught before the PR merged. `mergeable` reports whether the branch merges cleanly and nothing else, so it reads MERGEABLE while CI is still running or has failed outright. #633 was APPROVED, MERGEABLE and had Algorithm tests and E2E still IN_PROGRESS, and the rule duly reported "gh pr ready 633 — then it is the maintainers to merge". GitHub itself disagreed: `mergeStateStatus` was BLOCKED. Flipping a red or pending PR out of draft is worse than leaving it there. `ready` is supposed to mean the maintainer can merge without checking anything else, and that claim is the only thing making the flag worth setting. So `mark_ready` now also requires every check to have concluded SUCCESS, SKIPPED or NEUTRAL. SKIPPED is green on purpose: this repo path-filters Algorithm tests and Docker build, so every backend-only PR skips them and treating that as not-green would withhold the action from almost everything. An empty rollup is green too — a PR with no checks configured has nothing failing. The deferred list mirrors the same condition, so an approved-but-pending PR with a P4 card appears in exactly one place rather than both. Live effect: #633 falls back to resume_implementation until its CI settles, which is the correct answer and the one the first version got wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LExo6fcbup75vtc9NfoAR --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture tests could not see either of these. Both turned up on the first live run. 1. GraphQL node limit. `--json commits` expands each commit's authors connection, so gh's cost estimate is limit x commits x authors. At --limit 100 that is 1,000,000 possible nodes and the query is rejected outright, so the command produced no output at all. --limit 30 keeps the worst case at 300,000. There is no cheaper field for "when did HEAD last move": `gh pr list --json` has no last-commit date, and a review's own commit SHA is REST-only. The cap is announced when hit rather than silently truncating, per sweep-prs. 2. Lazy `mergeable`, which is the dangerous one. The first query on a cold PR returns UNKNOWN *and* only then triggers the computation, so treating UNKNOWN as "not conflicted" hides precisely the stale PRs this script exists to surface. Measured: a first fleet run classified #167 and #619 with no conflict flag; once earlier queries had warmed them, the identical command returned `needs-refresh` for both. They were CONFLICTING the whole time. sweep-prs documents this trap and retries for the same reason; this reintroduced it. Now: re-ask while anything is UNKNOWN, and if it survives, render it as "(+mergeability UNKNOWN — re-run)" rather than letting it fall through to the clean branch. Verified by mutation: forcing $mergeUnknown to false reddens test_unknown_mergeability_is_never_reported_as_clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary
battery.external_solar_mode(defaultfalse) so AC-coupled installations can charge during solar hours.SOLAR_STORAGEperiods map togrid_charge=Truein the inverter controller; all other intents keep their default mapping.false).Closes #162.
Why
On AC-coupled installations (e.g. SolarEdge for PV + Growatt for battery, microinverters, or any external-inverter setup) the battery inverter has no DC solar input. Surplus solar reaches the battery only via the meter. Today inverter_controller.py:35 hard-codes
SOLAR_STORAGEtogrid_charge=False, so the battery sits idle the entire solar window even though the DP planner has scheduled storage.Files
core/bess/settings.py—external_solar_mode: bool = FalseonBatterySettingscore/bess/inverter_controller.py—_effective_grid_chargehelper applied in_map_intent_to_rates,get_period_settings, andget_detailed_period_groupsbackend/settings_store.py— bootstrap default + schema-migration entrybackend/api.py—_BATTERY_MAP+ live-update payload for/api/setup/completebackend/api_dataclasses.py—externalSolarModeonAPISetupCompletePayloadfrontend/src/components/settings/BatteryFormSection.tsx— new "PV coupling" section with togglefrontend/src/pages/SettingsPage.tsx,SetupWizardPage.tsx— wire load/savefrontend/src/types.ts— optionalexternalSolarModeTest plan
pytest core/bess/tests/unit/test_external_solar_mode.py— 10 new tests pass (default-disabled, SOLAR_STORAGE override on/off, other intents unaffected, get_period_settings + get_detailed_period_groups apply override)pytest core/bess/tests/unit/ -m "not slow"— 600 passed, 12 skippedpytest backend/tests/ -m "not slow"— 201 passed