fix(plan): split low-power charge windows at dawn, not window end - #4577
Conversation
A charge window built from a single long cheap-rate period spanning sunrise was treated as one window by find_charge_rate's PV-overlap check (#4373), which abandons low power charging for the whole window the moment any PV is forecast in it - including the still-dark hours before sunrise (#4557). Add calc_dawn: classify each plan_interval_minutes slot as PV-light or PV-dark and split charge windows at that boundary in find_charge_window (pv_light_dark param), reusing the same mechanism already used for export windows' alternate_rate_boundary. The dark portion now stays low power throughout; the light portion keeps today's behaviour. Dawn is the first bucket that crosses LOW_POWER_PV_LIGHT_FRACTION of the peak PV forecast anywhere in the horizon - a fraction of the forecast's own peak rather than a fixed Watts figure, so the same threshold is meaningful whether the system is 2kWp or 15kWp. Within a calendar day it's a one-way latch: once crossed, later buckets stay light even if PV genuinely dips back under the threshold (a cloud passing), so an intermittently cloudy morning can't chop the window into several flip-flopping pieces. The latch resets at each day boundary, which also makes it behave correctly at extreme latitudes - a polar-night day never crosses (stays all-dark) and a polar-day day crosses from the first bucket (stays all-light). No dusk-side equivalent needed: the PV-overlap check is a forward sum from "now" to window end, recomputed every loop, so it already self-corrects once a light patch is behind it - the false-trigger- then-never-corrects failure mode this fix addresses only exists in the dawn direction. Verified against the #4557 reporter's own debug.yaml throughout - the real 03:00-10:00 window splits cleanly at the actual crossing point, using that household's own forecast peak. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses #4557 by preventing a single long cheap-rate charge window that spans sunrise from being treated as one window for the low-power PV-overlap logic (which previously caused low-power charging to be abandoned for the entire window as soon as any PV was forecast later in it). It introduces a dawn-based PV light/dark classification so charge windows can be split at the dawn boundary, keeping the pre-dawn portion eligible for low-power charging.
Changes:
- Added
calc_dawn()to derive a per-minute PV “light/dark” map based on a fraction of the forecast peak, latched per day. - Extended
find_charge_window()/rate_scan_window()to acceptpv_light_darkand split charge windows at the light/dark transition. - Added regression/unit tests covering both the dawn classifier and the window-splitting behaviour.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/predbat/fetch.py | Adds calc_dawn() and uses pv_light_dark to split charge windows at the dawn boundary during rate scanning. |
| apps/predbat/const.py | Introduces LOW_POWER_PV_LIGHT_FRACTION constant used by the dawn classifier. |
| apps/predbat/tests/test_find_charge_window.py | Adds Path J regression test for PV boundary splitting and a test_calc_dawn suite. |
Suppressed comments (1)
apps/predbat/fetch.py:1978
pv_light_darkdefaults to{}in the function signature. Using a mutable object as a default argument is error-prone (shared across calls if it’s ever mutated later). It would be safer to default toNoneand normalise inside the function.
def rate_scan_window(self, rates, rate_low_min_window, threshold_rate, find_high, return_raw=False, alt_rates={}, pv_light_dark={}):
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e_scan_window Addresses Copilot review on #4577 - alt_rates={}/pv_light_dark={} defaults are a latent trap (shared across calls if a future edit mutates them, even though neither function does today). Switched both to None with normalisation inside; no behaviour change, no call site passes None explicitly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Fixed the mutable default argument on both On the PV-boundary split timing point: leaving that as-is. The |
Summary
Fixes #4557: a charge window built from a single long cheap-rate period spanning sunrise (e.g. 03:00-10:00) was treated as one window by
find_charge_rate's PV-overlap check (#4373), which abandons low power charging for the whole window the moment any PV is forecast in it - including the still-dark hours before sunrise.calc_dawn(fetch.py): classifies eachplan_interval_minutesbucket as PV-light or PV-dark, andfind_charge_windownow splits charge windows at that boundary (newpv_light_darkparam), reusing the same mechanism already used for export windows'alternate_rate_boundary. The dark portion now stays low power throughout; the light portion keeps today's existing behaviour.LOW_POWER_PV_LIGHT_FRACTION(10%) of the peak PV forecast anywhere in the horizon - a fraction of the forecast's own peak rather than a fixed Watts figure, so the same threshold is meaningful whether the system is 2kWp or 15kWp.Verified throughout against the #4557 reporter's own debug.yaml - the real 03:00-10:00 window splits cleanly at the actual crossing point, using that household's own forecast peak.
Test plan
test_calc_dawncovering: no forecast, all-zero forecast, within-bucket noise stability, first-crossing confirmation, cloud-dip latching, day-boundary reset, and threshold scaling with the forecast's own peakfind_charge_windowregression test (Path J) covering the light/dark boundary split./run_all --quick) passes./run_pre_commitcleandebug.yamlbefore and after - confirms the real 03:00-10:00 window now splits cleanly at the dawn crossing point🤖 Generated with Claude Code