Skip to content

Commit 40d2238

Browse files
feat(fetch): split charge windows at dawn for any combine_charge_slots user, not just low power mode
The dawn light/dark split (calc_dawn, #4557) only ever mattered when combine_charge_slots could merge a window across sunrise - with it off, find_charge_window already forces a break every charge_slot_split minutes (= plan_interval_minutes, the same granularity calc_dawn buckets at), so the split was already a no-op there. It was gated on set_charge_low_power instead, which meant combine_charge_slots users without low power charging never got it, even though the split also lets the plan optimizer charge just the dark portion of a combined window and skip the daylight portion where solar may cover the load - a benefit independent of low power charging. New calc_pv_light_dark() gates the split on combine_charge_slots directly. Also logs the calculated dawn time each time it runs, so it's visible whether the detected dawn looks sane. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 05a150a commit 40d2238

2 files changed

Lines changed: 113 additions & 9 deletions

File tree

apps/predbat/fetch.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ def fetch_sensor_data(self, save=True):
10491049

10501050
# Find charging windows
10511051
if self.rate_import:
1052-
pv_light_dark = self.calc_dawn() if self.set_charge_low_power else {}
1052+
pv_light_dark = self.calc_pv_light_dark()
10531053

10541054
# Find charging window
10551055
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)
@@ -1562,12 +1562,30 @@ def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False):
15621562

15631563
return rates, replicated_rates
15641564

1565+
def calc_pv_light_dark(self):
1566+
"""
1567+
Decide whether a dawn light/dark boundary is worth computing at all, and return it via
1568+
calc_dawn if so - otherwise an empty dict (no split).
1569+
1570+
Only combine_charge_slots can merge a charge window across dawn in the first place - with it
1571+
off, find_charge_window already forces a break every charge_slot_split minutes (which equals
1572+
plan_interval_minutes, the same granularity calc_dawn buckets at), so the dawn boundary could
1573+
never be reached and computing it would be a pure no-op. This used to be gated on
1574+
set_charge_low_power instead, since that was the only feature that needed the split - but the
1575+
split also lets the plan optimizer charge just the dark portion of a combined window and skip
1576+
the daylight portion (where solar may cover the load) on its own merits, independent of low
1577+
power charging, so it now runs for any combine_charge_slots user.
1578+
"""
1579+
return self.calc_dawn() if self.combine_charge_slots else {}
1580+
15651581
def calc_dawn(self):
15661582
"""
15671583
Find dawn in self.pv_forecast_minute and return a pv_light_dark dict classifying each minute
1568-
as light (1, at/after dawn) or dark (0, before it). Used by find_charge_window to split a
1569-
charge window at the light/dark boundary rather than abandoning low power charging for a whole
1570-
window just because its tail overlaps the sun - see #4557.
1584+
as light (1, at/after dawn) or dark (0, before it). Used by find_charge_window (via
1585+
calc_pv_light_dark) to split a charge window at the light/dark boundary - originally so it
1586+
wouldn't abandon low power charging for a whole window just because its tail overlaps the sun
1587+
(#4557), and now also so the plan optimizer can choose the dark portion of a combined window
1588+
independently of the light portion.
15711589
15721590
Classified per plan_interval_minutes bucket (averaged), not per raw minute - a threshold
15731591
compared minute to minute would let ordinary forecast noise near the cutoff (e.g. a patchy dawn
@@ -1592,6 +1610,9 @@ def calc_dawn(self):
15921610
Built from whatever PV forecast is already in self.pv_forecast_minute, which at the point this
15931611
is called from fetch_sensor_data is up to one cycle stale (refreshed later this same loop by
15941612
fetch_pv_forecast()) - fine for a forecast that doesn't meaningfully change minute to minute.
1613+
1614+
Logs the calculated dawn time (today's, or the earliest day the forecast reaches if today's PV
1615+
data isn't there) each time it runs, so it's visible whether the detected dawn looks sane.
15951616
"""
15961617
pv_light_dark = {}
15971618
if not self.pv_forecast_minute:
@@ -1612,6 +1633,7 @@ def calc_dawn(self):
16121633
bucket_crossed = {bucket: (1 if (bucket_sums[bucket] / bucket_counts[bucket]) >= light_threshold else 0) for bucket in bucket_sums}
16131634

16141635
bucket_light = {}
1636+
dawn_minute_by_day = {}
16151637
after_dawn = False
16161638
current_day = None
16171639
for bucket in sorted(bucket_crossed):
@@ -1620,9 +1642,21 @@ def calc_dawn(self):
16201642
after_dawn = False
16211643
current_day = bucket_day
16221644
if bucket_crossed[bucket]:
1645+
if not after_dawn:
1646+
dawn_minute_by_day[bucket_day] = bucket * interval
16231647
after_dawn = True
16241648
bucket_light[bucket] = 1 if after_dawn else 0
16251649

1650+
# Day 0 is today (self.minutes_now is itself minutes since midnight_utc), so report today's
1651+
# dawn when the forecast reaches it; otherwise fall back to the earliest day that does (e.g. a
1652+
# forecast that only starts covering PV from tomorrow) so the log still says something useful.
1653+
report_day = 0 if 0 in dawn_minute_by_day else (min(dawn_minute_by_day) if dawn_minute_by_day else None)
1654+
if report_day is not None:
1655+
dawn_timestamp = self.midnight_utc + timedelta(minutes=dawn_minute_by_day[report_day])
1656+
self.log("Calculated dawn (start of daylight, used to split charge windows at) at {}".format(dawn_timestamp.strftime(TIME_FORMAT)))
1657+
else:
1658+
self.log("Calculated dawn (start of daylight, used to split charge windows at) - no dawn found in the PV forecast")
1659+
16261660
pv_light_dark = {pv_minute: bucket_light[pv_minute // interval] for pv_minute in self.pv_forecast_minute}
16271661
return pv_light_dark
16281662

@@ -1631,11 +1665,13 @@ def find_charge_window(self, rates, minute, threshold_rate, find_high, alt_rates
16311665
Find the charging windows based on the low rate threshold (percent below average)
16321666
16331667
pv_light_dark, when scanning for charge (not find_high) windows, is a minute-indexed dict of 0/1
1634-
marking whether PV forecast is at/after dawn ("light") or not ("dark") - see calc_dawn.
1635-
A transition between the two forces a window split, so a charge window that would otherwise
1636-
span sunrise (e.g. a single long cheap-rate period) is instead built as separate dark and
1637-
light windows - see #4557, where low power charging was defeated for the whole window,
1638-
including the still-dark hours, just because the window's tail overlapped PV later on.
1668+
marking whether PV forecast is at/after dawn ("light") or not ("dark") - see calc_dawn and
1669+
calc_pv_light_dark. A transition between the two forces a window split, so a charge window
1670+
that would otherwise span sunrise (e.g. a single long cheap-rate period) is instead built as
1671+
separate dark and light windows. Originally added (#4557) so low power charging wasn't
1672+
defeated for the whole window, including the still-dark hours, just because the window's tail
1673+
overlapped PV later on - it also lets the plan optimizer pick the dark portion of a combined
1674+
window without the light portion, regardless of low power charging.
16391675
"""
16401676
alt_rates = alt_rates or {}
16411677
pv_light_dark = pv_light_dark or {}

apps/predbat/tests/test_find_charge_window.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ def test_find_charge_window(my_predbat):
354354
my_predbat.plan_interval_minutes = old_plan_interval
355355

356356
failed |= test_calc_dawn(my_predbat)
357+
failed |= test_calc_pv_light_dark(my_predbat)
357358
return failed
358359

359360

@@ -490,3 +491,70 @@ def set_buckets(bucket_values):
490491
my_predbat.plan_interval_minutes = old_plan_interval
491492
my_predbat.set_charge_low_power = old_low_power
492493
return failed
494+
495+
496+
def test_calc_pv_light_dark(my_predbat):
497+
"""
498+
Tests for calc_pv_light_dark: decides whether calc_dawn is worth computing at all.
499+
500+
Only combine_charge_slots can merge a charge window across dawn, so that's what gates the split -
501+
not set_charge_low_power. With combine_charge_slots off, find_charge_window already forces a break
502+
every charge_slot_split (=plan_interval_minutes) minutes regardless, so the dawn boundary could
503+
never be reached and computing it would be a no-op:
504+
505+
- combine_charge_slots=True, set_charge_low_power=False -> dawn split still runs (the new case -
506+
the optimizer can pick the dark portion of a combined window on its own merits)
507+
- combine_charge_slots=True, set_charge_low_power=True -> dawn split runs (unchanged behaviour)
508+
- combine_charge_slots=False, regardless of set_charge_low_power -> {} (moot, skipped)
509+
"""
510+
failed = 0
511+
old_pv_forecast_minute = my_predbat.pv_forecast_minute
512+
old_plan_interval = my_predbat.plan_interval_minutes
513+
old_combine_charge = my_predbat.combine_charge_slots
514+
old_low_power = my_predbat.set_charge_low_power
515+
516+
my_predbat.plan_interval_minutes = 30
517+
my_predbat.pv_forecast_minute = {}
518+
for m in range(0, 30, 5):
519+
my_predbat.pv_forecast_minute[m] = 0.0 # dark
520+
for m in range(30, 60, 5):
521+
my_predbat.pv_forecast_minute[m] = 1.0 # light, crosses the threshold
522+
523+
print("Test calc_pv_light_dark: combine_charge_slots=True, set_charge_low_power=False -> dawn split still runs")
524+
my_predbat.combine_charge_slots = True
525+
my_predbat.set_charge_low_power = False
526+
result = my_predbat.calc_pv_light_dark()
527+
if result != my_predbat.calc_dawn():
528+
print("ERROR: calc_pv_light_dark: expected calc_dawn's result when combine_charge_slots is True, got {}".format(result))
529+
failed = 1
530+
if result.get(0) != 0 or result.get(30) != 1:
531+
print("ERROR: calc_pv_light_dark: expected a dark->light split at minute 30, got {}".format({m: result.get(m) for m in (0, 30)}))
532+
failed = 1
533+
534+
print("Test calc_pv_light_dark: combine_charge_slots=True, set_charge_low_power=True -> dawn split runs")
535+
my_predbat.set_charge_low_power = True
536+
result = my_predbat.calc_pv_light_dark()
537+
if result != my_predbat.calc_dawn():
538+
print("ERROR: calc_pv_light_dark: expected calc_dawn's result when combine_charge_slots is True, got {}".format(result))
539+
failed = 1
540+
541+
print("Test calc_pv_light_dark: combine_charge_slots=False, set_charge_low_power=True -> {} (moot, skipped)")
542+
my_predbat.combine_charge_slots = False
543+
my_predbat.set_charge_low_power = True
544+
result = my_predbat.calc_pv_light_dark()
545+
if result != {}:
546+
print("ERROR: calc_pv_light_dark: expected {{}} when combine_charge_slots is False, got {}".format(result))
547+
failed = 1
548+
549+
print("Test calc_pv_light_dark: combine_charge_slots=False, set_charge_low_power=False -> {}")
550+
my_predbat.set_charge_low_power = False
551+
result = my_predbat.calc_pv_light_dark()
552+
if result != {}:
553+
print("ERROR: calc_pv_light_dark: expected {{}} when combine_charge_slots is False, got {}".format(result))
554+
failed = 1
555+
556+
my_predbat.pv_forecast_minute = old_pv_forecast_minute
557+
my_predbat.plan_interval_minutes = old_plan_interval
558+
my_predbat.combine_charge_slots = old_combine_charge
559+
my_predbat.set_charge_low_power = old_low_power
560+
return failed

0 commit comments

Comments
 (0)