Skip to content

Commit 1cba612

Browse files
Merge pull request #5121 from springfall2008/fix/pv-calibration-self-reference-5116
fix(solar): calibrate PV against the raw provider forecast, not Predbat's own calibrated output
2 parents 8e0e295 + 97abfc0 commit 1cba612

9 files changed

Lines changed: 592 additions & 18 deletions

File tree

‎apps/predbat/solcast.py‎

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from datetime import datetime, timedelta, timezone
2525

2626
from 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
2828
from predbat_metrics import record_api_call, metrics
2929
from component_base import ComponentBase
3030
from 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

Comments
 (0)