Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()))
Expand Down
7 changes: 6 additions & 1 deletion src/batcontrol/forecastsolar/fcsolar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +116 to +121
if response.status_code == 200:
return json.loads(response.text)
elif response.status_code == 429:
Expand Down
50 changes: 50 additions & 0 deletions tests/batcontrol/forecastsolar/test_fcsolar.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions tests/batcontrol/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down