Skip to content

Commit 6e73d4e

Browse files
Merge pull request #4726 from springfall2008/fix/dawn-detection-fix
fix(fetch): split charge windows at dawn again, unify low-power PV thresholds
2 parents 340205f + c45dd6a commit 6e73d4e

15 files changed

Lines changed: 269 additions & 74 deletions

‎apps/predbat/config.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,25 @@
10751075
"enable": "set_charge_low_power",
10761076
"default": 10,
10771077
},
1078+
{
1079+
"name": "low_power_pv_threshold_w",
1080+
"friendly_name": "Low power mode PV threshold",
1081+
"type": "input_number",
1082+
"min": 0,
1083+
"max": 2000,
1084+
"step": 10,
1085+
"unit": "W",
1086+
"icon": "mdi:weather-sunny",
1087+
"default": 150,
1088+
},
1089+
{
1090+
"name": "set_charge_low_power_solar_full_rate",
1091+
"friendly_name": "Low power mode full rate in solar",
1092+
"type": "switch",
1093+
"icon": "mdi:solar-power",
1094+
"enable": "set_charge_low_power",
1095+
"default": True,
1096+
},
10781097
{
10791098
"name": "set_reserve_enable",
10801099
"friendly_name": "Set Reserve Enable",

‎apps/predbat/const.py‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,6 @@
8585
MAX_INCREMENT = 240 * 100 * 3 / 1000 / 60
8686
MINUTE_WATT = 60 * 1000
8787

88-
# PV production (kWh) forecast across the remainder of a charge window above which low power charging is
89-
# abandoned in favour of the max charge rate. Throttling the charge rate while the sun is shining stops the
90-
# PV reaching the battery, the surplus is exported cheaply and the target is then made up with grid import,
91-
# which increases the cost of the plan over the full rate charge the planner costed the window at.
92-
LOW_POWER_PV_THRESHOLD = 0.1
93-
94-
# Fraction of the peak forecast PV power above which a plan_interval_minutes bucket is classed as
95-
# "light" rather than "dark" when deciding where to split a charge window (calc_dawn). A charge window
96-
# otherwise built from a single long cheap-rate period spanning sunrise would apply LOW_POWER_PV_THRESHOLD
97-
# across the whole thing and abandon low power charging even for the still-dark hours before the sun is
98-
# up (#4557) - splitting at dawn keeps the dark portion as its own window, genuinely PV-free, so it stays
99-
# throttled. A fraction of that forecast's own peak, rather than a fixed Watts figure, scales with the
100-
# site - a fixed threshold picked for a typical system would be noise-level for a large array and
101-
# unreachable for a small one.
102-
LOW_POWER_PV_LIGHT_FRACTION = 0.1
103-
10488
INVERTER_TEST = False # Run inverter control self test
10589

10690
# Sentinel values for an export window's target SoC/limit (export_limits_best and friends).

‎apps/predbat/execute.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ def execute_plan(self):
274274
self.battery_temperature_charge_curve,
275275
current_charge_rate=current_charge_rate / MINUTE_WATT,
276276
pv_window_kwh=pv_window_kwh,
277+
low_power_pv_threshold_w=self.low_power_pv_threshold_w,
278+
solar_full_rate=self.set_charge_low_power_solar_full_rate,
277279
)
278280
new_charge_rate = int(new_charge_rate * MINUTE_WATT)
279281

‎apps/predbat/fetch.py‎

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
LOAD_FORECAST_HISTORY_MAX_DAYS,
3333
PREDBAT_MAX_CARS,
3434
CAR_CHARGING_LIMIT_UNCAPPED,
35-
LOW_POWER_PV_LIGHT_FRACTION,
3635
CLOUD_WINDOW_MINUTES,
3736
CLOUD_ARRAY_MARGIN,
3837
PV_ARRAY_KWP_UNKNOWN,
@@ -905,6 +904,31 @@ def minute_data_load(self, now_utc, entity_name, max_days_previous, load_scaling
905904
load_minutes = MinuteArray(load_minutes, size)
906905
return load_minutes, age_days
907906

907+
def fetch_pv_forecast_and_dawn(self):
908+
"""
909+
Fetch the PV forecast, compute the dawn light/dark split from it, and publish the dawn
910+
binary_sensor - as one call so the ordering that #4699 regressed on (the split must be
911+
computed from a freshly-fetched forecast, not the empty dict fetch_sensor_data() resets it
912+
to at the top of each cycle) can't drift apart again. Published unconditionally regardless
913+
of whether import rates currently exist, so the sensor never goes stale.
914+
915+
Returns:
916+
pv_light_dark: dict as returned by calc_pv_light_dark(), also stored on self.pv_light_dark.
917+
"""
918+
self.pv_forecast_minute, self.pv_forecast_minute10, self.pv_forecast_minute90 = self.fetch_pv_forecast()
919+
920+
# Stored on self, not just local, so it can be used elsewhere rather than only by the
921+
# window split below.
922+
pv_light_dark = self.pv_light_dark = self.calc_pv_light_dark()
923+
# "on" past dawn, "off" before it or when unclassified (combine_charge_slots off, or no
924+
# PV forecast) - not the same as PV actually producing right now, see calc_dawn's docstring
925+
self.dashboard_item(
926+
"binary_sensor." + self.prefix + "_dawn",
927+
state="on" if pv_light_dark.get(self.minutes_now) == 1 else "off",
928+
attributes={"friendly_name": "Predbat is past dawn (light, not dark, in the low-power charge window split)", "icon": "mdi:weather-sunset-up"},
929+
)
930+
return pv_light_dark
931+
908932
def combine_active_keep(self):
909933
"""
910934
Combine the SOC keep floors (alerts, manual_soc) and ceilings (manual_soc_max) into all_active_keep/all_active_keep_max.
@@ -970,6 +994,7 @@ def fetch_sensor_data(self, save=True):
970994
self.pv_forecast_minute = {}
971995
self.pv_forecast_minute10 = {}
972996
self.pv_forecast_minute90 = {}
997+
self.pv_light_dark = {}
973998
# See Plan.refresh_pv_forecast_minute90(): both series are re-fetched together below, so no
974999
# earlier pair of signatures may be held against them
9751000
self.pv_forecast_minute90_signatures = None
@@ -1251,6 +1276,12 @@ def fetch_sensor_data(self, save=True):
12511276
if self.rate_import or self.rate_export:
12521277
self.set_rate_thresholds()
12531278

1279+
# Needed here, ahead of "Find charging windows" below, because calc_pv_light_dark() reads
1280+
# self.pv_forecast_minute to locate dawn - it was previously fetched further down in this
1281+
# function, after the dawn calculation had already run against the freshly-reset empty dict
1282+
# from the top of fetch_sensor_data(), so the dawn split could never actually trigger (#4699).
1283+
pv_light_dark = self.fetch_pv_forecast_and_dawn()
1284+
12541285
# Find discharging windows
12551286
if self.rate_export:
12561287
self.high_export_rates, lowest, highest = self.rate_scan_window(self.rate_export, 5, self.rate_export_cost_threshold, True, alt_rates=self.rate_import)
@@ -1261,8 +1292,6 @@ def fetch_sensor_data(self, save=True):
12611292

12621293
# Find charging windows
12631294
if self.rate_import:
1264-
pv_light_dark = self.calc_pv_light_dark()
1265-
12661295
# Find charging window
12671296
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)
12681297
self.log("Low Import rate found rates in range {}{} to {}{}".format(lowest, curr, highest, curr))
@@ -1299,9 +1328,6 @@ def fetch_sensor_data(self, save=True):
12991328
if self.import_today:
13001329
self.cost_today_sofar, self.carbon_today_sofar = self.today_cost(self.import_today, self.export_today, self.car_charging_energy, self.load_minutes, save=save)
13011330

1302-
# Fetch PV forecast if enabled, today must be enabled, other days are optional
1303-
self.pv_forecast_minute, self.pv_forecast_minute10, self.pv_forecast_minute90 = self.fetch_pv_forecast()
1304-
13051331
if self.load_minutes and not self.load_forecast_only and not self.load_forecast_history:
13061332
# Apply modal filter to historical data. Skipped for days_previous_auto: the weighted-bucket
13071333
# forecast deliberately ignores missing-data buckets, so the gap-filling/padding the modal filter
@@ -1886,13 +1912,21 @@ def calc_dawn(self):
18861912
day correctly stay all-dark (dawn never crosses) and a polar-day day correctly stay all-light
18871913
(crossed from the very first bucket).
18881914
1889-
The threshold itself is LOW_POWER_PV_LIGHT_FRACTION of the peak PV forecast anywhere in
1890-
self.pv_forecast_minute, not a fixed Watts figure, so it scales with the site rather than being
1891-
picked for a "typical" system size.
1892-
1893-
Built from whatever PV forecast is already in self.pv_forecast_minute, which at the point this
1894-
is called from fetch_sensor_data is up to one cycle stale (refreshed later this same loop by
1895-
fetch_pv_forecast()) - fine for a forecast that doesn't meaningfully change minute to minute.
1915+
The threshold is the user-configurable low_power_pv_threshold_w, an absolute Watts figure,
1916+
rather than a fraction of this forecast's own peak. A fraction-of-peak threshold sounds like it
1917+
"scales with the site", but it actually scales with today's weather: a heavily overcast day has
1918+
a low peak of its own, so a fixed fraction of it can call a trickle of PV "light" at a much
1919+
lower absolute wattage than the same fraction would on a clear day - and there is nothing in
1920+
Predbat today recording the site's real panel capacity to normalise against instead (only
1921+
inverter_limit, which caps the inverter's own output, not the array's rating). An absolute
1922+
figure the user sets once for their own system does not have that problem (#4699 follow-up).
1923+
The same value and the same comparison is reused by find_charge_rate's low-power abandon check
1924+
(utils.py) - one threshold answering "is this bright enough to matter" everywhere it is asked,
1925+
rather than two independently-tuned ones that could disagree on genuine twilight PV.
1926+
1927+
Built from self.pv_forecast_minute as populated earlier this same fetch_sensor_data() call by
1928+
fetch_pv_forecast() - see #4699, where calc_pv_light_dark() ran before that populating call and
1929+
so always saw the freshly-reset empty dict, silently disabling the dawn split for everyone.
18961930
18971931
Logs the calculated dawn time (today's, or the earliest day the forecast reaches if today's PV
18981932
data isn't there) each time it runs, so it's visible whether the detected dawn looks sane.
@@ -1901,11 +1935,7 @@ def calc_dawn(self):
19011935
if not self.pv_forecast_minute:
19021936
return pv_light_dark
19031937

1904-
peak_pv = max(self.pv_forecast_minute.values())
1905-
if peak_pv <= 0:
1906-
return {pv_minute: 0 for pv_minute in self.pv_forecast_minute}
1907-
1908-
light_threshold = peak_pv * LOW_POWER_PV_LIGHT_FRACTION
1938+
light_threshold = self.low_power_pv_threshold_w / MINUTE_WATT
19091939
interval = self.plan_interval_minutes
19101940
bucket_sums = {}
19111941
bucket_counts = {}
@@ -3123,6 +3153,8 @@ def fetch_config_options(self):
31233153
self.set_charge_low_power = self.get_arg("set_charge_low_power")
31243154
self.set_export_low_power = self.get_arg("set_export_low_power")
31253155
self.charge_low_power_margin = self.get_arg("charge_low_power_margin")
3156+
self.low_power_pv_threshold_w = self.get_arg("low_power_pv_threshold_w")
3157+
self.set_charge_low_power_solar_full_rate = self.get_arg("set_charge_low_power_solar_full_rate")
31263158
self.calculate_export_first = True
31273159

31283160
self.set_status_notify = self.get_arg("set_status_notify")

‎apps/predbat/output.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,8 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star
10821082
self.battery_temperature,
10831083
self.battery_temperature_charge_curve,
10841084
pv_window_kwh=pv_window_kwh,
1085+
low_power_pv_threshold_w=self.low_power_pv_threshold_w,
1086+
solar_full_rate=self.set_charge_low_power_solar_full_rate,
10851087
)
10861088
return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0)
10871089

‎apps/predbat/predbat.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,7 @@ def reset(self):
586586
self.pv_forecast_minute = {}
587587
self.pv_forecast_minute10 = {}
588588
self.pv_forecast_minute90 = {}
589+
self.pv_light_dark = {}
589590
# (p50, p90) content signatures from the previous plan run, used to spot a p90 that has been
590591
# left behind by a p50 reassigned underneath it - see Plan.refresh_pv_forecast_minute90()
591592
self.pv_forecast_minute90_signatures = None

‎apps/predbat/prediction.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def __init__(
105105
self.set_export_window = base.set_export_window
106106
self.calculate_export_on_pv = base.calculate_export_on_pv
107107
self.charge_low_power_margin = base.charge_low_power_margin
108+
self.low_power_pv_threshold_w = base.low_power_pv_threshold_w
109+
self.set_charge_low_power_solar_full_rate = base.set_charge_low_power_solar_full_rate
108110
self.car_charging_slots = base.car_charging_slots
109111
# Model-facing car charge limit (#4967): fetch raises this above the real limit for cars
110112
# following an Octopus Intelligent dispatch plan with consider_full off, making the fill
@@ -1054,6 +1056,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
10541056
battery_temperature,
10551057
self.battery_temperature_charge_curve,
10561058
pv_window_kwh=pv_window_kwh,
1059+
low_power_pv_threshold_w=self.low_power_pv_threshold_w,
1060+
solar_full_rate=self.set_charge_low_power_solar_full_rate,
10571061
)
10581062
charge_rate_now_curve_step = charge_rate_now_curve * step
10591063

‎apps/predbat/tests/test_find_charge_rate.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def test_find_charge_rate_pv_overlap(my_predbat):
7474
7575
Throttling the charge rate while the sun is shining stops PV going into the battery, the surplus
7676
is exported cheaply and the target is then made up with grid import, which increases the plan cost.
77+
78+
set_charge_low_power_solar_full_rate turns that bypass off for a window where the trade does not
79+
hold - a free or very cheap import period, where the spilled PV costs nothing to buy back and the
80+
throttled rate is wanted anyway (#4975).
7781
"""
7882
failed = 0
7983

@@ -104,7 +108,7 @@ def test_find_charge_rate_pv_overlap(my_predbat):
104108
0.96,
105109
log_to,
106110
]
107-
kwargs = {"battery_temperature": 17.0, "battery_temperature_curve": battery_temperature_curve, "current_charge_rate": max_rate / MINUTE_WATT}
111+
kwargs = {"battery_temperature": 17.0, "battery_temperature_curve": battery_temperature_curve, "current_charge_rate": max_rate / MINUTE_WATT, "low_power_pv_threshold_w": my_predbat.low_power_pv_threshold_w}
108112

109113
# Without PV low power mode should slow the charge down below the max rate
110114
dark_rate, dark_rate_real = find_charge_rate(*args, **kwargs)
@@ -127,6 +131,23 @@ def test_find_charge_rate_pv_overlap(my_predbat):
127131
print("**** ERROR: PV production in the window should force the max rate {}W, got {}W ****".format(max_rate, sun_rate * MINUTE_WATT))
128132
failed = 1
129133

134+
# With the full rate in solar switch off, the same PV must not force the max rate any more - the
135+
# window is throttled on its own charge_left / minutes_left just as a dark one would be (#4975)
136+
kwargs_no_full_rate = dict(kwargs)
137+
kwargs_no_full_rate["solar_full_rate"] = False
138+
opt_out_rate, opt_out_rate_real = find_charge_rate(*args, pv_window_kwh=2.0, **kwargs_no_full_rate)
139+
print("With PV, full rate in solar off - Best_rate {} Best_rate_real {}".format(opt_out_rate * MINUTE_WATT, opt_out_rate_real * MINUTE_WATT))
140+
if opt_out_rate != dark_rate:
141+
print("**** ERROR: with solar_full_rate off the PV should not change the rate, expected {}W got {}W ****".format(dark_rate * MINUTE_WATT, opt_out_rate * MINUTE_WATT))
142+
failed = 1
143+
144+
# The switch only has meaning when there is PV to abandon low power for - with no PV in the window
145+
# it must make no difference either way
146+
no_pv_opt_out_rate, _ = find_charge_rate(*args, **kwargs_no_full_rate)
147+
if no_pv_opt_out_rate != dark_rate:
148+
print("**** ERROR: with no PV solar_full_rate should not change the rate, expected {}W got {}W ****".format(dark_rate * MINUTE_WATT, no_pv_opt_out_rate * MINUTE_WATT))
149+
failed = 1
150+
130151
return failed
131152

132153

0 commit comments

Comments
 (0)