fix(plan): skip low power charging when the charge window overlaps solar - #4373
Merged
Conversation
The planner costs every charge window at the full charge rate - low power
charging is only applied to the final plan (prediction.py gates it on
save in best/best10/test). find_charge_rate then sizes the throttled rate
from charge_left / minutes_left with no knowledge of PV.
In a charge window that overlaps solar production the rate cap in
battery_draw = -max(min(charge_rate_now_curve_step, ...), 0, -battery_to_max)
stops the PV reaching the battery. The surplus is exported at the export
rate and the charge target is then made up from grid import once the sun
has gone, costing more than the full rate charge that was planned for.
find_charge_rate now returns the max rate when the PV forecast summed
across the remainder of the charge window exceeds LOW_POWER_PV_THRESHOLD
(0.1kWh). Both callers supply that sum: the prediction from a suffix sum
of the PV step array built once per run (only when low power is active,
so the optimiser hot loop is untouched), and execute from
pv_forecast_minute between now and the window end.
No C++ kernel change is needed - the kernel only runs when save is falsy,
which is exactly when charge low power is off, so it already takes the max
rate path and stays in parity.
Tests: model scenarios low_power_pv_* / low_power_dark_* (without the fix
the low power run costs 44p against a 0p full rate plan), execute
scenarios charge_low_power_pv / charge_low_power_pv_trace, and a
find_charge_rate unit test for the threshold either side.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR prevents “low power charging” from being applied in charge windows that overlap forecast PV production, avoiding cases where throttling inadvertently forces PV export and later grid re-import (making the plan more expensive than the planner’s full-rate costing).
Changes:
- Add a PV-overlap guard to
find_charge_rateto default to max charge rate when forecast PV within the remaining window exceedsLOW_POWER_PV_THRESHOLD. - Plumb “PV remaining in window (kWh)” into
find_charge_ratefrom both the prediction path (via a suffix sum) and the execution path (via summing minute PV forecast). - Add model, execute, and unit tests plus small test-harness extensions to cover PV-overlap behavior and prevent PV forecast leakage between execute scenarios.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| docs/customisation.md | Documents that low-power charging is skipped when PV overlaps a charge window, with rationale. |
| apps/predbat/const.py | Introduces LOW_POWER_PV_THRESHOLD (kWh) constant for the PV-overlap cutoff. |
| apps/predbat/utils.py | Extends find_charge_rate with pv_window_kwh and enforces max-rate charging when PV overlap exceeds threshold. |
| apps/predbat/prediction.py | Computes PV suffix sums when low-power is active and passes window PV overlap to find_charge_rate. |
| apps/predbat/execute.py | Sums PV forecast over the active window remainder and passes the overlap energy to find_charge_rate. |
| apps/predbat/unit_test.py | Registers the new find_charge_rate_pv unit test. |
| apps/predbat/tests/test_infra.py | Adds pv_hours support to constrain PV duration in simple_scenario for targeted model tests. |
| apps/predbat/tests/test_model.py | Adds model tests proving low-power does not increase cost with PV overlap, and still applies in dark windows. |
| apps/predbat/tests/test_find_charge_rate.py | Adds a focused unit test for PV-threshold behavior in find_charge_rate. |
| apps/predbat/tests/test_execute.py | Adds execute tests for PV overlap (above/below threshold) and resets PV forecast per scenario to avoid leakage. |
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.
Problem
In low power charging mode, a charge window that overlaps solar production can make the plan more expensive.
The planner costs every charge window at the full charge rate — low power is only applied to the final plan (
prediction.pygates it onsave in ["best", "best10", "test"]).find_charge_ratethen sizes the throttled rate purely fromcharge_left / minutes_left, with no knowledge of PV.In a window overlapping solar the rate cap in the charge branch
stops the PV reaching the battery. The surplus is exported at the export rate and the charge target is then made up from grid import once the sun has gone — costing more than the full rate charge the planner had already committed to.
Fix
find_charge_ratereturns the max rate when the PV forecast summed across the remainder of the charge window exceedsLOW_POWER_PV_THRESHOLD(0.1 kWh). The test is window-wide rather than per-step because the throttled rate is chosen to span the whole remaining window, not the current instant.Both callers supply that sum:
prediction.py— a suffix sum of the PV step array, built once per run and only when low power is active, so the optimiser's hot loop is untouched.execute.py—pv_forecast_minutesummed between now and the window end.No C++ kernel change is needed: the kernel only runs when
saveis falsy, which is exactly when charge low power is off, so it already takes the max rate path (prediction_kernel.cpp:708) and stays in parity. Parity revisions deliberately not bumped.Tests
All written first and watched fail:
low_power_pv_*(model)ERROR: Metric 0.44 should be 0.0— 44p worse than the 0p full rate plancharge_low_power_pv(execute)Charge rate should be 2000 got 600find_charge_rate_pv(unit)TypeError: unexpected keyword argumentlow_power_dark_*confirms low power still applies with no sun.charge_low_power_pv(1kWh forecast in the window → max rate) andcharge_low_power_pv_trace(0.05kWh, under threshold → keeps the throttled 600W).find_charge_rateeither side of the threshold.Harness additions:
pv_hoursonsimple_scenario(it only did constant PV) andpv_forecastonrun_execute_test, which rebuildspv_forecast_minuteper scenario so a solar run cannot leak into the next one.Full suite passes (110.72s), pre-commit clean, docs updated.
Trade-off worth noting
The test covers the remaining window, so a window starting in darkness and ending in sun now charges at full rate from the outset rather than deferring into the solar period. That is deliberate — it reproduces what the planner costed — but it does give up low-power operation on the dark portion of such windows.
🤖 Generated with Claude Code