Skip to content
Merged
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
58 changes: 58 additions & 0 deletions src/batcontrol/forecastsolar/baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
84 changes: 78 additions & 6 deletions tests/batcontrol/forecastsolar/test_baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Comment thread
MaStr marked this conversation as resolved.
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,
Expand All @@ -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"""
Expand Down
27 changes: 19 additions & 8 deletions tests/batcontrol/forecastsolar/test_baseclass_alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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"):
Expand All @@ -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"):
Expand Down