feat(pvforecast): local pvlib provider with measurement calibration

Add PVForecastAkkudoktorLocal, which runs the modelling chain inside EOS on
raw Open-Meteo irradiance instead of calling a forecast service: solar
position, horizon shading, plane transposition, incidence-angle modifier,
cell temperature, PVWatts DC and inverter AC.

It needs no API key and serves up to 16 days at 15-minute resolution from a
single hourly request, which is what keeps `optimization.tail_horizon_hours`
fed - services wrapping Open-Meteo cut the horizon much shorter. Several
Open-Meteo models can be listed in `weather_models` and are averaged per
variable at no extra request cost.

With `calibration_enabled` the provider fits itself against
`measurement.pv_production_emr_keys` over the past `calibration_days`: a
global scale factor plus optional per-solar-azimuth factors, each weighted by
modelled energy, shrunk toward the global factor by `calibration_prior_kwh`
and clamped to `[calibration_min_factor, calibration_max_factor]`. The
comparison runs on past intervals, where Open-Meteo serves analysed rather
than forecast weather, so it corrects the error of the PV model and not that
of the weather forecast.

Calibration is a scale factor on the output and never touches `userhorizon`,
`surface_tilt`, `surface_azimuth` or `peakpower`. The docs say so, and say
why a short window and a plant fault inside it are the two ways to end up
with a misleading factor.

Also add `Measurement.pv_production_total_kwh()` alongside the existing load
total, and `scripts/pvforecast_backtest.py`, which scores configuration
variants against the stored meter readings without waiting for new forecasts
to come true.
This commit is contained in:
Andreas
2026-09-06 18:21:20 +02:00
parent c0c9a1f669
commit f976335122
15 changed files with 1814 additions and 79 deletions
+72 -4
View File
@@ -34,7 +34,8 @@
"PVForecastVrm": null,
"PVForecastPVNode": null,
"PVForecastForecastSolar": null,
"PVForecastSolcast": null
"PVForecastSolcast": null,
"PVForecastAkkudoktorLocal": null
},
"planes": [
{
@@ -102,7 +103,8 @@
"PVForecastVrm": null,
"PVForecastPVNode": null,
"PVForecastForecastSolar": null,
"PVForecastSolcast": null
"PVForecastSolcast": null,
"PVForecastAkkudoktorLocal": null
},
"planes": [
{
@@ -157,7 +159,8 @@
"PVForecastPVNode",
"PVForecastForecastSolar",
"PVForecastSolcast",
"PVForecastImport"
"PVForecastImport",
"PVForecastAkkudoktorLocal"
],
"planes_peakpower": [
5.0,
@@ -192,6 +195,69 @@
```
<!-- pyml enable line-length -->
### Common settings for the local (pvlib) PV forecast provider
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings::PVForecastAkkudoktorLocal
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `float` | `rw` | `0.25` | Ground albedo used for planes that do not set their own. |
| apply_iam | `bool` | `rw` | `True` | Apply the ASHRAE incidence-angle modifier to the beam component. |
| calibration_azimuth_bin_degrees | `int` | `rw` | `15` | Width of the solar-azimuth bins for the correction. 0 fits a single global factor only. |
| calibration_days | `int` | `rw` | `30` | Length of the measurement window used to fit the correction. |
| calibration_enabled | `bool` | `rw` | `False` | Correct systematic model error against measured PV production. Requires `measurement.pv_production_emr_keys` to be configured and fed. Fits a global scale factor plus per-solar-azimuth factors, which is what catches near-field shading the horizon profile misses. |
| calibration_max_factor | `float` | `rw` | `1.5` | Upper clamp on any fitted correction factor. |
| calibration_min_factor | `float` | `rw` | `0.5` | Lower clamp on any fitted correction factor. |
| calibration_prior_kwh | `float` | `rw` | `5.0` | Shrinkage strength: a bin needs this much modelled energy before its own factor outweighs the global one. Higher is more conservative. |
| forecast_days | `Optional[int]` | `rw` | `None` | Forecast horizon in days (1-16). Leave empty to derive it from `prediction.hours`, which is what keeps the optimizer's tail horizon fed. |
| inverter_efficiency | `float` | `rw` | `0.96` | Nominal inverter efficiency (PVWatts eta_inv_nom). |
| past_days | `Optional[int]` | `rw` | `None` | Days of past data to request (0-92). Leave empty to derive it from `prediction.historic_hours`. |
| 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. |
| 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. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastAkkudoktorLocal": {
"resolution_minutes": 15,
"forecast_days": null,
"past_days": null,
"weather_models": [
"best_match"
],
"transposition_model": "perez",
"albedo": 0.25,
"inverter_efficiency": 0.96,
"temperature_coefficient": -0.36,
"apply_iam": true,
"shift_to_interval_start": true,
"calibration_enabled": true,
"calibration_days": 30,
"calibration_azimuth_bin_degrees": 15,
"calibration_prior_kwh": 5.0,
"calibration_min_factor": 0.5,
"calibration_max_factor": 1.5
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for the Solcast PV forecast provider
<!-- pyml disable line-length -->
@@ -368,6 +434,7 @@
| ---- | ---- | --------- | ------- | ----------- |
| PVForecastForecastSolar | `Optional[akkudoktoreos.prediction.pvforecastforecastsolar.PVForecastForecastSolarCommonSettings]` | `rw` | `None` | PVForecastForecastSolar settings |
| PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings |
| PVForecastAkkudoktorLocal | `Optional[akkudoktoreos.prediction.pvforecastlocal.PVForecastAkkudoktorLocalCommonSettings]` | `rw` | `None` | PVForecastAkkudoktorLocal settings |
| PVForecastPVNode | `Optional[akkudoktoreos.prediction.pvforecastpvnode.PVForecastPVNodeCommonSettings]` | `rw` | `None` | PVForecastPVNode settings |
| PVForecastSolcast | `Optional[akkudoktoreos.prediction.pvforecastsolcast.PVForecastSolcastCommonSettings]` | `rw` | `None` | PVForecastSolcast settings |
| PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings |
@@ -387,7 +454,8 @@
"PVForecastVrm": null,
"PVForecastPVNode": null,
"PVForecastForecastSolar": null,
"PVForecastSolcast": null
"PVForecastSolcast": null,
"PVForecastAkkudoktorLocal": null
}
}
}
@@ -0,0 +1,68 @@
// Node-RED function: cumulative PV production meter readings -> EOS measurement API.
//
// Strang:
// inject (repeat 3600 s, msg.topic = die SQL aus dem Tutorial)
// -> mysql "MariaDB" (DB `sensor`)
// -> DIESE function
// -> http request (Method: "- set by msg.method -")
//
// EOS speichert unter `pv_production_emr_keys` Zaehlerstaende (EMR) in kWh und
// bildet die Differenzen selbst. Aus den Momentanleistungen in `data.solarallpower`
// muss also erst ein monoton steigender Zaehler werden - das macht die SQL.
//
// Wichtig: das Startdatum in der SQL bleibt FEST. Ein rollendes
// `NOW() - INTERVAL n DAY` verschiebt den Nullpunkt der kumulativen Summe bei
// jedem Lauf, und EOS liest den Sprung als Produktion.
const BASE_URL = "http://192.168.1.151:8503";
const KEY = "pv_produktion_emr";
const TZ = "Europe/Berlin";
function toIsoWithOffset(value) {
// DATE_FORMAT() kommt als String in lokaler Zeit zurueck. Den Offset aus dem
// Datum selbst bilden, damit CEST und CET beide stimmen.
const date = value instanceof Date ? value : new Date(String(value).replace(" ", "T"));
const pad = n => String(Math.trunc(Math.abs(n))).padStart(2, "0");
const offsetMin = -date.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
`${sign}${pad(offsetMin / 60)}:${pad(offsetMin % 60)}`;
}
const rows = Array.isArray(msg.payload) ? msg.payload : [];
const data = {};
let last = null;
let skipped = 0;
for (const row of rows) {
const value = Number(row.emr);
if (!Number.isFinite(value)) {
skipped += 1;
continue;
}
// Ein Zaehler laeuft nur vorwaerts. Ein Rueckschritt bedeutet eine Luecke oder
// einen kaputten Messwert - EOS wuerde daraus eine negative Produktionsstunde
// machen.
if (last !== null && value < last) {
skipped += 1;
continue;
}
last = value;
data[toIsoWithOffset(row.ts)] = Number(value.toFixed(6));
}
const count = Object.keys(data).length;
if (count === 0) {
node.warn("Keine PV-Messwerte gefunden - topic und Zeitfenster in der SQL pruefen.");
return null;
}
if (skipped > 0) {
node.warn(`${skipped} Zeilen uebersprungen (nicht-numerisch oder Zaehler rueckwaerts).`);
}
node.status({ text: `${count} EMR-Werte, letzter ${last.toFixed(1)} kWh` });
msg.method = "PUT";
msg.url = `${BASE_URL}/v1/measurement/series?key=${encodeURIComponent(KEY)}`;
msg.headers = { "Content-Type": "application/json" };
msg.payload = { data: data, dtype: "float64", tz: TZ };
return msg;
+134
View File
@@ -489,6 +489,7 @@ Configuration options:
- `PVForecastForecastSolar`: Retrieves forecasts from the free Forecast.Solar API.
- `PVForecastSolcast`: Retrieves forecasts from the Solcast rooftop-site API.
- `PVForecastImport`: Imports from a file or JSON string or by endpoint data provision.
- `PVForecastAkkudoktorLocal`: Computes the forecast inside EOS from Open-Meteo weather with pvlib.
- `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking.
- `planes[].surface_azimuth`: Orientation (azimuth angle) of the (fixed) plane.
@@ -522,6 +523,17 @@ Configuration options:
- `provider_settings.PVForecastForecastSolar.api_key`: Forecast.Solar API key (optional).
- `provider_settings.PVForecastSolcast.api_key`: Solcast API key.
- `provider_settings.PVForecastSolcast.site_id`: Solcast rooftop resource (site) id.
- `provider_settings.PVForecastAkkudoktorLocal.resolution_minutes`: 15 or 60.
- `provider_settings.PVForecastAkkudoktorLocal.forecast_days`: 1-16, empty derives it from `prediction.hours`.
- `provider_settings.PVForecastAkkudoktorLocal.past_days`: 0-92, empty derives it from `prediction.historic_hours`.
- `provider_settings.PVForecastAkkudoktorLocal.weather_models`: Open-Meteo models; several are averaged.
- `provider_settings.PVForecastAkkudoktorLocal.transposition_model`: pvlib sky-diffuse model.
- `provider_settings.PVForecastAkkudoktorLocal.albedo`: Fallback albedo for planes without their own.
- `provider_settings.PVForecastAkkudoktorLocal.inverter_efficiency`: Nominal inverter efficiency.
- `provider_settings.PVForecastAkkudoktorLocal.temperature_coefficient`: Module power coefficient in %/degC.
- `provider_settings.PVForecastAkkudoktorLocal.apply_iam`: Apply the ASHRAE incidence-angle modifier.
- `provider_settings.PVForecastAkkudoktorLocal.shift_to_interval_start`: Relabel Open-Meteo interval-end stamps.
- `provider_settings.PVForecastAkkudoktorLocal.calibration_*`: Self-calibration against measured PV production.
---
@@ -752,6 +764,128 @@ The prediction keys for the PV forecast data are:
- `pvforecast_ac_power`: Total AC power (W).
- `pvforecast_dc_power`: Total DC power (W).
### PVForecastAkkudoktorLocal Provider
The `PVForecastAkkudoktorLocal` provider does not call a PV forecast service at all. It fetches raw
irradiance and weather from [Open-Meteo](https://open-meteo.com) and runs the whole modelling
chain locally with `pvlib`:
solar position -> horizon shading -> transposition to the module plane ->
incidence-angle modifier -> cell temperature -> PVWatts DC -> inverter AC
Three properties make it the right default for long-horizon optimization:
- **Horizon.** Up to 16 forecast days at 15-minute resolution from a single request. Services
that wrap Open-Meteo cut the horizon much shorter, which starves
`optimization.tail_horizon_hours`.
- **Call budget.** One request per hour against a ~10k/day non-commercial budget, instead of
competing for someone else's upstream quota.
- **Honest parameters.** `albedo`, inverter efficiency and the module temperature coefficient are
real configuration rather than constants baked into a service URL.
No API key is required. The location comes from `general.latitude`/`longitude` and the geometry
from the configured `planes`, including `userhorizon`, `trackingtype`, `mountingplace` and `loss`.
```python
{
"pvforecast": {
"provider": "PVForecastAkkudoktorLocal",
"provider_settings": {
"PVForecastAkkudoktorLocal": {
"resolution_minutes": 15,
"weather_models": ["icon_seamless", "ecmwf_ifs025", "gfs_seamless"],
"calibration_enabled": true
}
}
}
}
```
#### Improving accuracy
**Model ensemble.** Listing several models in `weather_models` averages them per variable. This
is the cheapest reliable way to cut irradiance forecast error and costs no extra API calls,
because Open-Meteo returns all members in the same response. `icon_seamless` (DWD, strong over
Central Europe), `ecmwf_ifs025` and `gfs_seamless` are a reasonable trio.
**Self-calibration.** With `calibration_enabled` the provider compares its own model against
measured PV production over the past `calibration_days` and fits a correction:
- a **global scale factor**, which absorbs a wrong `peakpower`, soiling, degradation and any
systematic offset in the loss assumption;
- **per-solar-azimuth factors**, which absorb near-field shading that a coarse `userhorizon`
cannot express - a chimney, a tree, a neighbouring roof.
Each bin is weighted by its modelled energy and shrunk toward the global factor by
`calibration_prior_kwh`, so a thinly sampled bin cannot swing the forecast on its own, and every
factor is clamped to `[calibration_min_factor, calibration_max_factor]` so a broken meter cannot
either. The fitted factors and the resulting change in mean absolute error are logged at INFO
level on every update.
Calibration requires `measurement.pv_production_emr_keys` to be configured and fed with
cumulative PV production meter readings in kWh:
```python
{
"measurement": {
"pv_production_emr_keys": ["pv1_emr"]
}
}
```
Note what this does and does not correct. The comparison runs on past intervals, where the
Open-Meteo rows are analysed rather than forecast weather, so it isolates the error of the *PV
model* from the error of the *weather forecast*. That is deliberate: only the former is
systematic enough to correct. A cloudy day that the weather model got wrong stays wrong.
**Choosing the window.** `calibration_days` trades responsiveness against stability. A long
window averages more weather and gives a steadier factor, but it also reaches back into the
plant's own history - and calibration cannot tell a modelling error from a *plant* error. A
window that spans a string outage, an inverter derating or a period of heavy soiling will fit
that fault as if it were a permanent property of the installation, and the forecast stays
depressed long after the plant recovered. Before trusting a factor, compare modelled against
measured energy *per day*: a run of days at a markedly different ratio is a plant event, and
the window should start after it.
**Bins need data.** The per-azimuth factors are only worth fitting when the window holds enough
daylight hours to populate the bins - roughly a few hundred, so several weeks at the default
15 degrees. With a short window, set `calibration_azimuth_bin_degrees` to 0 to fit the global
factor alone. One well-determined number beats twenty-four noisy ones.
**Geometry is out of scope.** Calibration is a scale factor on the model's output; it never
touches `userhorizon`, `surface_tilt`, `surface_azimuth` or `peakpower`. That makes it the right
tool for *multiplicative* errors and the wrong one for geometric errors. Horizon shading in
particular gates the beam component as a hard function of both solar azimuth *and* elevation, so
a per-azimuth scale factor cannot move the edge of the shadow to where it belongs, and what it
learns in one season is wrong in the next, when the sun crosses the same azimuth at a different
height. A wrong horizon should be corrected in `userhorizon`, not calibrated away.
`scripts/pvforecast_backtest.py` scores configuration variants against the stored meter readings
without waiting for new forecasts to come true, which is the quickest way to test a geometry
change:
```bash
python scripts/pvforecast_backtest.py --days 30 --tilt 88 --azimuth 175
```
#### Conventions
Two timing conventions are handled explicitly and are worth knowing when comparing against other
providers:
- Open-Meteo radiation values are the mean over the **preceding** interval, so the representative
sun position for a value stamped `t` is `t - interval/2`.
- EOS records label an interval by its **start**, so a value stamped `t` by Open-Meteo is stored
at `t - interval`. Set `shift_to_interval_start` to false to keep the raw stamps.
Note also that Open-Meteo's `direct_radiation` is beam irradiance on the *horizontal* plane; the
DNI this chain needs is `direct_normal_irradiance`.
The prediction keys for the PV forecast data are:
- `pvforecast_ac_power`: Total AC power (W).
- `pvforecast_dc_power`: Total DC power (W).
### PVForecastForecastSolar Provider
The `PVForecastForecastSolar` provider retrieves PV power forecasts from the free