2424from datetime import datetime , timedelta , timezone
2525
2626from const import TIME_FORMAT , TIME_FORMAT_SOLCAST
27- from utils import dp2 , dp4 , history_attribute_to_minute_data , minute_data , history_attribute , prune_today
27+ from utils import dp2 , dp4 , history_attribute_to_minute_data , minute_data , history_attribute , prune_today , str2time
2828from predbat_metrics import record_api_call , metrics
2929from component_base import ComponentBase
3030from solar_model import convert_azimuth , gti_hourly_to_period_kwh , pvwatts_cell_temperature # noqa: F401 - re-exported for tests/test_open_meteo.py parity checks
@@ -824,6 +824,24 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
824824 },
825825 app = "solar" ,
826826 )
827+ # The provider's forecast for now, before calibration, as a state of its own. It is
828+ # the same value as h0's "now" attribute, but calibration reads it back as history and
829+ # Home Assistant's history API only returns the rows where an entity's *state* changed
830+ # (significant_changes_only), so an attribute moving under a flat calibrated state is
831+ # lost. It is published whether or not calibration is on, so the history is there when
832+ # calibration is switched on, and it is not scaled by pv_scaling - see pv_forecast_history.
833+ self .dashboard_item (
834+ "sensor." + self .prefix + "_pv_forecast_h0_uncalibrated" ,
835+ state = dp2 (power_now ),
836+ attributes = {
837+ "friendly_name" : "PV Forecast Now Uncalibrated" ,
838+ "state_class" : "measurement" ,
839+ "unit_of_measurement" : "kW" ,
840+ "icon" : "mdi:solar-power" ,
841+ "device_class" : "power" ,
842+ },
843+ app = "solar" ,
844+ )
827845 else :
828846 day_name = "tomorrow" if day == 1 else "d{}" .format (day )
829847 day_name_long = day_name if day == 1 else "day {}" .format (day )
@@ -847,6 +865,67 @@ def publish_pv_stats(self, pv_forecast_data, divide_by, period):
847865 app = "solar" ,
848866 )
849867
868+ def pv_forecast_history (self , days ):
869+ """
870+ Return the past uncalibrated PV forecast in kW, keyed by timestamp and oldest first, on the same
871+ pv_scaling basis as pv_forecast_minute.
872+
873+ Read from the uncalibrated forecast sensor, whose state is the provider's figure. Calibration must
874+ not measure against the h0 sensor's state: while calibration is on that is the calibrated forecast,
875+ so calibration would learn from its own output while its factors are applied to the uncalibrated
876+ series, settling at sqrt(actual / forecast) rather than the true ratio (GH#5116).
877+
878+ Wherever that sensor's history does not reach back to the start of the window - the first week
879+ after upgrading to the version that added it, a fresh install, or a purged recorder - the older
880+ points come from the h0 sensor instead: its "now" attribute (the same raw value) and, for a point
881+ without one, its state. That has to be done on read, because Home Assistant's recorder cannot be
882+ back-dated. "now" was added in the same change that made the state calibrated (c448c9ff), so a
883+ point without it recorded the raw forecast as its state. The h0 history is fetched only when it is
884+ needed, so once the uncalibrated sensor covers the window this costs nothing.
885+
886+ The history is scaled by the current pv_scaling rather than stored scaled, so a pv_scaling change
887+ takes effect over the whole window at once instead of settling in over the following week.
888+ """
889+ history = history_attribute (self .get_history_wrapper ("sensor." + self .prefix + "_pv_forecast_h0_uncalibrated" , days , required = False ), scale = self .pv_scaling )
890+
891+ oldest = None
892+ for key in history :
893+ try :
894+ oldest = str2time (key )
895+ except (ValueError , TypeError ):
896+ continue
897+ break
898+
899+ window_start = self .now_utc_exact - timedelta (days = days )
900+ if oldest is None or oldest - window_start > timedelta (hours = 1 ):
901+ legacy = history_attribute (
902+ self .get_history_wrapper ("sensor." + self .prefix + "_pv_forecast_h0" , days , required = False ),
903+ state_key = "now" ,
904+ attributes = True ,
905+ scale = self .pv_scaling ,
906+ fallback_to_state = True ,
907+ )
908+ # Only the points before the uncalibrated history starts, so it is never double counted
909+ # and the result stays oldest first for prune_today.
910+ merged = {}
911+ for key , value in legacy .items ():
912+ try :
913+ if oldest is not None and str2time (key ) >= oldest :
914+ continue
915+ except (ValueError , TypeError ):
916+ continue
917+ merged [key ] = value
918+ if merged :
919+ self .log ("SolarAPI: PV Calibration: using {} older forecast history points from sensor.{}_pv_forecast_h0 where sensor.{}_pv_forecast_h0_uncalibrated has no history yet" .format (len (merged ), self .prefix , self .prefix ))
920+ merged .update (history )
921+ history = merged
922+
923+ if not history :
924+ # Neither sensor had any usable history - e.g. both are excluded from the recorder, or the DB
925+ # mirror's stored attributes JSON failed to decode for the whole window (db_engine.py).
926+ self .log ("Warn: SolarAPI: PV Calibration: could not read any forecast history from sensor.{}_pv_forecast_h0_uncalibrated or sensor.{}_pv_forecast_h0 - calibration will be disabled until history builds up" .format (self .prefix , self .prefix ))
927+ return history
928+
850929 def pv_calibration (self , pv_forecast_minute , pv_forecast_minute10 , pv_forecast_minute90 , pv_forecast_data , create_pv10 , divide_by , max_kwh , forecast_days , period = None ):
851930 """
852931 Perform PV calibration based on historical data and forecast data.
@@ -891,10 +970,8 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_m
891970 days_prev = int (abs (minute_absolute ) / (24 * 60 )) + 1
892971 past_day_actual [days_prev ] = past_day_actual .get (days_prev , 0 ) + pv_power_hist [minute ]
893972
894- # Find the forecast history
895- pv_forecast , pv_forecast_hist_days = history_attribute_to_minute_data (
896- self .now_utc_exact , prune_today (history_attribute (self .get_history_wrapper ("sensor." + self .prefix + "_pv_forecast_h0" , days + 1 , required = False )), self .now_utc_exact , self .midnight_utc , prune = False , intermediate = True )
897- )
973+ # Find the forecast history - the uncalibrated forecast, never the calibrated h0 state (GH#5116)
974+ pv_forecast , pv_forecast_hist_days = history_attribute_to_minute_data (self .now_utc_exact , prune_today (self .pv_forecast_history (days + 1 ), self .now_utc_exact , self .midnight_utc , prune = False , intermediate = True ))
898975
899976 hist_days = min (pv_today_hist_days , pv_forecast_hist_days , days )
900977 enabled_calibration = True
@@ -1114,9 +1191,10 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_m
11141191 # sunny day. Because the ceiling and the inner max are both >= observed_slot, the cap
11151192 # can never fall below observed generation.
11161193 #
1117- # max_pv_power_forecast is deliberately NOT used here: it is read back from the
1118- # published pv_forecast_h0 sensor, whose state is this same capped output, so including
1119- # it made the cap depend on its own previous result.
1194+ # max_pv_power_forecast is deliberately NOT used here: it is a past forecast read back from
1195+ # sensor history (pv_forecast_history), not measured generation, so it is no evidence of what the
1196+ # array can actually produce. Until GH#5116 it was worse than that - it was read from h0's state,
1197+ # which is the calibrated forecast, so including it made the cap depend on its own previous result.
11201198 observed_slot = max_pv_power_hist / 60 * self .plan_interval_minutes
11211199 ceiling_slot = max (1.2 * max_kwh , max_pv_power_hist ) / 60 * self .plan_interval_minutes
11221200 capped_slots = 0
0 commit comments