diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index d5651b1..b69749f 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 c281e0f..f17cc0f 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""" @@ -138,22 +142,28 @@ def create_tarif_provider(config: dict, timezone, raise RuntimeError( f'[DynTariff] Please include {field} in your configuration file' ) + if provider.lower() == 'energyforecast_96': + logger.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 acf0acb..094257e 100644 --- a/src/batcontrol/dynamictariff/energyforecast.py +++ b/src/batcontrol/dynamictariff/energyforecast.py @@ -1,24 +1,25 @@ """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, - min_time_between_API_calls=0): + token, + 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. 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 +31,55 @@ 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 + +# 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 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 (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, - 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 + 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 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 +92,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 hour-aligned prices at native (15-min) 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 +157,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_baseclass.py b/tests/batcontrol/dynamictariff/test_baseclass.py index 15476b2..96e6726 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_energyforecast.py b/tests/batcontrol/dynamictariff/test_energyforecast.py index 39340d3..66e0ba4 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,62 @@ 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 the default market_zone 'DE' is normalized to 'DE-LU'""" + energyforecast = Energyforecast(self.timezone, self.token) + self.assertEqual(energyforecast.market_zone, 'DE-LU') + + def test_market_zone_aliases(self): + """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') + + 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() + + 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""" + 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() diff --git a/tests/batcontrol/dynamictariff/test_network_fees.py b/tests/batcontrol/dynamictariff/test_network_fees.py index c5db1a2..c57b92a 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)