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", "") 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 @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"]), ], ) @patch("requests.get") 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 mock_response = Mock() mock_response.status_code = 200 mock_response.json.return_value = sample_openmeteo_1_json mock_response.content = str(sample_openmeteo_1_json) mock_get.return_value = mock_response # Set deterministic start time now = to_datetime(in_timezone="Europe/Berlin") start = now + pd.Timedelta(days=start_offset_days) ems_eos = get_ems() ems_eos.set_start_datetime(start) # Execute provider._request_forecast() # 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 # ------------------------------------------------ # 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