feat(pvforecast): model snow on the modules in the local backend (#1484)

No PV forecast modelled snow lying on the modules, so after a snowfall the
optimizer planned with PV that never came. The local Open-Meteo/pvlib
backend can now reduce the forecast by the snow coverage (opt-in via
pvforecast.akkudoktor.snow_enabled):

- Request hourly snowfall and snow depth with the weather and run the NREL
  snow coverage model (pvlib.snow) per plane.
- Correct the coverage with measured PV production: production above the
  model clears it at once; low production raises it only while snow is
  plausible, so curtailment and inverter outages are not learned as snow.
  Persisting snow damps the slide rate, fading with
  snow_feedback_memory_hours.
- Rebuild the coverage from snow_lookback_days on every update instead of
  storing state.
- Exclude intervals with possible snow from the calibration fit.
- Warn when PV meter readings are missing or stale; ignore impossible meter
  jumps.

_plane_power keeps its two-value signature (used by the adaptive provider in
#1422); the irradiance comes from the new _plane_power_and_poa.

Tests replay real February 2026 data from one plant (snow on 02-19/20).

Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SchaafAlexander
2026-11-08 07:42:20 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a06473b6d6
commit 628226d3d9
7 changed files with 1193 additions and 18 deletions
+10 -1
View File
@@ -336,7 +336,16 @@
"calibration_azimuth_bin_degrees": 45,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
"calibration_max_factor": 1.5,
"snow_enabled": false,
"snow_lookback_days": 7,
"snow_threshold_snowfall_cm_per_hour": 0.5,
"snow_threshold_depth_cm": 1.0,
"snow_feedback_enabled": true,
"snow_feedback_gain": 0.8,
"snow_feedback_min_fraction": 0.05,
"snow_feedback_memory_hours": 12.0,
"snow_detection_ratio": 0.5
},
"adaptive": {
"backend": "local",
+39 -3
View File
@@ -60,7 +60,16 @@
"calibration_azimuth_bin_degrees": 45,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
"calibration_max_factor": 1.5,
"snow_enabled": false,
"snow_lookback_days": 7,
"snow_threshold_snowfall_cm_per_hour": 0.5,
"snow_threshold_depth_cm": 1.0,
"snow_feedback_enabled": true,
"snow_feedback_gain": 0.8,
"snow_feedback_min_fraction": 0.05,
"snow_feedback_memory_hours": 12.0,
"snow_detection_ratio": 0.5
},
"adaptive": {
"backend": "local",
@@ -246,7 +255,16 @@
"calibration_azimuth_bin_degrees": 45,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
"calibration_max_factor": 1.5,
"snow_enabled": false,
"snow_lookback_days": 7,
"snow_threshold_snowfall_cm_per_hour": 0.5,
"snow_threshold_depth_cm": 1.0,
"snow_feedback_enabled": true,
"snow_feedback_gain": 0.8,
"snow_feedback_min_fraction": 0.05,
"snow_feedback_memory_hours": 12.0,
"snow_detection_ratio": 0.5
},
"adaptive": {
"backend": "local",
@@ -773,6 +791,15 @@
| past_days | `Optional[int]` | `rw` | `None` | Days of past data to request (0-92). Leave empty to derive it from `prediction.historic_hours`. |
| resolution_minutes | `int` | `rw` | `15` | Forecast resolution in minutes. 15 requests Open-Meteo's `minutely_15` block (natively resolved over Central Europe and North America, interpolated from hourly elsewhere); 60 requests the `hourly` block. |
| shift_to_interval_start | `bool` | `rw` | `True` | Open-Meteo stamps an interval mean with the interval END. EOS labels an interval by its START, so records are shifted back by one interval. Disable only to compare like-for-like against a provider that does not. |
| snow_detection_ratio | `float` | `rw` | `0.5` | Measured/clear-model production ratio below which an interval counts as evidence of snow the weather model missed. |
| snow_enabled | `bool` | `rw` | `False` | Model snow lying on the modules. Requests snowfall and snow depth from Open-Meteo and runs the NREL snow coverage model (pvlib.snow) per plane, which covers the modules on heavy snowfall and lets the snow slide off with warmth and sunshine. Without this, the forecast after a snowfall assumes clean modules. |
| snow_feedback_enabled | `bool` | `rw` | `True` | Correct the modelled snow coverage with measured PV production from `measurement.pv_production_emr_keys`. Production well above the snow model clears the coverage at once; production far below the clear-module model raises it, but only while snow is plausible (snow on the ground or already modelled on the modules). Needs cumulative meter readings. |
| snow_feedback_gain | `float` | `rw` | `0.8` | How far one measured interval pulls the coverage towards the observed value (1.0 = adopt the observation). |
| snow_feedback_memory_hours | `float` | `rw` | `12.0` | When measured production shows more snow than modelled, the snow is assumed to slide off slower than the NREL model says. This damping fades back to the model's rate with this time constant after the last measurement. |
| snow_feedback_min_fraction | `float` | `rw` | `0.05` | A measured interval is only used when the clear-module model expects at least this fraction of the installed AC capacity. Dawn, dusk and dark overcast carry no usable evidence about snow. |
| snow_lookback_days | `int` | `rw` | `7` | Days of past weather (and PV measurements) replayed on every update to rebuild the current snow coverage. No state is stored between runs, so this must reach back before the last relevant snowfall. |
| snow_threshold_depth_cm | `float` | `rw` | `1.0` | Ground snow depth below which the modules are assumed clear (pvlib `threshold_depth`). |
| snow_threshold_snowfall_cm_per_hour | `float` | `rw` | `0.5` | Snowfall rate that fully covers the modules (pvlib `threshold_snowfall`). pvlib's default of 1.0 cm/h was tuned on US station data; Open-Meteo's hourly model snowfall is smoother, so a lower rate fits better. |
| temperature_coefficient | `float` | `rw` | `-0.36` | Module power temperature coefficient in %/degC (negative). Matches the `cellCoEff` the akkudoktor.net forecast uses. |
| transposition_model | `str` | `rw` | `perez` | pvlib sky-diffuse transposition model: isotropic, klucher, haydavies, reindl, king or perez. |
| weather_models | `list[str]` | `rw` | `['best_match']` | Open-Meteo weather models to request. Listing more than one turns the input into a poor-man's ensemble: the members are averaged per variable, which is the cheapest reliable way to cut irradiance forecast error. Costs no extra API calls. |
@@ -810,7 +837,16 @@
"calibration_azimuth_bin_degrees": 45,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
"calibration_max_factor": 1.5,
"snow_enabled": false,
"snow_lookback_days": 7,
"snow_threshold_snowfall_cm_per_hour": 0.5,
"snow_threshold_depth_cm": 1.0,
"snow_feedback_enabled": true,
"snow_feedback_gain": 0.8,
"snow_feedback_min_fraction": 0.05,
"snow_feedback_memory_hours": 12.0,
"snow_detection_ratio": 0.5
}
}
}
+44
View File
@@ -908,6 +908,50 @@ Hourly meters are fitted hourly; native quarter-hour meters retain their cadence
Probable outage or curtailment days are excluded by default. Without sufficient
usable measurements the uncalibrated physical model is used.
##### Snow on the modules
Set `snow_enabled` to true to model snow lying on the modules. The local backend
then also requests hourly `snowfall` and `snow_depth` from Open-Meteo and runs the
NREL snow coverage model (`pvlib.snow.coverage_nrel`) per plane: heavy snowfall
covers the modules, warmth and sunshine let the snow slide off, and the modules
count as clear once the ground snow depth drops below `snow_threshold_depth_cm`.
The forecast is reduced by the covered fraction. No state is stored between runs;
every update replays the last `snow_lookback_days` of weather to rebuild the
current coverage.
The weather model alone often misjudges how long snow stays on the modules. With
`measurement.pv_production_emr_keys` configured (cumulative meter readings in
kWh), `snow_feedback_enabled` corrects the coverage with the measured production
of the past hours:
- Production above the snow model clears the coverage at once.
- Production far below the clean-module model (less than `snow_detection_ratio`)
raises the coverage, but only while snow is plausible: snow lies on the ground
or the model already holds some coverage. Low production on a snow-free day
(curtailment, an inverter fault) is ignored.
- Persisting snow also slows the modelled slide rate. This fades back with
`snow_feedback_memory_hours`, so a warm night after a covered day still clears
the modules in the forecast.
Intervals with possible snow are excluded from the calibration fit, so snow is
never learned as a permanent model loss. Meter jumps larger than the plant can
produce are ignored.
```json
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"akkudoktor": {
"backend": "local",
"snow_enabled": true
}
},
"measurement": {
"pv_production_emr_keys": ["pv_production_emr"]
}
}
```
Transient weather errors are retried. If an update still fails, existing usable
AC forecasts are retained; a cold start or expired-only forecast reports the
error. Retention does not extend the available forecast horizon. Calibration and