Skip to content

fix(plan): split low-power charge windows at dawn, not window end - #4577

Merged
springfall2008 merged 2 commits into
mainfrom
fix/low-power-charge-dark-window-pv-lookahead
Aug 19, 2026
Merged

fix(plan): split low-power charge windows at dawn, not window end#4577
springfall2008 merged 2 commits into
mainfrom
fix/low-power-charge-dark-window-pv-lookahead

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

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.

  • Adds calc_dawn (fetch.py): classifies each plan_interval_minutes bucket as PV-light or PV-dark, and find_charge_window now splits charge windows at that boundary (new 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 existing behaviour.
  • Dawn is the first bucket that crosses 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.
  • 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 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

  • New test_calc_dawn covering: 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 peak
  • New find_charge_window regression test (Path J) covering the light/dark boundary split
  • Full quick suite (./run_all --quick) passes
  • ./run_pre_commit clean
  • Replayed against the Low power charge toggle does nothing #4557 reporter's actual debug.yaml before and after - confirms the real 03:00-10:00 window now splits cleanly at the dawn crossing point

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 accept pv_light_dark and 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_dark defaults 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 to None and 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.

Comment thread apps/predbat/fetch.py
Comment thread apps/predbat/fetch.py Outdated
…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>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Fixed the mutable default argument on both find_charge_window() and rate_scan_window() (the second location Copilot flagged) - alt_rates/pv_light_dark now default to None, normalised to {} inside. No call site passes None explicitly, so no behaviour change.

On the PV-boundary split timing point: leaving that as-is. The pv_boundary flag is sticky once set, so worst case the split lands up to plan_interval_minutes - 5 late when the window's own start isn't aligned to a plan-interval boundary - but that's exactly the window right at dawn, where PV output is still ramping up from near-zero anyway. A precise fix would just relocate an already-small approximation error, not remove it.

@springfall2008
springfall2008 merged commit 81c7799 into main Aug 19, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/low-power-charge-dark-window-pv-lookahead branch August 19, 2026 18:43
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.

Low power charge toggle does nothing

3 participants