Skip to content

Add an optional export energy budget: only export battery energy that came from PV (export_budget_mode) - #5538

Open
piomar123 wants to merge 6 commits into
springfall2008:mainfrom
piomar123:pr/export-energy-budget
Open

piomar123 wants to merge 6 commits into
springfall2008:mainfrom
piomar123:pr/export-energy-budget

Conversation

@piomar123

@piomar123 piomar123 commented Oct 13, 2026 •

Copy link
Copy Markdown

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:

  • Poland:
    • Energy Law (Prawo energetyczne) art. 3 pkt 13a: "do własnego użytku nie zalicza się energii elektrycznej zakupionej w celu jej magazynowania" (electricity bought in order to store it doesn't count as own use).
    • The Ministry of Energy, in a July 2026 statement, calls buying cheap, storing and reselling price arbitrage, a business activity that needs a licence from the energy regulator (URE).
    • A prosumer may still sell PV surplus, including PV stored in a battery and sold later at a better price (commentary).
  • Germany: the Bundesnetzagentur's MiSpeL determination (adopted 1 October 2026) makes this a support-eligibility rule rather than a ban. It offers origin accounting from metering, or a flat option for PV up to 30 kWp with eligible feed-in up to 500 kWh/kWp/year. The flat option still awaits EU state-aid approval.
  • UK: Ofgem's co-location guidance (v6), Appendix 3. Under SEG, licensees must pay for stored electricity exported from the SEG installation ("green export"). They're not obliged to pay for "brown export" from other sources.

Closes #3973, which asks for this ("Allow export only from PV surplus / PV-charged battery energy"):

  • set_export_freeze_only and higher export thresholds didn't stop the charge → export cycles there.
  • The open question in that thread was which energy the house used when grid charge, PV and load overlap. pv_origin answers it with an explicit rule: the house uses the grid part first.
  • The rolling "don't export more battery energy than recent PV" rule suggested there is close to pv_equivalent.

This PR adds an opt-in apps.yaml setting that limits battery-to-grid export to energy derived from the household's own PV:

  export_budget_mode: pv_origin   # off (default) | pv_origin | pv_equivalent
  # export_budget_cap_kwh: 7.1    # pv_equivalent only, defaults to the battery size
  • pv_origin splits 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_equivalent is 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.
    • Direct PV surplus export is never limited and doesn't reduce the credit, because the PV it sends out is added and removed in the same step. Only battery export draws the credit down.
    • 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.
    • This can export grid-charged energy while the bucket has credit.

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:

    • the per-step allowance and state update;
    • the export-target floor;
    • the history replay that seeds the budget.

    These are the reference that both engines follow.

  • Both prediction engines (prediction.py and prediction_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. PkContext gains export_budget_mode, export_budget_cap and the seed fields at the end.

  • Seed from measured history (Fetch.update_export_budget_seed, every cycle after fetch_inverter_data()):

    • It replays up to 7 days of pv_today, export_today, import_today and load_today, the history already loaded for load prediction.
    • For pv_origin it also reads the battery's soc_percent (or soc_kw, scaled by battery_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 a Warn:.
    • Without any usable history nothing is exportable.
  • 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_slots raises 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_floor re-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_budget publishes:

    • the kWh exportable now;
    • mode and cap;
    • planned_battery_export_kwh;
    • noncompliant_export_kwh (past export the rule wouldn't have allowed);
    • history_steps;
    • for pv_origin, pv_part_kwh and grid_part_kwh.

    A results forecast over the best plan, in the same format as soc_kw_best, means it can be charted next to the planned SoC. A manual export that the budget caps is logged.

  • Config. export_budget_mode and export_budget_cap_kwh are added to APPS_SCHEMA; an unquoted off (YAML false) validates. An unknown mode logs a Warn: and turns the budget off. A negative or non-numeric cap logs a Warn: 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-binaries job only runs for branches in this repository. So the six prediction_kernel_lib_*.so files are rebuilt in a separate commit. They were built with build_kernel_cross.sh and 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 as export_budget):

  • Unit tests of the step functions and replay against hand calculations:
    • both modes;
    • the cap;
    • glitchy meter deltas;
    • the measured-SoC path vs the flow-estimate fallback;
    • non-compliant export accounting.
  • BUDGET_CASES: end-to-end Prediction.run_prediction scenarios 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.
  • Other engine tests:
    • the PV and grid parts always summing to the SoC;
    • the fast-mode (10-minute step) path;
    • the saved forecast outputs;
    • the capped step's effect on the iBoost solar excess.
  • Config parsing: valid and invalid modes, a fractional cap that must not be truncated, a bad cap, and off as YAML false passing schema validation.
  • The seed hook, SoC history fetch (soc_percent, and soc_kw with battery_scaling), publishing of predbat.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 from run_edge_case_tests, so verify_kernel_binary.py covers it:
    • Every BUDGET_CASES row × 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.
    • A seed with the mode off must be ignored by both engines.
    • Each of the 150 random-sweep seeds gets one extra budget run: random mode, cap, seed and a lighter load so the cap binds. It draws from its own generator, so existing seeded scenarios are unchanged.
    • SCENARIO_STATE_ATTRS includes the three new attributes, so later suites see the same fixture state.

Results (macOS arm64, Python 3.12):

  • Full unit_test.py (all 344 tests, including the debug_cases golden replays): all passed, with the rebuilt prediction_kernel_lib_darwin_arm64.so loaded.
  • verify_kernel_binary.py prediction_kernel_lib_darwin_arm64.so: passed parity.
  • The rebuilt prediction_kernel_lib_aarch64.so loads 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.
  • Off means unchanged: every new code path is guarded by the mode (clip_export_slots gets 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

  • Attribution is a model, not a meter. Within a 5-minute step, pv_origin labels 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).
  • The prediction credits PV as forecast. On systems where the inverter or export limit clips PV, the forecast budget can run slightly ahead of the replayed (metered) one. The next cycle's seed and floor correct it, so the commanded targets stay within the measured budget.
  • The floors only raise targets, so the inverter can stop exporting before the plan expected. The floor assumes all of the SoC drop is export, but some of it serves the house. So the realised export can be less than planned, but never more than the budget.
  • Cost. With the budget on, the clipping run is a Python saving run. The kernel doesn't return the per-step budget the clip floor needs. On a Raspberry Pi 4 that run takes about 55 ms of an ~11 s replan, and cycles that keep the plan skip it. For pv_origin, each cycle also fetches 8 days of SoC history per inverter. With the mode off there is no extra cost.
  • History length. The replay length follows the history days_previous loads, so the seed covers fewer than 7 days when that's shorter.
  • Kernel numbering vs Add optional net settlement of import/export within wall-clock windows (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 changes PkContext. Whichever of the two merges second needs its ABI/parity numbers bumped and the binaries rebuilt.
  • GitNexus. I didn't have the GitNexus tooling mentioned in CLAUDE.md. I checked the blast radius manually instead: all run_prediction callers, clip_export_slots, the update_pred order, 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

piomar123 and others added 6 commits October 12, 2026 14:29
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>

This branch has not been deployed

No deployments
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.

prevent export of grid-charged energy / self-consumption-only grid charging

1 participant