Repository navigation
Conversation
Some prosumer schemes only allow a household to sell energy its own PV produced (e.g. Poland: grid energy stored in a battery may not be resold). export_budget_mode in apps.yaml limits forced battery export to that energy: - pv_origin: stored energy is split into a PV part and a grid part; battery output up to the on-site load draws the grid part first, only the PV part may be force exported. - pv_equivalent: a bucket filled by PV production and drained by all export (capped by export_budget_cap_kwh, default the battery size). The rule lives in export_budget.py and is mirrored by both prediction engines (kernel ABI 8, parity 18). Each cycle the budget is seeded by replaying 7 days of pv/export/import/load history (and battery SoC for pv_origin). Export targets are raised so the inverter, which exports at full power down to the target, can never sell more than the budget allows, both when clipping a new plan and every cycle for the export running now. predbat.export_budget publishes the budget now and over the best plan. Off by default; with the mode off nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cross-built with build_kernel_cross.sh and zig 0.16.0 (as in CI) for ABI 8 / parity 18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- export_budget_cap_kwh was read with an int default, so get_arg truncated 7.1 to 7. - soc_kw history for the pv_origin replay is now scaled by battery_scaling, as the live SoC is. - export_budget_mode: off unquoted is YAML false; the schema accepts it. - Docs: the replay length follows the history days_previous loads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cap only bounds carried credit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… load in the same period (springfall2008#3973) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edit, only battery export does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
piomar123
marked this pull request as ready for review
October 13, 2026 18:53
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Some prosumer schemes only let a household sell energy its own PV produced. Predbat currently plans forced battery exports purely on price, so on those schemes it can plan exports the household isn't allowed to make. Rules found so far:
Closes #3973, which asks for this ("Allow export only from PV surplus / PV-charged battery energy"):
set_export_freeze_onlyand higher export thresholds didn't stop the charge → export cycles there.pv_originanswers it with an explicit rule: the house uses the grid part first.pv_equivalent.This PR adds an opt-in
apps.yamlsetting that limits battery-to-grid export to energy derived from the household's own PV:pv_originsplits the stored energy into a PV part and a grid part. Charging counts as PV up to the PV produced at the time, and the rest as grid. Battery output up to the on-site load (house, car, iBoost) counts as own use and draws the grid part first. Only the PV part may be force exported.pv_equivalentis a looser, energy-equivalence reading with no origin tracking. A credit bucket is filled by all PV produced (including PV the house used) and drained by every kWh exported. The battery may force export while the bucket has credit, so over time battery export can't exceed PV produced minus PV already exported.export_budget_cap_kwh(default: the battery size) caps how much credit can be carried over, so a run of sunny days can't build up an unlimited allowance.PV surplus export and grid charging for own use are never limited. When the budget runs out during an export window, the battery keeps covering the house and only the export stops. With the mode off (the default), behaviour is unchanged (see Testing).
What changes
export_budget.py(new) holds the rule as small pure functions:These are the reference that both engines follow.
Both prediction engines (
prediction.pyandprediction_kernel.cpp) cap forced battery export in a step to what the budget allows. Whatever serves on-site load is never capped. A capped step recomputes the grid balance before the iBoost solar-excess logic, so iBoost only sees the export left after the cap.Kernel ABI 7 → 8, parity 17 → 18.
PkContextgainsexport_budget_mode,export_budget_capand the seed fields at the end.Seed from measured history (
Fetch.update_export_budget_seed, every cycle afterfetch_inverter_data()):pv_today,export_today,import_todayandload_today, the history already loaded for load prediction.pv_originit also reads the battery'ssoc_percent(orsoc_kw, scaled bybattery_scaling) history. That way conversion losses aren't mistaken for PV charge. Without SoC history it falls back to estimating the battery flow and logs aWarn:.Commanded targets. The inverter force exports at full power down to the target SoC while the battery also covers the house, so a target alone could sell more than the budget. Two places raise targets so that can't happen:
clip_export_slotsraises each window's target to at least SoC at window start − exportable budget, rounded up to a whole percent. The clipping run becomes a saving (Python) run when the budget is on, so it can see the budget at each window start.apply_export_budget_floorre-checks, every cycle, the export running now (or starting before the next cycle) against the measured SoC and seed. A kept plan, or one clipped from an older forecast, can never command more. Manual forced exports are limited too.Output.
predbat.export_budgetpublishes:modeandcap;planned_battery_export_kwh;noncompliant_export_kwh(past export the rule wouldn't have allowed);history_steps;pv_origin,pv_part_kwhandgrid_part_kwh.A
resultsforecast over the best plan, in the same format assoc_kw_best, means it can be charted next to the planned SoC. A manual export that the budget caps is logged.Config.
export_budget_modeandexport_budget_cap_kwhare added toAPPS_SCHEMA; an unquotedoff(YAML false) validates. An unknown mode logs aWarn:and turns the budget off. A negative or non-numeric cap logs aWarn:and uses the battery size.Docs: new "Export energy budget" section in
docs/energy-rates.md.Kernel binaries
This PR comes from a fork, and the
kernel-binariesjob only runs for branches in this repository. So the sixprediction_kernel_lib_*.sofiles are rebuilt in a separate commit. They were built withbuild_kernel_cross.shand the zig version the workflow pins (0.16.0), with no other changes. Feel free to drop that commit and let CI rebuild them.Testing
New
tests/test_export_budget.py(registered asexport_budget):BUDGET_CASES: end-to-endPrediction.run_predictionscenarios with hand-computed SoC and export for off /pv_origin/pv_equivalent. They cover night charging, the battery covering the house, sunny charging, sunny exports with and without a stored PV part, and PV during an export.offas YAML false passing schema validation.soc_percent, andsoc_kwwithbattery_scaling), publishing ofpredbat.export_budget, the clipping floor and the per-cycle floor.Elsewhere:
tests/test_execute.py: a kept plan's export to 0% is raised to the measured floor before it reaches the inverter. With 9 kWh stored and 10% of it PV, the target is 81%.tests/test_kernel_parity.py, run fromrun_edge_case_tests, soverify_kernel_binary.pycovers it:BUDGET_CASESrow × mode (off/pv_origin/pv_equivalent) × {lossless, lossy, lossy hybrid with an export limit below the battery rate, lossy with iBoost} × nominal/pv10/pv90, through both engines.SCENARIO_STATE_ATTRSincludes the three new attributes, so later suites see the same fixture state.Results (macOS arm64, Python 3.12):
unit_test.py(all 344 tests, including thedebug_casesgolden replays): all passed, with the rebuiltprediction_kernel_lib_darwin_arm64.soloaded.verify_kernel_binary.py prediction_kernel_lib_darwin_arm64.so: passed parity.prediction_kernel_lib_aarch64.soloads in a Linux arm64 container and reports ABI 8 / parity 18. I didn't run the parity suite on the Linux binaries; CI checks x86_64.clip_export_slotsgets no extra argument, the floor and the publish return early, and both engines skip the budget block). The golden debug-case replays and the existing parity and random-sweep suites pass unchanged.Real use: this has been running on my own system in Poland since early October 2026, on a Raspberry Pi 4 with a Goodwe inverter and
pv_origin. There it was on top of v9.3.4 and rebasing regularly.Known limitations
pv_originlabels energy charged into the battery as PV first (up to the PV produced in that step) and serves the house from the grid part first. That is the most favourable consistent reading. A stricter reading would only label as PV the charge that came from the PV surplus (PV − load).pv_origin, each cycle also fetches 8 days of SoC history per inverter. With the mode off there is no extra cost.days_previousloads, so the seed covers fewer than 7 days when that's shorter.metric_net_settlement_window_minutes) #5429. The net settlement PR (Add optional net settlement of import/export within wall-clock windows (metric_net_settlement_window_minutes) #5429) also changesPkContext. Whichever of the two merges second needs its ABI/parity numbers bumped and the binaries rebuilt.CLAUDE.md. I checked the blast radius manually instead: allrun_predictioncallers,clip_export_slots, theupdate_predorder, and the debug dump/replay path. The seed is dumped as a list and only indexed, so replays use the dumped seed.This is a planning aid, not legal advice; the docs say so too.
🤖 Generated with help from Claude Code