mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-15 02:56:17 +00:00
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:
@@ -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(
|
||||
|
||||
357
src/akkudoktoreos/prediction/weatheropenmeteo.py
Normal file
357
src/akkudoktoreos/prediction/weatheropenmeteo.py
Normal 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)
|
||||
Reference in New Issue
Block a user