diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 1a05a2e2..0f883fca 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -515,6 +515,21 @@ def handle_forecast_error(self): time_passed) self.allow_discharging() + def _get_production_forecast(self, price_dict): + """Return solar forecast or a conservative fixed-target fallback.""" + try: + return self.fc_solar.get_forecast() + except Exception as solar_error: + if (self.min_grid_charge_soc is None + or self.grid_charge_target_strategy != 'fixed'): + raise + logger.warning( + 'Solar forecast unavailable. Assuming zero solar ' + 'production for fixed grid charge target this cycle: %s', + solar_error, + ) + return {slot: 0.0 for slot in price_dict} + def run(self): """One control cycle. Aborts cleanly on a transient inverter outage. @@ -562,7 +577,7 @@ def _run_once(self): # get forecasts try: price_dict = self.dynamic_tariff.get_prices() - production_forecast = self.fc_solar.get_forecast() + production_forecast = self._get_production_forecast(price_dict) # harmonize forecast horizon fc_period = min(max(price_dict.keys()), max(production_forecast.keys())) diff --git a/src/batcontrol/forecastsolar/fcsolar.py b/src/batcontrol/forecastsolar/fcsolar.py index 3cb7dadb..d7c9efcb 100644 --- a/src/batcontrol/forecastsolar/fcsolar.py +++ b/src/batcontrol/forecastsolar/fcsolar.py @@ -113,7 +113,12 @@ def get_raw_data_from_provider(self, pvinstallation_name) -> dict: logger.info( 'Requesting Information for PV Installation %s', name) - response = requests.get(url, timeout=60) + try: + response = requests.get(url, timeout=60) + except requests.exceptions.RequestException as error: + raise ProviderError( + f'Forecast solar API request failed: {error}' + ) from error if response.status_code == 200: return json.loads(response.text) elif response.status_code == 429: diff --git a/tests/batcontrol/forecastsolar/test_fcsolar.py b/tests/batcontrol/forecastsolar/test_fcsolar.py new file mode 100644 index 00000000..49b646d9 --- /dev/null +++ b/tests/batcontrol/forecastsolar/test_fcsolar.py @@ -0,0 +1,50 @@ +"""Tests for the forecast.solar provider.""" + +import pytest +import pytz +import requests + +from batcontrol.forecastsolar.baseclass import ProviderError +from batcontrol.forecastsolar.fcsolar import FCSolar + + +@pytest.fixture +def instance(): + """Create a forecast.solar provider with one PV installation.""" + return FCSolar( + [{ + 'name': 'roof', + 'lat': 52.17, + 'lon': 21.25, + 'declination': 30, + 'azimuth': 34, + 'kWp': 10.0, + }], + pytz.timezone('Europe/Warsaw'), + min_time_between_api_calls=900, + ) + + +def test_request_error_is_wrapped_for_cached_fallback(instance, mocker): + """Network failures must reach the baseclass as ProviderError.""" + mocker.patch( + 'batcontrol.forecastsolar.fcsolar.requests.get', + side_effect=requests.exceptions.ConnectionError('dns failure'), + ) + + with pytest.raises(ProviderError, match='request failed'): + instance.get_raw_data_from_provider('roof') + + +def test_refresh_keeps_cached_data_on_request_error(instance, mocker): + """A transient request failure must leave the last good response cached.""" + cached_response = {'result': 'last-known-good'} + instance.store_raw_data('roof', cached_response) + mocker.patch( + 'batcontrol.forecastsolar.fcsolar.requests.get', + side_effect=requests.exceptions.ConnectionError('dns failure'), + ) + + instance.refresh_data() + + assert instance.get_raw_data('roof') == cached_response diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 1278babb..e5d493c3 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -470,6 +470,59 @@ def test_warns_when_min_grid_charge_soc_exceeds_grid_charge_limit( ) bc.shutdown() + @pytest.mark.parametrize( + 'existing_error_age_seconds', + [None, 3600], + ids=['transient', 'prolonged'], + ) + def test_run_uses_zero_solar_forecast_for_fixed_target_on_provider_error( + self, run_dispatch_setup, mocker, caplog, + existing_error_age_seconds): + bc, mock_inverter, fake_logic = run_dispatch_setup + bc.min_grid_charge_soc = 0.60 + bc.fc_solar.get_forecast.side_effect = RuntimeError( + 'solar forecast unavailable') + mocker.patch('batcontrol.core.time.time', return_value=20_000) + if existing_error_age_seconds is not None: + bc.time_at_forecast_error = 20_000 - existing_error_age_seconds + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=False, + charge_from_grid=True, + charge_rate=2345, + limit_battery_charge_rate=-1, + ) + caplog.set_level(logging.WARNING, logger='batcontrol.core') + + bc.run() + + calc_input = fake_logic.calculate.call_args.args[0] + assert calc_input.production.tolist() == [0.0, 0.0, 0.0] + calc_params = fake_logic.set_calculation_parameters.call_args.args[0] + assert calc_params.min_grid_charge_soc == 0.60 + mock_inverter.set_mode_force_charge.assert_called_once_with(2345) + mock_inverter.set_mode_allow_discharge.assert_not_called() + assert bc.time_at_forecast_error == -1 + assert 'Assuming zero solar production' in caplog.text + + @pytest.mark.parametrize( + 'min_grid_charge_soc,target_strategy', + [(None, 'fixed'), (0.60, 'forecast')], + ) + def test_run_keeps_error_fallback_without_fixed_target( + self, run_dispatch_setup, mocker, + min_grid_charge_soc, target_strategy): + bc, _mock_inverter, fake_logic = run_dispatch_setup + bc.min_grid_charge_soc = min_grid_charge_soc + bc.grid_charge_target_strategy = target_strategy + bc.fc_solar.get_forecast.side_effect = RuntimeError( + 'solar forecast unavailable') + mocker.patch.object(bc, 'handle_forecast_error') + + bc.run() + + bc.handle_forecast_error.assert_called_once_with() + fake_logic.calculate.assert_not_called() + def test_run_dispatches_allow_discharge(self, run_dispatch_setup): bc, mock_inverter, fake_logic = run_dispatch_setup fake_logic.get_inverter_control_settings.return_value = MagicMock(