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" )
0 commit comments