2026-03-13 12:23:21 +01:00
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
2026-07-15 16:38:53 +02:00
@pytest.mark.asyncio
2026-03-13 12:23:21 +01:00
@patch ( "requests.get" )
2026-07-15 16:38:53 +02:00
async def test_update_data ( mock_get , provider , sample_openmeteo_1_json , cache_store ):
2026-03-13 12:23:21 +01:00
"""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 )
2026-07-15 16:38:53 +02:00
await provider . update_data ( force_enable = True , force_update = True )
2026-03-13 12:23:21 +01:00
# 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" )
2026-07-15 16:38:53 +02:00
weather_ghi = await provider . key_to_value ( "weather_ghi" , target_datetime = start_datetime )
weather_dni = await provider . key_to_value ( "weather_dni" , target_datetime = start_datetime )
weather_dhi = await provider . key_to_value ( "weather_dhi" , target_datetime = start_datetime )
assert weather_ghi == 21.8
assert weather_dni == 1.2
assert weather_dhi == 20.5
2026-03-13 12:23:21 +01:00
# ------------------------------------------------
# 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
2026-03-17 13:59:17 +01:00
@pytest.mark.parametrize (
"start_offset_days, expected_keys, forbidden_keys" ,
[
# Future → forecast mode
( 1 , [ "forecast_days" ], [ "start_date" , "end_date" , "models" ]),
# Today (edge case, should still be forecast)
( 0 , [ "forecast_days" ], [ "start_date" , "end_date" , "models" ]),
# Past → historical mode
( - 2 , [ "start_date" , "end_date" , "models" ], [ "forecast_days" ]),
],
)
2026-03-13 12:23:21 +01:00
@patch ( "requests.get" )
2026-03-17 13:59:17 +01:00
def test_openmeteo_request_mode_selection (
mock_get ,
provider ,
sample_openmeteo_1_json ,
start_offset_days ,
expected_keys ,
forbidden_keys ,
):
"""Test that Open-Meteo request switches correctly between forecast and historical modes."""
# Mock response
2026-03-13 12:23:21 +01:00
mock_response = Mock ()
mock_response . status_code = 200
mock_response . json . return_value = sample_openmeteo_1_json
2026-03-17 13:59:17 +01:00
mock_response . content = str ( sample_openmeteo_1_json )
2026-03-13 12:23:21 +01:00
mock_get . return_value = mock_response
2026-03-26 12:32:30 +01:00
# Fixed current time to avoid timing issues
fixed_now = to_datetime ( "2026-03-26 12:00:00+01:00" , in_timezone = "Europe/Berlin" )
2026-03-13 12:23:21 +01:00
2026-03-26 12:32:30 +01:00
# Patch to_datetime to return fixed_now when called without a datetime argument
with patch ( "akkudoktoreos.prediction.weatheropenmeteo.to_datetime" ) as mock_to_datetime :
def to_datetime_side_effect ( dt = None , in_timezone = None ):
if dt is None :
return fixed_now
# Otherwise fall back to the real function
from akkudoktoreos.utils.datetimeutil import to_datetime as real_to_datetime
return real_to_datetime ( dt , in_timezone = in_timezone )
2026-03-13 12:23:21 +01:00
2026-03-26 12:32:30 +01:00
mock_to_datetime . side_effect = to_datetime_side_effect
# Set deterministic start time based on fixed_now
start = fixed_now + pd . Timedelta ( days = start_offset_days )
ems_eos = get_ems ()
ems_eos . set_start_datetime ( start )
# Execute
provider . _request_forecast ()
2026-03-13 12:23:21 +01:00
2026-03-17 13:59:17 +01:00
# Inspect request params
params = mock_get . call_args [ 1 ][ "params" ]
# Assertions
for key in expected_keys :
assert key in params , f "Expected ' { key } ' in params but got { params } "
for key in forbidden_keys :
assert key not in params , f "Did not expect ' { key } ' in params but got { params } "
if "forecast_days" in params :
assert params [ "forecast_days" ] >= 1
assert params [ "forecast_days" ] <= 16
2026-03-13 12:23:21 +01:00
# ------------------------------------------------
# Development Open-Meteo
# ------------------------------------------------
2026-07-15 16:38:53 +02:00
@pytest.mark.asyncio
async def test_openmeteo_development_forecast_data ( provider , config_eos , is_system_test ):
2026-03-13 12:23:21 +01:00
"""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
2026-07-15 16:38:53 +02:00
await provider . update_data ( force_enable = True , force_update = True )
2026-03-13 12:23:21 +01:00
# 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 :
2026-07-15 16:38:53 +02:00
records = list ( provider . records )
2026-03-13 12:23:21 +01:00
# 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