feat(pvforecast): keep outages out of the local provider's calibration

A battery or inverter failure limits PV to local demand for days. The
calibration read that as the plant's true output and learned it as a permanent
model loss, so one outage degraded the forecast long after the hardware was
fixed.

Calibration now estimates the healthy plant ratio over
`calibration_reference_days`, excludes days below `calibration_outage_threshold`
of it, and falls back to the most recent `calibration_min_healthy_days` when the
normal window is contaminated. `calibration_outage_filter_enabled` turns this
off for plants where measured curtailment, not available potential, is the
prediction target.

The fit also uses native 15-minute meter readings when every configured PV meter
supplies them - never interpolating hourly counters into an invented
quarter-hour profile - interpolates azimuth factors smoothly between bin centres
instead of stepping the EMS input curve, and normalizes the shape per forecast
day so it redistributes energy without changing that day's kWh correction. The
default azimuth bin widens from 15 to 45 degrees, which is what a typical
window actually supports.

Fixes the calibration window itself: it was derived from the measurement store
as a whole rather than from the configured PV production meters. A load meter
reaching further than the PV meter placed the window where no PV reading exists,
so calibration silently fell back to hourly fitting or skipped itself.

Also fixes `Measurement.load()`, which discarded every stored record. It
validated the file into a "temporary" Measurement, but Measurement is a
singleton, so that instance was the live one and the parsed records were
dropped.
This commit is contained in:
Andreas
2026-09-09 07:56:55 +02:00
parent a2f4ef6f54
commit 6dc58c33e2
8 changed files with 700 additions and 77 deletions
+11 -3
View File
@@ -206,12 +206,16 @@
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `float` | `rw` | `0.25` | Ground albedo used for planes that do not set their own. |
| apply_iam | `bool` | `rw` | `True` | Apply the ASHRAE incidence-angle modifier to the beam component. |
| calibration_azimuth_bin_degrees | `int` | `rw` | `15` | Width of the solar-azimuth bins for the correction. 0 fits a single global factor only. |
| calibration_azimuth_bin_degrees | `int` | `rw` | `45` | Width of the solar-azimuth bins for the correction. 0 fits a single global factor only. |
| calibration_days | `int` | `rw` | `30` | Length of the measurement window used to fit the correction. |
| calibration_enabled | `bool` | `rw` | `False` | Correct systematic model error against measured PV production. Requires `measurement.pv_production_emr_keys` to be configured and fed. Fits a global scale factor plus per-solar-azimuth factors, which is what catches near-field shading the horizon profile misses. |
| calibration_max_factor | `float` | `rw` | `1.5` | Upper clamp on any fitted correction factor. |
| calibration_min_factor | `float` | `rw` | `0.5` | Lower clamp on any fitted correction factor. |
| calibration_min_healthy_days | `int` | `rw` | `3` | Minimum number of healthy days used for a fit. Older healthy days from the reference window are added when the recent window contains fewer. |
| calibration_outage_filter_enabled | `bool` | `rw` | `True` | Exclude days whose measured production is far below the recent healthy plant level. This prevents inverter, battery and curtailment events from being learned as permanent PV model losses. |
| calibration_outage_threshold | `float` | `rw` | `0.55` | A day is treated as unavailable when its measured/modelled energy ratio is below this fraction of the robust healthy reference ratio. |
| calibration_prior_kwh | `float` | `rw` | `5.0` | Shrinkage strength: a bin needs this much modelled energy before its own factor outweighs the global one. Higher is more conservative. |
| calibration_reference_days | `int` | `rw` | `30` | Lookback used to distinguish healthy production from outages or curtailment. If the calibration window contains too few healthy days, the most recent healthy days from this reference window are used. |
| forecast_days | `Optional[int]` | `rw` | `None` | Forecast horizon in days (1-16). Leave empty to derive it from `prediction.hours`, which is what keeps the optimizer's tail horizon fed. |
| inverter_efficiency | `float` | `rw` | `0.96` | Nominal inverter efficiency (PVWatts eta_inv_nom). |
| past_days | `Optional[int]` | `rw` | `None` | Days of past data to request (0-92). Leave empty to derive it from `prediction.historic_hours`. |
@@ -247,7 +251,11 @@
"shift_to_interval_start": true,
"calibration_enabled": true,
"calibration_days": 30,
"calibration_azimuth_bin_degrees": 15,
"calibration_reference_days": 30,
"calibration_outage_filter_enabled": true,
"calibration_outage_threshold": 0.55,
"calibration_min_healthy_days": 3,
"calibration_azimuth_bin_degrees": 45,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
@@ -432,9 +440,9 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| PVForecastAkkudoktorLocal | `Optional[akkudoktoreos.prediction.pvforecastakkudoktorlocal.PVForecastAkkudoktorLocalCommonSettings]` | `rw` | `None` | PVForecastAkkudoktorLocal settings |
| PVForecastForecastSolar | `Optional[akkudoktoreos.prediction.pvforecastforecastsolar.PVForecastForecastSolarCommonSettings]` | `rw` | `None` | PVForecastForecastSolar settings |
| PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings |
| PVForecastAkkudoktorLocal | `Optional[akkudoktoreos.prediction.pvforecastlocal.PVForecastAkkudoktorLocalCommonSettings]` | `rw` | `None` | PVForecastAkkudoktorLocal settings |
| PVForecastPVNode | `Optional[akkudoktoreos.prediction.pvforecastpvnode.PVForecastPVNodeCommonSettings]` | `rw` | `None` | PVForecastPVNode settings |
| PVForecastSolcast | `Optional[akkudoktoreos.prediction.pvforecastsolcast.PVForecastSolcastCommonSettings]` | `rw` | `None` | PVForecastSolcast settings |
| PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings |
+19
View File
@@ -822,6 +822,25 @@ factor is clamped to `[calibration_min_factor, calibration_max_factor]` so a bro
either. The fitted factors and the resulting change in mean absolute error are logged at INFO
level on every update.
By default, calibration also rejects probable outage or curtailment days. It estimates the
healthy plant ratio from `calibration_reference_days`, excludes days below
`calibration_outage_threshold` of that reference, and falls back to the most recent
`calibration_min_healthy_days` when the normal calibration window contains an outage. This keeps
a battery or inverter failure that limits PV to local demand from becoming a permanent forecast
loss. Set `calibration_outage_filter_enabled` to false only when measured curtailed production,
rather than available PV potential, is the intended prediction target.
The measurement cadence controls the detail that can be learned. Hourly cumulative meter
readings calibrate hourly energy while the native Open-Meteo/pvlib chain continues to supply the
15-minute shape. If every configured PV meter supplies genuine 15-minute readings, calibration
automatically uses those native slots as well. It never interpolates hourly counters into an
invented quarter-hour profile. Azimuth factors are interpolated smoothly between bin centres so
they do not introduce steps into the EMS input curve. The shape fit uses all healthy days in the
reference window, while the global factor still follows the shorter recent window. Finally, the
shape is normalized per forecast day: it redistributes the calibrated energy across the day's
15-minute slots without changing that day's global kWh correction (unless the physical inverter
limit clips a peak).
Calibration requires `measurement.pv_production_emr_keys` to be configured and fed with
cumulative PV production meter readings in kWh: