From 13b60696af4a5462a527e51ed8ddae5b2c05d19c Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 19 Sep 2026 09:33:54 +0100 Subject: [PATCH] fix(fetch): compute dawn sensor regardless of combine_charge_slots binary_sensor.predbat_dawn was gated on switch.predbat_combine_charge_slots (off by default) purely as a no-op optimisation for window splitting, so anyone with that switch off saw the sensor read permanently "off" even in broad daylight. calc_pv_light_dark() now always computes calc_dawn()'s result; the window-splitting use was already a no-op in that case (a break every charge_slot_split minutes happens regardless), so behaviour there is unchanged. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/fetch.py | 28 ++++----- apps/predbat/tests/test_find_charge_window.py | 62 ++++++------------- docs/output-data.md | 2 +- 3 files changed, 34 insertions(+), 58 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index d06945a42..f27c0fc3c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -920,12 +920,13 @@ def fetch_pv_forecast_and_dawn(self): # Stored on self, not just local, so it can be used elsewhere rather than only by the # window split below. pv_light_dark = self.pv_light_dark = self.calc_pv_light_dark() - # "on" past dawn, "off" before it or when unclassified (combine_charge_slots off, or no - # PV forecast) - not the same as PV actually producing right now, see calc_dawn's docstring + # "on" past dawn, "off" before it or when unclassified (no PV forecast) - based on the PV + # forecast crossing low_power_pv_threshold_w, not the same as PV actually producing right + # now, see calc_dawn's docstring. Independent of combine_charge_slots/set_charge_low_power. self.dashboard_item( "binary_sensor." + self.prefix + "_dawn", state="on" if pv_light_dark.get(self.minutes_now) == 1 else "off", - attributes={"friendly_name": "Predbat is past dawn (light, not dark, in the low-power charge window split)", "icon": "mdi:weather-sunset-up"}, + attributes={"friendly_name": "Predbat is past dawn (light, not dark, by PV forecast)", "icon": "mdi:weather-sunset-up"}, ) return pv_light_dark @@ -1902,19 +1903,18 @@ def carbon_replicate(self, carbon_data): def calc_pv_light_dark(self): """ - Decide whether a dawn light/dark boundary is worth computing at all, and return it via - calc_dawn if so - otherwise an empty dict (no split). + Compute the dawn light/dark split via calc_dawn(). - Only combine_charge_slots can merge a charge window across dawn in the first place - with it - off, find_charge_window already forces a break every charge_slot_split minutes (which equals - plan_interval_minutes, the same granularity calc_dawn buckets at), so the dawn boundary could - never be reached and computing it would be a pure no-op. This used to be gated on - set_charge_low_power instead, since that was the only feature that needed the split - but 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) on its own merits, independent of low - power charging, so it now runs for any combine_charge_slots user. + Always computed, even when combine_charge_slots is off: with it off, find_charge_window + already forces a window break every charge_slot_split minutes (which equals + plan_interval_minutes, the same granularity calc_dawn buckets at), so the dawn boundary can + never actually be reached there and calc_dawn's result is a no-op for window splitting in + that case - but binary_sensor.predbat_dawn (published from this same result, see + fetch_pv_forecast_and_dawn) is a useful standalone signal regardless of combine_charge_slots, + and used to read permanently "off" for anyone with it disabled (the default) even in broad + daylight, which is what it is not meant to mean. """ - return self.calc_dawn() if self.combine_charge_slots else {} + return self.calc_dawn() def calc_dawn(self): """ diff --git a/apps/predbat/tests/test_find_charge_window.py b/apps/predbat/tests/test_find_charge_window.py index 0209a1997..be6947ad3 100644 --- a/apps/predbat/tests/test_find_charge_window.py +++ b/apps/predbat/tests/test_find_charge_window.py @@ -503,17 +503,14 @@ def set_buckets(bucket_values): def test_calc_pv_light_dark(my_predbat): """ - Tests for calc_pv_light_dark: decides whether calc_dawn is worth computing at all. - - Only combine_charge_slots can merge a charge window across dawn, so that's what gates the split - - not set_charge_low_power. With combine_charge_slots off, find_charge_window already forces a break - every charge_slot_split (=plan_interval_minutes) minutes regardless, so the dawn boundary could - never be reached and computing it would be a no-op: - - - combine_charge_slots=True, set_charge_low_power=False -> dawn split still runs (the new case - - the optimizer can pick the dark portion of a combined window on its own merits) - - combine_charge_slots=True, set_charge_low_power=True -> dawn split runs (unchanged behaviour) - - combine_charge_slots=False, regardless of set_charge_low_power -> {} (moot, skipped) + Tests for calc_pv_light_dark: always returns calc_dawn's result. + + calc_dawn is computed regardless of combine_charge_slots or set_charge_low_power - + binary_sensor.predbat_dawn (published from this same result) is a useful standalone light/dark + signal even when neither feature is enabled, and used to read permanently "off" whenever + combine_charge_slots was off (the default), which is not what the sensor is meant to mean. The + combine_charge_slots gate on window-splitting itself lives in find_charge_window instead, where it + is moot anyway (a break every charge_slot_split minutes already happens there regardless). """ failed = 0 old_pv_forecast_minute = my_predbat.pv_forecast_minute @@ -528,38 +525,17 @@ def test_calc_pv_light_dark(my_predbat): for m in range(30, 60, 5): my_predbat.pv_forecast_minute[m] = 1.0 # light, crosses the threshold - print("Test calc_pv_light_dark: combine_charge_slots=True, set_charge_low_power=False -> dawn split still runs") - my_predbat.combine_charge_slots = True - my_predbat.set_charge_low_power = False - result = my_predbat.calc_pv_light_dark() - if result != my_predbat.calc_dawn(): - print("ERROR: calc_pv_light_dark: expected calc_dawn's result when combine_charge_slots is True, got {}".format(result)) - failed = 1 - if result.get(0) != 0 or result.get(30) != 1: - 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)})) - failed = 1 - - print("Test calc_pv_light_dark: combine_charge_slots=True, set_charge_low_power=True -> dawn split runs") - my_predbat.set_charge_low_power = True - result = my_predbat.calc_pv_light_dark() - if result != my_predbat.calc_dawn(): - print("ERROR: calc_pv_light_dark: expected calc_dawn's result when combine_charge_slots is True, got {}".format(result)) - failed = 1 - - print("Test calc_pv_light_dark: combine_charge_slots=False, set_charge_low_power=True -> {} (moot, skipped)") - my_predbat.combine_charge_slots = False - my_predbat.set_charge_low_power = True - result = my_predbat.calc_pv_light_dark() - if result != {}: - print("ERROR: calc_pv_light_dark: expected {{}} when combine_charge_slots is False, got {}".format(result)) - failed = 1 - - print("Test calc_pv_light_dark: combine_charge_slots=False, set_charge_low_power=False -> {}") - my_predbat.set_charge_low_power = False - result = my_predbat.calc_pv_light_dark() - if result != {}: - print("ERROR: calc_pv_light_dark: expected {{}} when combine_charge_slots is False, got {}".format(result)) - failed = 1 + for combine_charge, low_power in ((True, False), (True, True), (False, True), (False, False)): + print("Test calc_pv_light_dark: combine_charge_slots={}, set_charge_low_power={} -> dawn split always runs".format(combine_charge, low_power)) + my_predbat.combine_charge_slots = combine_charge + my_predbat.set_charge_low_power = low_power + result = my_predbat.calc_pv_light_dark() + if result != my_predbat.calc_dawn(): + print("ERROR: calc_pv_light_dark: expected calc_dawn's result, got {}".format(result)) + failed = 1 + if result.get(0) != 0 or result.get(30) != 1: + 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)})) + failed = 1 my_predbat.pv_forecast_minute = old_pv_forecast_minute my_predbat.plan_interval_minutes = old_plan_interval diff --git a/docs/output-data.md b/docs/output-data.md index c040c3671..9427e3bbf 100644 --- a/docs/output-data.md +++ b/docs/output-data.md @@ -703,7 +703,7 @@ The solar sensor attributes include: - remaining/remaining10/remaining90/remainingCL - forecast solar generation for the remainder of the day - detailedForecast - a half hourly breakdown of solar forecast for the day, with similar PV estimate, 10% estimate, 90% estimate and calibrated estimate values -- binary_sensor.predbat_dawn - Set to 'on' when the current time is past dawn (forecast solar power at or above [low_power_pv_threshold_w](customisation.md#inverter-control-options)), 'off' before dawn or when the underlying split (which requires **switch.predbat_combine_charge_slots**) isn't in use. This reflects the same dawn boundary Predbat uses to split a low-power charge window at sunrise, not whether solar is producing enough to be useful right now. +- binary_sensor.predbat_dawn - Set to 'on' when the current time is past dawn (forecast solar power at or above [low_power_pv_threshold_w](customisation.md#inverter-control-options)), 'off' before dawn or when no PV forecast is available. This reflects the same dawn boundary Predbat uses to split a low-power charge window at sunrise, not whether solar is producing enough to be useful right now. ## Dummy inverter sensors