From 92f903a09f3fb66a751167012861cbe3d490a0e0 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Sun, 31 May 2026 18:56:04 +0200 Subject: [PATCH 1/5] energyforecast: migrate to API v2 with 4h refresh interval - Switch endpoint from /api/v1/predictions/next_48_hours to /api/v2/forecast - Remove resolution parameter; API v2 is always quarter-hourly (native_resolution=15) - Remove upgrade_48h_to_96h(); v2 returns plan-based multi-day forecasts automatically - Add optional market_zone config parameter (default: DE) - Convert price field from v1 price (EUR/kWh) to v2 price_ct_kwh / 100 (ct->EUR) - Enforce 4h minimum refresh interval via max(), matching solarprognose pattern - Deprecate energyforecast_96 provider name with a warning log - Update tests: v2 response format, quarter-hourly intervals, new market_zone and refresh interval coverage; remove obsolete v1-only test cases Closes #365 Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/dynamictariff/dynamictariff.py | 13 +- .../dynamictariff/energyforecast.py | 124 ++++++------ .../dynamictariff/test_energyforecast.py | 184 ++++++++++-------- 3 files changed, 174 insertions(+), 147 deletions(-) diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index c281e0fa..2de065c6 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -138,22 +138,29 @@ def create_tarif_provider(config: dict, timezone, raise RuntimeError( f'[DynTariff] Please include {field} in your configuration file' ) + if provider.lower() == 'energyforecast_96': + import logging as _log + _log.getLogger(__name__).warning( + '[DynTariff] Provider "energyforecast_96" is deprecated. ' + 'API v2 delivers plan-based multi-day forecasts automatically. ' + 'Use "energyforecast" instead.' + ) vat = float(config.get('vat', 0)) markup = float(config.get('markup', 0)) fees = float(config.get('fees', 0)) token = config.get('apikey') + market_zone = config.get('market_zone', 'DE') selected_tariff = Energyforecast( timezone, token, min_time_between_api_calls, delay_evaluation_by_seconds, - target_resolution=target_resolution + target_resolution=target_resolution, + market_zone=market_zone ) selected_tariff.set_price_parameters(vat, fees, markup) if network_fees_fetcher is not None: selected_tariff.set_network_fees_fetcher(network_fees_fetcher) - if provider.lower() == 'energyforecast_96': - selected_tariff.upgrade_48h_to_96h() elif provider.lower() == 'tariff_zones': # Only tariff_zone_1 is strictly required. A single-zone diff --git a/src/batcontrol/dynamictariff/energyforecast.py b/src/batcontrol/dynamictariff/energyforecast.py index acf0acb6..445f6cc7 100644 --- a/src/batcontrol/dynamictariff/energyforecast.py +++ b/src/batcontrol/dynamictariff/energyforecast.py @@ -1,24 +1,23 @@ """Energyforecast.de Class -This module implements the energyforecast.de API to retrieve dynamic electricity prices. +This module implements the energyforecast.de API v2 to retrieve dynamic electricity prices. It inherits from the DynamicTariffBaseclass. Classes: - Energyforecast: A class to interact with the energyforecast.de API + Energyforecast: A class to interact with the energyforecast.de API v2 and process electricity prices. Methods: __init__(self, timezone, - price_fees: float, - price_markup: float, - vat: float, + token, + market_zone='DE-LU', min_time_between_API_calls=0): Initializes the Energyforecast class with the specified parameters. get_raw_data_from_provider(self): - Fetches raw data from the energyforecast.de API. + Fetches raw data from the energyforecast.de API v2. _get_prices_native(self): Processes the raw data to extract and calculate electricity prices. @@ -30,56 +29,50 @@ logger = logging.getLogger(__name__) +# API v2 always returns quarter-hourly data; use 4-hour refresh floor so that +# 6 daily calls cover the full day with headroom for the forced 12:30 UTC fetch. +_PROVIDER_MIN_INTERVAL = 4 * 60 * 60 # 14400 s + class Energyforecast(DynamicTariffBaseclass): - """ Implement energyforecast.de API to get dynamic electricity prices + """ Implement energyforecast.de API v2 to get dynamic electricity prices Inherits from DynamicTariffBaseclass - Uses 48-hour forecast window for better day-ahead planning. - - Energyforecast API supports both resolutions: - - hourly: Hourly prices (60-minute intervals) - - quarter_hourly: 15-minute prices + API v2 delivers complete calendar days (plan-dependent horizon). + Data is always quarter-hourly; no resolution parameter is needed. - The native resolution is set based on target_resolution to fetch - data at the optimal granularity from the API. + Supported market zones: DE-LU (default), AT, FR, NL, BE, PL, DK1, DK2 """ def __init__(self, timezone, token, min_time_between_API_calls=0, - delay_evaluation_by_seconds=0, target_resolution: int = 60): + delay_evaluation_by_seconds=0, target_resolution: int = 60, + market_zone: str = 'DE'): """ Initialize Energyforecast class with parameters """ - # Energyforecast API supports both resolutions - if target_resolution == 15: - native_resolution = 15 - self.api_resolution = "quarter_hourly" - else: - native_resolution = 60 - self.api_resolution = "hourly" + # Enforce provider-specific minimum refresh interval. + effective_interval = max(min_time_between_API_calls, _PROVIDER_MIN_INTERVAL) + # API v2 always delivers quarter-hourly data. super().__init__( timezone, - min_time_between_API_calls, + effective_interval, delay_evaluation_by_seconds, target_resolution=target_resolution, - native_resolution=native_resolution + native_resolution=15 ) - self.url = 'https://www.energyforecast.de/api/v1/predictions/next_48_hours' + self.url = 'https://www.energyforecast.de/api/v2/forecast' self.token = token + self.market_zone = market_zone self.vat = 0 self.price_fees = 0 self.price_markup = 0 self.network_fees_fetcher = None logger.info( - 'Energyforecast: Configured to fetch %s data (resolution=%d min)', - self.api_resolution, - self.native_resolution + 'Energyforecast: Configured for market_zone=%s, refresh every %d s', + self.market_zone, + effective_interval ) - def upgrade_48h_to_96h(self): - """ During initialization, we can upgrade the forecast if user wants 96h horizon """ - self.url = 'https://www.energyforecast.de/api/v1/predictions/next_96_hours' - def set_price_parameters( self, vat: float, price_fees: float, price_markup: float): """ Set the extra price parameters for the tariff calculation """ @@ -92,65 +85,63 @@ def set_network_fees_fetcher(self, fetcher): self.network_fees_fetcher = fetcher def get_raw_data_from_provider(self): - """ Get raw data from energyforecast.de API and return parsed json """ - logger.debug('Requesting price forecast from energyforecast.de API (resolution=%s)', - self.api_resolution) + """ Get raw data from energyforecast.de API v2 and return parsed json """ + logger.debug('Requesting price forecast from energyforecast.de API v2 (zone=%s)', + self.market_zone) if not self.token: raise RuntimeError('[Energyforecast] API token is required') try: - # Request base prices without provider-side calculations - # We apply vat, fees, and markup locally + # Request base prices without provider-side calculations; + # we apply vat, fees, and markup locally. params = { - 'resolution': self.api_resolution, 'token': self.token, + 'market_zone': self.market_zone, 'vat': 0, 'fixed_cost_cent': 0 } response = requests.get(self.url, params=params, timeout=30) response.raise_for_status() - if response.status_code != 200: - raise ConnectionError( - f'[Energyforecast] API returned {response}') except requests.exceptions.RequestException as e: raise ConnectionError( f'[Energyforecast] API request failed: {e}') from e - response_json = response.json() - return {'data': response_json} + return response.json() def _get_prices_native(self) -> dict[int, float]: - """Get hour-aligned prices at native resolution. - - Expected API response format: - data: [ - { - "start": "2025-11-11T06:00:35.531Z", - "end": "2025-11-11T06:00:35.531Z", - "price": 0, - "price_origin": "string" - } - ] + """Get 15-min-aligned prices at native resolution. + + Expected API v2 response format: + { + "generated_at": "...", + "valid_until": "...", + "data": [ + { + "start": "2025-11-11T06:00:00+01:00", + "end": "2025-11-11T06:15:00+01:00", + "price_ct_kwh": 12.3456, + "total_ct_kwh": 27.8901, + "price_origin": "market" + } + ] + } + + Prices from the API are in ct/kWh; we convert to EUR/kWh (/100) to + stay consistent with the existing fees/markup/vat config values. Returns: - Dict mapping interval index to price value + Dict mapping interval index to price value (EUR/kWh) Index 0 = start of current hour - For 15-min resolution: indices 0-3 represent the current hour """ raw_data = self.get_raw_data() data = raw_data.get('data', []) now = datetime.datetime.now(self.timezone) - # Align to start of current hour current_hour_start = now.replace(minute=0, second=0, microsecond=0) prices = {} - # Determine interval duration in seconds - interval_seconds = self.native_resolution * 60 + interval_seconds = self.native_resolution * 60 # 900 s for item in data: - # Parse ISO format timestamp - # Python <3.11 does not support 'Z' (UTC) in fromisoformat(), - # so we replace it with '+00:00'. - # Remove this workaround if only supporting Python 3.11+. + # Python <3.11 does not support 'Z' in fromisoformat(). timestamp = datetime.datetime.fromisoformat( item['start'].replace('Z', '+00:00') ).astimezone(self.timezone) @@ -159,11 +150,12 @@ def _get_prices_native(self) -> dict[int, float]: rel_interval = int(diff.total_seconds() / interval_seconds) if rel_interval >= 0: - base_price = item['price'] + # price_ct_kwh is in ct/kWh; convert to EUR/kWh for consistency + # with fees/markup/vat config values. + base_price = item['price_ct_kwh'] / 100 network_fee = 0.0 if self.network_fees_fetcher is not None: - network_fee = self.network_fees_fetcher.get_fee_at( - timestamp) + network_fee = self.network_fees_fetcher.get_fee_at(timestamp) end_price = ( (base_price * (1 + self.price_markup) + self.price_fees + network_fee) diff --git a/tests/batcontrol/dynamictariff/test_energyforecast.py b/tests/batcontrol/dynamictariff/test_energyforecast.py index 39340d39..4dc8213d 100644 --- a/tests/batcontrol/dynamictariff/test_energyforecast.py +++ b/tests/batcontrol/dynamictariff/test_energyforecast.py @@ -3,7 +3,7 @@ import datetime import pytz from unittest.mock import patch -from batcontrol.dynamictariff.energyforecast import Energyforecast +from batcontrol.dynamictariff.energyforecast import Energyforecast, _PROVIDER_MIN_INTERVAL class TestEnergyforecast(unittest.TestCase): @@ -16,117 +16,109 @@ def setUp(self): self.markup = 0.03 def test_basic_price_extraction(self): - """Test basic price extraction from API response""" + """Test basic price extraction from API v2 response""" energyforecast = Energyforecast(self.timezone, self.token) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - # Mock raw data - store as dict with 'data' key + # Mock raw data with v2 format: price_ct_kwh in ct/kWh raw_data = { 'data': [ { 'start': '2024-06-20T10:00:00+02:00', - 'end': '2024-06-20T11:00:00+02:00', - 'price': 0.20, - 'price_origin': 'test' + 'end': '2024-06-20T10:15:00+02:00', + 'price_ct_kwh': 20.0, + 'total_ct_kwh': 25.0, + 'price_origin': 'market' }, { 'start': '2024-06-20T11:00:00+02:00', - 'end': '2024-06-20T12:00:00+02:00', - 'price': 0.25, - 'price_origin': 'test' + 'end': '2024-06-20T11:15:00+02:00', + 'price_ct_kwh': 25.0, + 'total_ct_kwh': 30.0, + 'price_origin': 'market' }, { 'start': '2024-06-20T12:00:00+02:00', - 'end': '2024-06-20T13:00:00+02:00', - 'price': 0.22, - 'price_origin': 'test' + 'end': '2024-06-20T12:15:00+02:00', + 'price_ct_kwh': 22.0, + 'total_ct_kwh': 27.0, + 'price_origin': 'forecast' } ] } energyforecast.store_raw_data(raw_data) - # Mock datetime to return a specific time with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: - # Set current time to 10:00 so all prices are in the future mock_now = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) mock_datetime.datetime.now.return_value = mock_now mock_datetime.datetime.fromisoformat = datetime.datetime.fromisoformat prices = energyforecast._get_prices_native() - # Verify prices are calculated with fees, markup and vat - # Formula: (price * (1 + markup) + fees) * (1 + vat) + # price_ct_kwh is divided by 100 before applying markup/fees/vat expected_price_0 = (0.20 * (1 + 0.03) + 0.015) * (1 + 0.20) - expected_price_1 = (0.25 * (1 + 0.03) + 0.015) * (1 + 0.20) - expected_price_2 = (0.22 * (1 + 0.03) + 0.015) * (1 + 0.20) + expected_price_4 = (0.25 * (1 + 0.03) + 0.015) * (1 + 0.20) + expected_price_8 = (0.22 * (1 + 0.03) + 0.015) * (1 + 0.20) self.assertAlmostEqual(prices[0], expected_price_0, places=5) - self.assertAlmostEqual(prices[1], expected_price_1, places=5) - self.assertAlmostEqual(prices[2], expected_price_2, places=5) + self.assertAlmostEqual(prices[4], expected_price_4, places=5) + self.assertAlmostEqual(prices[8], expected_price_8, places=5) - def test_48_hour_window(self): - """Test that 48-hour forecast window is supported""" + def test_quarter_hourly_resolution(self): + """Test that API v2 quarter-hourly data is parsed correctly""" energyforecast = Energyforecast(self.timezone, self.token) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - # Create 48 hours of data - use timezone-aware timestamps matching Berlin timezone + # Create 2 hours of quarter-hourly data (8 intervals) data_list = [] base_time = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) - for hour in range(48): - start_time = base_time + datetime.timedelta(hours=hour) + for i in range(8): + start_time = base_time + datetime.timedelta(minutes=15 * i) data_list.append({ 'start': start_time.isoformat(), - 'end': (start_time + datetime.timedelta(hours=1)).isoformat(), - 'price': 0.20 + (hour * 0.001), # Vary price slightly - 'price_origin': 'test' + 'end': (start_time + datetime.timedelta(minutes=15)).isoformat(), + 'price_ct_kwh': 20.0 + i * 0.5, + 'total_ct_kwh': 35.0 + i * 0.5, + 'price_origin': 'market' }) - raw_data = {'data': data_list} - energyforecast.store_raw_data(raw_data) + energyforecast.store_raw_data({'data': data_list}) - # Mock datetime to return a specific time with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: - # Set current time to match the first data point - mock_now = base_time - mock_datetime.datetime.now.return_value = mock_now + mock_datetime.datetime.now.return_value = base_time mock_datetime.datetime.fromisoformat = datetime.datetime.fromisoformat prices = energyforecast._get_prices_native() - # Should have prices for all 48 hours - self.assertEqual(len(prices), 48) - # Verify we have consecutive hours from 0 to 47 - for hour in range(48): - self.assertIn(hour, prices, f"Hour {hour} should be present") + self.assertEqual(len(prices), 8) + for i in range(8): + self.assertIn(i, prices) def test_timezone_handling_utc(self): """Test correct timezone handling with UTC timestamps""" energyforecast = Energyforecast(self.timezone, self.token) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - # Use UTC timestamp with 'Z' suffix raw_data = { 'data': [ { - 'start': '2024-06-20T08:00:00Z', # UTC time - 'end': '2024-06-20T09:00:00Z', - 'price': 0.20, - 'price_origin': 'test' + 'start': '2024-06-20T08:00:00Z', # UTC = 10:00 Europe/Berlin + 'end': '2024-06-20T08:15:00Z', + 'price_ct_kwh': 20.0, + 'total_ct_kwh': 35.0, + 'price_origin': 'market' } ] } energyforecast.store_raw_data(raw_data) - # Mock datetime to return Europe/Berlin time with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: - # 08:00 UTC = 10:00 Europe/Berlin in summer mock_now = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) mock_datetime.datetime.now.return_value = mock_now mock_datetime.datetime.fromisoformat = datetime.datetime.fromisoformat prices = energyforecast._get_prices_native() - # Should have the price at rel_hour 0 self.assertIn(0, prices) def test_filter_past_prices(self): @@ -138,33 +130,36 @@ def test_filter_past_prices(self): 'data': [ { 'start': '2024-06-20T08:00:00+02:00', # Past - 'end': '2024-06-20T09:00:00+02:00', - 'price': 0.18, - 'price_origin': 'test' + 'end': '2024-06-20T08:15:00+02:00', + 'price_ct_kwh': 18.0, + 'total_ct_kwh': 33.0, + 'price_origin': 'market' }, { - 'start': '2024-06-20T09:00:00+02:00', # Past + 'start': '2024-06-20T09:45:00+02:00', # Past 'end': '2024-06-20T10:00:00+02:00', - 'price': 0.19, - 'price_origin': 'test' + 'price_ct_kwh': 19.0, + 'total_ct_kwh': 34.0, + 'price_origin': 'market' }, { - 'start': '2024-06-20T10:00:00+02:00', # Current/future - 'end': '2024-06-20T11:00:00+02:00', - 'price': 0.20, - 'price_origin': 'test' + 'start': '2024-06-20T10:00:00+02:00', # Current + 'end': '2024-06-20T10:15:00+02:00', + 'price_ct_kwh': 20.0, + 'total_ct_kwh': 35.0, + 'price_origin': 'market' }, { - 'start': '2024-06-20T11:00:00+02:00', # Future - 'end': '2024-06-20T12:00:00+02:00', - 'price': 0.21, - 'price_origin': 'test' + 'start': '2024-06-20T10:15:00+02:00', # Future + 'end': '2024-06-20T10:30:00+02:00', + 'price_ct_kwh': 21.0, + 'total_ct_kwh': 36.0, + 'price_origin': 'forecast' } ] } energyforecast.store_raw_data(raw_data) - # Mock datetime to return 10:00 with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: mock_now = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) mock_datetime.datetime.now.return_value = mock_now @@ -172,7 +167,7 @@ def test_filter_past_prices(self): prices = energyforecast._get_prices_native() - # Should only have future/current prices (rel_hour >= 0) + # Only current and future intervals (rel_interval >= 0) self.assertEqual(len(prices), 2) self.assertIn(0, prices) self.assertIn(1, prices) @@ -189,9 +184,10 @@ def test_price_calculation_formula(self): 'data': [ { 'start': '2024-06-20T10:00:00+02:00', - 'end': '2024-06-20T11:00:00+02:00', - 'price': 0.30, - 'price_origin': 'test' + 'end': '2024-06-20T10:15:00+02:00', + 'price_ct_kwh': 30.0, # = 0.30 EUR/kWh + 'total_ct_kwh': 45.0, + 'price_origin': 'market' } ] } @@ -204,8 +200,8 @@ def test_price_calculation_formula(self): prices = energyforecast._get_prices_native() - # Formula: (price * (1 + markup) + fees) * (1 + vat) - # (0.30 * 1.05 + 0.01) * 1.19 + # base_price = price_ct_kwh / 100 = 0.30 + # Formula: (0.30 * 1.05 + 0.01) * 1.19 expected = (0.30 * 1.05 + 0.01) * 1.19 self.assertAlmostEqual(prices[0], expected, places=5) @@ -214,8 +210,7 @@ def test_empty_forecast_data(self): energyforecast = Energyforecast(self.timezone, self.token) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - raw_data = {'data': []} - energyforecast.store_raw_data(raw_data) + energyforecast.store_raw_data({'data': []}) with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: mock_now = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) @@ -224,27 +219,22 @@ def test_empty_forecast_data(self): prices = energyforecast._get_prices_native() - # Should return empty dict self.assertEqual(len(prices), 0) - def test_missing_forecast_key(self): + def test_missing_data_key(self): """Test handling when 'data' key is missing""" energyforecast = Energyforecast(self.timezone, self.token) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - # Store dict without 'data' key to test error handling - raw_data = {} - energyforecast.store_raw_data(raw_data) + energyforecast.store_raw_data({}) with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_datetime: mock_now = self.timezone.localize(datetime.datetime(2024, 6, 20, 10, 0, 0)) mock_datetime.datetime.now.return_value = mock_now mock_datetime.datetime.fromisoformat = datetime.datetime.fromisoformat - # Should handle missing 'data' key gracefully prices = energyforecast._get_prices_native() - # Should return empty dict when 'data' key is missing self.assertEqual(len(prices), 0) def test_token_required(self): @@ -252,12 +242,50 @@ def test_token_required(self): energyforecast = Energyforecast(self.timezone, None) energyforecast.set_price_parameters(self.vat, self.fees, self.markup) - # Should raise RuntimeError when trying to get data without token with self.assertRaises(RuntimeError) as context: energyforecast.get_raw_data_from_provider() self.assertIn('token is required', str(context.exception).lower()) + def test_market_zone_default(self): + """Test that market_zone defaults to DE-LU""" + energyforecast = Energyforecast(self.timezone, self.token) + self.assertEqual(energyforecast.market_zone, 'DE') + + def test_market_zone_custom(self): + """Test that custom market_zone is passed to the API request""" + energyforecast = Energyforecast(self.timezone, self.token, market_zone='AT') + self.assertEqual(energyforecast.market_zone, 'AT') + + with patch('batcontrol.dynamictariff.energyforecast.requests.get') as mock_get: + mock_response = unittest.mock.Mock() + mock_response.json.return_value = {'data': []} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + energyforecast.get_raw_data_from_provider() + + call_kwargs = mock_get.call_args + params = call_kwargs[1]['params'] if 'params' in call_kwargs[1] else call_kwargs[0][1] + self.assertEqual(params['market_zone'], 'AT') + + def test_refresh_interval_floor(self): + """Test that refresh interval is at least 4 hours""" + ef_default = Energyforecast(self.timezone, self.token, min_time_between_API_calls=0) + self.assertEqual(ef_default.min_time_between_updates, _PROVIDER_MIN_INTERVAL) + + ef_short = Energyforecast(self.timezone, self.token, min_time_between_API_calls=60) + self.assertEqual(ef_short.min_time_between_updates, _PROVIDER_MIN_INTERVAL) + + longer = _PROVIDER_MIN_INTERVAL + 3600 + ef_long = Energyforecast(self.timezone, self.token, min_time_between_API_calls=longer) + self.assertEqual(ef_long.min_time_between_updates, longer) + + def test_api_v2_url(self): + """Test that the v2 API endpoint is used""" + energyforecast = Energyforecast(self.timezone, self.token) + self.assertIn('/api/v2/forecast', energyforecast.url) + if __name__ == '__main__': unittest.main() From 1628ca2be25bb5d87eb95a281be831cb0054080d Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Sun, 31 May 2026 19:01:09 +0200 Subject: [PATCH 2/5] test: fix test_baseclass and test_network_fees for API v2 Update tests that still referenced v1 API behavior: - test_baseclass: remove api_resolution assertions (attribute gone in v2), update native_resolution expectation to always 15 (quarter-hourly) - test_network_fees: replace 'price' (EUR/kWh) with 'price_ct_kwh' (ct/kWh) in all Energyforecast mock data Co-Authored-By: Claude Sonnet 4.6 --- tests/batcontrol/dynamictariff/test_baseclass.py | 9 ++++----- tests/batcontrol/dynamictariff/test_network_fees.py | 8 ++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/batcontrol/dynamictariff/test_baseclass.py b/tests/batcontrol/dynamictariff/test_baseclass.py index 15476b2c..96e67269 100644 --- a/tests/batcontrol/dynamictariff/test_baseclass.py +++ b/tests/batcontrol/dynamictariff/test_baseclass.py @@ -332,24 +332,23 @@ def timezone(self): return pytz.timezone('Europe/Berlin') def test_energyforecast_initialization_hourly(self, timezone): - """Test Energyforecast provider initialization with hourly""" + """Test Energyforecast provider initialization with hourly target resolution""" from batcontrol.dynamictariff.energyforecast import Energyforecast provider = Energyforecast(timezone, 'test_token', 900, 0, target_resolution=60) - assert provider.native_resolution == 60 + # API v2 always delivers quarter-hourly; native_resolution is always 15 + assert provider.native_resolution == 15 assert provider.target_resolution == 60 - assert provider.api_resolution == "hourly" def test_energyforecast_initialization_15min(self, timezone): - """Test Energyforecast provider initialization with 15-min""" + """Test Energyforecast provider initialization with 15-min target resolution""" from batcontrol.dynamictariff.energyforecast import Energyforecast provider = Energyforecast(timezone, 'test_token', 900, 0, target_resolution=15) assert provider.native_resolution == 15 assert provider.target_resolution == 15 - assert provider.api_resolution == "quarter_hourly" class TestDynamicTariffFactory: diff --git a/tests/batcontrol/dynamictariff/test_network_fees.py b/tests/batcontrol/dynamictariff/test_network_fees.py index c5db1a26..c57b92ac 100644 --- a/tests/batcontrol/dynamictariff/test_network_fees.py +++ b/tests/batcontrol/dynamictariff/test_network_fees.py @@ -191,11 +191,11 @@ def test_energyforecast_price_includes_network_fee_hourly(self): raw_data = {'data': [{ 'start': fixed_ts.isoformat(), 'end': (fixed_ts + datetime.timedelta(hours=1)).isoformat(), - 'price': 0.10 + 'price_ct_kwh': 10.0 # 10 ct/kWh = 0.10 EUR/kWh }]} ef.store_raw_data(raw_data) - base = 0.10 + base = 0.10 # price_ct_kwh / 100 expected = (base + 0.005 + network_fee) * 1.19 with patch('batcontrol.dynamictariff.energyforecast.datetime') as mock_dt: @@ -225,7 +225,7 @@ def test_energyforecast_price_includes_network_fee_15min(self): raw_data = {'data': [ {'start': (fixed_ts + datetime.timedelta(minutes=15 * i)).isoformat(), 'end': (fixed_ts + datetime.timedelta(minutes=15 * (i + 1))).isoformat(), - 'price': 0.10 + 0.01 * i} + 'price_ct_kwh': (0.10 + 0.01 * i) * 100} # ct/kWh, code divides by 100 for i in range(4) ]} ef.store_raw_data(raw_data) @@ -253,7 +253,7 @@ def test_energyforecast_without_fetcher_unchanged(self): raw_data = {'data': [{ 'start': fixed_ts.isoformat(), 'end': (fixed_ts + datetime.timedelta(hours=1)).isoformat(), - 'price': 0.10 + 'price_ct_kwh': 10.0 # 10 ct/kWh = 0.10 EUR/kWh }]} ef.store_raw_data(raw_data) From 3ae03f2fff9220954b4e6ce2ab92f3d2061cc34c Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Sun, 31 May 2026 19:06:07 +0200 Subject: [PATCH 3/5] energyforecast: normalize DE/LU market zone aliases to DE-LU The energyforecast.de API uses DE-LU as the market zone identifier for Germany and Luxembourg. Add a _MARKET_ZONE_ALIASES mapping so that users can configure market_zone: DE or market_zone: LU and get the correct DE-LU zone automatically. Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/dynamictariff/energyforecast.py | 5 ++++- .../dynamictariff/test_energyforecast.py | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/batcontrol/dynamictariff/energyforecast.py b/src/batcontrol/dynamictariff/energyforecast.py index 445f6cc7..72aeca28 100644 --- a/src/batcontrol/dynamictariff/energyforecast.py +++ b/src/batcontrol/dynamictariff/energyforecast.py @@ -33,6 +33,9 @@ # 6 daily calls cover the full day with headroom for the forced 12:30 UTC fetch. _PROVIDER_MIN_INTERVAL = 4 * 60 * 60 # 14400 s +# Convenience aliases: DE and LU are both served by the DE-LU market zone. +_MARKET_ZONE_ALIASES = {'DE': 'DE-LU', 'LU': 'DE-LU'} + class Energyforecast(DynamicTariffBaseclass): """ Implement energyforecast.de API v2 to get dynamic electricity prices @@ -61,7 +64,7 @@ def __init__(self, timezone, token, min_time_between_API_calls=0, ) self.url = 'https://www.energyforecast.de/api/v2/forecast' self.token = token - self.market_zone = market_zone + self.market_zone = _MARKET_ZONE_ALIASES.get(market_zone, market_zone) self.vat = 0 self.price_fees = 0 self.price_markup = 0 diff --git a/tests/batcontrol/dynamictariff/test_energyforecast.py b/tests/batcontrol/dynamictariff/test_energyforecast.py index 4dc8213d..585f031d 100644 --- a/tests/batcontrol/dynamictariff/test_energyforecast.py +++ b/tests/batcontrol/dynamictariff/test_energyforecast.py @@ -248,9 +248,20 @@ def test_token_required(self): self.assertIn('token is required', str(context.exception).lower()) def test_market_zone_default(self): - """Test that market_zone defaults to DE-LU""" + """Test that market_zone default 'DE' is normalized to 'DE-LU'""" energyforecast = Energyforecast(self.timezone, self.token) - self.assertEqual(energyforecast.market_zone, 'DE') + self.assertEqual(energyforecast.market_zone, 'DE-LU') + + def test_market_zone_aliases(self): + """Test that DE and LU are both normalized to DE-LU""" + ef_de = Energyforecast(self.timezone, self.token, market_zone='DE') + self.assertEqual(ef_de.market_zone, 'DE-LU') + + ef_lu = Energyforecast(self.timezone, self.token, market_zone='LU') + self.assertEqual(ef_lu.market_zone, 'DE-LU') + + ef_delu = Energyforecast(self.timezone, self.token, market_zone='DE-LU') + self.assertEqual(ef_delu.market_zone, 'DE-LU') def test_market_zone_custom(self): """Test that custom market_zone is passed to the API request""" From 7dc2ff9a3d75f57a43c26029c94b424a72953b02 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Sun, 31 May 2026 19:09:15 +0200 Subject: [PATCH 4/5] energyforecast: address Copilot review comments - dynamictariff.py: move logging init to module top; remove inline import-alias pattern in deprecation warning block - energyforecast.py: fix module and class docstrings to reflect actual default market_zone='DE' (normalized to DE-LU) instead of 'DE-LU' - test_energyforecast.py: align test docstring with actual default - batcontrol_config_dummy.yaml: document optional market_zone key for energyforecast with alias hint (DE/LU -> DE-LU) Co-Authored-By: Claude Sonnet 4.6 --- config/batcontrol_config_dummy.yaml | 1 + src/batcontrol/dynamictariff/dynamictariff.py | 7 +++++-- src/batcontrol/dynamictariff/energyforecast.py | 5 +++-- tests/batcontrol/dynamictariff/test_energyforecast.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index d5651b1b..b69749f4 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -129,6 +129,7 @@ utility: # tariff_zone_3: 0.2100 # optional third zone price # zone_3_hours: 17-20 # optional hours for zone 3 (must not overlap with zone 1 or 2) # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. + # market_zone: DE # optional for energyforecast: DE/LU (both map to DE-LU, default), AT, FR, NL, BE, PL, DK1, DK2 #-------------------------- # Dynamic Network Fees (para. 14a EnWG) diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index 2de065c6..f17cc0fb 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -15,6 +15,8 @@ RuntimeError: If required fields are missing in the configuration or if the provider type is unknown. """ +import logging + from .awattar import Awattar from .tibber import Tibber from .evcc import Evcc @@ -23,6 +25,8 @@ from .network_fees import NetworkFeesFetcher from .dynamictariff_interface import TariffInterface +logger = logging.getLogger(__name__) + class DynamicTariff: """ DynamicTariff factory""" @@ -139,8 +143,7 @@ def create_tarif_provider(config: dict, timezone, f'[DynTariff] Please include {field} in your configuration file' ) if provider.lower() == 'energyforecast_96': - import logging as _log - _log.getLogger(__name__).warning( + logger.warning( '[DynTariff] Provider "energyforecast_96" is deprecated. ' 'API v2 delivers plan-based multi-day forecasts automatically. ' 'Use "energyforecast" instead.' diff --git a/src/batcontrol/dynamictariff/energyforecast.py b/src/batcontrol/dynamictariff/energyforecast.py index 72aeca28..2f83c983 100644 --- a/src/batcontrol/dynamictariff/energyforecast.py +++ b/src/batcontrol/dynamictariff/energyforecast.py @@ -11,7 +11,7 @@ __init__(self, timezone, token, - market_zone='DE-LU', + market_zone='DE', min_time_between_API_calls=0): Initializes the Energyforecast class with the specified parameters. @@ -44,7 +44,8 @@ class Energyforecast(DynamicTariffBaseclass): API v2 delivers complete calendar days (plan-dependent horizon). Data is always quarter-hourly; no resolution parameter is needed. - Supported market zones: DE-LU (default), AT, FR, NL, BE, PL, DK1, DK2 + Supported market zones: DE (default, normalized to DE-LU), LU (normalized to DE-LU), + AT, FR, NL, BE, PL, DK1, DK2 """ def __init__(self, timezone, token, min_time_between_API_calls=0, diff --git a/tests/batcontrol/dynamictariff/test_energyforecast.py b/tests/batcontrol/dynamictariff/test_energyforecast.py index 585f031d..79433ad7 100644 --- a/tests/batcontrol/dynamictariff/test_energyforecast.py +++ b/tests/batcontrol/dynamictariff/test_energyforecast.py @@ -248,7 +248,7 @@ def test_token_required(self): self.assertIn('token is required', str(context.exception).lower()) def test_market_zone_default(self): - """Test that market_zone default 'DE' is normalized to 'DE-LU'""" + """Test that the default market_zone 'DE' is normalized to 'DE-LU'""" energyforecast = Energyforecast(self.timezone, self.token) self.assertEqual(energyforecast.market_zone, 'DE-LU') From cca10697c38685b4ee82792ec4e60225af4ea59c Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Sun, 31 May 2026 19:20:35 +0200 Subject: [PATCH 5/5] energyforecast: fix docstrings, normalize market_zone, simplify test - Module docstring: sync __init__ signature (add delay_evaluation_by_seconds, target_resolution; fix parameter order) - _get_prices_native docstring: "15-min-aligned" -> "hour-aligned" to match baseclass contract (index 0 = start of current hour) - market_zone input: strip + upper before alias lookup so 'de'/'at' etc. are accepted alongside canonical uppercase values - test_market_zone_custom: replace brittle call_args fallback with mock_get.call_args.kwargs['params']; add lowercase alias test case Co-Authored-By: Claude Sonnet 4.6 --- src/batcontrol/dynamictariff/energyforecast.py | 11 +++++++---- tests/batcontrol/dynamictariff/test_energyforecast.py | 9 +++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/batcontrol/dynamictariff/energyforecast.py b/src/batcontrol/dynamictariff/energyforecast.py index 2f83c983..094257ef 100644 --- a/src/batcontrol/dynamictariff/energyforecast.py +++ b/src/batcontrol/dynamictariff/energyforecast.py @@ -11,8 +11,10 @@ __init__(self, timezone, token, - market_zone='DE', - min_time_between_API_calls=0): + min_time_between_API_calls=0, + delay_evaluation_by_seconds=0, + target_resolution=60, + market_zone='DE'): Initializes the Energyforecast class with the specified parameters. @@ -65,7 +67,8 @@ def __init__(self, timezone, token, min_time_between_API_calls=0, ) self.url = 'https://www.energyforecast.de/api/v2/forecast' self.token = token - self.market_zone = _MARKET_ZONE_ALIASES.get(market_zone, market_zone) + normalized = market_zone.strip().upper() + self.market_zone = _MARKET_ZONE_ALIASES.get(normalized, normalized) self.vat = 0 self.price_fees = 0 self.price_markup = 0 @@ -112,7 +115,7 @@ def get_raw_data_from_provider(self): return response.json() def _get_prices_native(self) -> dict[int, float]: - """Get 15-min-aligned prices at native resolution. + """Get hour-aligned prices at native (15-min) resolution. Expected API v2 response format: { diff --git a/tests/batcontrol/dynamictariff/test_energyforecast.py b/tests/batcontrol/dynamictariff/test_energyforecast.py index 79433ad7..66e0ba4e 100644 --- a/tests/batcontrol/dynamictariff/test_energyforecast.py +++ b/tests/batcontrol/dynamictariff/test_energyforecast.py @@ -253,13 +253,16 @@ def test_market_zone_default(self): self.assertEqual(energyforecast.market_zone, 'DE-LU') def test_market_zone_aliases(self): - """Test that DE and LU are both normalized to DE-LU""" + """Test that DE and LU (and lowercase variants) are normalized to DE-LU""" ef_de = Energyforecast(self.timezone, self.token, market_zone='DE') self.assertEqual(ef_de.market_zone, 'DE-LU') ef_lu = Energyforecast(self.timezone, self.token, market_zone='LU') self.assertEqual(ef_lu.market_zone, 'DE-LU') + ef_lower = Energyforecast(self.timezone, self.token, market_zone='de') + self.assertEqual(ef_lower.market_zone, 'DE-LU') + ef_delu = Energyforecast(self.timezone, self.token, market_zone='DE-LU') self.assertEqual(ef_delu.market_zone, 'DE-LU') @@ -276,9 +279,7 @@ def test_market_zone_custom(self): energyforecast.get_raw_data_from_provider() - call_kwargs = mock_get.call_args - params = call_kwargs[1]['params'] if 'params' in call_kwargs[1] else call_kwargs[0][1] - self.assertEqual(params['market_zone'], 'AT') + self.assertEqual(mock_get.call_args.kwargs['params']['market_zone'], 'AT') def test_refresh_interval_floor(self): """Test that refresh interval is at least 4 hours"""