diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index d6c81173..9218d0f1 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -149,6 +149,9 @@ def get_forecast(self) -> dict[int, float]: # Shift indices to start from CURRENT interval current_aligned_forecast = self._shift_to_current_interval(converted_forecast) + # Pad with zeros to midnight so the forecast horizon always reaches end-of-day + current_aligned_forecast = self._pad_to_midnight(current_aligned_forecast) + # Validate minimum forecast length if self.target_resolution == 60: min_intervals = 12 # 12 hours @@ -227,6 +230,61 @@ def _shift_to_current_interval(self, forecast: dict[int, float]) -> dict[int, fl return shifted_forecast + def _pad_to_midnight(self, forecast: dict[int, float]) -> dict[int, float]: + """ + Ensure the forecast reaches end-of-day by appending zero-valued intervals + up to the midnight that follows the last provided entry. + + Providers often stop at sunset (e.g. today 21:00 or tomorrow 21:00). + Without padding the forecast horizon is a few hours shorter than the + last calendar day covered, which limits how far ahead batcontrol can plan. + """ + if not forecast: + return forecast + + now = datetime.datetime.now(datetime.timezone.utc).astimezone(self.timezone) + # Snap to the start of the current interval (index 0 in the shifted forecast). + interval_start = now.replace( + minute=(now.minute // self.target_resolution) * self.target_resolution, + second=0, + microsecond=0, + ) + + max_idx = max(forecast.keys()) + + # Local datetime of the last forecast interval. normalize() re-resolves the + # pytz offset so adding a timedelta across a DST boundary stays correct. + last_dt = self.timezone.normalize( + interval_start + datetime.timedelta(minutes=max_idx * self.target_resolution) + ) + + # Midnight following the last interval, built from a naive wall-clock date and + # localized so the correct (DST-aware) offset is chosen for that day. + naive_next_midnight = last_dt.replace( + tzinfo=None, hour=0, minute=0, second=0, microsecond=0 + ) + datetime.timedelta(days=1) + next_midnight = self.timezone.localize(naive_next_midnight) + + # Number of intervals from index 0 (interval_start) to next_midnight. + seconds_to_midnight = (next_midnight - interval_start).total_seconds() + intervals_to_midnight = int(seconds_to_midnight // (self.target_resolution * 60)) + + if max_idx >= intervals_to_midnight - 1: + return forecast + + padded = dict(forecast) + for idx in range(max_idx + 1, intervals_to_midnight): + padded[idx] = 0.0 + + logger.debug( + '%s: Padded forecast from index %d to %d (%s midnight) with zeros', + self.__class__.__name__, + max_idx + 1, + intervals_to_midnight - 1, + next_midnight.strftime('%Y-%m-%d'), + ) + return padded + def get_raw_data_from_provider(self, pvinstallation_name) -> dict: """ Prototype for get_raw_data_from_provider and store in cache """ raise RuntimeError("[Forecast Solar Base Class] Function " diff --git a/tests/batcontrol/forecastsolar/test_baseclass.py b/tests/batcontrol/forecastsolar/test_baseclass.py index d778f591..3eb6621c 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass.py +++ b/tests/batcontrol/forecastsolar/test_baseclass.py @@ -318,19 +318,32 @@ def mock_forecast(): ) forecast = instance.get_forecast() - assert len(forecast) == 24 + # Original 24 entries must be intact; _pad_to_midnight may add trailing zeros + # up to the midnight following the last entry, so total length >= 24. + assert len(forecast) >= 24 assert forecast[0] == 0.0 assert forecast[18] == 180.0 + # Any padding must be zero + for idx in range(24, len(forecast)): + assert forecast[idx] == 0.0 def test_get_forecast_insufficient_hours(self, single_installation, timezone): - """Test get_forecast raises error with insufficient forecast hours""" + """Test get_forecast raises error with insufficient forecast hours. + + Clock is fixed at 22:00. The provider returns only 2 intervals (22:00 + and 23:00). _pad_to_midnight() fills to midnight (2 intervals total), + which is still below the 12-interval minimum, so RuntimeError is raised. + """ + import datetime as dt + + fixed_now = timezone.localize(dt.datetime(2024, 6, 1, 22, 0, 0)) def mock_provider(name): return {'data': 'test'} def mock_forecast(): - # Only 10 hours of data - return {i: float(i * 10) for i in range(10)} + # Only 2 hours of data - stays below 12 even after padding to midnight + return {i: float(i * 10) for i in range(2)} instance = ConcreteForecastSolar( single_installation, @@ -341,8 +354,67 @@ def mock_forecast(): mock_forecast_func=mock_forecast ) - with pytest.raises(RuntimeError, match="Less than 12 hours"): - instance.get_forecast() + with patch('batcontrol.forecastsolar.baseclass.datetime') as mock_dt: + mock_dt.datetime.now.return_value = fixed_now + mock_dt.timedelta = dt.timedelta + mock_dt.timezone = dt.timezone + with pytest.raises(RuntimeError, match="Less than 12 hours"): + instance.get_forecast() + + def test_pad_to_midnight_dst_spring_forward(self, single_installation, timezone): + """_pad_to_midnight must count a 23-hour day correctly (spring forward). + + On 2024-03-31 Europe/Berlin loses an hour at 02:00 (CET -> CEST), so the + day has only 23 hourly intervals. A naive interval_start + timedelta would + keep the CET offset and yield 24; normalize/localize must give 23. + """ + import datetime as dt + + instance = ConcreteForecastSolar( + single_installation, timezone, + min_time_between_API_calls=900, delay_evaluation_by_seconds=0, + ) + + # Start of the DST day, before the 02:00 jump. + fixed_now = timezone.localize(dt.datetime(2024, 3, 31, 0, 0, 0)) + + with patch('batcontrol.forecastsolar.baseclass.datetime') as mock_dt: + mock_dt.datetime.now.return_value = fixed_now + mock_dt.timedelta = dt.timedelta + mock_dt.timezone = dt.timezone + result = instance._pad_to_midnight({0: 100.0}) + + # 00:00 to next midnight on a 23-hour day = 23 hourly intervals (0..22). + assert len(result) == 23 + assert result[0] == 100.0 + assert result[22] == 0.0 + + def test_pad_to_midnight_dst_fall_back(self, single_installation, timezone): + """_pad_to_midnight must count a 25-hour day correctly (fall back). + + On 2024-10-27 Europe/Berlin gains an hour at 03:00 (CEST -> CET), so the + day has 25 hourly intervals. Naive arithmetic would yield 24; the + normalize/localize path must give 25. + """ + import datetime as dt + + instance = ConcreteForecastSolar( + single_installation, timezone, + min_time_between_API_calls=900, delay_evaluation_by_seconds=0, + ) + + fixed_now = timezone.localize(dt.datetime(2024, 10, 27, 0, 0, 0)) + + with patch('batcontrol.forecastsolar.baseclass.datetime') as mock_dt: + mock_dt.datetime.now.return_value = fixed_now + mock_dt.timedelta = dt.timedelta + mock_dt.timezone = dt.timezone + result = instance._pad_to_midnight({0: 100.0}) + + # 00:00 to next midnight on a 25-hour day = 25 hourly intervals (0..24). + assert len(result) == 25 + assert result[0] == 100.0 + assert result[24] == 0.0 def test_base_class_not_implemented_errors(self, single_installation, timezone): """Test that base class methods raise NotImplementedError""" diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index 9d681f17..8dd971b8 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -267,9 +267,12 @@ def test_hourly_provider_hourly_target(self, pvinstallations, timezone): with patch.object(provider, 'refresh_data'): result = provider.get_forecast() - # Should return data without modification (at hour start) - assert len(result) == 24 + # Original 24 entries intact; _pad_to_midnight may append trailing zeros + # up to the midnight following the last entry. + assert len(result) >= 24 assert result[0] == 1000 + for idx in range(24, len(result)): + assert result[idx] == 0.0 def test_hourly_provider_15min_target(self, pvinstallations, timezone): """Test hourly provider with 15-min target (upsampling).""" @@ -306,15 +309,19 @@ def test_minimum_forecast_validation_hourly(self, pvinstallations, timezone): target_resolution=60, native_resolution=60 ) - # Set insufficient data (less than 18 hours) - hourly_data = {i: 1000 for i in range(10)} + # Set insufficient data (less than 12 hours). + # Clock is fixed at 22:00. 2 intervals (22:00 and 23:00) end before midnight; + # _pad_to_midnight() fills index 2 up to midnight (total 2 intervals). That + # is still below the 12-interval minimum, so RuntimeError is raised. + hourly_data = {i: 1000 for i in range(2)} provider.set_mock_data(hourly_data) - mock_time = datetime.datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone) + mock_time = timezone.localize(datetime.datetime(2024, 1, 1, 22, 0, 0)) with patch('datetime.datetime') as mock_datetime: mock_datetime.now.return_value = mock_time mock_datetime.timezone = datetime.timezone + mock_datetime.timedelta = datetime.timedelta with patch.object(provider, 'refresh_data'): with pytest.raises(RuntimeError, match="Less than 12 hours"): @@ -327,15 +334,19 @@ def test_minimum_forecast_validation_15min(self, pvinstallations, timezone): target_resolution=15, native_resolution=15 ) - # Set insufficient data (less than 48 intervals = 12 hours) - data_15min = {i: 250 for i in range(40)} + # Set insufficient data (less than 48 intervals = 12 hours). + # Clock is fixed at 22:00. 7 intervals (22:00..23:30) end before midnight; + # _pad_to_midnight() fills to midnight (total 8 intervals = 2 h). That is + # still below the 48-interval minimum, so RuntimeError is raised. + data_15min = {i: 250 for i in range(7)} provider.set_mock_data(data_15min) - mock_time = datetime.datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone) + mock_time = timezone.localize(datetime.datetime(2024, 1, 1, 22, 0, 0)) with patch('datetime.datetime') as mock_datetime: mock_datetime.now.return_value = mock_time mock_datetime.timezone = datetime.timezone + mock_datetime.timedelta = datetime.timedelta with patch.object(provider, 'refresh_data'): with pytest.raises(RuntimeError, match="Less than 12 hours"):