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
+7 -4
View File
@@ -27,6 +27,7 @@ from akkudoktoreos.prediction.prediction import (
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import PVForecastAkkudoktorLocal
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
@@ -68,6 +69,7 @@ def forecast_providers():
PVForecastForecastSolar(),
PVForecastSolcast(),
PVForecastImport(),
PVForecastAkkudoktorLocal(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherOpenMeteo(),
@@ -126,10 +128,11 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[19], PVForecastForecastSolar)
assert isinstance(prediction.providers[20], PVForecastSolcast)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], WeatherBrightSky)
assert isinstance(prediction.providers[23], WeatherClearOutside)
assert isinstance(prediction.providers[24], WeatherOpenMeteo)
assert isinstance(prediction.providers[25], WeatherImport)
assert isinstance(prediction.providers[22], PVForecastAkkudoktorLocal)
assert isinstance(prediction.providers[23], WeatherBrightSky)
assert isinstance(prediction.providers[24], WeatherClearOutside)
assert isinstance(prediction.providers[25], WeatherOpenMeteo)
assert isinstance(prediction.providers[26], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
+291
View File
@@ -0,0 +1,291 @@
"""Tests for the native (pvlib) PV forecast provider."""
from unittest.mock import patch
import numpy as np
import pandas as pd
import pendulum
import pvlib
import pytest
from akkudoktoreos.core.coreabc import get_measurement
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import (
PVForecastAkkudoktorLocal,
PVForecastAkkudoktorLocalCommonSettings,
)
LATITUDE = 52.52
LONGITUDE = 13.405
# A window that starts well before `START` so the calibration fit has past data.
WINDOW_START = pendulum.datetime(2025, 6, 1, 0, 0, tz="UTC")
WINDOW_END = pendulum.datetime(2025, 6, 20, 0, 0, tz="UTC")
START = pendulum.datetime(2025, 6, 15, 0, 0, tz="UTC")
def synthetic_openmeteo(resolution_minutes: int = 15, models: list[str] | None = None) -> dict:
"""Build an Open-Meteo-shaped response from a pvlib clear-sky series.
Open-Meteo stamps an interval mean with the interval END, and that is what the
provider expects, so the values are generated at those stamps directly.
"""
freq = f"{resolution_minutes}min"
index = pd.date_range(
start=WINDOW_START.format("YYYY-MM-DD HH:mm"),
end=WINDOW_END.format("YYYY-MM-DD HH:mm"),
freq=freq,
tz="UTC",
)
location = pvlib.location.Location(LATITUDE, LONGITUDE, tz="UTC", altitude=37.0)
clearsky = location.get_clearsky(index, model="ineichen")
block = "minutely_15" if resolution_minutes == 15 else "hourly"
values = {
"shortwave_radiation": clearsky["ghi"].round(1).tolist(),
"diffuse_radiation": clearsky["dhi"].round(1).tolist(),
"direct_normal_irradiance": clearsky["dni"].round(1).tolist(),
"temperature_2m": [20.0] * len(index),
"relative_humidity_2m": [50.0] * len(index),
"wind_speed_10m": [2.0] * len(index),
}
data: dict = {"elevation": 37.0}
payload = {"time": [t.strftime("%Y-%m-%dT%H:%M") for t in index]}
if models:
# Multi-model requests come back with one suffixed series per member.
for name in models:
for key, series in values.items():
payload[f"{key}_{name}"] = series
else:
payload.update(values)
data[block] = payload
return data
@pytest.fixture
def pvforecast_instance(config_eos):
config_eos.merge_settings_from_dict(
{
"general": {"latitude": LATITUDE, "longitude": LONGITUDE, "timezone": "UTC"},
"prediction": {"hours": 96, "historic_hours": 48},
"pvforecast": {
"provider": "PVForecastAkkudoktorLocal",
"planes": [
{
"surface_tilt": 30.0,
"surface_azimuth": 180.0,
"peakpower": 10.0,
"inverter_paco": 10000,
"loss": 14.0,
}
],
"provider_settings": {"PVForecastAkkudoktorLocal": {"resolution_minutes": 15}},
},
}
)
return PVForecastAkkudoktorLocal(config=config_eos.load, start_datetime=START)
def test_provider_id(pvforecast_instance):
assert PVForecastAkkudoktorLocal.provider_id() == "PVForecastAkkudoktorLocal"
assert pvforecast_instance.enabled() is True
@pytest.mark.parametrize("value", [0, 5, 30, 61])
def test_resolution_must_be_15_or_60(value):
with pytest.raises(ValueError, match="resolution_minutes must be 15 or 60"):
PVForecastAkkudoktorLocalCommonSettings(resolution_minutes=value)
def test_invalid_transposition_model():
with pytest.raises(ValueError, match="Invalid transposition_model"):
PVForecastAkkudoktorLocalCommonSettings(transposition_model="nonsense")
def test_forecast_frame_is_quarter_hourly_and_plausible(pvforecast_instance):
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo())
assert not frame.empty
deltas = frame.index.to_series().diff().dropna().unique()
assert list(deltas) == [pd.Timedelta(minutes=15)]
# A 10 kWp south-facing roof under clear June skies: below the inverter cap,
# but a substantial fraction of it.
peak = frame["ac_power"].max()
assert 5000.0 < peak <= 10000.0
assert (frame["ac_power"] >= 0.0).all()
assert (frame["ac_power"] <= frame["dc_power"] + 1e-6).all()
# Nights are dark.
midnight = frame.between_time("00:00", "01:00")["ac_power"]
assert midnight.max() == pytest.approx(0.0)
def test_records_are_shifted_to_interval_start(pvforecast_instance):
"""Open-Meteo labels an interval by its end; EOS labels it by its start."""
shifted = pvforecast_instance._forecast_frame(synthetic_openmeteo())
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
PVForecastAkkudoktorLocalCommonSettings(resolution_minutes=15, shift_to_interval_start=False)
)
raw = pvforecast_instance._forecast_frame(synthetic_openmeteo())
assert raw.index[0] - shifted.index[0] == pd.Timedelta(minutes=15)
assert raw["ac_power"].to_numpy() == pytest.approx(shifted["ac_power"].to_numpy())
def test_ensemble_members_are_averaged(pvforecast_instance):
"""Several models in one request must be averaged, not dropped or duplicated."""
single = pvforecast_instance._forecast_frame(synthetic_openmeteo())
ensemble = pvforecast_instance._forecast_frame(
synthetic_openmeteo(models=["icon_seamless", "gfs_seamless", "ecmwf_ifs025"])
)
# The synthetic members are identical, so the mean must reproduce the single run.
assert ensemble["ac_power"].to_numpy() == pytest.approx(single["ac_power"].to_numpy())
def test_horizon_elevation_wraps_around_north():
horizon = PVForecastAkkudoktorLocal._horizon_elevation(
[0.0, 10.0, 20.0, 30.0], np.array([0.0, 90.0, 180.0, 270.0, 359.999])
)
assert horizon[:4] == pytest.approx([0.0, 10.0, 20.0, 30.0])
# Wrapping back to due north interpolates from 30 deg towards 0 deg.
assert horizon[4] == pytest.approx(0.0, abs=0.01)
def test_horizon_shading_reduces_yield(pvforecast_instance):
baseline = pvforecast_instance._forecast_frame(synthetic_openmeteo())
# A 40 deg wall all around blocks the beam for most of the day.
pvforecast_instance.config.pvforecast.planes[0].userhorizon = [40.0] * 12
shaded = pvforecast_instance._forecast_frame(synthetic_openmeteo())
assert shaded["ac_power"].sum() < baseline["ac_power"].sum() * 0.9
assert shaded["ac_power"].min() >= 0.0
# The low morning sun (below 40 deg elevation until ~07:00 UTC in June) is behind
# the wall, so its beam is gone entirely and only diffuse is left.
assert (
shaded["ac_power"].between_time("05:00", "07:00").sum()
< baseline["ac_power"].between_time("05:00", "07:00").sum() * 0.5
)
def test_update_data_writes_records(pvforecast_instance):
with patch.object(PVForecastAkkudoktorLocal, "_request_forecast", return_value=synthetic_openmeteo()):
pvforecast_instance._update_data(force_update=True)
assert len(pvforecast_instance.records) > 0
record = pvforecast_instance.records[0]
assert record.pvforecast_ac_power is not None
assert record.pvforecast_dc_power is not None
def _feed_measurements(
instance: PVForecastAkkudoktorLocal, frame: pd.DataFrame, bias: float, key: str
) -> None:
"""Write cumulative PV meter readings that are `bias` times the modelled power.
Each test passes its own `key`. `Measurement` is database-backed, so clearing the
in-memory record list would not remove readings another test already stored.
"""
instance.config.measurement.pv_production_emr_keys = [key]
measurement = get_measurement()
hourly = frame["ac_power"].resample("1h").mean()
hourly = hourly.loc[hourly.index < START]
cumulative = 0.0
for timestamp, power_w in hourly.items():
measurement.update_value(
pendulum.instance(timestamp.to_pydatetime()), key, round(cumulative, 6)
)
cumulative += float(power_w) * bias / 1000.0
# Closing reading so the last interval has a difference to work with.
measurement.update_value(
pendulum.instance(hourly.index[-1].to_pydatetime()).add(hours=1),
key,
round(cumulative, 6),
)
def test_calibration_is_off_by_default(pvforecast_instance):
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
assert pvforecast_instance._fit_calibration(frame) is None
def test_calibration_skips_without_measurement_keys(pvforecast_instance):
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
PVForecastAkkudoktorLocalCommonSettings(calibration_enabled=True)
)
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
assert pvforecast_instance._fit_calibration(frame) is None
def test_calibration_recovers_a_systematic_bias(pvforecast_instance):
"""A plant that consistently delivers 80% of the model must be corrected to 0.8."""
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
PVForecastAkkudoktorLocalCommonSettings(
calibration_enabled=True,
calibration_days=14,
calibration_azimuth_bin_degrees=0,
)
)
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
_feed_measurements(pvforecast_instance, frame, bias=0.8, key="pv_bias_emr")
calibration = pvforecast_instance._fit_calibration(frame)
assert calibration is not None
global_factor, factors = calibration
assert global_factor == pytest.approx(0.8, abs=0.03)
assert factors == pytest.approx([global_factor])
corrected = pvforecast_instance._apply_calibration(frame, factors, 10000.0)
assert corrected["ac_power"].sum() == pytest.approx(frame["ac_power"].sum() * global_factor)
def test_calibration_factor_is_clamped(pvforecast_instance):
"""A wildly wrong meter must not be allowed to swing the forecast."""
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
PVForecastAkkudoktorLocalCommonSettings(
calibration_enabled=True,
calibration_days=14,
calibration_azimuth_bin_degrees=0,
calibration_min_factor=0.9,
calibration_max_factor=1.1,
)
)
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
_feed_measurements(pvforecast_instance, frame, bias=0.2, key="pv_clamp_emr")
calibration = pvforecast_instance._fit_calibration(frame)
assert calibration is not None
global_factor, _ = calibration
assert global_factor == pytest.approx(0.9)
def test_calibration_respects_the_inverter_cap(pvforecast_instance):
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
corrected = PVForecastAkkudoktorLocal._apply_calibration(frame, np.array([1.5]), 10000.0)
assert corrected["ac_power"].max() <= 10000.0 + 1e-6
def test_forecast_frame_applies_the_calibration(pvforecast_instance):
"""The correction must reach every caller of the chain, not just `_update_data`."""
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
PVForecastAkkudoktorLocalCommonSettings(
calibration_enabled=True,
calibration_days=14,
calibration_azimuth_bin_degrees=0,
)
)
raw = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
_feed_measurements(pvforecast_instance, raw, bias=0.8, key="pv_chain_emr")
calibrated = pvforecast_instance._forecast_frame(synthetic_openmeteo())
ratio = calibrated["ac_power"].sum() / raw["ac_power"].sum()
assert ratio == pytest.approx(0.8, abs=0.03)
+10 -6
View File
@@ -131,7 +131,7 @@ def test_request_forecast(mock_get, provider, sample_openmeteo_1_json):
assert "time" in openmeteo_data["hourly"]
assert "temperature_2m" in openmeteo_data["hourly"]
assert "shortwave_radiation" in openmeteo_data["hourly"] # GHI
assert "direct_radiation" in openmeteo_data["hourly"] # DNI
assert "direct_normal_irradiance" in openmeteo_data["hourly"] # DNI
assert "diffuse_radiation" in openmeteo_data["hourly"] # DHI
@@ -161,7 +161,7 @@ def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
# Get the first record and check for irradiance values
value_datetime = to_datetime("2026-03-04 09:00:00+01:00", in_timezone="Europe/Berlin")
assert provider.key_to_value("weather_ghi", target_datetime=start_datetime) == 21.8
assert provider.key_to_value("weather_dni", target_datetime=start_datetime) == 1.2
assert provider.key_to_value("weather_dni", target_datetime=start_datetime) == 17.9
assert provider.key_to_value("weather_dhi", target_datetime=start_datetime) == 20.5
@@ -176,18 +176,22 @@ def test_openmeteo_radiation_mapping(provider):
from akkudoktoreos.prediction.weatheropenmeteo import WeatherDataOpenMeteoMapping
radiation_keys = [item[0] for item in WeatherDataOpenMeteoMapping
if item[0] in ['shortwave_radiation', 'direct_radiation', 'diffuse_radiation']]
if item[0] in ['shortwave_radiation', 'direct_normal_irradiance',
'diffuse_radiation']]
assert 'shortwave_radiation' in radiation_keys
assert 'direct_radiation' in radiation_keys
assert 'direct_normal_irradiance' in radiation_keys
assert 'diffuse_radiation' in radiation_keys
# Verify they map to correct descriptions
# Verify they map to correct descriptions. Open-Meteo's `direct_radiation` is beam
# irradiance on the HORIZONTAL plane, so it must not be mapped to DNI.
for key, desc, _ in WeatherDataOpenMeteoMapping:
if key == 'shortwave_radiation':
assert desc == "Global Horizontal Irradiance (W/m2)"
elif key == 'direct_radiation':
elif key == 'direct_normal_irradiance':
assert desc == "Direct Normal Irradiance (W/m2)"
elif key == 'direct_radiation':
assert desc is None
elif key == 'diffuse_radiation':
assert desc == "Diffuse Horizontal Irradiance (W/m2)"
+84 -9
View File
@@ -8,7 +8,7 @@
"elevation": 291.0,
"hourly_units": {
"time": "iso8601",
"temperature_2m": "\u00b0C",
"temperature_2m": "°C",
"relative_humidity_2m": "%",
"precipitation": "mm",
"rain": "mm",
@@ -19,16 +19,17 @@
"pressure_msl": "hPa",
"surface_pressure": "hPa",
"wind_speed_10m": "km/h",
"wind_direction_10m": "\u00b0",
"wind_direction_10m": "°",
"wind_gusts_10m": "km/h",
"shortwave_radiation": "W/m\u00b2",
"direct_radiation": "W/m\u00b2",
"diffuse_radiation": "W/m\u00b2",
"dew_point_2m": "\u00b0C",
"apparent_temperature": "\u00b0C",
"shortwave_radiation": "W/m²",
"direct_radiation": "W/m²",
"diffuse_radiation": "W/m²",
"dew_point_2m": "°C",
"apparent_temperature": "°C",
"precipitation_probability": "%",
"visibility": "m",
"sunshine_duration": "s"
"sunshine_duration": "s",
"direct_normal_irradiance": "W/m²"
},
"hourly": {
"time": [
@@ -1658,6 +1659,80 @@
0.0,
0.0,
0.0
],
"direct_normal_irradiance": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
17.9,
62.9,
174.7,
669.0,
735.6,
765.5,
576.9,
433.0,
631.8,
465.1,
222.8,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
11.0,
44.4,
76.8,
394.1,
736.5,
774.1,
772.9,
738.2,
656.6,
502.2,
225.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
6.4,
54.1,
205.1,
490.6,
735.7,
767.7,
771.1,
731.0,
648.3,
498.4,
224.8,
0.0,
0.0,
0.0,
0.0,
0.0
]
}
}
}
+37 -37
View File
@@ -231,7 +231,7 @@
"weather_pressure": 10.26,
"weather_ozone": null,
"weather_ghi": 0.0,
"weather_dni": 0.0,
"weather_dni": 17.9,
"weather_dhi": 0.0
},
{
@@ -257,7 +257,7 @@
"weather_pressure": 10.265,
"weather_ozone": null,
"weather_ghi": 21.8,
"weather_dni": 1.2,
"weather_dni": 62.9,
"weather_dhi": 20.5
},
{
@@ -283,7 +283,7 @@
"weather_pressure": 10.269000000000002,
"weather_ozone": null,
"weather_ghi": 101.2,
"weather_dni": 13.8,
"weather_dni": 174.7,
"weather_dhi": 87.5
},
{
@@ -309,7 +309,7 @@
"weather_pressure": 10.262,
"weather_ozone": null,
"weather_ghi": 207.0,
"weather_dni": 61.5,
"weather_dni": 669.0,
"weather_dhi": 145.5
},
{
@@ -335,7 +335,7 @@
"weather_pressure": 10.257000000000001,
"weather_ozone": null,
"weather_ghi": 407.5,
"weather_dni": 304.2,
"weather_dni": 735.6,
"weather_dhi": 103.2
},
{
@@ -361,7 +361,7 @@
"weather_pressure": 10.252,
"weather_ozone": null,
"weather_ghi": 487.2,
"weather_dni": 382.5,
"weather_dni": 765.5,
"weather_dhi": 104.8
},
{
@@ -387,7 +387,7 @@
"weather_pressure": 10.247,
"weather_ozone": null,
"weather_ghi": 519.5,
"weather_dni": 416.0,
"weather_dni": 576.9,
"weather_dhi": 103.5
},
{
@@ -413,7 +413,7 @@
"weather_pressure": 10.235,
"weather_ozone": null,
"weather_ghi": 450.8,
"weather_dni": 302.0,
"weather_dni": 433.0,
"weather_dhi": 148.8
},
{
@@ -439,7 +439,7 @@
"weather_pressure": 10.23,
"weather_ozone": null,
"weather_ghi": 367.2,
"weather_dni": 199.8,
"weather_dni": 631.8,
"weather_dhi": 167.5
},
{
@@ -465,7 +465,7 @@
"weather_pressure": 10.228,
"weather_ozone": null,
"weather_ghi": 315.0,
"weather_dni": 228.5,
"weather_dni": 465.1,
"weather_dhi": 86.5
},
{
@@ -491,7 +491,7 @@
"weather_pressure": 10.227,
"weather_ozone": null,
"weather_ghi": 174.2,
"weather_dni": 107.5,
"weather_dni": 222.8,
"weather_dhi": 66.8
},
{
@@ -517,7 +517,7 @@
"weather_pressure": 10.23,
"weather_ozone": null,
"weather_ghi": 42.5,
"weather_dni": 17.8,
"weather_dni": 0.0,
"weather_dhi": 24.8
},
{
@@ -855,7 +855,7 @@
"weather_pressure": 10.278,
"weather_ozone": null,
"weather_ghi": 0.0,
"weather_dni": 0.0,
"weather_dni": 11.0,
"weather_dhi": 0.0
},
{
@@ -881,7 +881,7 @@
"weather_pressure": 10.287,
"weather_ozone": null,
"weather_ghi": 23.5,
"weather_dni": 0.8,
"weather_dni": 44.4,
"weather_dhi": 22.8
},
{
@@ -907,7 +907,7 @@
"weather_pressure": 10.288,
"weather_ozone": null,
"weather_ghi": 90.0,
"weather_dni": 10.0,
"weather_dni": 76.8,
"weather_dhi": 80.0
},
{
@@ -933,7 +933,7 @@
"weather_pressure": 10.286,
"weather_ozone": null,
"weather_ghi": 177.0,
"weather_dni": 27.5,
"weather_dni": 394.1,
"weather_dhi": 149.5
},
{
@@ -959,7 +959,7 @@
"weather_pressure": 10.279000000000002,
"weather_ozone": null,
"weather_ghi": 352.0,
"weather_dni": 181.5,
"weather_dni": 736.5,
"weather_dhi": 170.5
},
{
@@ -985,7 +985,7 @@
"weather_pressure": 10.272,
"weather_ozone": null,
"weather_ghi": 496.0,
"weather_dni": 387.2,
"weather_dni": 774.1,
"weather_dhi": 108.8
},
{
@@ -1011,7 +1011,7 @@
"weather_pressure": 10.267999999999999,
"weather_ozone": null,
"weather_ghi": 530.5,
"weather_dni": 425.0,
"weather_dni": 772.9,
"weather_dhi": 105.5
},
{
@@ -1037,7 +1037,7 @@
"weather_pressure": 10.265,
"weather_ozone": null,
"weather_ghi": 509.0,
"weather_dni": 408.8,
"weather_dni": 738.2,
"weather_dhi": 100.2
},
{
@@ -1063,7 +1063,7 @@
"weather_pressure": 10.261,
"weather_ozone": null,
"weather_ghi": 437.2,
"weather_dni": 344.5,
"weather_dni": 656.6,
"weather_dhi": 92.8
},
{
@@ -1089,7 +1089,7 @@
"weather_pressure": 10.257000000000001,
"weather_ozone": null,
"weather_ghi": 321.2,
"weather_dni": 240.8,
"weather_dni": 502.2,
"weather_dhi": 80.5
},
{
@@ -1115,7 +1115,7 @@
"weather_pressure": 10.257000000000001,
"weather_ozone": null,
"weather_ghi": 179.0,
"weather_dni": 118.5,
"weather_dni": 225.0,
"weather_dhi": 60.5
},
{
@@ -1141,7 +1141,7 @@
"weather_pressure": 10.26,
"weather_ozone": null,
"weather_ghi": 44.2,
"weather_dni": 19.0,
"weather_dni": 0.0,
"weather_dhi": 25.2
},
{
@@ -1479,7 +1479,7 @@
"weather_pressure": 10.290000000000001,
"weather_ozone": null,
"weather_ghi": 0.0,
"weather_dni": 0.0,
"weather_dni": 6.4,
"weather_dhi": 0.0
},
{
@@ -1505,7 +1505,7 @@
"weather_pressure": 10.293,
"weather_ozone": null,
"weather_ghi": 24.2,
"weather_dni": 0.5,
"weather_dni": 54.1,
"weather_dhi": 23.8
},
{
@@ -1531,7 +1531,7 @@
"weather_pressure": 10.295,
"weather_ozone": null,
"weather_ghi": 98.0,
"weather_dni": 12.5,
"weather_dni": 205.1,
"weather_dhi": 85.5
},
{
@@ -1557,7 +1557,7 @@
"weather_pressure": 10.294,
"weather_ozone": null,
"weather_ghi": 218.8,
"weather_dni": 74.6,
"weather_dni": 490.6,
"weather_dhi": 144.2
},
{
@@ -1583,7 +1583,7 @@
"weather_pressure": 10.288,
"weather_ozone": null,
"weather_ghi": 380.7,
"weather_dni": 228.8,
"weather_dni": 735.7,
"weather_dhi": 151.8
},
{
@@ -1609,7 +1609,7 @@
"weather_pressure": 10.278,
"weather_ozone": null,
"weather_ghi": 496.1,
"weather_dni": 391.0,
"weather_dni": 767.7,
"weather_dhi": 105.1
},
{
@@ -1635,7 +1635,7 @@
"weather_pressure": 10.267999999999999,
"weather_ozone": null,
"weather_ghi": 528.0,
"weather_dni": 425.8,
"weather_dni": 771.1,
"weather_dhi": 102.2
},
{
@@ -1661,7 +1661,7 @@
"weather_pressure": 10.263,
"weather_ozone": null,
"weather_ghi": 508.0,
"weather_dni": 412.0,
"weather_dni": 731.0,
"weather_dhi": 96.0
},
{
@@ -1687,7 +1687,7 @@
"weather_pressure": 10.261,
"weather_ozone": null,
"weather_ghi": 435.0,
"weather_dni": 345.0,
"weather_dni": 648.3,
"weather_dhi": 90.0
},
{
@@ -1713,7 +1713,7 @@
"weather_pressure": 10.258,
"weather_ozone": null,
"weather_ghi": 320.0,
"weather_dni": 241.0,
"weather_dni": 498.4,
"weather_dhi": 79.0
},
{
@@ -1739,7 +1739,7 @@
"weather_pressure": 10.255,
"weather_ozone": null,
"weather_ghi": 180.0,
"weather_dni": 120.0,
"weather_dni": 224.8,
"weather_dhi": 60.0
},
{
@@ -1765,7 +1765,7 @@
"weather_pressure": 10.255,
"weather_ozone": null,
"weather_ghi": 45.0,
"weather_dni": 20.0,
"weather_dni": 0.0,
"weather_dhi": 25.0
},
{
@@ -1956,4 +1956,4 @@
"keep_datetime": "2026-02-28 00:00:00+01:00",
"total_hours": 48,
"keep_hours": 48
}
}