feat: add openmeteo weather provider (#939)

Add OpenMeteo to the selectable weather prediction providers.

Also add tests and documentation.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-03-13 12:23:21 +01:00
committed by GitHub
parent a5d8fd35e3
commit 8a9aec6d57
11 changed files with 4316 additions and 7 deletions

View File

@@ -6,7 +6,7 @@
# the root directory (no add-on folder as usual).
name: "Akkudoktor-EOS"
version: "0.2.0.dev2603110720349451"
version: "0.2.0.dev2603130753300674"
slug: "eos"
description: "Akkudoktor-EOS add-on"
url: "https://github.com/Akkudoktor-EOS/EOS"

View File

@@ -45,6 +45,7 @@
"providers": [
"BrightSky",
"ClearOutside",
"OpenMeteo",
"WeatherImport"
]
}

View File

@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.2.0.dev2603110720349451`
**Version**: `v0.2.0.dev2603130753300674`
<!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.

View File

@@ -571,6 +571,7 @@ Configuration options:
- `BrightSky`: Retrieves from [BrightSky](https://api.brightsky.dev).
- `ClearOutside`: Retrieves from [ClearOutside](https://clearoutside.com/forecast).
- `OpenMeteo`: Retrieves from [OpenMeteo](https://api.open-meteo.com/v1/forecast).
- `LoadImport`: Imports from a file or JSON string.
- `provider_settings.import_file_path`: Path to the file to import weatherforecast data from.
@@ -578,7 +579,7 @@ Configuration options:
### BrightSky Provider
The `BrightSky` provider retrieves the PV power forecast data directly from
The `BrightSky` provider retrieves the weather forecast data directly from
[**BrightSky**](https://api.brightsky.dev).
The provider provides forecast data for the following prediction keys:
@@ -597,7 +598,7 @@ The provider provides forecast data for the following prediction keys:
### ClearOutside Provider
The `ClearOutside` provider retrieves the PV power forecast data directly from
The `ClearOutside` provider retrieves the weather forecast data directly from
[**ClearOutside**](https://clearoutside.com/forecast).
The provider provides forecast data for the following prediction keys:
@@ -625,6 +626,31 @@ The provider provides forecast data for the following prediction keys:
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
### OpenMeteo Provider
The `OpenMeteo` provider retrieves the weather forecast data directly from
[**OpenMeteo**](https://api.open-meteo.com/v1/forecast).
The provider provides forecast data for the following prediction keys:
- `weather_dew_point`: Dew Point (°C)
- `weather_dhi`: Diffuse Horizontal Irradiance (W/m2)
- `weather_dni`: Direct Normal Irradiance (W/m2)
- `weather_feels_like`: Feels Like (°C)
- `weather_ghi`: Global Horizontal Irradiance (W/m2)
- `weather_high_clouds`: High Clouds (% Sky Obscured)
- `weather_low_clouds`: Low Clouds (% Sky Obscured)
- `weather_medium_clouds`: Medium Clouds (% Sky Obscured)
- `weather_precip_amt`: Precipitation Amount (mm)
- `weather_precip_prob`: Precipitation Probability (%)
- `weather_pressure`: Pressure (mb)
- `weather_relative_humidity`: Relative Humidity (%)
- `weather_temp_air`: Temperature (°C)
- `weather_total_clouds`: Total Clouds (% Sky Obscured)
- `weather_visibility`: Visibility (m)
- `weather_wind_direction`: Wind Direction (°)
- `weather_wind_speed`: Wind Speed (kmph)
### WeatherImport Provider
The `WeatherImport` provider is designed to import weather forecast data from a file or a JSON

View File

@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.2.0.dev2603110720349451"
"version": "v0.2.0.dev2603130753300674"
},
"paths": {
"/v1/admin/cache/clear": {

View File

@@ -50,6 +50,7 @@ from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.prediction.weatheropenmeteo import WeatherOpenMeteo
class PredictionCommonSettings(SettingsBaseModel):
@@ -86,6 +87,7 @@ pvforecast_vrm = PVForecastVrm()
pvforecast_import = PVForecastImport()
weather_brightsky = WeatherBrightSky()
weather_clearoutside = WeatherClearOutside()
weather_openmeteo = WeatherOpenMeteo()
weather_import = WeatherImport()
@@ -106,10 +108,14 @@ def prediction_providers() -> list[
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
]
]:
"""Return list of prediction providers."""
"""Return list of prediction providers.
Factory for prediction container.
"""
global \
elecprice_akkudoktor, \
elecprice_energy_charts, \
@@ -126,6 +132,7 @@ def prediction_providers() -> list[
pvforecast_import, \
weather_brightsky, \
weather_clearoutside, \
weather_openmeteo, \
weather_import
# Care for provider sequence as providers may rely on others to be updated before.
@@ -145,6 +152,7 @@ def prediction_providers() -> list[
pvforecast_import,
weather_brightsky,
weather_clearoutside,
weather_openmeteo,
weather_import,
]
@@ -169,6 +177,7 @@ class Prediction(PredictionContainer):
PVForecastImport,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
]
] = Field(

View File

@@ -0,0 +1,357 @@
"""Retrieves and processes weather forecast data from Open-Meteo.
This module provides classes and mappings to manage weather data obtained from the
Open-Meteo API, including support for various weather attributes such as temperature,
humidity, cloud cover, and solar irradiance. The data is mapped to the `WeatherDataRecord`
format, enabling consistent access to forecasted and historical weather attributes.
"""
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
import pandas as pd
import pvlib
import requests
from loguru import logger
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.weatherabc import WeatherDataRecord, WeatherProvider
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
WeatherDataOpenMeteoMapping: List[Tuple[str, Optional[str], Optional[Union[str, float]]]] = [
# openmeteo_key, description, corr_factor
("time", "DateTime", "to datetime in timezone"),
("temperature_2m", "Temperature (°C)", 1),
("relative_humidity_2m", "Relative Humidity (%)", 1),
("precipitation", "Precipitation Amount (mm)", 1),
("rain", None, None),
("showers", None, None),
("snowfall", None, None),
("weather_code", None, None),
("cloud_cover", "Total Clouds (% Sky Obscured)", 1),
("cloud_cover_low", "Low Clouds (% Sky Obscured)", 1),
("cloud_cover_mid", "Medium Clouds (% Sky Obscured)", 1),
("cloud_cover_high", "High Clouds (% Sky Obscured)", 1),
("pressure_msl", "Pressure (mb)", 0.01), # Pa to hPa
("surface_pressure", None, None),
("wind_speed_10m", "Wind Speed (kmph)", 3.6), # m/s to km/h
("wind_direction_10m", "Wind Direction (°)", 1),
("wind_gusts_10m", "Wind Gust Speed (kmph)", 3.6), # m/s to km/h
("shortwave_radiation", "Global Horizontal Irradiance (W/m2)", 1),
("direct_radiation", "Direct Normal Irradiance (W/m2)", 1),
("diffuse_radiation", "Diffuse Horizontal Irradiance (W/m2)", 1),
("direct_normal_irradiance", None, None),
("global_tilted_irradiance", None, None),
("terrestrial_radiation", None, None),
("shortwave_radiation_instant", None, None),
("direct_radiation_instant", None, None),
("diffuse_radiation_instant", None, None),
("direct_normal_irradiance_instant", None, None),
("global_tilted_irradiance_instant", None, None),
("terrestrial_radiation_instant", None, None),
("dew_point_2m", "Dew Point (°C)", 1),
("apparent_temperature", "Feels Like (°C)", 1),
("precipitation_probability", "Precipitation Probability (%)", 1),
("visibility", "Visibility (m)", 1),
("cape", None, None),
("evapotranspiration", None, None),
("et0_fao_evapotranspiration", None, None),
("vapour_pressure_deficit", None, None),
("soil_temperature_0_to_7cm", None, None),
("soil_temperature_7_to_28cm", None, None),
("soil_temperature_28_to_100cm", None, None),
("soil_temperature_100_to_255cm", None, None),
("soil_moisture_0_to_7cm", None, None),
("soil_moisture_7_to_28cm", None, None),
("soil_moisture_28_to_100cm", None, None),
("soil_moisture_100_to_255cm", None, None),
("sunshine_duration", None, None), # seconds
]
"""Mapping of Open-Meteo weather data keys to WeatherDataRecord field descriptions.
Each tuple represents a field in the Open-Meteo data, with:
- The Open-Meteo field key,
- The corresponding `WeatherDataRecord` description, if applicable,
- A correction factor for unit or value scaling.
Fields without descriptions or correction factors are mapped to `None`.
"""
class WeatherOpenMeteo(WeatherProvider):
"""Fetch and process weather forecast data from Open-Meteo.
WeatherOpenMeteo is a singleton-based class that retrieves weather forecast data
from the Open-Meteo API and maps it to `WeatherDataRecord` fields, applying
any necessary scaling or unit corrections. It manages the forecast over a range
of hours into the future and retains historical data.
Attributes:
hours (int, optional): Number of hours in the future for the forecast.
historic_hours (int, optional): Number of past hours for retaining data.
latitude (float, optional): The latitude in degrees, validated to be between -90 and 90.
longitude (float, optional): The longitude in degrees, validated to be between -180 and 180.
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current
datetime.
end_datetime (datetime, computed): The forecast's end datetime, computed based on
`start_datetime` and `hours`.
keep_datetime (datetime, computed): The datetime to retain historical data, computed from
`start_datetime` and `historic_hours`.
Methods:
provider_id(): Returns a unique identifier for the provider.
_request_forecast(): Fetches the forecast from the Open-Meteo API.
_update_data(): Processes and updates forecast data from Open-Meteo in WeatherDataRecord format.
"""
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the Open-Meteo provider."""
return "OpenMeteo"
@cache_in_file(with_ttl="1 hour")
def _request_forecast(self) -> dict:
"""Fetch weather forecast data from Open-Meteo API.
This method sends a request to Open-Meteo's API to retrieve forecast data
for a specified date range and location. The response data is parsed and
returned as JSON for further processing.
Returns:
dict: The parsed JSON response from Open-Meteo API containing forecast data.
Raises:
ValueError: If the API response does not include expected `hourly` data.
"""
source = "https://api.open-meteo.com/v1/forecast"
# Parameters for Open-Meteo API
params = {
"latitude": self.config.general.latitude,
"longitude": self.config.general.longitude,
"hourly": [
"temperature_2m",
"relative_humidity_2m",
"precipitation",
"rain",
"showers",
"snowfall",
"weather_code",
"cloud_cover",
"cloud_cover_low",
"cloud_cover_mid",
"cloud_cover_high",
"pressure_msl",
"surface_pressure",
"wind_speed_10m",
"wind_direction_10m",
"wind_gusts_10m",
"shortwave_radiation", # GHI
"direct_radiation", # DNI
"diffuse_radiation", # DHI
"dew_point_2m",
"apparent_temperature",
"precipitation_probability",
"visibility",
"sunshine_duration",
],
"timezone": self.config.general.timezone,
}
# Calculate the number of days between start and end
start_dt = to_datetime(self.ems_start_datetime)
end_dt = to_datetime(self.end_datetime)
days_diff = (end_dt - start_dt).days + 1 # +1 for inclusive range
# Open-Meteo has a maximum of 16 days
forecast_days = min(days_diff, 16)
# Decide whether we need forecast or historical data
now = to_datetime(in_timezone=self.config.general.timezone)
if start_dt.date() >= now.date():
# Future data - use forecast_days
params["forecast_days"] = forecast_days
else:
# Historical data - use start_date and end_date
params["start_date"] = start_dt.strftime("%Y-%m-%d")
params["end_date"] = end_dt.strftime("%Y-%m-%d")
# For historical data we must specify the forecast model
params["models"] = "best_match" # or specific e.g. "dwd", "icon", etc.
logger.debug(f"Open-Meteo Request params: {params}")
response = requests.get(source, params=params, timeout=10)
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {source}: {response.status_code}")
openmeteo_data = response.json()
if "hourly" not in openmeteo_data:
error_msg = f"Open-Meteo schema change. `hourly` expected to be part of Open-Meteo data: {openmeteo_data}."
logger.error(error_msg)
raise ValueError(error_msg)
# We are working with fresh data (no cache), report update time
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
return openmeteo_data
def _description_to_series(self, description: str) -> pd.Series:
"""Retrieve a pandas Series corresponding to a weather data description.
This method fetches the key associated with the provided description
and retrieves the data series mapped to that key. If the description
does not correspond to a valid key, a `ValueError` is raised.
Args:
description (str): The description of the WeatherDataRecord to retrieve.
Returns:
pd.Series: The data series corresponding to the description.
Raises:
ValueError: If no key is found for the provided description.
"""
key = WeatherDataRecord.key_from_description(description)
if key is None:
error_msg = f"No WeatherDataRecord key for '{description}'"
logger.error(error_msg)
raise ValueError(error_msg)
series = self.key_to_series(key)
return series
def _description_from_series(self, description: str, data: pd.Series) -> None:
"""Update a weather data with a pandas Series based on its description.
This method fetches the key associated with the provided description
and updates the weather data with the provided data series. If the description
does not correspond to a valid key, a `ValueError` is raised.
Args:
description (str): The description of the weather data to update.
data (pd.Series): The pandas Series containing the data to update.
Raises:
ValueError: If no key is found for the provided description.
"""
key = WeatherDataRecord.key_from_description(description)
if key is None:
error_msg = f"No WeatherDataRecord key for '{description}'"
logger.error(error_msg)
raise ValueError(error_msg)
self.key_from_series(key, data)
def _update_data(self, force_update: Optional[bool] = False) -> None:
"""Update forecast data in the WeatherDataRecord format.
Retrieves data from Open-Meteo, maps each Open-Meteo field to the corresponding
`WeatherDataRecord` attribute using `WeatherDataOpenMeteoMapping`, and applies
any necessary scaling. Open-Meteo provides direct GHI, DNI, and DHI values which
are used directly without additional calculation. The final mapped and processed
data is inserted into the sequence as `WeatherDataRecord`.
"""
# Retrieve Open-Meteo weather data for the given coordinates
openmeteo_data = self._request_forecast(force_update=force_update) # type: ignore
# Create key mapping from the description
openmeteo_key_mapping: Dict[str, Tuple[Optional[str], Optional[Union[str, float]]]] = {}
for openmeteo_key, description, corr_factor in WeatherDataOpenMeteoMapping:
if description is None:
openmeteo_key_mapping[openmeteo_key] = (None, None)
continue
weatherdata_key = WeatherDataRecord.key_from_description(description)
if weatherdata_key is None:
# Should not occur
error_msg = f"No WeatherDataRecord key for description '{description}'"
logger.error(error_msg)
raise ValueError(error_msg)
openmeteo_key_mapping[openmeteo_key] = (weatherdata_key, corr_factor)
# Extract timestamps and values from Open-Meteo response
hourly_data = openmeteo_data["hourly"]
timestamps = hourly_data["time"]
logger.info("Using direct radiation values from Open-Meteo (GHI, DNI, DHI)")
# Process the data for each timestamp
for idx, timestamp in enumerate(timestamps):
weather_record = WeatherDataRecord()
for openmeteo_key, item in openmeteo_key_mapping.items():
key = item[0]
if key is None:
continue
# Take value from hourly data, if available
if openmeteo_key in hourly_data:
value = hourly_data[openmeteo_key][idx]
else:
value = None
corr_factor = item[1]
if value is not None:
if corr_factor == "to datetime in timezone":
value = to_datetime(value, in_timezone=self.config.general.timezone)
elif isinstance(corr_factor, (int, float)):
value = value * corr_factor
setattr(weather_record, key, value)
self.insert_by_datetime(weather_record)
# Check whether radiation values exist (for logging)
description_ghi = "Global Horizontal Irradiance (W/m2)"
ghi_series = self._description_to_series(description_ghi)
if ghi_series.isnull().all():
logger.warning("No GHI data received from Open-Meteo")
else:
logger.debug(
f"GHI data successfully loaded from Open-Meteo. Range: {ghi_series.min():.1f} - {ghi_series.max():.1f} W/m²"
)
# Add Precipitable Water (PWAT) using PVLib method
key = WeatherDataRecord.key_from_description("Temperature (°C)")
assert key # noqa: S101
temperature = self.key_to_array(
key=key,
start_datetime=self.ems_start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in temperature):
# PWAT cannot be calculated
debug_msg = f"Invalid temperature '{temperature}'"
logger.debug(debug_msg)
return
key = WeatherDataRecord.key_from_description("Relative Humidity (%)")
assert key # noqa: S101
humidity = self.key_to_array(
key=key,
start_datetime=self.ems_start_datetime,
end_datetime=self.end_datetime,
interval=to_duration("1 hour"),
)
if any(x is None or isinstance(x, float) and np.isnan(x) for x in humidity):
# PWAT cannot be calculated
debug_msg = f"Invalid humidity '{humidity}'"
logger.debug(debug_msg)
return
data = pvlib.atmosphere.gueymard94_pw(temperature, humidity)
pwat = pd.Series(
data=data,
index=pd.DatetimeIndex(
pd.date_range(
start=self.ems_start_datetime,
end=self.end_datetime,
freq="1h",
inclusive="left",
)
),
)
description = "Precipitable Water (cm)"
self._description_from_series(description, pwat)

View File

@@ -24,6 +24,7 @@ from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.prediction.weatheropenmeteo import WeatherOpenMeteo
@pytest.fixture
@@ -51,6 +52,7 @@ def forecast_providers():
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherOpenMeteo(),
WeatherImport(),
]
@@ -99,7 +101,8 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[12], PVForecastImport)
assert isinstance(prediction.providers[13], WeatherBrightSky)
assert isinstance(prediction.providers[14], WeatherClearOutside)
assert isinstance(prediction.providers[15], WeatherImport)
assert isinstance(prediction.providers[15], WeatherOpenMeteo)
assert isinstance(prediction.providers[16], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
@@ -126,6 +129,7 @@ def test_prediction_repr(prediction):
assert "PVForecastImport" in result
assert "WeatherBrightSky" in result
assert "WeatherClearOutside" in result
assert "WeatherOpenMeteo" in result
assert "WeatherImport" in result

View File

@@ -0,0 +1,290 @@
import json
from pathlib import Path
from unittest.mock import Mock, patch
import pandas as pd
import pytest
from loguru import logger
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.weatheropenmeteo import WeatherOpenMeteo
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_WEATHEROPENMETEO_1_JSON = DIR_TESTDATA.joinpath("weatherforecast_openmeteo_1.json")
FILE_TESTDATA_WEATHEROPENMETEO_2_JSON = DIR_TESTDATA.joinpath("weatherforecast_openmeteo_2.json")
@pytest.fixture
def provider(monkeypatch):
"""Fixture to create a WeatherProvider instance."""
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "OpenMeteo")
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "50.0")
monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "10.0")
return WeatherOpenMeteo()
@pytest.fixture
def sample_openmeteo_1_json():
"""Fixture that returns sample forecast data report from Open-Meteo."""
with FILE_TESTDATA_WEATHEROPENMETEO_1_JSON.open("r", encoding="utf-8", newline=None) as f_res:
input_data = json.load(f_res)
return input_data
@pytest.fixture
def sample_openmeteo_2_json():
"""Fixture that returns sample processed forecast data from Open-Meteo."""
with FILE_TESTDATA_WEATHEROPENMETEO_2_JSON.open("r", encoding="utf-8", newline=None) as f_res:
input_data = json.load(f_res)
return input_data
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(provider):
"""Test that WeatherForecast behaves as a singleton."""
another_instance = WeatherOpenMeteo()
assert provider is another_instance
def test_invalid_provider(provider, monkeypatch):
"""Test requesting an unsupported provider."""
monkeypatch.setenv("EOS_WEATHER__WEATHER_PROVIDER", "<invalid>")
provider.config.reset_settings()
assert not provider.enabled()
def test_invalid_coordinates(provider, monkeypatch):
"""Test invalid coordinates raise ValueError."""
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "1000")
monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "1000")
with pytest.raises(
ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range."
):
provider.config.reset_settings()
# ------------------------------------------------
# Irradiance calculation
# ------------------------------------------------
def test_irridiance_estimate_from_cloud_cover(provider):
"""Test cloud cover to irradiance estimation (fallback method)."""
cloud_cover_data = pd.Series(
data=[20, 50, 80], index=pd.date_range("2023-10-22", periods=3, freq="h")
)
ghi, dni, dhi = provider.estimate_irradiance_from_cloud_cover(50.0, 10.0, cloud_cover_data)
# This is just the fallback method - Open-Meteo normally provides direct values
assert len(ghi) == 3
assert len(dni) == 3
assert len(dhi) == 3
# Values should be floats (actual values depend on the algorithm)
assert all(isinstance(x, float) for x in ghi)
assert all(isinstance(x, float) for x in dni)
assert all(isinstance(x, float) for x in dhi)
# ------------------------------------------------
# Open-Meteo
# ------------------------------------------------
@patch("requests.get")
def test_request_forecast(mock_get, provider, sample_openmeteo_1_json):
"""Test requesting forecast from Open-Meteo."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = sample_openmeteo_1_json
mock_response.content = json.dumps(sample_openmeteo_1_json)
mock_get.return_value = mock_response
# Test function
openmeteo_data = provider._request_forecast()
# Verify API was called with correct parameters
mock_get.assert_called_once()
call_args = mock_get.call_args
assert call_args[0][0] == "https://api.open-meteo.com/v1/forecast"
assert "latitude" in call_args[1]["params"]
assert "longitude" in call_args[1]["params"]
assert "hourly" in call_args[1]["params"]
# Verify returned data structure
assert isinstance(openmeteo_data, dict)
assert "hourly" in openmeteo_data
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 "diffuse_radiation" in openmeteo_data["hourly"] # DHI
@patch("requests.get")
def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
"""Test fetching and processing forecast from Open-Meteo."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = sample_openmeteo_1_json
mock_response.content = json.dumps(sample_openmeteo_1_json)
mock_get.return_value = mock_response
cache_store.clear(clear_all=True)
# Call the method
ems_eos = get_ems()
start_datetime = to_datetime("2026-03-02 09:00:00+01:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_datetime)
provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected
mock_get.assert_called_once()
assert len(provider) > 0
# Verify that direct radiation values were properly mapped
# 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_dhi", target_datetime=start_datetime) == 20.5
# ------------------------------------------------
# Test specific Open-Meteo features
# ------------------------------------------------
def test_openmeteo_radiation_mapping(provider):
"""Test that radiation values are correctly mapped from Open-Meteo keys."""
# Verify mapping contains the radiation fields
from akkudoktoreos.prediction.weatheropenmeteo import WeatherDataOpenMeteoMapping
radiation_keys = [item[0] for item in WeatherDataOpenMeteoMapping
if item[0] in ['shortwave_radiation', 'direct_radiation', 'diffuse_radiation']]
assert 'shortwave_radiation' in radiation_keys
assert 'direct_radiation' in radiation_keys
assert 'diffuse_radiation' in radiation_keys
# Verify they map to correct descriptions
for key, desc, _ in WeatherDataOpenMeteoMapping:
if key == 'shortwave_radiation':
assert desc == "Global Horizontal Irradiance (W/m2)"
elif key == 'direct_radiation':
assert desc == "Direct Normal Irradiance (W/m2)"
elif key == 'diffuse_radiation':
assert desc == "Diffuse Horizontal Irradiance (W/m2)"
def test_openmeteo_unit_conversions(provider):
"""Test that unit conversions are correctly applied."""
from akkudoktoreos.prediction.weatheropenmeteo import WeatherDataOpenMeteoMapping
# Check wind speed conversion (m/s to km/h)
wind_speed_mapping = next(item for item in WeatherDataOpenMeteoMapping
if item[0] == 'wind_speed_10m')
assert wind_speed_mapping[2] == 3.6 # Conversion factor
# Check pressure conversion (Pa to hPa)
pressure_mapping = next(item for item in WeatherDataOpenMeteoMapping
if item[0] == 'pressure_msl')
assert pressure_mapping[2] == 0.01 # Conversion factor
@patch("requests.get")
def test_forecast_days_calculation(mock_get, provider, sample_openmeteo_1_json):
"""Test that forecast_days is correctly calculated."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = sample_openmeteo_1_json
mock_response.content = json.dumps(sample_openmeteo_1_json)
mock_get.return_value = mock_response
ems_eos = get_ems()
# Test with 3 days forecast
start = to_datetime(in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start)
provider._request_forecast()
# Check that forecast_days was set correctly
call_args = mock_get.call_args
params = call_args[1]["params"]
assert params["forecast_days"]
# ------------------------------------------------
# Development Open-Meteo
# ------------------------------------------------
def test_openmeteo_development_forecast_data(provider, config_eos, is_system_test):
"""Fetch data from real Open-Meteo server for development purposes."""
if not is_system_test:
return
# Us actual date for forecast (not historic data)
now = to_datetime(in_timezone="Europe/Berlin")
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = start_date + pd.Timedelta(days=3) # 3 Tage Vorhersage
ems_eos = get_ems()
ems_eos.set_start_datetime(start_date)
config_eos.general.latitude = 50.0
config_eos.general.longitude = 10.0
# Fetch raw data from Open-Meteo
try:
openmeteo_data = provider._request_forecast()
# Save raw API response
with FILE_TESTDATA_WEATHEROPENMETEO_1_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
json.dump(openmeteo_data, f_out, indent=4)
# Update and process data
provider.update_data(force_enable=True, force_update=True)
# Save processed data
with FILE_TESTDATA_WEATHEROPENMETEO_2_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
f_out.write(provider.model_dump_json(indent=4))
# Verify radiation values
if len(provider) > 0:
records = list(provider.data_records.values())
# Check fo radiation values available
has_ghi = any(hasattr(r, 'ghi') and r.ghi is not None for r in records)
has_dni = any(hasattr(r, 'dni') and r.dni is not None for r in records)
has_dhi = any(hasattr(r, 'dhi') and r.dhi is not None for r in records)
logger.info(f"Open-Meteo data verification: GHI={has_ghi}, DNI={has_dni}, DHI={has_dhi}")
# Optional: Check for positive values (at day time)
daytime_values = [getattr(r, 'ghi', 0) for r in records[:24]
if hasattr(r, 'ghi') and r.ghi is not None and r.ghi > 10]
if daytime_values:
logger.info(f"Found {len(daytime_values)} positive GHI values")
except Exception as e:
logger.error(f"Error fetching Open-Meteo data: {e}")
# Debug-Ausgabe
logger.error(f"Request would have been: https://api.open-meteo.com/v1/forecast?latitude=50.0&longitude=10.0&hourly=temperature_2m,relative_humidity_2m,shortwave_radiation&timezone=Europe/Berlin&forecast_days=3")
raise

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff