mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user