Skip to content

Commit e0cf588

Browse files
fix(plan): split low-power charge windows at dawn, not window end
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>
1 parent a6043fa commit e0cf588

3 files changed

Lines changed: 288 additions & 5 deletions

File tree

apps/predbat/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@
5252
# which increases the cost of the plan over the full rate charge the planner costed the window at.
5353
LOW_POWER_PV_THRESHOLD = 0.1
5454

55+
# Fraction of the peak forecast PV power above which a plan_interval_minutes bucket is classed as
56+
# "light" rather than "dark" when deciding where to split a charge window (calc_dawn). A charge window
57+
# otherwise built from a single long cheap-rate period spanning sunrise would apply LOW_POWER_PV_THRESHOLD
58+
# across the whole thing and abandon low power charging even for the still-dark hours before the sun is
59+
# up (#4557) - splitting at dawn keeps the dark portion as its own window, genuinely PV-free, so it stays
60+
# throttled. A fraction of that forecast's own peak, rather than a fixed Watts figure, scales with the
61+
# site - a fixed threshold picked for a typical system would be noise-level for a large array and
62+
# unreachable for a small one.
63+
LOW_POWER_PV_LIGHT_FRACTION = 0.1
64+
5565
INVERTER_TEST = False # Run inverter control self test
5666

5767
# Create an array of times in the day in 5-minute intervals

apps/predbat/fetch.py

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,19 @@
2020

2121
from datetime import datetime, timedelta
2222
from utils import minutes_to_time, str2time, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, get_now_from_cumulative, MinuteArray
23-
from const import MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, PREDBAT_MODE_OPTIONS, PREDBAT_MODE_CONTROL_SOC, PREDBAT_MODE_CONTROL_CHARGEDISCHARGE, PREDBAT_MODE_CONTROL_CHARGE, PREDBAT_MODE_MONITOR, LOAD_FORECAST_HISTORY_MAX_DAYS, PREDBAT_MAX_CARS
23+
from const import (
24+
MINUTE_WATT,
25+
PREDICT_STEP,
26+
TIME_FORMAT,
27+
PREDBAT_MODE_OPTIONS,
28+
PREDBAT_MODE_CONTROL_SOC,
29+
PREDBAT_MODE_CONTROL_CHARGEDISCHARGE,
30+
PREDBAT_MODE_CONTROL_CHARGE,
31+
PREDBAT_MODE_MONITOR,
32+
LOAD_FORECAST_HISTORY_MAX_DAYS,
33+
PREDBAT_MAX_CARS,
34+
LOW_POWER_PV_LIGHT_FRACTION,
35+
)
2436
from predbat_metrics import metrics
2537
from futurerate import FutureRate
2638
from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active
@@ -1028,8 +1040,10 @@ def fetch_sensor_data(self, save=True):
10281040

10291041
# Find charging windows
10301042
if self.rate_import:
1043+
pv_light_dark = self.calc_dawn() if self.set_charge_low_power else {}
1044+
10311045
# Find charging window
1032-
self.low_rates, lowest, highest = self.rate_scan_window(self.rate_import, 5, self.rate_import_cost_threshold, False, alt_rates=self.rate_export)
1046+
self.low_rates, lowest, highest = self.rate_scan_window(self.rate_import, 5, self.rate_import_cost_threshold, False, alt_rates=self.rate_export, pv_light_dark=pv_light_dark)
10331047
self.log("Low Import rate found rates in range {}{} to {}{}".format(lowest, curr, highest, curr))
10341048
# Update threshold automatically
10351049
if self.rate_low_threshold == 0 and highest >= self.rate_min:
@@ -1539,9 +1553,80 @@ def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False):
15391553

15401554
return rates, replicated_rates
15411555

1542-
def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates={}):
1556+
def calc_dawn(self):
1557+
"""
1558+
Find dawn in self.pv_forecast_minute and return a pv_light_dark dict classifying each minute
1559+
as light (1, at/after dawn) or dark (0, before it). Used by find_charge_window to split a
1560+
charge window at the light/dark boundary rather than abandoning low power charging for a whole
1561+
window just because its tail overlaps the sun - see #4557.
1562+
1563+
Classified per plan_interval_minutes bucket (averaged), not per raw minute - a threshold
1564+
compared minute to minute would let ordinary forecast noise near the cutoff (e.g. a patchy dawn
1565+
hovering around the threshold) retrigger the split repeatedly and chop the window into several
1566+
small pieces. Bucketing makes the classification a step function that can change at most once
1567+
per bucket boundary, at the same granularity the plan already displays.
1568+
1569+
Within each calendar day, light is a one-way latch: the first bucket that crosses the threshold
1570+
confirms dawn, and every later bucket that day stays light too, even if PV genuinely dips back
1571+
under the threshold later (a cloud passing). This is a dawn detector, not a tracker of every
1572+
rise and fall - it only cares about finding the first dawn each day, so an intermittently
1573+
cloudy morning can't chop the window into several flip-flopping pieces. The latch resets at
1574+
each day boundary so the next day's dawn is found independently rather than one early crossing
1575+
holding "light" for the rest of the multi-day forecast horizon - this also makes a polar-night
1576+
day correctly stay all-dark (dawn never crosses) and a polar-day day correctly stay all-light
1577+
(crossed from the very first bucket).
1578+
1579+
The threshold itself is LOW_POWER_PV_LIGHT_FRACTION of the peak PV forecast anywhere in
1580+
self.pv_forecast_minute, not a fixed Watts figure, so it scales with the site rather than being
1581+
picked for a "typical" system size.
1582+
1583+
Built from whatever PV forecast is already in self.pv_forecast_minute, which at the point this
1584+
is called from fetch_sensor_data is up to one cycle stale (refreshed later this same loop by
1585+
fetch_pv_forecast()) - fine for a forecast that doesn't meaningfully change minute to minute.
1586+
"""
1587+
pv_light_dark = {}
1588+
if not self.pv_forecast_minute:
1589+
return pv_light_dark
1590+
1591+
peak_pv = max(self.pv_forecast_minute.values())
1592+
if peak_pv <= 0:
1593+
return {pv_minute: 0 for pv_minute in self.pv_forecast_minute}
1594+
1595+
light_threshold = peak_pv * LOW_POWER_PV_LIGHT_FRACTION
1596+
interval = self.plan_interval_minutes
1597+
bucket_sums = {}
1598+
bucket_counts = {}
1599+
for pv_minute, pv in self.pv_forecast_minute.items():
1600+
bucket = pv_minute // interval
1601+
bucket_sums[bucket] = bucket_sums.get(bucket, 0.0) + pv
1602+
bucket_counts[bucket] = bucket_counts.get(bucket, 0) + 1
1603+
bucket_crossed = {bucket: (1 if (bucket_sums[bucket] / bucket_counts[bucket]) >= light_threshold else 0) for bucket in bucket_sums}
1604+
1605+
bucket_light = {}
1606+
after_dawn = False
1607+
current_day = None
1608+
for bucket in sorted(bucket_crossed):
1609+
bucket_day = (bucket * interval) // (24 * 60)
1610+
if bucket_day != current_day:
1611+
after_dawn = False
1612+
current_day = bucket_day
1613+
if bucket_crossed[bucket]:
1614+
after_dawn = True
1615+
bucket_light[bucket] = 1 if after_dawn else 0
1616+
1617+
pv_light_dark = {pv_minute: bucket_light[pv_minute // interval] for pv_minute in self.pv_forecast_minute}
1618+
return pv_light_dark
1619+
1620+
def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates={}, pv_light_dark={}):
15431621
"""
15441622
Find the charging windows based on the low rate threshold (percent below average)
1623+
1624+
pv_light_dark, when scanning for charge (not find_high) windows, is a minute-indexed dict of 0/1
1625+
marking whether PV forecast is at/after dawn ("light") or not ("dark") - see calc_dawn.
1626+
A transition between the two forces a window split, so a charge window that would otherwise
1627+
span sunrise (e.g. a single long cheap-rate period) is instead built as separate dark and
1628+
light windows - see #4557, where low power charging was defeated for the whole window,
1629+
including the still-dark hours, just because the window's tail overlapped PV later on.
15451630
"""
15461631
rate_low_start = -1
15471632
rate_low_end = -1
@@ -1550,6 +1635,8 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
15501635
rate_low_count = 0
15511636
alternate_rate_boundary = False
15521637
alt_rate_last = None
1638+
pv_boundary = False
1639+
pv_light_dark_last = None
15531640

15541641
# Work out alternate rate threshold
15551642
alt_rate_max = max(alt_rates.values()) if alt_rates else 0
@@ -1563,6 +1650,13 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
15631650
if (alt_rate is not None) and (alt_rate_last is not None) and (abs(alt_rate - alt_rate_last) >= alt_rate_threshold):
15641651
# Create alternate rate boundary if the rate is different
15651652
alternate_rate_boundary = True
1653+
pv_state = pv_light_dark.get(minute, None)
1654+
if (rate_low_start >= 0) and (pv_state is not None) and (pv_light_dark_last is not None) and (pv_state != pv_light_dark_last):
1655+
# Create a PV boundary when the light/dark classification changes since the window
1656+
# started. Gated on rate_low_start so a transition crossed only during the pre-window
1657+
# search phase (e.g. scanning past last night's sunset before this window is even
1658+
# found) doesn't leave a stale boundary that breaks the window the moment it starts.
1659+
pv_boundary = True
15661660
if minute in rates:
15671661
rate = rates[minute]
15681662
if ((not find_high) and (rate <= threshold_rate)) or (find_high and (rate >= threshold_rate) and (rate > 0)) or (minute in self.manual_all_times) or (rate_low_start in self.manual_all_times):
@@ -1587,6 +1681,10 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
15871681
# Export slot can never be bigger than 4 hours
15881682
rate_low_end = minute
15891683
break
1684+
if (not find_high) and (rate_low_start >= 0) and ((minute - rate_low_start) >= self.plan_interval_minutes) and pv_boundary:
1685+
# Split a charge window where PV forecast crosses the light/dark threshold
1686+
rate_low_end = minute
1687+
break
15901688
if rate_low_start < 0:
15911689
rate_low_start = minute
15921690
rate_low_end = stop_at
@@ -1606,6 +1704,7 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
16061704
break
16071705
minute += 5
16081706
alt_rate_last = alt_rate
1707+
pv_light_dark_last = pv_state
16091708

16101709
if rate_low_count:
16111710
rate_low_average = dp2(rate_low_average / rate_low_count)
@@ -1876,7 +1975,7 @@ def rate_min_forward_calc(self, rates):
18761975

18771976
return rate_min_forward
18781977

1879-
def rate_scan_window(self, rates, rate_low_min_window, threshold_rate, find_high, return_raw=False, alt_rates={}):
1978+
def rate_scan_window(self, rates, rate_low_min_window, threshold_rate, find_high, return_raw=False, alt_rates={}, pv_light_dark={}):
18801979
"""
18811980
Scan for the next high/low rate window
18821981
"""
@@ -1887,7 +1986,7 @@ def rate_scan_window(self, rates, rate_low_min_window, threshold_rate, find_high
18871986
upcoming_period = self.minutes_now + 4 * 60
18881987

18891988
while True:
1890-
rate_low_start, rate_low_end, rate_low_average = self.find_charge_window(rates, minute, threshold_rate, find_high, alt_rates=alt_rates)
1989+
rate_low_start, rate_low_end, rate_low_average = self.find_charge_window(rates, minute, threshold_rate, find_high, alt_rates=alt_rates, pv_light_dark=pv_light_dark)
18911990
window = {}
18921991
window["start"] = rate_low_start
18931992
window["end"] = rate_low_end

0 commit comments

Comments
 (0)