mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-09 15:24:01 +01:00
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:
co-authored by
Claude Opus 5.5
parent
a06473b6d6
commit
628226d3d9
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
+1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user