diff --git a/README.MD b/README.MD index 458a7a7..73b6871 100644 --- a/README.MD +++ b/README.MD @@ -10,7 +10,7 @@ To integrate batcontrol with Home Assistant, use the following repository: [batc ## Prerequisites: 1. A PV installation with a BYD Battery and a Fronius Gen24 inverter. -2. An EPEX Spot based contract with hourly electricity pricing, like Awattar, Tibber etc. (Get a €50 bonus on sign-up to Tibber using this [link](https://invite.tibber.com/x8ci52nj).) +2. A zone based pricing, like Octopus, or an EPEX Spot based contract with hourly electricity pricing, like Awattar, Tibber etc. (Get a €50 bonus on sign-up to Tibber using this [link](https://invite.tibber.com/x8ci52nj).) 3. Customer login details to the inverter. **OR** use the MQTT inverter driver to integrate any battery/inverter system - see [MQTT Inverter Integration](#mqtt-inverter-integration) below. diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 18ec92a..c938f92 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -64,10 +64,16 @@ inverter: # See more Details in: https://github.com/MaStr/batcontrol/wiki/Dynamic-tariff-provider #-------------------------- utility: - type: awattar_de # [tibber, awattar_at, awattar_de, evcc, energyforecast] + type: awattar_de # [tibber, awattar_at, awattar_de, evcc, energyforecast, tariff_zones] vat: 0.19 # only required for awattar and energyforecast fees: 0.015 # only required for awattar and energyforecast markup: 0.03 # only required for awattar and energyforecast + # tariff_zone_1: 0.2733 # only required for tariff_zones, Euro/kWh incl. vat/fees (peak hours) + # zone_1_hours: 7-22 # hours assigned to zone 1; supports ranges (7-22), singles (7), or mixed (0-5,6,7) + # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees (off-peak hours) + # zone_2_hours: 0-6,23 # hours assigned to zone 2 (must cover remaining hours) + # 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. #-------------------------- diff --git a/run_tests.ps1 b/run_tests.ps1 new file mode 100644 index 0000000..2f5e9ff --- /dev/null +++ b/run_tests.ps1 @@ -0,0 +1,23 @@ +# PowerShell version of run_tests.sh + +# Activate virtual environment if it exists (Windows path used by venv) +if (Test-Path -Path .\.venv\Scripts\Activate.ps1) { + . .\.venv\Scripts\Activate.ps1 +} + +# Ensure pytest and helpers are installed +python -m pip install --upgrade pip +python -m pip install pytest pytest-cov pytest-asyncio + +# Run pytest with coverage and logging options +$params = @( + 'tests/', + '--cov=src/batcontrol', + '--log-cli-level=DEBUG', + '--log-cli-format=%(asctime)s [%(levelname)8s] %(name)s: %(message)s', + '--log-cli-date-format=%Y-%m-%d %H:%M:%S' +) + +python -m pytest @params + +exit $LASTEXITCODE diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index b67cae1..030b7d1 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -19,6 +19,7 @@ from .tibber import Tibber from .evcc import Evcc from .energyforecast import Energyforecast +from .tariffzones import TariffZones from .dynamictariff_interface import TariffInterface @@ -129,6 +130,28 @@ def create_tarif_provider(config: dict, timezone, if provider.lower() == 'energyforecast_96': selected_tariff.upgrade_48h_to_96h() + elif provider.lower() == 'tariff_zones': + required_fields = ['tariff_zone_1', 'zone_1_hours', 'tariff_zone_2', 'zone_2_hours'] + for field in required_fields: + if field not in config: + raise RuntimeError( + f'[DynTariff] Please include {field} in your configuration file' + ) + zone_3_hours = config.get('zone_3_hours') + tariff_zone_3 = config.get('tariff_zone_3') + selected_tariff = TariffZones( + timezone, + min_time_between_api_calls, + delay_evaluation_by_seconds, + target_resolution=target_resolution, + tariff_zone_1=float(config['tariff_zone_1']), + zone_1_hours=config['zone_1_hours'], + tariff_zone_2=float(config['tariff_zone_2']), + zone_2_hours=config['zone_2_hours'], + tariff_zone_3=float(tariff_zone_3) if tariff_zone_3 is not None else None, + zone_3_hours=zone_3_hours, + ) + else: - raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}') + raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}') return selected_tariff diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py new file mode 100644 index 0000000..8e23b88 --- /dev/null +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -0,0 +1,289 @@ +"""Tariff_zones provider + +Simple dynamic tariff provider that assigns a fixed price to each hour of the day +using up to three configurable zones. + +Config options (in utility config for provider): +- type: tariff_zones +- tariff_zone_1: price for zone 1 hours (float, Euro/kWh incl. VAT/fees, required) +- zone_1_hours: comma-separated list of hours assigned to zone 1, e.g. "7,8,9,10" +- tariff_zone_2: price for zone 2 hours (float, Euro/kWh incl. VAT/fees, required) +- zone_2_hours: comma-separated list of hours assigned to zone 2, e.g. "0,1,2,3,4,5,6" +- tariff_zone_3: price for zone 3 hours (float, optional) +- zone_3_hours: comma-separated list of hours assigned to zone 3 (optional) + +Rules: +- Every hour 0-23 must appear in exactly one zone (ValueError if any hour is missing). +- No hour may appear more than once across all zones (ValueError on duplicate). +- zone_3_hours and tariff_zone_3 must both be set or both omitted. + +The class produces hourly prices (native_resolution=60) for the next 48 +hours aligned to the current hour. The baseclass will handle conversion to +15min if the target resolution is 15. + +Note: +The charge rate is not evenly distributed across the low price hours. +If you prefer a more even distribution during the low price hours, you can adjust the +soften_price_difference_on_charging to enabled +and +max_grid_charge_rate to a low value, e.g. capacity of the battery divided +by the hours of low price periods. + +If you prefer a late charging start (=optimize efficiency, have battery only short +time at high SOC), you can adjust the +soften_price_difference_on_charging to disabled +""" +import datetime +import logging +from .baseclass import DynamicTariffBaseclass + +logger = logging.getLogger(__name__) + + +class TariffZones(DynamicTariffBaseclass): + """Multi-zone tariff with up to 3 zones; each zone owns a set of hours.""" + + def __init__( + self, + timezone, + min_time_between_API_calls=0, + delay_evaluation_by_seconds=0, + target_resolution: int = 60, + tariff_zone_1: float = None, + zone_1_hours=None, + tariff_zone_2: float = None, + zone_2_hours=None, + tariff_zone_3: float = None, + zone_3_hours=None, + ): + super().__init__( + timezone, + min_time_between_API_calls, + delay_evaluation_by_seconds, + target_resolution=target_resolution, + native_resolution=60, + ) + + self._tariff_zone_1 = None + self._tariff_zone_2 = None + self._tariff_zone_3 = None + self._zone_1_hours = None + self._zone_2_hours = None + self._zone_3_hours = None + + if tariff_zone_1 is not None: + self.tariff_zone_1 = tariff_zone_1 + if zone_1_hours is not None: + self.zone_1_hours = zone_1_hours + if tariff_zone_2 is not None: + self.tariff_zone_2 = tariff_zone_2 + if zone_2_hours is not None: + self.zone_2_hours = zone_2_hours + if tariff_zone_3 is not None: + self.tariff_zone_3 = tariff_zone_3 + if zone_3_hours is not None: + self.zone_3_hours = zone_3_hours + + def get_raw_data_from_provider(self) -> dict: + """No external API — configuration is static.""" + return {} + + def _validate_configuration(self) -> None: + """Raise RuntimeError/ValueError if the zone configuration is incomplete or invalid.""" + if self._tariff_zone_1 is None: + raise RuntimeError('[TariffZones] tariff_zone_1 must be set') + if self._zone_1_hours is None: + raise RuntimeError('[TariffZones] zone_1_hours must be set') + if self._tariff_zone_2 is None: + raise RuntimeError('[TariffZones] tariff_zone_2 must be set') + if self._zone_2_hours is None: + raise RuntimeError('[TariffZones] zone_2_hours must be set') + + zone3_hours_set = self._zone_3_hours is not None + zone3_price_set = self._tariff_zone_3 is not None + if zone3_hours_set != zone3_price_set: + raise RuntimeError( + '[TariffZones] zone_3_hours and tariff_zone_3 must both be set or both omitted' + ) + + # Check for duplicate hours across all zones + seen = {} + for zone_name, hours in [ + ('zone_1_hours', self._zone_1_hours), + ('zone_2_hours', self._zone_2_hours), + ('zone_3_hours', self._zone_3_hours), + ]: + if hours is None: + continue + for h in hours: + if h in seen: + raise ValueError( + f'Hour {h} is defined in both {seen[h]} and {zone_name}' + ) + seen[h] = zone_name + + # Check all 24 hours are covered + missing = sorted(set(range(24)) - set(seen)) + if missing: + raise ValueError( + f'Hours {missing} are not assigned to any zone; ' + 'all 24 hours (0-23) must be covered' + ) + + def _get_prices_native(self) -> dict[int, float]: + """Build hourly prices for the next 48 hours, hour-aligned. + + Returns a dict mapping interval index (0 = start of current hour) + to price (float). + """ + self._validate_configuration() + + hour_to_price = {} + for hours, price in [ + (self._zone_1_hours, self._tariff_zone_1), + (self._zone_2_hours, self._tariff_zone_2), + (self._zone_3_hours, self._tariff_zone_3), + ]: + if hours is not None: + for h in hours: + hour_to_price[h] = price + + now = datetime.datetime.now().astimezone(self.timezone) + current_hour_start = now.replace(minute=0, second=0, microsecond=0) + + prices = {} + for rel_hour in range(48): + ts = current_hour_start + datetime.timedelta(hours=rel_hour) + prices[rel_hour] = hour_to_price[ts.hour] + + logger.debug('tariffZones: Generated %d hourly prices', len(prices)) + return prices + + @staticmethod + def _parse_hours(value, name: str) -> list: + """Parse hour specifications into a validated list of hours. + + Accepted formats (may be mixed): + - Single integer: 5 + - Comma-separated values: "0,1,2,3" + - Inclusive ranges: "0-5" → [0, 1, 2, 3, 4, 5] + - Mixed: "0-5,6,7" → [0, 1, 2, 3, 4, 5, 6, 7] + - Python list/tuple of ints or range-strings: [0, '1-3', 4] + + Raises ValueError if any hour is out of range [0, 23], if a range is + invalid (start > end), or if an hour appears more than once within the + same zone. + """ + def expand_token(token: str) -> list: + """Expand a single string token (range or integer) to a list of ints.""" + if '-' in token: + parts = token.split('-', 1) + try: + start, end = int(parts[0].strip()), int(parts[1].strip()) + except (ValueError, TypeError) as exc: + raise ValueError( + f'[{name}] invalid range: {token!r}' + ) from exc + if start > end: + raise ValueError( + f'[{name}] range start must be <= end, got {token!r}' + ) + return list(range(start, end + 1)) + try: + return [int(token)] + except (ValueError, TypeError) as exc: + raise ValueError( + f'[{name}] invalid hour value: {token!r}' + ) from exc + + if isinstance(value, int): + raw_ints = [value] + tokens = [] + elif isinstance(value, str): + raw_ints = [] + tokens = [p.strip() for p in value.split(',') if p.strip()] + elif isinstance(value, (list, tuple)): + # split into direct integers (no range parsing) and string tokens + raw_ints = [p for p in value if isinstance(p, int)] + tokens = [str(p).strip() for p in value + if not isinstance(p, int) and str(p).strip()] + else: + raise ValueError( + f'[{name}] must be a comma-separated string, list, or integer' + ) + + hours = [] + for h in raw_ints: + if h < 0 or h > 23: + raise ValueError(f'[{name}] hour {h} is out of range [0, 23]') + if h in hours: + raise ValueError(f'[{name}] hour {h} appears more than once') + hours.append(h) + + for token in tokens: + for h in expand_token(token): + if h < 0 or h > 23: + raise ValueError(f'[{name}] hour {h} is out of range [0, 23]') + if h in hours: + raise ValueError(f'[{name}] hour {h} appears more than once') + hours.append(h) + + return hours + + @staticmethod + def _validate_price(val, name: str) -> float: + try: + fval = float(val) + except (ValueError, TypeError) as exc: + raise ValueError(f'[{name}] must be a positive number') from exc + if fval <= 0: + raise ValueError(f'[{name}] must be positive (got {fval})') + return fval + + @property + def tariff_zone_1(self) -> float: + return self._tariff_zone_1 + + @tariff_zone_1.setter + def tariff_zone_1(self, value: float) -> None: + self._tariff_zone_1 = self._validate_price(value, 'tariff_zone_1') + + @property + def tariff_zone_2(self) -> float: + return self._tariff_zone_2 + + @tariff_zone_2.setter + def tariff_zone_2(self, value: float) -> None: + self._tariff_zone_2 = self._validate_price(value, 'tariff_zone_2') + + @property + def tariff_zone_3(self) -> float: + return self._tariff_zone_3 + + @tariff_zone_3.setter + def tariff_zone_3(self, value: float) -> None: + self._tariff_zone_3 = self._validate_price(value, 'tariff_zone_3') + + @property + def zone_1_hours(self) -> list: + return self._zone_1_hours + + @zone_1_hours.setter + def zone_1_hours(self, value) -> None: + self._zone_1_hours = self._parse_hours(value, 'zone_1_hours') + + @property + def zone_2_hours(self) -> list: + return self._zone_2_hours + + @zone_2_hours.setter + def zone_2_hours(self, value) -> None: + self._zone_2_hours = self._parse_hours(value, 'zone_2_hours') + + @property + def zone_3_hours(self) -> list: + return self._zone_3_hours + + @zone_3_hours.setter + def zone_3_hours(self, value) -> None: + self._zone_3_hours = self._parse_hours(value, 'zone_3_hours') diff --git a/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py new file mode 100644 index 0000000..05e2321 --- /dev/null +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -0,0 +1,303 @@ +import datetime +import pytest +import pytz + +from batcontrol.dynamictariff.tariffzones import TariffZones +from batcontrol.dynamictariff.dynamictariff import DynamicTariff + +HOURS_ALL = list(range(24)) +HOURS_PEAK = list(range(7, 23)) # 7-22 (16 hours) +HOURS_OFFPEAK = [0, 1, 2, 3, 4, 5, 6, 23] # 8 hours + + +def make_tz(): + return pytz.timezone('Europe/Berlin') + + +def make_tariff(**kwargs): + """Create a fully configured 2-zone TariffZones instance.""" + defaults = dict( + tariff_zone_1=0.27, + zone_1_hours=HOURS_PEAK, + tariff_zone_2=0.17, + zone_2_hours=HOURS_OFFPEAK, + ) + defaults.update(kwargs) + return TariffZones(make_tz(), **defaults) + + +# --------------------------------------------------------------------------- +# _parse_hours +# --------------------------------------------------------------------------- + +def test_parse_hours_csv_string(): + result = TariffZones._parse_hours('0,1,2,3', 'zone_1_hours') + assert result == [0, 1, 2, 3] + + +def test_parse_hours_list_of_ints(): + result = TariffZones._parse_hours([7, 8, 9], 'zone_1_hours') + assert result == [7, 8, 9] + + +def test_parse_hours_single_int(): + result = TariffZones._parse_hours(5, 'zone_1_hours') + assert result == [5] + + +def test_parse_hours_range_string(): + result = TariffZones._parse_hours('0-5', 'zone_1_hours') + assert result == [0, 1, 2, 3, 4, 5] + + +def test_parse_hours_range_full_day(): + result = TariffZones._parse_hours('7-22', 'zone_1_hours') + assert result == list(range(7, 23)) + + +def test_parse_hours_mixed_range_and_singles(): + result = TariffZones._parse_hours('0-5,6,7', 'zone_1_hours') + assert result == [0, 1, 2, 3, 4, 5, 6, 7] + + +def test_parse_hours_range_single_element(): + # "5-5" is a valid range that yields just [5] + result = TariffZones._parse_hours('5-5', 'zone_1_hours') + assert result == [5] + + +def test_parse_hours_list_with_range_strings(): + result = TariffZones._parse_hours(['0-3', '4', '5-6'], 'zone_1_hours') + assert result == [0, 1, 2, 3, 4, 5, 6] + + +def test_parse_hours_rejects_inverted_range(): + with pytest.raises(ValueError, match='start must be <= end'): + TariffZones._parse_hours('5-3', 'zone_1_hours') + + +def test_parse_hours_rejects_out_of_range(): + with pytest.raises(ValueError, match='out of range'): + TariffZones._parse_hours('0,24', 'zone_1_hours') + with pytest.raises(ValueError, match='out of range'): + TariffZones._parse_hours([-1], 'zone_1_hours') + + +def test_parse_hours_rejects_range_out_of_bounds(): + with pytest.raises(ValueError, match='out of range'): + TariffZones._parse_hours('20-25', 'zone_1_hours') + + +def test_parse_hours_rejects_duplicate_within_zone(): + with pytest.raises(ValueError, match='more than once'): + TariffZones._parse_hours('7,8,7', 'zone_1_hours') + + +def test_parse_hours_rejects_non_numeric(): + with pytest.raises(ValueError, match='invalid hour value'): + TariffZones._parse_hours('7,abc', 'zone_1_hours') + + +def test_parse_hours_rejects_invalid_type(): + with pytest.raises(ValueError): + TariffZones._parse_hours({'set'}, 'zone_1_hours') + + +# --------------------------------------------------------------------------- +# _validate_price +# --------------------------------------------------------------------------- + +def test_validate_price_accepts_positive(): + assert TariffZones._validate_price(0.27, 'tariff_zone_1') == pytest.approx(0.27) + + +def test_validate_price_rejects_zero(): + with pytest.raises(ValueError): + TariffZones._validate_price(0, 'tariff_zone_1') + + +def test_validate_price_rejects_negative(): + with pytest.raises(ValueError): + TariffZones._validate_price(-0.1, 'tariff_zone_1') + + +# --------------------------------------------------------------------------- +# Constructor and _validate_configuration +# --------------------------------------------------------------------------- + +def test_constructor_sets_all_fields(): + t = make_tariff() + assert t.tariff_zone_1 == pytest.approx(0.27) + assert t.tariff_zone_2 == pytest.approx(0.17) + assert t.zone_1_hours == HOURS_PEAK + assert t.zone_2_hours == HOURS_OFFPEAK + + +def test_missing_prices_raises(): + t = TariffZones(make_tz(), zone_1_hours=HOURS_PEAK, zone_2_hours=HOURS_OFFPEAK) + with pytest.raises(RuntimeError, match='tariff_zone_1'): + t._get_prices_native() + + +def test_missing_hours_raises(): + t = TariffZones(make_tz(), tariff_zone_1=0.27, tariff_zone_2=0.17) + with pytest.raises(RuntimeError, match='zone_1_hours'): + t._get_prices_native() + + +def test_cross_zone_duplicate_raises(): + """Hour 7 in both zone_1 and zone_2 must raise ValueError.""" + with pytest.raises(ValueError, match='Hour 7'): + make_tariff( + zone_1_hours=list(range(7, 23)), + zone_2_hours=[0, 1, 2, 3, 4, 5, 6, 7, 23], # 7 duplicated + )._get_prices_native() + + +def test_missing_hour_coverage_raises(): + """If hour 23 is not in any zone, ValueError must be raised.""" + with pytest.raises(ValueError, match='not assigned'): + make_tariff( + zone_1_hours=list(range(7, 23)), + zone_2_hours=list(range(0, 7)), # missing hour 23 + )._get_prices_native() + + +def test_zone3_hours_without_price_raises(): + t = TariffZones( + make_tz(), + tariff_zone_1=0.27, zone_1_hours=list(range(7, 17)), + tariff_zone_2=0.17, zone_2_hours=list(range(0, 7)) + [23], + zone_3_hours=list(range(17, 23)), + # tariff_zone_3 intentionally omitted + ) + with pytest.raises(RuntimeError, match='zone_3_hours and tariff_zone_3'): + t._get_prices_native() + + +def test_zone3_price_without_hours_raises(): + t = TariffZones( + make_tz(), + tariff_zone_1=0.27, zone_1_hours=HOURS_PEAK, + tariff_zone_2=0.17, zone_2_hours=HOURS_OFFPEAK, + tariff_zone_3=0.35, + # zone_3_hours intentionally omitted + ) + with pytest.raises(RuntimeError, match='zone_3_hours and tariff_zone_3'): + t._get_prices_native() + + +# --------------------------------------------------------------------------- +# _get_prices_native — 2-zone +# --------------------------------------------------------------------------- + +def test_get_prices_native_returns_48_hours(): + prices = make_tariff()._get_prices_native() + assert len(prices) == 48 + + +def test_get_prices_native_correct_zone_assignment(): + t = make_tariff(zone_1_hours=HOURS_PEAK, zone_2_hours=HOURS_OFFPEAK) + prices = t._get_prices_native() + + now = datetime.datetime.now().astimezone(t.timezone) + base = now.replace(minute=0, second=0, microsecond=0) + + for rel_hour, price in prices.items(): + h = (base + datetime.timedelta(hours=rel_hour)).hour + expected = t.tariff_zone_1 if h in HOURS_PEAK else t.tariff_zone_2 + assert price == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# _get_prices_native — 3-zone +# --------------------------------------------------------------------------- + +def test_get_prices_native_three_zones(): + peak = list(range(9, 17)) # 8 hours + shoulder = list(range(17, 23)) + list(range(7, 9)) # 8 hours + offpeak = list(range(0, 7)) + [23] # 8 hours + + t = TariffZones( + make_tz(), + tariff_zone_1=0.30, zone_1_hours=peak, + tariff_zone_2=0.15, zone_2_hours=offpeak, + tariff_zone_3=0.22, zone_3_hours=shoulder, + ) + prices = t._get_prices_native() + assert len(prices) == 48 + + now = datetime.datetime.now().astimezone(t.timezone) + base = now.replace(minute=0, second=0, microsecond=0) + + zone_map = {h: 0.30 for h in peak} + zone_map.update({h: 0.15 for h in offpeak}) + zone_map.update({h: 0.22 for h in shoulder}) + + for rel_hour, price in prices.items(): + h = (base + datetime.timedelta(hours=rel_hour)).hour + assert price == pytest.approx(zone_map[h]) + + +# --------------------------------------------------------------------------- +# CSV string input (as YAML would provide) +# --------------------------------------------------------------------------- + +def test_csv_string_hours_accepted(): + t = TariffZones( + make_tz(), + tariff_zone_1=0.27, + zone_1_hours='7-22', + tariff_zone_2=0.17, + zone_2_hours='0-6,23', + ) + prices = t._get_prices_native() + assert len(prices) == 48 + + +# --------------------------------------------------------------------------- +# Factory integration (DynamicTariff.create_tarif_provider) +# --------------------------------------------------------------------------- + +def test_factory_creates_tariff_zones(): + config = { + 'type': 'tariff_zones', + 'tariff_zone_1': 0.27, + 'zone_1_hours': '7-22', + 'tariff_zone_2': 0.17, + 'zone_2_hours': '0-6,23', + } + provider = DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0) + assert isinstance(provider, TariffZones) + assert provider.tariff_zone_1 == pytest.approx(0.27) + assert provider.tariff_zone_2 == pytest.approx(0.17) + assert provider.zone_1_hours == list(range(7, 23)) + assert provider.zone_2_hours == list(range(0, 7)) + [23] + + +def test_factory_three_zones(): + config = { + 'type': 'tariff_zones', + 'tariff_zone_1': 0.30, + 'zone_1_hours': '9,10,11,12,13,14,15,16', + 'tariff_zone_2': 0.15, + 'zone_2_hours': '0,1,2,3,4,5,6,23', + 'tariff_zone_3': 0.22, + 'zone_3_hours': '7,8,17,18,19,20,21,22', + } + provider = DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0) + assert isinstance(provider, TariffZones) + assert provider.tariff_zone_3 == pytest.approx(0.22) + assert provider.zone_3_hours == [7, 8, 17, 18, 19, 20, 21, 22] + + +def test_factory_missing_required_field_raises(): + config = { + 'type': 'tariff_zones', + 'tariff_zone_1': 0.27, + 'zone_1_hours': '7,8,9', + # zone_2_hours missing + 'tariff_zone_2': 0.17, + } + with pytest.raises(RuntimeError, match='zone_2_hours'): + DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0)