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
+99
View File
@@ -15194,6 +15194,105 @@
"examples": [
1.5
]
},
"snow_enabled": {
"type": "boolean",
"title": "Snow Enabled",
"description": "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.",
"default": false,
"examples": [
false,
true
]
},
"snow_lookback_days": {
"type": "integer",
"maximum": 92.0,
"minimum": 1.0,
"title": "Snow Lookback Days",
"description": "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.",
"default": 7,
"examples": [
7,
14
]
},
"snow_threshold_snowfall_cm_per_hour": {
"type": "number",
"exclusiveMinimum": 0.0,
"title": "Snow Threshold Snowfall Cm Per Hour",
"description": "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.",
"default": 0.5,
"examples": [
0.5,
1.0
]
},
"snow_threshold_depth_cm": {
"type": "number",
"minimum": 0.0,
"title": "Snow Threshold Depth Cm",
"description": "Ground snow depth below which the modules are assumed clear (pvlib `threshold_depth`).",
"default": 1.0,
"examples": [
1.0
]
},
"snow_feedback_enabled": {
"type": "boolean",
"title": "Snow Feedback Enabled",
"description": "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.",
"default": true,
"examples": [
true
]
},
"snow_feedback_gain": {
"type": "number",
"maximum": 1.0,
"exclusiveMinimum": 0.0,
"title": "Snow Feedback Gain",
"description": "How far one measured interval pulls the coverage towards the observed value (1.0 = adopt the observation).",
"default": 0.8,
"examples": [
0.8,
1.0
]
},
"snow_feedback_min_fraction": {
"type": "number",
"exclusiveMaximum": 1.0,
"exclusiveMinimum": 0.0,
"title": "Snow Feedback Min Fraction",
"description": "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.",
"default": 0.05,
"examples": [
0.05,
0.1
]
},
"snow_feedback_memory_hours": {
"type": "number",
"exclusiveMinimum": 0.0,
"title": "Snow Feedback Memory Hours",
"description": "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.",
"default": 12.0,
"examples": [
12.0,
24.0
]
},
"snow_detection_ratio": {
"type": "number",
"exclusiveMaximum": 1.0,
"exclusiveMinimum": 0.0,
"title": "Snow Detection Ratio",
"description": "Measured/clear-model production ratio below which an interval counts as evidence of snow the weather model missed.",
"default": 0.5,
"examples": [
0.5,
0.3
]
}
},
"type": "object",
@@ -63,6 +63,13 @@ OPENMETEO_VARIABLES = (
"direct_normal_irradiance",
)
# Snow variables. Open-Meteo serves snow depth only hourly, so both are always taken
# from the `hourly` block, whatever the PV resolution.
OPENMETEO_SNOW_VARIABLES = ("snowfall", "snow_depth")
# Snow feedback warns when the newest PV meter reading is older than this.
SNOW_FEEDBACK_STALE_HOURS = 3
# Open-Meteo hard limits for a single forecast request.
MAX_FORECAST_DAYS = 16
MAX_PAST_DAYS = 92
@@ -346,6 +353,119 @@ class PVForecastAkkudoktorLocalCommonSettings(SettingsBaseModel):
},
)
snow_enabled: bool = Field(
default=False,
json_schema_extra={
"description": (
"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."
),
"examples": [False, True],
},
)
snow_lookback_days: int = Field(
default=7,
ge=1,
le=MAX_PAST_DAYS,
json_schema_extra={
"description": (
"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."
),
"examples": [7, 14],
},
)
snow_threshold_snowfall_cm_per_hour: float = Field(
default=0.5,
gt=0.0,
json_schema_extra={
"description": (
"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."
),
"examples": [0.5, 1.0],
},
)
snow_threshold_depth_cm: float = Field(
default=1.0,
ge=0.0,
json_schema_extra={
"description": (
"Ground snow depth below which the modules are assumed clear "
"(pvlib `threshold_depth`)."
),
"examples": [1.0],
},
)
snow_feedback_enabled: bool = Field(
default=True,
json_schema_extra={
"description": (
"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."
),
"examples": [True],
},
)
snow_feedback_gain: float = Field(
default=0.8,
gt=0.0,
le=1.0,
json_schema_extra={
"description": (
"How far one measured interval pulls the coverage towards the observed "
"value (1.0 = adopt the observation)."
),
"examples": [0.8, 1.0],
},
)
snow_feedback_min_fraction: float = Field(
default=0.05,
gt=0.0,
lt=1.0,
json_schema_extra={
"description": (
"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."
),
"examples": [0.05, 0.1],
},
)
snow_feedback_memory_hours: float = Field(
default=12.0,
gt=0.0,
json_schema_extra={
"description": (
"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."
),
"examples": [12.0, 24.0],
},
)
snow_detection_ratio: float = Field(
default=0.5,
gt=0.0,
lt=1.0,
json_schema_extra={
"description": (
"Measured/clear-model production ratio below which an interval counts as "
"evidence of snow the weather model missed."
),
"examples": [0.5, 0.3],
},
)
@field_validator("resolution_minutes")
@classmethod
def validate_resolution(cls, value: int) -> int:
@@ -404,6 +524,9 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
calibration_lookback, settings.calibration_reference_days
)
past_days = max(past_days, calibration_lookback)
if settings.snow_enabled:
# The coverage is rebuilt from the replayed weather on every run.
past_days = max(past_days, settings.snow_lookback_days)
return forecast_days, min(MAX_PAST_DAYS, past_days)
@@ -433,6 +556,11 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
"past_days": past_days,
"models": ",".join(settings.weather_models),
}
if settings.snow_enabled:
hourly_variables = list(OPENMETEO_SNOW_VARIABLES)
if block == "hourly":
hourly_variables = list(OPENMETEO_VARIABLES) + hourly_variables
params["hourly"] = ",".join(hourly_variables)
response = None
for attempt in range(1, 4):
@@ -500,8 +628,65 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
frame["wind_speed_10m"] = frame["wind_speed_10m"].fillna(1.0).clip(lower=0.0)
frame["temperature_2m"] = frame["temperature_2m"].fillna(15.0)
if self._settings.snow_enabled:
snowfall, snow_depth = self._snow_weather(data, index)
frame["snowfall_cm"] = snowfall
frame["snow_depth_cm"] = snow_depth
return frame
def _snow_weather(self, data: Any, index: pd.DatetimeIndex) -> tuple[pd.Series, pd.Series]:
"""Map Open-Meteo's hourly snow variables onto the PV weather rows.
Returns snowfall in cm per PV row and ground snow depth in cm. Hourly snowfall
is the sum over the preceding hour and is spread evenly over the rows that make
up that hour. Snow depth is instantaneous and interpolated in time.
"""
zero = pd.Series(0.0, index=index)
raw = data.get("hourly")
if not isinstance(raw, dict) or "time" not in raw:
logger.warning(
"Open-Meteo response carries no hourly snow data; local pvforecast assumes "
"snow-free modules."
)
return zero, zero
hourly_index = pd.DatetimeIndex(
[pendulum.parse(str(t), tz="UTC") for t in raw["time"]], name="time"
).tz_convert("UTC")
def member_mean(variable: str) -> Optional[pd.Series]:
members = [
pd.to_numeric(pd.Series(raw[key], index=hourly_index), errors="coerce")
for key in raw
if key == variable or key.startswith(f"{variable}_")
]
if not members:
return None
return pd.concat(members, axis=1).mean(axis=1, skipna=True)
snowfall_hourly = member_mean("snowfall")
depth_hourly = member_mean("snow_depth")
if snowfall_hourly is None or depth_hourly is None:
logger.warning(
"Open-Meteo response is missing snowfall or snow_depth; local pvforecast "
"assumes snow-free modules."
)
return zero, zero
rows_per_hour = 60 / self._settings.resolution_minutes
# A row stamped t (interval end) belongs to the hour that ends at or after t.
snowfall = (
snowfall_hourly.fillna(0.0).clip(lower=0.0).reindex(index, method="bfill").fillna(0.0)
/ rows_per_hour
)
depth_m = depth_hourly.interpolate(method="time").ffill().bfill()
combined = depth_m.index.union(index)
snow_depth = (
depth_m.reindex(combined).interpolate(method="time").ffill().bfill().reindex(index)
)
snow_depth = (snow_depth.fillna(0.0) * 100.0).clip(lower=0.0)
return snowfall, snow_depth
# ------------------------------------------------------------------ geometry
@staticmethod
@@ -576,6 +761,25 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
airmass: pd.Series,
) -> tuple[pd.Series, pd.Series]:
"""Run the pvlib chain for one plane, returning (dc_power_w, ac_power_w)."""
dc_power, ac_power, _ = self._plane_power_and_poa(
plane, weather, solpos, dni_extra, airmass
)
return dc_power, ac_power
def _plane_power_and_poa(
self,
plane: Any,
weather: pd.DataFrame,
solpos: pd.DataFrame,
dni_extra: pd.Series,
airmass: pd.Series,
) -> tuple[pd.Series, pd.Series, pd.Series]:
"""Run the pvlib chain for one plane, also returning its irradiance.
Returns:
(dc_power_w, ac_power_w, poa_global_w_m2). The plane-of-array irradiance
drives the snow slide model.
"""
import pvlib
settings = self._settings
@@ -584,7 +788,7 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
if peakpower_kw is None:
logger.warning("Plane without peakpower skipped by local pvforecast.")
zero = pd.Series(0.0, index=weather.index)
return zero, zero
return zero, zero, zero
pdc0 = float(peakpower_kw) * 1000.0
ghi = weather["shortwave_radiation"]
@@ -665,16 +869,16 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
)
ac_power = ac_power.fillna(0.0).clip(lower=0.0)
return dc_power, ac_power
return dc_power, ac_power, poa_global
async def _forecast_frame(self, data: Any, calibrate: bool = True) -> pd.DataFrame:
"""Run the full chain and return a frame with dc/ac power indexed as EOS records.
Args:
data: The Open-Meteo response.
calibrate: Apply the measurement-fitted correction when it is enabled and
fittable. Pass False to obtain the raw model output, which is what the
fit itself compares against.
calibrate: Apply the measurement-fitted correction and, when enabled, the
snow coverage. Pass False to obtain the raw clean-module model output,
which is what the fit itself compares against.
"""
import pvlib
@@ -710,24 +914,43 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
total_dc = pd.Series(0.0, index=weather.index)
total_ac = pd.Series(0.0, index=weather.index)
plane_ac: list[np.ndarray] = []
plane_poa: list[np.ndarray] = []
plane_tilt: list[np.ndarray] = []
for plane in self.config.pvforecast.planes or []:
dc_power, ac_power = self._plane_power(plane, weather, solpos, dni_extra, airmass)
dc_power, ac_power, poa_global = self._plane_power_and_poa(
plane, weather, solpos, dni_extra, airmass
)
total_dc = total_dc.add(dc_power, fill_value=0.0)
total_ac = total_ac.add(ac_power, fill_value=0.0)
if settings.snow_enabled:
surface_tilt, _ = self._tracked_orientation(plane, solpos)
plane_ac.append(ac_power.reindex(weather.index).fillna(0.0).to_numpy(float))
plane_poa.append(poa_global.reindex(weather.index).fillna(0.0).to_numpy(float))
plane_tilt.append(
np.broadcast_to(np.asarray(surface_tilt, dtype=float), (len(weather),)).copy()
)
frame = pd.DataFrame(
{
"dc_power": total_dc,
"ac_power": total_ac,
"solar_elevation": solpos["apparent_elevation"].to_numpy(),
"solar_azimuth": solpos["azimuth"].to_numpy(),
}
)
columns: dict[str, Any] = {
"dc_power": total_dc,
"ac_power": total_ac,
"solar_elevation": solpos["apparent_elevation"].to_numpy(),
"solar_azimuth": solpos["azimuth"].to_numpy(),
}
if settings.snow_enabled:
# Rows where snow may lie on the modules. They carry no information about
# the plant's clean-module performance and are kept out of the calibration.
columns["snow_possible"] = (
(weather["snow_depth_cm"] >= settings.snow_threshold_depth_cm)
| (weather["snowfall_cm"] > 0.0)
).to_numpy()
frame = pd.DataFrame(columns)
# Relabel from Open-Meteo's interval-end stamps to the interval-start stamps
# that EOS records use.
if settings.shift_to_interval_start:
frame.index = pd.DatetimeIndex(frame.index) - interval
raw_ac = frame["ac_power"].to_numpy(dtype=float).copy()
if calibrate:
# The fit needs the raw model to compare against, which is exactly `frame`.
calibration = await self._fit_calibration(frame)
@@ -740,6 +963,15 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
global_factor=global_factor,
timezone=self.config.general.timezone,
)
if calibrate and settings.snow_enabled and plane_ac:
frame = await self._apply_snow(
frame,
weather,
raw_ac,
np.vstack(plane_ac),
np.vstack(plane_poa),
np.vstack(plane_tilt),
)
return frame
# ------------------------------------------------------------ calibration
@@ -754,6 +986,26 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
total += float(plane.peakpower) * 1000.0
return total
def _plant_peak_power_w(self) -> float:
"""Plant output limit per plane: the smaller of module peak power and inverter.
Planes often share one inverter and each repeats its full rating, which makes
the summed inverter rating far too large to judge what a plant can deliver.
"""
total = 0.0
for plane in self.config.pvforecast.planes or []:
limits = [
float(value)
for value in (
plane.inverter_paco,
None if plane.peakpower is None else plane.peakpower * 1000.0,
)
if value is not None
]
if limits:
total += min(limits)
return total
async def _pv_measurement_window(self) -> Optional[tuple[Any, Any]]:
"""First and last timestamp that actually carries PV production readings.
@@ -969,6 +1221,21 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
samples_frame = samples_frame.reindex(grid)
modelled_kwh = samples_frame["ac_power"].to_numpy(dtype=float) / 1000.0 * interval_hours
azimuth = samples_frame["solar_azimuth"].to_numpy(dtype=float)
snow_possible = np.zeros(len(samples_frame), dtype=bool)
if "snow_possible" in frame.columns:
# Snow on the modules is a transient loss, not a model error. The day-level
# outage filter catches fully covered days, but partly covered ones would
# still drag the factor down.
snow_possible = (
frame["snow_possible"]
.astype(float)
.resample(f"{interval_minutes}min")
.max()
.reindex(grid)
.fillna(0.0)
.to_numpy(dtype=float)
> 0.0
)
# Only fit where the model says something meaningful is being produced. Dawn and
# dusk intervals otherwise dominate the ratio with noise.
@@ -983,6 +1250,14 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
& (modelled_kwh > floor_kwh)
& (measured_kwh >= 0.0)
)
if snow_possible.any():
excluded = int((usable & snow_possible).sum())
usable &= ~snow_possible
if excluded:
logger.info(
f"PVForecastAkkudoktorLocal calibration: excluded {excluded} "
f"{interval_minutes}-minute intervals with possible snow on the modules."
)
shape_usable = usable.copy()
# Calibration represents the available PV potential. A battery or inverter
@@ -1190,6 +1465,290 @@ class PVForecastAkkudoktorLocal(PVForecastProvider[PVForecastDataRecordT]):
frame["ac_power"] = frame["ac_power"].clip(upper=ac_cap_w)
return frame
# ------------------------------------------------------------------ snow
@staticmethod
def _simulate_snow_coverage(
snowfall_cm: np.ndarray,
snow_depth_cm: np.ndarray,
temp_air: np.ndarray,
poa_global: np.ndarray,
surface_tilt: np.ndarray,
interval_hours: float,
threshold_snowfall: float,
threshold_depth: float,
clear_ac_w: Optional[np.ndarray] = None,
observations: Optional[list[tuple[int, int, float]]] = None,
installed_ac_w: float = 0.0,
feedback_gain: float = 0.8,
feedback_min_fraction: float = 0.1,
detection_ratio: float = 0.5,
feedback_memory_hours: float = 12.0,
can_slide_coefficient: float = -80.0,
slide_amount_coefficient: float = 0.197,
) -> tuple[np.ndarray, int]:
"""Step the NREL snow coverage model, correcting it with measured production.
Without observations this reproduces ``pvlib.snow.coverage_nrel`` row by row:
a row whose snowfall rate exceeds ``threshold_snowfall`` (with snow on the
ground) covers the modules fully; otherwise the snow slides off by
``slide_amount_coefficient * sin(tilt)`` per hour while
``temp_air > poa / can_slide_coefficient``; and the modules are clear as soon
as the ground snow depth drops below ``threshold_depth``. pvlib evaluates the
series in one go, which leaves no point to inject a measurement, hence the
stepwise form.
After each observed interval the plant-level coverage is compared with the one
implied by the measurement (1 - measured / clear-module model):
* More production than modelled is accepted at once - the modules are at
least that clear, whatever the weather data says.
* Less production only raises the coverage while snow is plausible: either the
model already holds some coverage, or snow lies on the ground and the
production is below ``detection_ratio`` of the clear model. Everything else
(curtailment, an inverter fault, a cloudier day than analysed) is ignored.
Raising the coverage also damps the slide rate: if the snow is still there,
it slides slower than the model assumes. The damping fades back with
``feedback_memory_hours`` since the last observation, so a warm night after a
covered day still clears the modules in the forecast. Clearing evidence or
fresh snowfall resets it.
Args:
snowfall_cm: Snowfall per row [cm], shape (N,).
snow_depth_cm: Ground snow depth per row [cm], shape (N,).
temp_air: Air temperature per row [degC], shape (N,).
poa_global: Plane-of-array irradiance [W/m2], shape (P, N).
surface_tilt: Plane tilt [deg], shape (P, N).
interval_hours: Row length in hours.
threshold_snowfall: Snowfall rate that covers the modules [cm/h].
threshold_depth: Ground snow depth below which the modules are clear [cm].
clear_ac_w: Clean-module AC power per plane [W], shape (P, N). Needed for
observations.
observations: (first_row, end_row_exclusive, measured_kwh) per measured
interval, ordered by time.
installed_ac_w: Installed AC capacity, for the evidence floor.
feedback_gain: Fraction of the observed difference adopted per interval.
feedback_min_fraction: Minimum clear-model output, as a fraction of the
installed capacity, for an interval to count as evidence.
detection_ratio: Production ratio below which unmodelled snow is assumed.
feedback_memory_hours: Time constant over which a learned slower slide
rate fades back to the model's rate.
Returns:
(coverage, corrections): coverage per plane and row in [0, 1], shape
(P, N), and the number of measured intervals that changed the coverage.
"""
planes, rows = poa_global.shape
coverage = np.zeros((planes, rows), dtype=float)
if rows == 0:
return coverage, 0
rate = snowfall_cm / interval_hours
covered = (rate > threshold_snowfall) & (snow_depth_cm >= threshold_depth)
ground_clear = snow_depth_cm < threshold_depth
can_slide = temp_air[np.newaxis, :] > poa_global / can_slide_coefficient
slide = slide_amount_coefficient * np.sin(np.radians(surface_tilt)) * interval_hours
slide = np.where(can_slide, slide, 0.0)
slide[:, covered] = 0.0
slide[:, 0] = 0.0
evidence_at: dict[int, tuple[int, float]] = {}
if observations and clear_ac_w is not None:
for first_row, end_row, measured_kwh in observations:
if 0 <= first_row < end_row <= rows:
evidence_at[end_row - 1] = (first_row, measured_kwh)
state = np.zeros(planes, dtype=float)
corrections = 0
# Learned slide damping: 1.0 is the plain NREL slide rate. When the plant shows
# more snow than the model, the snow evidently slides slower than modelled; the
# damping then fades back to 1.0 with the time since that observation.
damping_observed = 1.0
damping_row = 0
for row in range(rows):
if covered[row]:
# Fresh snow restarts the physical model.
state = np.ones(planes, dtype=float)
damping_observed = 1.0
elif ground_clear[row]:
state = np.zeros(planes, dtype=float)
else:
damping = 1.0
if damping_observed < 1.0:
elapsed = (row - damping_row) * interval_hours
damping = 1.0 - (1.0 - damping_observed) * math.exp(
-elapsed / feedback_memory_hours
)
state = np.clip(state - slide[:, row] * damping, 0.0, 1.0)
coverage[:, row] = state
evidence = evidence_at.get(row)
if evidence is None or clear_ac_w is None:
continue
first_row, measured_kwh = evidence
clear = clear_ac_w[:, first_row : row + 1]
clear_kwh = float(clear.sum()) * interval_hours / 1000.0
span_hours = (row + 1 - first_row) * interval_hours
if clear_kwh < feedback_min_fraction * installed_ac_w / 1000.0 * span_hours:
continue
modelled = float((clear * coverage[:, first_row : row + 1]).sum()) / float(clear.sum())
ratio = measured_kwh / clear_kwh
observed = float(np.clip(1.0 - ratio, 0.0, 1.0))
if abs(observed - modelled) < 0.02:
continue
if observed > modelled:
snow_on_ground = float(snow_depth_cm[first_row : row + 1].max()) >= threshold_depth
plausible = modelled > 0.01 or (snow_on_ground and ratio < detection_ratio)
if not plausible:
continue
target = modelled + feedback_gain * (observed - modelled)
if target < modelled:
state = state * (target / modelled)
damping_observed = 1.0
else:
state = 1.0 - (1.0 - state) * (1.0 - target) / (1.0 - modelled)
damping_observed *= 1.0 - feedback_gain
damping_row = row
state = np.clip(state, 0.0, 1.0)
corrections += 1
return coverage, corrections
async def _snow_observations(
self, frame: pd.DataFrame
) -> tuple[list[tuple[int, int, float]], Optional[float]]:
"""Measured PV energy per meter interval, mapped onto forecast rows.
Returns:
(observations, interval_hours): (first_row, end_row_exclusive, kWh) per
complete meter interval within the lookback, and the meter interval length.
"""
settings = self._settings
if not settings.snow_feedback_enabled or not self.config.measurement.pv_production_emr_keys:
return [], None
pv_window = await self._pv_measurement_window()
if pv_window is None:
logger.warning(
"PVForecastAkkudoktorLocal snow feedback: no PV production readings - "
"using the weather-only snow model."
)
return [], None
pv_first, pv_last = pv_window
if compare_datetimes(
pv_last.add(hours=SNOW_FEEDBACK_STALE_HOURS), self.ems_start_datetime
).lt:
logger.warning(
f"PVForecastAkkudoktorLocal snow feedback: last PV production reading "
f"{pv_last} is older than {SNOW_FEEDBACK_STALE_HOURS} h - the snow "
"coverage relies on the weather model since then."
)
resolution = pd.Timedelta(minutes=settings.resolution_minutes)
row_start = pd.DatetimeIndex(frame.index)
if not settings.shift_to_interval_start:
row_start = row_start - resolution
lookback_start = pv_last.subtract(days=settings.snow_lookback_days)
interval_minutes = await self._calibration_interval_minutes(lookback_start, pv_last)
interval = pd.Timedelta(minutes=interval_minutes)
def floor_to_interval(value: pd.Timestamp) -> pd.Timestamp:
return value.floor(f"{interval_minutes}min")
start = max(
floor_to_interval(pd.Timestamp(lookback_start.in_timezone("UTC").isoformat())),
pd.Timestamp(pv_first.in_timezone("UTC").isoformat()).ceil(f"{interval_minutes}min"),
row_start[0].tz_convert("UTC").ceil(f"{interval_minutes}min"),
)
end = floor_to_interval(pd.Timestamp(pv_last.in_timezone("UTC").isoformat()))
if start >= end:
return [], None
measured = np.asarray(
await self._pv_production_total_kwh(
start_datetime=to_datetime(start.to_pydatetime()),
end_datetime=to_datetime(end.to_pydatetime()),
interval=to_duration(f"{interval_minutes} minutes"),
),
dtype=float,
)
interval_hours = interval_minutes / 60.0
# A meter jump (reset, a sub-meter dropping out of a sum) shows up as an
# impossible yield. Drop it rather than learn snow - or its absence - from it.
ceiling_kwh = 1.1 * self._plant_peak_power_w() / 1000.0 * interval_hours
rows_per_interval = round(interval / resolution)
starts = row_start.tz_convert("UTC")
observations: list[tuple[int, int, float]] = []
for slot, value in enumerate(measured):
if not np.isfinite(value) or value < 0.0 or value > ceiling_kwh:
continue
slot_start = start + slot * interval
first_row = int(starts.searchsorted(slot_start, side="left"))
end_row = int(starts.searchsorted(slot_start + interval, side="left"))
if end_row - first_row != rows_per_interval:
continue
observations.append((first_row, end_row, float(value)))
return observations, interval_hours
async def _apply_snow(
self,
frame: pd.DataFrame,
weather: pd.DataFrame,
raw_ac: np.ndarray,
plane_ac: np.ndarray,
plane_poa: np.ndarray,
plane_tilt: np.ndarray,
) -> pd.DataFrame:
"""Reduce the forecast by the (measurement-corrected) snow coverage."""
settings = self._settings
calibrated_ac = frame["ac_power"].to_numpy(dtype=float)
# Calibration scales the plant total per row; carry that onto the planes.
with np.errstate(divide="ignore", invalid="ignore"):
scale = np.where(raw_ac > 0.0, calibrated_ac / raw_ac, 1.0)
clear_ac = plane_ac * scale[np.newaxis, :]
observations, _ = await self._snow_observations(frame)
coverage, corrections = self._simulate_snow_coverage(
snowfall_cm=weather["snowfall_cm"].to_numpy(dtype=float),
snow_depth_cm=weather["snow_depth_cm"].to_numpy(dtype=float),
temp_air=weather["temperature_2m"].to_numpy(dtype=float),
poa_global=plane_poa,
surface_tilt=plane_tilt,
interval_hours=settings.resolution_minutes / 60.0,
threshold_snowfall=settings.snow_threshold_snowfall_cm_per_hour,
threshold_depth=settings.snow_threshold_depth_cm,
clear_ac_w=clear_ac,
observations=observations,
installed_ac_w=self._plant_peak_power_w(),
feedback_gain=settings.snow_feedback_gain,
feedback_min_fraction=settings.snow_feedback_min_fraction,
detection_ratio=settings.snow_detection_ratio,
feedback_memory_hours=settings.snow_feedback_memory_hours,
)
clear_total = clear_ac.sum(axis=0)
with np.errstate(divide="ignore", invalid="ignore"):
plant_coverage = np.where(
clear_total > 0.0,
(clear_ac * coverage).sum(axis=0) / clear_total,
coverage.mean(axis=0),
)
frame = frame.copy()
frame["ac_power"] = frame["ac_power"] * (1.0 - plant_coverage)
frame["dc_power"] = frame["dc_power"] * (1.0 - plant_coverage)
frame["snow_coverage"] = plant_coverage
if plant_coverage.max() > 0.0 or corrections:
now = pd.Timestamp(self.ems_start_datetime.in_timezone("UTC").isoformat())
position = int(pd.DatetimeIndex(frame.index).searchsorted(now))
current = float(plant_coverage[min(position, len(plant_coverage) - 1)])
logger.info(
f"PVForecastAkkudoktorLocal snow: coverage {current:.0%} at run start, "
f"{corrections} correction(s) from {len(observations)} measured interval(s)."
)
return frame
# ------------------------------------------------------------------ update
async def _holds_usable_forecast(self) -> bool:
+427
View File
@@ -0,0 +1,427 @@
"""Tests for snow coverage in the native (pvlib) PV forecast provider.
The scenario tests replay real winter data (February 2026, see
``testdata/pvforecast_snow_feb2026.json``): hourly Open-Meteo weather and the plant's
measured production. Snow covered the modules on 02-19 and 02-20. The weather after
the simulated run start is the analysed weather, so the tests isolate the snow logic
from weather-forecast error.
"""
import copy
import json
from pathlib import Path
from typing import Any
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pendulum
import pvlib
import pytest
import pytest_asyncio
from akkudoktoreos.core.coreabc import get_ems, get_measurement
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import (
PVForecastAkkudoktorLocal,
PVForecastAkkudoktorLocalCommonSettings,
)
FIXTURE = Path(__file__).parent / "testdata" / "pvforecast_snow_feb2026.json"
TIMEZONE = "Europe/Berlin"
# Whole-degree grid point near the plant; the real site coordinates are not published.
LATITUDE = 48.0
LONGITUDE = 11.0
# Two 5.7 kWp roof halves plus two 1 kWp planes, as on the real plant. The two large
# planes share one inverter and each repeats its rating, as real configurations do.
PLANES = [
{"surface_tilt": 28.0, "surface_azimuth": 17.0, "peakpower": 5.7, "inverter_paco": 10000},
{"surface_tilt": 28.0, "surface_azimuth": 197.0, "peakpower": 5.7, "inverter_paco": 10000},
{"surface_tilt": 21.0, "surface_azimuth": 28.0, "peakpower": 1.0, "inverter_paco": 1600},
{"surface_tilt": 21.0, "surface_azimuth": 208.0, "peakpower": 1.0, "inverter_paco": 1600},
]
BASE_SETTINGS: dict[str, Any] = {"backend": "local", "resolution_minutes": 60, "snow_enabled": True}
def load_fixture() -> dict:
with FIXTURE.open(encoding="utf-8") as handle:
return json.load(handle)
def local(text: str) -> pendulum.DateTime:
parsed = pendulum.parse(text, tz=TIMEZONE)
assert isinstance(parsed, pendulum.DateTime)
return parsed
def window_kwh(frame: pd.DataFrame, start: str, end: str) -> float:
"""Forecast energy between two local times (hourly rows, interval-start labels)."""
index = pd.DatetimeIndex(frame.index).tz_convert(TIMEZONE)
mask = (index >= pd.Timestamp(start, tz=TIMEZONE)) & (index < pd.Timestamp(end, tz=TIMEZONE))
return float(frame["ac_power"].to_numpy()[mask].sum()) / 1000.0
def measured_kwh(fixture: dict, start: str, end: str) -> float:
readings = {pd.Timestamp(t): v for t, v in fixture["pv_meter_kwh"]}
return (
readings[pd.Timestamp(end, tz=TIMEZONE).tz_convert("UTC")]
- readings[pd.Timestamp(start, tz=TIMEZONE).tz_convert("UTC")]
)
async def feed_meter(
key: str, fixture: dict, until: pendulum.DateTime, override: dict | None = None
) -> None:
"""Store the cumulative meter readings up to the run start.
``override`` maps local hour starts to a replacement hourly yield in kWh, to
simulate an outage or a meter jump on real data. Each test uses its own key:
measurements are database-backed and would otherwise leak between tests.
"""
measurement = get_measurement()
replaced_hours = {pd.Timestamp(k, tz=TIMEZONE): v for k, v in (override or {}).items()}
readings = [(pd.Timestamp(t), float(v)) for t, v in fixture["pv_meter_kwh"]]
limit = pd.Timestamp(until.in_timezone("UTC").isoformat())
cumulative = readings[0][1]
previous = None
for timestamp, value in readings:
if timestamp > limit:
break
if previous is not None:
hour_start, hour_value = previous
replaced = replaced_hours.get(hour_start.tz_convert(TIMEZONE))
cumulative += (value - hour_value) if replaced is None else replaced
await measurement.update_value(
pendulum.instance(timestamp.to_pydatetime()), key, round(cumulative, 6)
)
previous = (timestamp, value)
@pytest_asyncio.fixture
async def snow_instance(config_eos):
config_eos.merge_settings_from_dict(
{
"general": {"latitude": LATITUDE, "longitude": LONGITUDE, "timezone": TIMEZONE},
"prediction": {"hours": 48, "historic_hours": 48},
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"planes": PLANES,
"akkudoktor": BASE_SETTINGS,
},
}
)
provider = PVForecastAkkudoktor()
await provider.delete_by_datetime()
await get_measurement().delete_by_datetime()
yield provider
await provider.delete_by_datetime()
await get_measurement().delete_by_datetime()
async def run_at(
instance: PVForecastAkkudoktorLocal,
now: str,
key: str | None,
fixture: dict,
override: dict | None = None,
weather: dict | None = None,
**settings,
) -> pd.DataFrame:
"""Compute the forecast frame as a run starting at local time ``now`` would.
``key`` None runs without PV measurements (snow model only).
"""
start = local(now)
get_ems().set_start_datetime(start)
instance.config.pvforecast.akkudoktor = PVForecastAkkudoktorLocalCommonSettings(
**{**BASE_SETTINGS, **settings}
)
if key is not None:
instance.config.measurement.pv_production_emr_keys = [key]
await feed_meter(key, fixture, start, override)
else:
instance.config.measurement.pv_production_emr_keys = None
return await instance._forecast_frame(weather or fixture["openmeteo"])
def with_injected_snowfall(fixture: dict, start: str, hours: int, depth_until: str) -> dict:
"""Return weather that shows heavy snowfall the plant never had."""
data = copy.deepcopy(fixture["openmeteo"])
hourly = data["hourly"]
first = pd.Timestamp(start, tz=TIMEZONE).tz_convert("UTC")
last = pd.Timestamp(depth_until, tz=TIMEZONE).tz_convert("UTC")
for position, stamp in enumerate(hourly["time"]):
moment = pd.Timestamp(stamp, tz="UTC")
if first < moment <= first + pd.Timedelta(hours=hours):
hourly["snowfall"][position] = 2.0
if first < moment <= last:
hourly["snow_depth"][position] = max(hourly["snow_depth"][position] or 0.0, 0.08)
return data
# ---------------------------------------------------------------- settings / request
def test_snow_is_off_by_default():
settings = PVForecastAkkudoktorLocalCommonSettings()
assert settings.snow_enabled is False
assert settings.snow_feedback_enabled is True
@pytest.mark.parametrize("resolution_minutes", [15, 60])
def test_request_asks_for_hourly_snow_and_enough_history(snow_instance, resolution_minutes):
snow_instance.config.pvforecast.akkudoktor = PVForecastAkkudoktorLocalCommonSettings(
**{**BASE_SETTINGS, "resolution_minutes": resolution_minutes, "snow_lookback_days": 9}
)
response = Mock()
response.url = "https://api.open-meteo.com/v1/forecast"
response.raise_for_status.return_value = None
block = "minutely_15" if resolution_minutes == 15 else "hourly"
response.json.return_value = {block: {"time": []}}
with patch(
"akkudoktoreos.prediction.pvforecastakkudoktorlocal.requests.get", return_value=response
) as request:
snow_instance._request_local_forecast(force_update=True)
params = request.call_args.kwargs["params"]
hourly = params["hourly"].split(",")
assert "snowfall" in hourly and "snow_depth" in hourly
if resolution_minutes == 60:
assert "shortwave_radiation" in hourly
else:
assert "shortwave_radiation" in params["minutely_15"]
assert params["past_days"] >= 9
def test_hourly_snowfall_is_spread_over_quarter_hours(snow_instance):
snow_instance.config.pvforecast.akkudoktor = PVForecastAkkudoktorLocalCommonSettings(
**{**BASE_SETTINGS, "resolution_minutes": 15}
)
data = {
"hourly": {
"time": ["2026-02-20T10:00", "2026-02-20T11:00", "2026-02-20T12:00"],
"snowfall": [0.0, 2.0, 0.0],
"snow_depth": [0.10, 0.12, 0.12],
}
}
index = pd.date_range("2026-02-20 10:15", "2026-02-20 12:00", freq="15min", tz="UTC")
snowfall, depth = snow_instance._snow_weather(data, index)
# Open-Meteo's 11:00 value is the snowfall of 10:00-11:00.
assert snowfall.loc["2026-02-20 10:15":"2026-02-20 11:00"].tolist() == [0.5] * 4
assert snowfall.sum() == pytest.approx(2.0)
assert depth.iloc[0] == pytest.approx(10.5) # metres -> cm, interpolated
assert depth.iloc[-1] == pytest.approx(12.0)
@pytest.mark.asyncio
async def test_missing_snow_variables_fall_back_to_clear_modules(snow_instance, caplog):
fixture = load_fixture()
weather = copy.deepcopy(fixture["openmeteo"])
del weather["hourly"]["snow_depth"]
frame = await run_at(snow_instance, "2026-02-20 10:00", None, fixture, weather=weather)
assert frame["snow_coverage"].max() == 0.0
assert "missing snowfall or snow_depth" in caplog.text
# ---------------------------------------------------------------- snow model
def test_step_model_matches_pvlib_coverage_nrel():
"""Without measurements the stepwise model is pvlib's NREL model."""
index = pd.date_range("2026-02-18 00:00", periods=96, freq="1h", tz="UTC")
hours = np.arange(len(index))
snowfall = np.where((hours >= 10) & (hours < 13), 1.5, 0.0)
snowfall[60] = 0.7 # below the threshold: no new event
temp_air = 2.0 * np.sin(hours / 24 * 2 * np.pi) - 0.5
poa = np.clip(600.0 * np.sin((hours % 24 - 6) / 12 * np.pi), 0.0, None)
depth = np.where(hours < 80, 12.0, 0.4)
tilt = 28.0
expected = pvlib.snow.coverage_nrel(
snowfall=pd.Series(snowfall, index=index),
poa_irradiance=pd.Series(poa, index=index),
temp_air=pd.Series(temp_air, index=index),
surface_tilt=tilt,
snow_depth=pd.Series(depth, index=index),
threshold_snowfall=1.0,
threshold_depth=1.0,
)
coverage, corrections = PVForecastAkkudoktorLocal._simulate_snow_coverage(
snowfall_cm=snowfall,
snow_depth_cm=depth,
temp_air=temp_air,
poa_global=poa[np.newaxis, :],
surface_tilt=np.full((1, len(index)), tilt),
interval_hours=1.0,
threshold_snowfall=1.0,
threshold_depth=1.0,
)
assert corrections == 0
# The series exercises full cover, partial sliding and the ground-clear reset.
assert expected.max() == 1.0
assert ((expected > 0.05) & (expected < 0.95)).any()
assert expected.iloc[-1] == 0.0
np.testing.assert_allclose(coverage[0], expected.to_numpy(), atol=1e-12)
@pytest.mark.asyncio
async def test_weather_alone_lowers_the_forecast_after_heavy_snowfall(snow_instance):
"""Run at midnight before the covered day: no measurement evidence yet."""
fixture = load_fixture()
clear = await run_at(snow_instance, "2026-02-20 00:00", None, fixture, snow_enabled=False)
model = await run_at(snow_instance, "2026-02-20 00:00", None, fixture)
day = ("2026-02-20 00:00", "2026-02-21 00:00")
assert window_kwh(model, *day) < 0.6 * window_kwh(clear, *day)
# ---------------------------------------------------------------- measurement feedback
@pytest.mark.asyncio
@pytest.mark.parametrize(
"now",
[
# Snow came at noon; the model starts to cover the modules but lets it slide.
"2026-02-19 12:00",
# Covered all day at around 0 degC; the NREL model slides it off by noon.
"2026-02-20 10:00",
"2026-02-20 13:00",
# Light snowfall the weather model never turns into coverage; 0.9 kWh made.
"2026-02-15 12:00",
],
)
async def test_feedback_catches_snow_the_model_underestimates(snow_instance, now):
fixture = load_fixture()
end = f"{pd.Timestamp(now):%Y-%m-%d} 23:00"
key = f"under_{now[5:10]}_{now[11:13]}"
model = await run_at(snow_instance, now, None, fixture)
feedback = await run_at(snow_instance, now, key, fixture)
actual = measured_kwh(fixture, now, end)
model_error = abs(window_kwh(model, now, end) - actual)
feedback_error = abs(window_kwh(feedback, now, end) - actual)
assert window_kwh(feedback, now, end) < window_kwh(model, now, end)
assert feedback_error < 0.5 * model_error
@pytest.mark.asyncio
async def test_feedback_clears_snow_the_plant_does_not_have(snow_instance):
"""Weather says heavy snowfall until 09:00; the real plant produced 23 kWh that day."""
fixture = load_fixture()
weather = with_injected_snowfall(fixture, "2026-02-22 06:00", 3, "2026-02-23 00:00")
now, end = "2026-02-22 11:00", "2026-02-22 23:00"
clear = await run_at(snow_instance, now, None, fixture, weather=weather, snow_enabled=False)
model = await run_at(snow_instance, now, None, fixture, weather=weather)
feedback = await run_at(snow_instance, now, "phantom_snow", fixture, weather=weather)
assert window_kwh(model, now, end) < 0.7 * window_kwh(clear, now, end)
assert window_kwh(feedback, now, end) > 0.95 * window_kwh(clear, now, end)
@pytest.mark.asyncio
async def test_low_production_without_snow_is_not_learned_as_snow(snow_instance):
"""A morning outage on a warm, snow-free day (inverter fault, curtailment)."""
fixture = load_fixture()
outage = {f"2026-02-23 {hour:02d}:00": 0.0 for hour in range(7, 12)}
now, end = "2026-02-23 12:00", "2026-02-23 23:00"
model = await run_at(snow_instance, now, None, fixture)
feedback = await run_at(snow_instance, now, "outage", fixture, override=outage)
assert window_kwh(feedback, now, end) == pytest.approx(window_kwh(model, now, end), rel=1e-6)
@pytest.mark.asyncio
async def test_light_snowfall_on_a_productive_day_is_not_overcorrected(snow_instance):
fixture = load_fixture()
now, end = "2026-02-16 11:00", "2026-02-16 23:00"
clear = await run_at(snow_instance, now, None, fixture, snow_enabled=False)
feedback = await run_at(snow_instance, now, "light_snow", fixture)
assert window_kwh(feedback, now, end) > 0.95 * window_kwh(clear, now, end)
@pytest.mark.asyncio
async def test_learned_slow_slide_fades_over_a_warm_night(snow_instance):
"""After the covered 02-20, a night at +3..5 degC clears the modules for 02-21."""
fixture = load_fixture()
now, end = "2026-02-21 06:00", "2026-02-21 23:00"
clear = await run_at(snow_instance, now, None, fixture, snow_enabled=False)
feedback = await run_at(snow_instance, now, "warm_night", fixture)
assert window_kwh(feedback, now, end) > 0.95 * window_kwh(clear, now, end)
@pytest.mark.asyncio
async def test_meter_jump_is_ignored(snow_instance):
"""A sub-meter dropping out of a sum makes the meter jump by its lifetime count."""
fixture = load_fixture()
now, end = "2026-02-20 13:00", "2026-02-20 23:00"
normal = await run_at(snow_instance, now, "jump_reference", fixture)
jumped = await run_at(snow_instance, now, "jump", fixture, override={"2026-02-20 11:00": 940.0})
assert window_kwh(jumped, now, end) == pytest.approx(window_kwh(normal, now, end), abs=0.3)
@pytest.mark.asyncio
async def test_feedback_can_be_disabled(snow_instance):
fixture = load_fixture()
now, end = "2026-02-20 13:00", "2026-02-20 23:00"
model = await run_at(snow_instance, now, None, fixture)
disabled = await run_at(
snow_instance, now, "feedback_off", fixture, snow_feedback_enabled=False
)
assert window_kwh(disabled, now, end) == pytest.approx(window_kwh(model, now, end))
@pytest.mark.asyncio
async def test_stale_meter_readings_are_reported(snow_instance, caplog):
fixture = load_fixture()
snow_instance.config.measurement.pv_production_emr_keys = ["stale"]
await feed_meter("stale", fixture, local("2026-02-20 09:00"))
get_ems().set_start_datetime(local("2026-02-20 15:00"))
await snow_instance._forecast_frame(fixture["openmeteo"])
assert "older than 3 h" in caplog.text
# ---------------------------------------------------------------- calibration
@pytest.mark.asyncio
async def test_calibration_skips_intervals_with_possible_snow(snow_instance, caplog):
fixture = load_fixture()
get_ems().set_start_datetime(local("2026-02-23 23:00"))
snow_instance.config.measurement.pv_production_emr_keys = ["calibration_snow"]
await feed_meter("calibration_snow", fixture, local("2026-02-23 23:00"))
factors = {}
for snow_enabled in (False, True):
snow_instance.config.pvforecast.akkudoktor = PVForecastAkkudoktorLocalCommonSettings(
**{
**BASE_SETTINGS,
"snow_enabled": snow_enabled,
"calibration_enabled": True,
"calibration_days": 10,
"calibration_outage_filter_enabled": False,
"calibration_azimuth_bin_degrees": 0,
}
)
raw = await snow_instance._forecast_frame(fixture["openmeteo"], calibrate=False)
calibration = await snow_instance._fit_calibration(raw)
assert calibration is not None
factors[snow_enabled] = calibration[0]
assert "possible snow on the modules" in caplog.text
# Snow days only pull the plant level down.
assert factors[True] > factors[False]
File diff suppressed because one or more lines are too long