@@ -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 {}
0 commit comments