Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down
62 changes: 19 additions & 43 deletions apps/predbat/tests/test_find_charge_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/output-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading