From 5e033a239c6910b2bfc0a3c6b7fe7b4300265b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 05:42:41 +0000 Subject: [PATCH 1/4] Pad PV forecast with zeros to midnight when provider stops at sunset Providers like FCSolar often return data only up to the last production interval (e.g. 21:00), leaving 22:00-23:59 missing. This shrinks the effective planning horizon of batcontrol by up to 3 hours. _pad_to_midnight() in ForecastSolarBaseclass now appends zero-valued intervals from the last provided index up to (but not crossing) midnight, so the forecast always reaches end-of-day regardless of provider behaviour. Works transparently for both hourly and 15-min target resolutions. https://claude.ai/code/session_01MeHmCGcc69WQHCi5cZoPFJ --- src/batcontrol/forecastsolar/baseclass.py | 36 +++++++++++++++++++ .../forecastsolar/test_baseclass.py | 18 ++++++++-- .../forecastsolar/test_baseclass_alignment.py | 14 +++++--- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index d6c81173..6416c6ac 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,39 @@ 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 the end of the current day by appending zero-valued + intervals from the last provided index up to (but not crossing) midnight. + + Providers often stop at sunset, leaving late-evening hours missing. Without this + padding the forecast horizon shrinks relative to midnight, which limits how far + ahead batcontrol can plan. + """ + if not forecast: + return forecast + + now = datetime.datetime.now(datetime.timezone.utc).astimezone(self.timezone) + midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) + seconds_to_midnight = (midnight - now).total_seconds() + intervals_to_midnight = int(seconds_to_midnight // (self.target_resolution * 60)) + + max_idx = max(forecast.keys()) + 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 (midnight) with zeros', + self.__class__.__name__, + max_idx + 1, + intervals_to_midnight - 1, + ) + 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..a46618c3 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass.py +++ b/tests/batcontrol/forecastsolar/test_baseclass.py @@ -323,7 +323,15 @@ def mock_forecast(): assert forecast[18] == 180.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. + + We fix the clock to 22:00 so that midnight is only 2 hours away. + The provider returns 10 hours of data but padding only reaches midnight + (2 intervals), leaving the total at 10 intervals which is still below 12. + """ + import datetime as dt + + fixed_now = dt.datetime(2024, 6, 1, 22, 0, 0, tzinfo=timezone) def mock_provider(name): return {'data': 'test'} @@ -341,8 +349,12 @@ 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_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..b5cde6d4 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -306,15 +306,18 @@ def test_minimum_forecast_validation_hourly(self, pvinstallations, timezone): target_resolution=60, native_resolution=60 ) - # Set insufficient data (less than 18 hours) + # Set insufficient data (less than 12 hours). + # Clock is fixed at 22:00 so midnight is only 2 hours away; padding cannot + # extend the 10-interval forecast to the required 12 hours. hourly_data = {i: 1000 for i in range(10)} provider.set_mock_data(hourly_data) - mock_time = datetime.datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone) + mock_time = datetime.datetime(2024, 1, 1, 22, 0, 0, tzinfo=timezone) 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 +330,18 @@ def test_minimum_forecast_validation_15min(self, pvinstallations, timezone): target_resolution=15, native_resolution=15 ) - # Set insufficient data (less than 48 intervals = 12 hours) + # Set insufficient data (less than 48 intervals = 12 hours). + # Clock is fixed at 22:00 so midnight is only 8 intervals (2 h) away; + # padding cannot extend the 40-interval forecast to the required 48 intervals. data_15min = {i: 250 for i in range(40)} provider.set_mock_data(data_15min) - mock_time = datetime.datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone) + mock_time = datetime.datetime(2024, 1, 1, 22, 0, 0, tzinfo=timezone) 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"): From 91e86df2e17620b7a55fd612b3914afa0740afdb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 05:48:38 +0000 Subject: [PATCH 2/4] Fix interval boundary bug in _pad_to_midnight and correct test comments Copilot review identified that computing intervals_to_midnight from the raw wall-clock time causes an off-by-one at non-boundary times: at 22:03 with 15-min resolution, floor division yielded 7 intervals while the shifted forecast index 0 starts at 22:00, giving 8 intervals to midnight. Fix by snapping 'now' back to the start of the current interval before computing the delta to midnight. Also fix test docstrings that incorrectly claimed padding would add 2 intervals at 22:00 -- a 10h forecast at 22:00 already extends past midnight, so _pad_to_midnight() is a no-op in those tests. https://claude.ai/code/session_01MeHmCGcc69WQHCi5cZoPFJ --- src/batcontrol/forecastsolar/baseclass.py | 9 ++++++++- tests/batcontrol/forecastsolar/test_baseclass.py | 6 +++--- .../forecastsolar/test_baseclass_alignment.py | 10 ++++++---- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index 6416c6ac..1d5747a1 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -244,7 +244,14 @@ def _pad_to_midnight(self, forecast: dict[int, float]) -> dict[int, float]: now = datetime.datetime.now(datetime.timezone.utc).astimezone(self.timezone) midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) - seconds_to_midnight = (midnight - now).total_seconds() + # Snap back to the start of the current interval so the count matches the + # shifted forecast indices (index 0 = current interval start, not "now"). + interval_start = now.replace( + minute=(now.minute // self.target_resolution) * self.target_resolution, + second=0, + microsecond=0, + ) + seconds_to_midnight = (midnight - interval_start).total_seconds() intervals_to_midnight = int(seconds_to_midnight // (self.target_resolution * 60)) max_idx = max(forecast.keys()) diff --git a/tests/batcontrol/forecastsolar/test_baseclass.py b/tests/batcontrol/forecastsolar/test_baseclass.py index a46618c3..607857dd 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass.py +++ b/tests/batcontrol/forecastsolar/test_baseclass.py @@ -325,9 +325,9 @@ def mock_forecast(): def test_get_forecast_insufficient_hours(self, single_installation, timezone): """Test get_forecast raises error with insufficient forecast hours. - We fix the clock to 22:00 so that midnight is only 2 hours away. - The provider returns 10 hours of data but padding only reaches midnight - (2 intervals), leaving the total at 10 intervals which is still below 12. + We fix the clock to 22:00. The 10-interval hourly forecast already extends + past midnight, so _pad_to_midnight() adds nothing. The 10-interval total + still falls below the 12-interval minimum and RuntimeError is raised. """ import datetime as dt diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index b5cde6d4..2540bc67 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -307,8 +307,9 @@ def test_minimum_forecast_validation_hourly(self, pvinstallations, timezone): ) # Set insufficient data (less than 12 hours). - # Clock is fixed at 22:00 so midnight is only 2 hours away; padding cannot - # extend the 10-interval forecast to the required 12 hours. + # Clock is fixed at 22:00. The 10-interval hourly forecast already extends + # past midnight, so _pad_to_midnight() adds nothing. The 10-interval total + # still falls below the 12-interval minimum and RuntimeError is raised. hourly_data = {i: 1000 for i in range(10)} provider.set_mock_data(hourly_data) @@ -331,8 +332,9 @@ def test_minimum_forecast_validation_15min(self, pvinstallations, timezone): ) # Set insufficient data (less than 48 intervals = 12 hours). - # Clock is fixed at 22:00 so midnight is only 8 intervals (2 h) away; - # padding cannot extend the 40-interval forecast to the required 48 intervals. + # Clock is fixed at 22:00. The 40-interval 15-min forecast already extends + # past midnight, so _pad_to_midnight() adds nothing. The 40-interval total + # still falls below the 48-interval minimum and RuntimeError is raised. data_15min = {i: 250 for i in range(40)} provider.set_mock_data(data_15min) From 443ec959ff8fae9f6b0c6b56d18896b660281b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 06:11:15 +0000 Subject: [PATCH 3/4] Fix _pad_to_midnight to target the midnight following the last forecast entry The previous implementation always padded to *tonight's* midnight. For 48-hour forecasts (today + tomorrow) that stop at e.g. tomorrow 21:00, max_idx already exceeded tonight's midnight so no padding was applied -- missing tomorrow 22:00 and 23:00 entirely. Fix: compute next_midnight as the midnight following last_dt (the datetime of the last forecast interval). This correctly pads to end-of-day regardless of whether the forecast covers just today or today+tomorrow. Update tests to reflect the new behavior: success-path tests accept the variable-length padded output and verify trailing entries are zero; insufficient-hours tests use forecasts that stay within the current calendar day so padding still leaves them below the 12-hour minimum. https://claude.ai/code/session_01MeHmCGcc69WQHCi5cZoPFJ --- src/batcontrol/forecastsolar/baseclass.py | 30 ++++++++++++------- .../forecastsolar/test_baseclass.py | 17 +++++++---- .../forecastsolar/test_baseclass_alignment.py | 23 +++++++------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index 1d5747a1..69ddeb81 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -232,29 +232,36 @@ def _shift_to_current_interval(self, forecast: dict[int, float]) -> dict[int, fl def _pad_to_midnight(self, forecast: dict[int, float]) -> dict[int, float]: """ - Ensure the forecast reaches the end of the current day by appending zero-valued - intervals from the last provided index up to (but not crossing) midnight. + 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, leaving late-evening hours missing. Without this - padding the forecast horizon shrinks relative to midnight, which limits how far - ahead batcontrol can plan. + 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) - midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) - # Snap back to the start of the current interval so the count matches the - # shifted forecast indices (index 0 = current interval start, not "now"). + # 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, ) - seconds_to_midnight = (midnight - interval_start).total_seconds() - intervals_to_midnight = int(seconds_to_midnight // (self.target_resolution * 60)) max_idx = max(forecast.keys()) + + # Compute the local datetime of the last forecast interval. + last_dt = interval_start + datetime.timedelta(minutes=max_idx * self.target_resolution) + + # Find the midnight that immediately follows the last interval. + next_midnight = last_dt.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) + + # 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 @@ -263,10 +270,11 @@ def _pad_to_midnight(self, forecast: dict[int, float]) -> dict[int, float]: padded[idx] = 0.0 logger.debug( - '%s: Padded forecast from index %d to %d (midnight) with zeros', + '%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 diff --git a/tests/batcontrol/forecastsolar/test_baseclass.py b/tests/batcontrol/forecastsolar/test_baseclass.py index 607857dd..7aa027c2 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass.py +++ b/tests/batcontrol/forecastsolar/test_baseclass.py @@ -318,16 +318,21 @@ 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. - We fix the clock to 22:00. The 10-interval hourly forecast already extends - past midnight, so _pad_to_midnight() adds nothing. The 10-interval total - still falls below the 12-interval minimum and RuntimeError is raised. + 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 @@ -337,8 +342,8 @@ 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, diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index 2540bc67..3846400b 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).""" @@ -307,10 +310,10 @@ def test_minimum_forecast_validation_hourly(self, pvinstallations, timezone): ) # Set insufficient data (less than 12 hours). - # Clock is fixed at 22:00. The 10-interval hourly forecast already extends - # past midnight, so _pad_to_midnight() adds nothing. The 10-interval total - # still falls below the 12-interval minimum and RuntimeError is raised. - hourly_data = {i: 1000 for i in range(10)} + # 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, 22, 0, 0, tzinfo=timezone) @@ -332,10 +335,10 @@ def test_minimum_forecast_validation_15min(self, pvinstallations, timezone): ) # Set insufficient data (less than 48 intervals = 12 hours). - # Clock is fixed at 22:00. The 40-interval 15-min forecast already extends - # past midnight, so _pad_to_midnight() adds nothing. The 40-interval total - # still falls below the 48-interval minimum and RuntimeError is raised. - data_15min = {i: 250 for i in range(40)} + # 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, 22, 0, 0, tzinfo=timezone) From 69d32feea91b1f635b3563cd734c8c14d1cf828e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 06:22:37 +0000 Subject: [PATCH 4/4] Make _pad_to_midnight DST-safe and fix pytz usage in tests Address Copilot review on PR #373: - baseclass.py: use pytz normalize()/localize() when deriving last_dt and next_midnight so timedelta arithmetic across a DST boundary yields the correct local instant (previously a bare + timedelta kept the stale offset, mis-counting 23h/25h days as 24h). - tests: replace datetime(..., tzinfo=pytz_tz) with timezone.localize(...), the correct pytz construction (the tzinfo= form attaches the LMT offset). - tests: remove a non-ASCII em dash (repo is ASCII-only). - Add spring-forward (23h) and fall-back (25h) regression tests for the padding interval count. https://claude.ai/code/session_01MeHmCGcc69WQHCi5cZoPFJ --- src/batcontrol/forecastsolar/baseclass.py | 15 +++-- .../forecastsolar/test_baseclass.py | 59 ++++++++++++++++++- .../forecastsolar/test_baseclass_alignment.py | 4 +- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index 69ddeb81..9218d0f1 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -252,11 +252,18 @@ def _pad_to_midnight(self, forecast: dict[int, float]) -> dict[int, float]: max_idx = max(forecast.keys()) - # Compute the local datetime of the last forecast interval. - last_dt = interval_start + datetime.timedelta(minutes=max_idx * self.target_resolution) + # 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) + ) - # Find the midnight that immediately follows the last interval. - next_midnight = last_dt.replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta(days=1) + # 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() diff --git a/tests/batcontrol/forecastsolar/test_baseclass.py b/tests/batcontrol/forecastsolar/test_baseclass.py index 7aa027c2..3eb6621c 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass.py +++ b/tests/batcontrol/forecastsolar/test_baseclass.py @@ -336,13 +336,13 @@ def test_get_forecast_insufficient_hours(self, single_installation, timezone): """ import datetime as dt - fixed_now = dt.datetime(2024, 6, 1, 22, 0, 0, tzinfo=timezone) + fixed_now = timezone.localize(dt.datetime(2024, 6, 1, 22, 0, 0)) def mock_provider(name): return {'data': 'test'} def mock_forecast(): - # Only 2 hours of data — stays below 12 even after padding to midnight + # 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( @@ -361,6 +361,61 @@ def mock_forecast(): 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""" instance = ForecastSolarBaseclass( diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index 3846400b..8dd971b8 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -316,7 +316,7 @@ def test_minimum_forecast_validation_hourly(self, pvinstallations, timezone): hourly_data = {i: 1000 for i in range(2)} provider.set_mock_data(hourly_data) - mock_time = datetime.datetime(2024, 1, 1, 22, 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 @@ -341,7 +341,7 @@ def test_minimum_forecast_validation_15min(self, pvinstallations, timezone): data_15min = {i: 250 for i in range(7)} provider.set_mock_data(data_15min) - mock_time = datetime.datetime(2024, 1, 1, 22, 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