From 8f1ebf944dae2dfbfde97303eceeb55539aa0c58 Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:04:06 +0100 Subject: [PATCH 01/10] Resolves #136 --- config/batcontrol_config_dummy.yaml | 6 +- src/batcontrol/dynamictariff/dynamictariff.py | 26 +++++ src/batcontrol/dynamictariff/twotariffmode.py | 103 ++++++++++++++++++ tests/batcontrol/logic/test_default.py | 33 ++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/batcontrol/dynamictariff/twotariffmode.py diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 18ec92a..3900324 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -64,10 +64,14 @@ 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, twotariffmode] 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_day: 0.2733 # only required for twotariffmode, Euro/kWh incl. vat/fees + # tariff_night: 0.1734 # only required for twotariffmode, Euro/kWh incl. vat/fees + # day_start: 5 # only required for twotariffmode, hour of day when day tariff starts + # day_end: 0 # only required for twotariffmode, hour of day when day tariff ends # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. #-------------------------- diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index b67cae1..d0c1c52 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 .twotariffmode import Twotariffmode from .dynamictariff_interface import TariffInterface @@ -129,6 +130,31 @@ def create_tarif_provider(config: dict, timezone, if provider.lower() == 'energyforecast_96': selected_tariff.upgrade_48h_to_96h() + elif provider.lower() == 'twotariffmode': + # require tariffs for day and night + required_fields = ['tariff_day', 'tariff_night'] + for field in required_fields: + if field not in config.keys(): + raise RuntimeError( + f'[DynTariff] Please include {field} in your configuration file' + ) + # read values and optional price parameters + tariff_day = float(config.get('tariff_day')) + tariff_night = float(config.get('tariff_night')) + day_start = int(config.get('day_start', 7)) + day_end = int(config.get('day_end', 22)) + selected_tariff = Twotariffmode( + timezone, + min_time_between_api_calls, + delay_evaluation_by_seconds, + target_resolution=target_resolution + ) + # store configured values in instance + selected_tariff.tariff_day = tariff_day + selected_tariff.tariff_night = tariff_night + selected_tariff.day_start = day_start + selected_tariff.day_end = day_end + else: raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}') return selected_tariff diff --git a/src/batcontrol/dynamictariff/twotariffmode.py b/src/batcontrol/dynamictariff/twotariffmode.py new file mode 100644 index 0000000..8fa94ba --- /dev/null +++ b/src/batcontrol/dynamictariff/twotariffmode.py @@ -0,0 +1,103 @@ +"""TwoTariffMode provider + +Simple dynamic tariff provider that returns a repeating day/night tariff. +Config options (in utility config for provider): +- type: twotariffmode +- tariff_day: price for day hours (float) +- tariff_night: price for night hours (float) +- day_start: hour when day tariff starts (int, default 7) +- day_end: hour when day tariff ends (int, default 22) + +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 effiency, 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 Twotariffmode(DynamicTariffBaseclass): + """Two-tier tariff: day / night fixed prices.""" + + def __init__( + self, + timezone, + min_time_between_API_calls=0, + delay_evaluation_by_seconds=0, + target_resolution: int = 60, + ): + super().__init__( + timezone, + min_time_between_API_calls, + delay_evaluation_by_seconds, + target_resolution=target_resolution, + native_resolution=60, + ) + + # defaults + self.tariff_day = 0.20 + self.tariff_night = 0.10 + self.day_start = 7 + self.day_end = 22 + + def get_raw_data_from_provider(self) -> dict: + """Return the configuration-like raw data stored in cache. + + This provider is purely local and does not call external APIs. + We return a dict containing the configured values so that + `_get_prices_native` can read from `get_raw_data()` uniformly. + """ + return { + 'tariff_day': self.tariff_day, + 'tariff_night': self.tariff_night, + 'day_start': self.day_start, + 'day_end': self.day_end, + } + + 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). + """ + raw = self.get_raw_data() + # allow values from raw data (cache) if present + tariff_day = raw.get('tariff_day', self.tariff_day) + tariff_night = raw.get('tariff_night', self.tariff_night) + day_start = int(raw.get('day_start', self.day_start)) + day_end = int(raw.get('day_end', self.day_end)) + + now = datetime.datetime.now().astimezone(self.timezone) + # Align to start of current hour + current_hour_start = now.replace(minute=0, second=0, microsecond=0) + + prices = {} + # produce next 48 hours + for rel_hour in range(0, 48): + ts = current_hour_start + datetime.timedelta(hours=rel_hour) + h = ts.hour + if day_start <= day_end: + is_day = (h >= day_start and h < day_end) + else: + # wrap-around (e.g., day_start=20, day_end=6) + is_day = not (h >= day_end and h < day_start) + + prices[rel_hour] = tariff_day if is_day else tariff_night + + logger.debug('Twotariffmode: Generated %d hourly prices', len(prices)) + return prices diff --git a/tests/batcontrol/logic/test_default.py b/tests/batcontrol/logic/test_default.py index 86f3075..fe33dac 100644 --- a/tests/batcontrol/logic/test_default.py +++ b/tests/batcontrol/logic/test_default.py @@ -188,6 +188,39 @@ def test_charge_calculation_when_charging_possible(self): self.assertGreater(result.charge_rate, 0, "Charge rate should be greater than 0") self.assertGreater(calc_output.required_recharge_energy, 0, "Should calculate required recharge energy") + def test_charge_calculation_when_charging_possible_modified(self): + """Test charge calculation when charging is possible due to low SOC""" + stored_energy = 2000 # 2 kWh, well below charging limit (79% = 7.9 kWh) + stored_usable_energy, free_capacity = self._calculate_battery_values( + stored_energy, self.max_capacity + ) + + # Setup scenario with high future prices to trigger charging + consumption = np.array([1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]) # High future consumption that requires reserves + production = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) # No production + + calc_input = CalculationInput( + consumption=consumption, + production=production, + prices={0: 0.20, 1: 0.20, 2: .30, 3: 0.30, 4: 0.30, 5: 0.30, 6: 0.30, 7: 0.30, 8: 0.30, 9: 0.30}, # Low current price, high future prices + stored_energy=stored_energy, + stored_usable_energy=stored_usable_energy, + free_capacity=free_capacity, + ) + + # Test at 30 minutes past the hour to test charge rate calculation + # calc_timestamp = datetime.datetime(2025, 6, 20, 12, 30, 0, tzinfo=datetime.timezone.utc) + calc_timestamp = datetime.datetime(2025, 6, 20, 12, 50, 0, tzinfo=datetime.timezone.utc) + self.assertTrue(self.logic.calculate(calc_input, calc_timestamp)) + result = self.logic.get_inverter_control_settings() + calc_output = self.logic.get_calculation_output() + + # Verify charging is enabled + self.assertFalse(result.allow_discharge, "Discharge should not be allowed when charging needed") + self.assertTrue(result.charge_from_grid, "Should charge from grid when energy needed for high price hours") + self.assertGreater(result.charge_rate, 0, "Charge rate should be greater than 0") + self.assertGreater(calc_output.required_recharge_energy, 0, "Should calculate required recharge energy") + def test_charge_calculation_when_charging_not_possible_high_soc(self): """Test charge calculation when charging is not possible due to high SOC""" # Set SOC above charging limit (79%) From 6fc8952d874a7012f588ac0b06e332ace96d81c4 Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:22:23 +0100 Subject: [PATCH 02/10] changed tariff naming --- config/batcontrol_config_dummy.yaml | 10 ++-- src/batcontrol/dynamictariff/dynamictariff.py | 26 +++++----- .../{twotariffmode.py => tariffzones.py} | 50 +++++++++---------- 3 files changed, 43 insertions(+), 43 deletions(-) rename src/batcontrol/dynamictariff/{twotariffmode.py => tariffzones.py} (66%) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 3900324..2cf2a09 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -64,14 +64,14 @@ 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, twotariffmode] + 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_day: 0.2733 # only required for twotariffmode, Euro/kWh incl. vat/fees - # tariff_night: 0.1734 # only required for twotariffmode, Euro/kWh incl. vat/fees - # day_start: 5 # only required for twotariffmode, hour of day when day tariff starts - # day_end: 0 # only required for twotariffmode, hour of day when day tariff ends + # tariff_zone_1: 0.2733 # only required for tariff_zones, Euro/kWh incl. vat/fees + # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees + # zone_1_start: 5 # only required for tariff_zones, hour of day when zone 1 tariff starts + # zone_1_end: 0 # only required for tariff_zones, hour of day when zone 1 tariff ends # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. #-------------------------- diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index d0c1c52..01c5811 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -19,7 +19,7 @@ from .tibber import Tibber from .evcc import Evcc from .energyforecast import Energyforecast -from .twotariffmode import Twotariffmode +from .tariffzones import Tariff_zones from .dynamictariff_interface import TariffInterface @@ -130,30 +130,30 @@ def create_tarif_provider(config: dict, timezone, if provider.lower() == 'energyforecast_96': selected_tariff.upgrade_48h_to_96h() - elif provider.lower() == 'twotariffmode': - # require tariffs for day and night - required_fields = ['tariff_day', 'tariff_night'] + elif provider.lower() == 'tariff_zones': + # require tariffs for zone 1 and zone 2 + required_fields = ['tariff_zone_1', 'tariff_zone_2'] for field in required_fields: if field not in config.keys(): raise RuntimeError( f'[DynTariff] Please include {field} in your configuration file' ) # read values and optional price parameters - tariff_day = float(config.get('tariff_day')) - tariff_night = float(config.get('tariff_night')) - day_start = int(config.get('day_start', 7)) - day_end = int(config.get('day_end', 22)) - selected_tariff = Twotariffmode( + tariff_zone_1 = float(config.get('tariff_zone_1')) + tariff_zone_2 = float(config.get('tariff_zone_2')) + zone_1_start = int(config.get('zone_1_start', 7)) + zone_1_end = int(config.get('zone_1_end', 22)) + selected_tariff = Tariff_zones( timezone, min_time_between_api_calls, delay_evaluation_by_seconds, target_resolution=target_resolution ) # store configured values in instance - selected_tariff.tariff_day = tariff_day - selected_tariff.tariff_night = tariff_night - selected_tariff.day_start = day_start - selected_tariff.day_end = day_end + selected_tariff.tariff_zone_1 = tariff_zone_1 + selected_tariff.tariff_zone_2 = tariff_zone_2 + selected_tariff.zone_1_start = zone_1_start + selected_tariff.zone_1_end = zone_1_end else: raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}') diff --git a/src/batcontrol/dynamictariff/twotariffmode.py b/src/batcontrol/dynamictariff/tariffzones.py similarity index 66% rename from src/batcontrol/dynamictariff/twotariffmode.py rename to src/batcontrol/dynamictariff/tariffzones.py index 8fa94ba..aa09bf8 100644 --- a/src/batcontrol/dynamictariff/twotariffmode.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -1,12 +1,12 @@ -"""TwoTariffMode provider +"""Tariff_zones provider Simple dynamic tariff provider that returns a repeating day/night tariff. Config options (in utility config for provider): -- type: twotariffmode -- tariff_day: price for day hours (float) -- tariff_night: price for night hours (float) -- day_start: hour when day tariff starts (int, default 7) -- day_end: hour when day tariff ends (int, default 22) +- type: tariff_zones +- tariff_zone_1: price for day hours (float) +- tariff_zone_2: price for night hours (float) +- zone_1_start: hour when tariff zone 1 starts (int, default 7) +- zone_1_end: hour when tariff zone 1 ends (int, default 22) The class produces hourly prices (native_resolution=60) for the next 48 hours aligned to the current hour. The baseclass will handle conversion to @@ -31,7 +31,7 @@ logger = logging.getLogger(__name__) -class Twotariffmode(DynamicTariffBaseclass): +class Tariff_zones(DynamicTariffBaseclass): """Two-tier tariff: day / night fixed prices.""" def __init__( @@ -50,10 +50,10 @@ def __init__( ) # defaults - self.tariff_day = 0.20 - self.tariff_night = 0.10 - self.day_start = 7 - self.day_end = 22 + self.tariff_zone_1 = 0.20 + self.tariff_zone_2 = 0.10 + self.zone_1_start = 7 + self.zone_1_end = 22 def get_raw_data_from_provider(self) -> dict: """Return the configuration-like raw data stored in cache. @@ -63,10 +63,10 @@ def get_raw_data_from_provider(self) -> dict: `_get_prices_native` can read from `get_raw_data()` uniformly. """ return { - 'tariff_day': self.tariff_day, - 'tariff_night': self.tariff_night, - 'day_start': self.day_start, - 'day_end': self.day_end, + 'tariff_zone_1': self.tariff_zone_1, + 'tariff_zone_2': self.tariff_zone_2, + 'zone_1_start': self.zone_1_start, + 'zone_1_end': self.zone_1_end, } def _get_prices_native(self) -> dict[int, float]: @@ -77,10 +77,10 @@ def _get_prices_native(self) -> dict[int, float]: """ raw = self.get_raw_data() # allow values from raw data (cache) if present - tariff_day = raw.get('tariff_day', self.tariff_day) - tariff_night = raw.get('tariff_night', self.tariff_night) - day_start = int(raw.get('day_start', self.day_start)) - day_end = int(raw.get('day_end', self.day_end)) + tariff_zone_1 = raw.get('tariff_zone_1', self.tariff_zone_1) + tariff_zone_2 = raw.get('tariff_zone_2', self.tariff_zone_2) + zone_1_start = int(raw.get('zone_1_start', self.zone_1_start)) + zone_1_end = int(raw.get('zone_1_end', self.zone_1_end)) now = datetime.datetime.now().astimezone(self.timezone) # Align to start of current hour @@ -91,13 +91,13 @@ def _get_prices_native(self) -> dict[int, float]: for rel_hour in range(0, 48): ts = current_hour_start + datetime.timedelta(hours=rel_hour) h = ts.hour - if day_start <= day_end: - is_day = (h >= day_start and h < day_end) + if zone_1_start <= zone_1_end: + is_day = (h >= zone_1_start and h < zone_1_end) else: - # wrap-around (e.g., day_start=20, day_end=6) - is_day = not (h >= day_end and h < day_start) + # wrap-around (e.g., zone_1_start=20, zone_1_end=6) + is_day = not (h >= zone_1_end and h < zone_1_start) - prices[rel_hour] = tariff_day if is_day else tariff_night + prices[rel_hour] = tariff_zone_1 if is_day else tariff_zone_2 - logger.debug('Twotariffmode: Generated %d hourly prices', len(prices)) + logger.debug('tariff_zones: Generated %d hourly prices', len(prices)) return prices From e0d38c2dc719178e36daa0e586cd73bf4662cdaa Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:38:36 +0100 Subject: [PATCH 03/10] added zone based pricing --- README.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From ab92f2a70637a576b3dfe63c433a17dc44d43db6 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 27 Feb 2026 11:02:01 +0100 Subject: [PATCH 04/10] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- config/batcontrol_config_dummy.yaml | 2 +- src/batcontrol/dynamictariff/dynamictariff.py | 2 +- src/batcontrol/dynamictariff/tariffzones.py | 2 +- tests/batcontrol/logic/test_default.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 2cf2a09..e4e2bbd 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -69,7 +69,7 @@ utility: 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 - # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees + # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees # zone_1_start: 5 # only required for tariff_zones, hour of day when zone 1 tariff starts # zone_1_end: 0 # only required for tariff_zones, hour of day when zone 1 tariff ends # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index 01c5811..959dc1c 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -156,5 +156,5 @@ def create_tarif_provider(config: dict, timezone, selected_tariff.zone_1_end = zone_1_end 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 index aa09bf8..1472d8f 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -20,7 +20,7 @@ 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 effiency, have battery only short +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 """ diff --git a/tests/batcontrol/logic/test_default.py b/tests/batcontrol/logic/test_default.py index fe33dac..4e86ad6 100644 --- a/tests/batcontrol/logic/test_default.py +++ b/tests/batcontrol/logic/test_default.py @@ -202,14 +202,14 @@ def test_charge_calculation_when_charging_possible_modified(self): calc_input = CalculationInput( consumption=consumption, production=production, - prices={0: 0.20, 1: 0.20, 2: .30, 3: 0.30, 4: 0.30, 5: 0.30, 6: 0.30, 7: 0.30, 8: 0.30, 9: 0.30}, # Low current price, high future prices + prices={0: 0.20, 1: 0.20, 2: 0.30, 3: 0.30, 4: 0.30, 5: 0.30, 6: 0.30, 7: 0.30, 8: 0.30, 9: 0.30}, # Low current price, high future prices stored_energy=stored_energy, stored_usable_energy=stored_usable_energy, free_capacity=free_capacity, ) # Test at 30 minutes past the hour to test charge rate calculation - # calc_timestamp = datetime.datetime(2025, 6, 20, 12, 30, 0, tzinfo=datetime.timezone.utc) + calc_timestamp = datetime.datetime(2025, 6, 20, 12, 50, 0, tzinfo=datetime.timezone.utc) self.assertTrue(self.logic.calculate(calc_input, calc_timestamp)) result = self.logic.get_inverter_control_settings() From 91cbb2b631590c317c57259ce5add066ab35722b Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:04:45 +0100 Subject: [PATCH 05/10] some adjustments according to Copilot --- config/batcontrol_config_dummy.yaml | 2 +- src/batcontrol/dynamictariff/dynamictariff.py | 6 ++-- src/batcontrol/dynamictariff/tariffzones.py | 32 ++++-------------- tests/batcontrol/logic/test_default.py | 33 ------------------- 4 files changed, 11 insertions(+), 62 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 2cf2a09..e4e2bbd 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -69,7 +69,7 @@ utility: 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 - # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees + # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees # zone_1_start: 5 # only required for tariff_zones, hour of day when zone 1 tariff starts # zone_1_end: 0 # only required for tariff_zones, hour of day when zone 1 tariff ends # apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers. diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index 01c5811..c62a8e8 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -19,7 +19,7 @@ from .tibber import Tibber from .evcc import Evcc from .energyforecast import Energyforecast -from .tariffzones import Tariff_zones +from .tariffzones import TariffZones from .dynamictariff_interface import TariffInterface @@ -143,7 +143,7 @@ def create_tarif_provider(config: dict, timezone, tariff_zone_2 = float(config.get('tariff_zone_2')) zone_1_start = int(config.get('zone_1_start', 7)) zone_1_end = int(config.get('zone_1_end', 22)) - selected_tariff = Tariff_zones( + selected_tariff = TariffZones( timezone, min_time_between_api_calls, delay_evaluation_by_seconds, @@ -156,5 +156,5 @@ def create_tarif_provider(config: dict, timezone, selected_tariff.zone_1_end = zone_1_end 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 index aa09bf8..70720fd 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -1,10 +1,10 @@ """Tariff_zones provider -Simple dynamic tariff provider that returns a repeating day/night tariff. +Simple dynamic tariff provider that returns a repeating two zone tariff. Config options (in utility config for provider): - type: tariff_zones -- tariff_zone_1: price for day hours (float) -- tariff_zone_2: price for night hours (float) +- tariff_zone_1: price for zone 1 hours (float) +- tariff_zone_2: price for zone 2 hours (float) - zone_1_start: hour when tariff zone 1 starts (int, default 7) - zone_1_end: hour when tariff zone 1 ends (int, default 22) @@ -20,7 +20,7 @@ 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 effiency, have battery only short +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 """ @@ -31,8 +31,8 @@ logger = logging.getLogger(__name__) -class Tariff_zones(DynamicTariffBaseclass): - """Two-tier tariff: day / night fixed prices.""" +class TariffZones(DynamicTariffBaseclass): + """Two-tier tariff: zone 1 / zone 2 fixed prices.""" def __init__( self, @@ -49,25 +49,7 @@ def __init__( native_resolution=60, ) - # defaults - self.tariff_zone_1 = 0.20 - self.tariff_zone_2 = 0.10 - self.zone_1_start = 7 - self.zone_1_end = 22 - def get_raw_data_from_provider(self) -> dict: - """Return the configuration-like raw data stored in cache. - - This provider is purely local and does not call external APIs. - We return a dict containing the configured values so that - `_get_prices_native` can read from `get_raw_data()` uniformly. - """ - return { - 'tariff_zone_1': self.tariff_zone_1, - 'tariff_zone_2': self.tariff_zone_2, - 'zone_1_start': self.zone_1_start, - 'zone_1_end': self.zone_1_end, - } def _get_prices_native(self) -> dict[int, float]: """Build hourly prices for the next 48 hours, hour-aligned. @@ -99,5 +81,5 @@ def _get_prices_native(self) -> dict[int, float]: prices[rel_hour] = tariff_zone_1 if is_day else tariff_zone_2 - logger.debug('tariff_zones: Generated %d hourly prices', len(prices)) + logger.debug('tariffZones: Generated %d hourly prices', len(prices)) return prices diff --git a/tests/batcontrol/logic/test_default.py b/tests/batcontrol/logic/test_default.py index fe33dac..86f3075 100644 --- a/tests/batcontrol/logic/test_default.py +++ b/tests/batcontrol/logic/test_default.py @@ -188,39 +188,6 @@ def test_charge_calculation_when_charging_possible(self): self.assertGreater(result.charge_rate, 0, "Charge rate should be greater than 0") self.assertGreater(calc_output.required_recharge_energy, 0, "Should calculate required recharge energy") - def test_charge_calculation_when_charging_possible_modified(self): - """Test charge calculation when charging is possible due to low SOC""" - stored_energy = 2000 # 2 kWh, well below charging limit (79% = 7.9 kWh) - stored_usable_energy, free_capacity = self._calculate_battery_values( - stored_energy, self.max_capacity - ) - - # Setup scenario with high future prices to trigger charging - consumption = np.array([1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]) # High future consumption that requires reserves - production = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) # No production - - calc_input = CalculationInput( - consumption=consumption, - production=production, - prices={0: 0.20, 1: 0.20, 2: .30, 3: 0.30, 4: 0.30, 5: 0.30, 6: 0.30, 7: 0.30, 8: 0.30, 9: 0.30}, # Low current price, high future prices - stored_energy=stored_energy, - stored_usable_energy=stored_usable_energy, - free_capacity=free_capacity, - ) - - # Test at 30 minutes past the hour to test charge rate calculation - # calc_timestamp = datetime.datetime(2025, 6, 20, 12, 30, 0, tzinfo=datetime.timezone.utc) - calc_timestamp = datetime.datetime(2025, 6, 20, 12, 50, 0, tzinfo=datetime.timezone.utc) - self.assertTrue(self.logic.calculate(calc_input, calc_timestamp)) - result = self.logic.get_inverter_control_settings() - calc_output = self.logic.get_calculation_output() - - # Verify charging is enabled - self.assertFalse(result.allow_discharge, "Discharge should not be allowed when charging needed") - self.assertTrue(result.charge_from_grid, "Should charge from grid when energy needed for high price hours") - self.assertGreater(result.charge_rate, 0, "Charge rate should be greater than 0") - self.assertGreater(calc_output.required_recharge_energy, 0, "Should calculate required recharge energy") - def test_charge_calculation_when_charging_not_possible_high_soc(self): """Test charge calculation when charging is not possible due to high SOC""" # Set SOC above charging limit (79%) From dc0571d15726e51a3b3eac0484c536954972a389 Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:03:41 +0100 Subject: [PATCH 06/10] added input validation --- src/batcontrol/dynamictariff/tariffzones.py | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 70720fd..3d0fac5 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -49,6 +49,10 @@ def __init__( native_resolution=60, ) + # default zone boundaries + self._zone_1_start = 7 + self._zone_1_end = 22 + def _get_prices_native(self) -> dict[int, float]: @@ -64,6 +68,10 @@ def _get_prices_native(self) -> dict[int, float]: zone_1_start = int(raw.get('zone_1_start', self.zone_1_start)) zone_1_end = int(raw.get('zone_1_end', self.zone_1_end)) + # validate hours are integers in range [0, 23] + zone_1_start = self._validate_hour(zone_1_start, 'zone_1_start') + zone_1_end = self._validate_hour(zone_1_end, 'zone_1_end') + now = datetime.datetime.now().astimezone(self.timezone) # Align to start of current hour current_hour_start = now.replace(minute=0, second=0, microsecond=0) @@ -83,3 +91,28 @@ def _get_prices_native(self) -> dict[int, float]: logger.debug('tariffZones: Generated %d hourly prices', len(prices)) return prices + + def _validate_hour(self, val: int, name: str) -> int: + try: + ival = int(val) + except Exception: + raise ValueError(f'[{name}] must be an integer between 0 and 23') + if ival < 0 or ival > 23: + raise ValueError(f'[{name}] must be between 0 and 23 (got {ival})') + return ival + + @property + def zone_1_start(self) -> int: + return self._zone_1_start + + @zone_1_start.setter + def zone_1_start(self, value: int) -> None: + self._zone_1_start = self._validate_hour(value, 'zone_1_start') + + @property + def zone_1_end(self) -> int: + return self._zone_1_end + + @zone_1_end.setter + def zone_1_end(self, value: int) -> None: + self._zone_1_end = self._validate_hour(value, 'zone_1_end') From f31ca8211769160655e843060fb2c5d1d8a8466c Mon Sep 17 00:00:00 2001 From: OliJue <70478960+OliJue@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:41:13 +0100 Subject: [PATCH 07/10] added test for tariffzones --- run_tests.ps1 | 23 +++++ .../dynamictariff/test_tariffzones.py | 98 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 run_tests.ps1 create mode 100644 tests/batcontrol/dynamictariff/test_tariffzones.py 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/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py new file mode 100644 index 0000000..938c861 --- /dev/null +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -0,0 +1,98 @@ +import datetime +import pytest +import pytz + +from batcontrol.dynamictariff.tariffzones import TariffZones + + +class DummyTariffZones(TariffZones): + """Concrete test subclass implementing the abstract provider method.""" + def __init__(self, timezone): + super().__init__(timezone) + # provide default zone prices for tests + self.tariff_zone_1 = 1.0 + self.tariff_zone_2 = 2.0 + + def get_raw_data_from_provider(self) -> dict: + return {} + + +def make_tz(): + return pytz.timezone('Europe/Berlin') + + +def test_validate_hour_accepts_integer(): + tz = make_tz() + t = DummyTariffZones(tz) + assert t._validate_hour(0, 'zone_1_start') == 0 + assert t._validate_hour(23, 'zone_1_end') == 23 + + +def test_validate_hour_rejects_out_of_range(): + tz = make_tz() + t = DummyTariffZones(tz) + with pytest.raises(ValueError): + t._validate_hour(-1, 'zone_1_start') + with pytest.raises(ValueError): + t._validate_hour(24, 'zone_1_end') + + +def test_validate_hour_accepts_float_by_int_conversion(): + tz = make_tz() + t = DummyTariffZones(tz) + # Current implementation converts floats via int(), so 7.9 -> 7 + assert t._validate_hour(7.9, 'zone_1_start') == 7 + + +def test_validate_hour_rejects_string_decimal(): + tz = make_tz() + t = DummyTariffZones(tz) + # Strings with decimal point cannot be int()-cast -> ValueError + with pytest.raises(ValueError): + t._validate_hour('7.5', 'zone_1_start') + + +def test_property_setters_and_getters(): + tz = make_tz() + t = DummyTariffZones(tz) + t.zone_1_start = 5 + t.zone_1_end = 22 + assert t.zone_1_start == 5 + assert t.zone_1_end == 22 + + with pytest.raises(ValueError): + t.zone_1_start = -2 + with pytest.raises(ValueError): + t.zone_1_end = 100 + + +def test_get_prices_native_uses_raw_data_boundaries(): + tz = make_tz() + t = DummyTariffZones(tz) + + # prepare raw data and store it in the provider cache + raw = { + 'tariff_zone_1': 10.0, + 'tariff_zone_2': 20.0, + 'zone_1_start': 7, + 'zone_1_end': 22, + } + t.store_raw_data(raw) + + prices = t._get_prices_native() + assert len(prices) == 48 + + # Compute current hour start same way as provider + now = datetime.datetime.now().astimezone(t.timezone) + current_hour_start = now.replace(minute=0, second=0, microsecond=0) + + for rel_hour, price in prices.items(): + ts = current_hour_start + datetime.timedelta(hours=rel_hour) + h = ts.hour + if 7 <= 22: + is_day = (h >= 7 and h < 22) + else: + is_day = not (h >= 22 and h < 7) + + expected = raw['tariff_zone_1'] if is_day else raw['tariff_zone_2'] + assert price == expected From aed6a1027098a0c606c9d1eb31319125d635afaf Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Mon, 9 Mar 2026 19:31:28 +0100 Subject: [PATCH 08/10] fix(tariffzones): constructor params, concrete provider, price validation - Add tariff_zone_1/2 and zone boundaries as constructor parameters instead of post-init attribute assignment - Implement get_raw_data_from_provider() returning {} (no external API) - Add _validate_price() static method for positive-float enforcement - Add properties with validation for tariff_zone_1 and tariff_zone_2 - Guard _get_prices_native() with RuntimeError if prices not set - Log warning when zone_1_start == zone_1_end (0 hours coverage) - Fix wrap-around logic: use or-condition instead of negation - Fix bare except Exception -> (ValueError, TypeError) - Fix range(0, 48) -> range(48) - Fix field not in config.keys() -> field not in config - Remove extra blank line in __init__ Tests: - Remove DummyTariffZones subclass (TariffZones now concrete) - Add _validate_price tests - Add constructor/defaults tests - Add test for prices-unset RuntimeError - Add wrap-around schedule test - Add equal start/end warning test - Add DynamicTariff factory integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/batcontrol/dynamictariff/dynamictariff.py | 19 +- src/batcontrol/dynamictariff/tariffzones.py | 98 ++++++--- .../dynamictariff/test_tariffzones.py | 193 +++++++++++++----- 3 files changed, 219 insertions(+), 91 deletions(-) diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index c62a8e8..04a3809 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -131,29 +131,22 @@ def create_tarif_provider(config: dict, timezone, selected_tariff.upgrade_48h_to_96h() elif provider.lower() == 'tariff_zones': - # require tariffs for zone 1 and zone 2 required_fields = ['tariff_zone_1', 'tariff_zone_2'] for field in required_fields: - if field not in config.keys(): + if field not in config: raise RuntimeError( f'[DynTariff] Please include {field} in your configuration file' ) - # read values and optional price parameters - tariff_zone_1 = float(config.get('tariff_zone_1')) - tariff_zone_2 = float(config.get('tariff_zone_2')) - zone_1_start = int(config.get('zone_1_start', 7)) - zone_1_end = int(config.get('zone_1_end', 22)) selected_tariff = TariffZones( timezone, min_time_between_api_calls, delay_evaluation_by_seconds, - target_resolution=target_resolution + target_resolution=target_resolution, + tariff_zone_1=float(config['tariff_zone_1']), + tariff_zone_2=float(config['tariff_zone_2']), + zone_1_start=int(config.get('zone_1_start', 7)), + zone_1_end=int(config.get('zone_1_end', 22)), ) - # store configured values in instance - selected_tariff.tariff_zone_1 = tariff_zone_1 - selected_tariff.tariff_zone_2 = tariff_zone_2 - selected_tariff.zone_1_start = zone_1_start - selected_tariff.zone_1_end = zone_1_end else: raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}') diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 3d0fac5..7ace1f9 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -3,11 +3,14 @@ Simple dynamic tariff provider that returns a repeating two zone tariff. Config options (in utility config for provider): - type: tariff_zones -- tariff_zone_1: price for zone 1 hours (float) -- tariff_zone_2: price for zone 2 hours (float) +- tariff_zone_1: price for zone 1 hours (float, Euro/kWh incl. VAT/fees, required) +- tariff_zone_2: price for zone 2 hours (float, Euro/kWh incl. VAT/fees, required) - zone_1_start: hour when tariff zone 1 starts (int, default 7) - zone_1_end: hour when tariff zone 1 ends (int, default 22) +Wrap-around is supported: setting zone_1_start=22 and zone_1_end=6 means zone 1 +covers hours 22-23 and 0-5. When zone_1_start == zone_1_end, zone 1 covers 0 hours. + 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. @@ -40,6 +43,10 @@ def __init__( min_time_between_API_calls=0, delay_evaluation_by_seconds=0, target_resolution: int = 60, + tariff_zone_1: float = None, + tariff_zone_2: float = None, + zone_1_start: int = 7, + zone_1_end: int = 22, ): super().__init__( timezone, @@ -49,11 +56,18 @@ def __init__( native_resolution=60, ) - # default zone boundaries - self._zone_1_start = 7 - self._zone_1_end = 22 - + self._zone_1_start = self._validate_hour(zone_1_start, 'zone_1_start') + self._zone_1_end = self._validate_hour(zone_1_end, 'zone_1_end') + self._tariff_zone_1 = None + self._tariff_zone_2 = None + if tariff_zone_1 is not None: + self.tariff_zone_1 = tariff_zone_1 + if tariff_zone_2 is not None: + self.tariff_zone_2 = tariff_zone_2 + def get_raw_data_from_provider(self) -> dict: + """No external API — configuration is static.""" + return {} def _get_prices_native(self) -> dict[int, float]: """Build hourly prices for the next 48 hours, hour-aligned. @@ -61,46 +75,78 @@ def _get_prices_native(self) -> dict[int, float]: Returns a dict mapping interval index (0 = start of current hour) to price (float). """ - raw = self.get_raw_data() - # allow values from raw data (cache) if present - tariff_zone_1 = raw.get('tariff_zone_1', self.tariff_zone_1) - tariff_zone_2 = raw.get('tariff_zone_2', self.tariff_zone_2) - zone_1_start = int(raw.get('zone_1_start', self.zone_1_start)) - zone_1_end = int(raw.get('zone_1_end', self.zone_1_end)) + if self._tariff_zone_1 is None or self._tariff_zone_2 is None: + raise RuntimeError( + '[TariffZones] tariff_zone_1 and tariff_zone_2 must be set ' + 'before generating prices' + ) + + zone_1_start = self._zone_1_start + zone_1_end = self._zone_1_end - # validate hours are integers in range [0, 23] - zone_1_start = self._validate_hour(zone_1_start, 'zone_1_start') - zone_1_end = self._validate_hour(zone_1_end, 'zone_1_end') + if zone_1_start == zone_1_end: + logger.warning( + 'tariffZones: zone_1_start == zone_1_end (%d): zone 1 covers 0 hours', + zone_1_start + ) now = datetime.datetime.now().astimezone(self.timezone) - # Align to start of current hour current_hour_start = now.replace(minute=0, second=0, microsecond=0) prices = {} - # produce next 48 hours - for rel_hour in range(0, 48): + for rel_hour in range(48): ts = current_hour_start + datetime.timedelta(hours=rel_hour) h = ts.hour - if zone_1_start <= zone_1_end: - is_day = (h >= zone_1_start and h < zone_1_end) + if zone_1_start < zone_1_end: + is_zone_1 = zone_1_start <= h < zone_1_end + elif zone_1_start > zone_1_end: + # wrap-around (e.g., zone_1_start=22, zone_1_end=6) + is_zone_1 = h >= zone_1_start or h < zone_1_end else: - # wrap-around (e.g., zone_1_start=20, zone_1_end=6) - is_day = not (h >= zone_1_end and h < zone_1_start) + # zone_1_start == zone_1_end: no zone 1 hours + is_zone_1 = False - prices[rel_hour] = tariff_zone_1 if is_day else tariff_zone_2 + prices[rel_hour] = self._tariff_zone_1 if is_zone_1 else self._tariff_zone_2 logger.debug('tariffZones: Generated %d hourly prices', len(prices)) return prices - def _validate_hour(self, val: int, name: str) -> int: + @staticmethod + def _validate_hour(val, name: str) -> int: try: ival = int(val) - except Exception: - raise ValueError(f'[{name}] must be an integer between 0 and 23') + except (ValueError, TypeError) as exc: + raise ValueError(f'[{name}] must be an integer between 0 and 23') from exc if ival < 0 or ival > 23: raise ValueError(f'[{name}] must be between 0 and 23 (got {ival})') return ival + @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 zone_1_start(self) -> int: return self._zone_1_start diff --git a/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py index 938c861..5df98ca 100644 --- a/tests/batcontrol/dynamictariff/test_tariffzones.py +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -3,62 +3,103 @@ import pytz from batcontrol.dynamictariff.tariffzones import TariffZones +from batcontrol.dynamictariff.dynamictariff import DynamicTariff -class DummyTariffZones(TariffZones): - """Concrete test subclass implementing the abstract provider method.""" - def __init__(self, timezone): - super().__init__(timezone) - # provide default zone prices for tests - self.tariff_zone_1 = 1.0 - self.tariff_zone_2 = 2.0 +def make_tz(): + return pytz.timezone('Europe/Berlin') - def get_raw_data_from_provider(self) -> dict: - return {} +def make_tariff(**kwargs): + defaults = dict(tariff_zone_1=0.27, tariff_zone_2=0.17) + defaults.update(kwargs) + return TariffZones(make_tz(), **defaults) -def make_tz(): - return pytz.timezone('Europe/Berlin') +# --------------------------------------------------------------------------- +# _validate_hour +# --------------------------------------------------------------------------- def test_validate_hour_accepts_integer(): - tz = make_tz() - t = DummyTariffZones(tz) - assert t._validate_hour(0, 'zone_1_start') == 0 - assert t._validate_hour(23, 'zone_1_end') == 23 + assert TariffZones._validate_hour(0, 'zone_1_start') == 0 + assert TariffZones._validate_hour(23, 'zone_1_end') == 23 def test_validate_hour_rejects_out_of_range(): - tz = make_tz() - t = DummyTariffZones(tz) with pytest.raises(ValueError): - t._validate_hour(-1, 'zone_1_start') + TariffZones._validate_hour(-1, 'zone_1_start') with pytest.raises(ValueError): - t._validate_hour(24, 'zone_1_end') + TariffZones._validate_hour(24, 'zone_1_end') def test_validate_hour_accepts_float_by_int_conversion(): - tz = make_tz() - t = DummyTariffZones(tz) - # Current implementation converts floats via int(), so 7.9 -> 7 - assert t._validate_hour(7.9, 'zone_1_start') == 7 + # int() truncates floats, so 7.9 -> 7 + assert TariffZones._validate_hour(7.9, 'zone_1_start') == 7 def test_validate_hour_rejects_string_decimal(): - tz = make_tz() - t = DummyTariffZones(tz) - # Strings with decimal point cannot be int()-cast -> ValueError with pytest.raises(ValueError): - t._validate_hour('7.5', 'zone_1_start') + TariffZones._validate_hour('7.5', 'zone_1_start') + + +def test_validate_hour_rejects_none(): + with pytest.raises(ValueError): + TariffZones._validate_hour(None, 'zone_1_start') + + +# --------------------------------------------------------------------------- +# _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') + + +def test_validate_price_rejects_non_numeric(): + with pytest.raises(ValueError): + TariffZones._validate_price('abc', 'tariff_zone_1') + + +# --------------------------------------------------------------------------- +# Constructor and property setters +# --------------------------------------------------------------------------- + +def test_constructor_sets_prices_and_boundaries(): + t = make_tariff(zone_1_start=6, zone_1_end=21) + assert t.tariff_zone_1 == pytest.approx(0.27) + assert t.tariff_zone_2 == pytest.approx(0.17) + assert t.zone_1_start == 6 + assert t.zone_1_end == 21 + + +def test_constructor_defaults_boundaries(): + t = make_tariff() + assert t.zone_1_start == 7 + assert t.zone_1_end == 22 + + +def test_prices_unset_raises_on_generate(): + t = TariffZones(make_tz()) + with pytest.raises(RuntimeError, match='tariff_zone_1 and tariff_zone_2'): + t._get_prices_native() def test_property_setters_and_getters(): - tz = make_tz() - t = DummyTariffZones(tz) + t = make_tariff() t.zone_1_start = 5 - t.zone_1_end = 22 + t.zone_1_end = 23 assert t.zone_1_start == 5 - assert t.zone_1_end == 22 + assert t.zone_1_end == 23 with pytest.raises(ValueError): t.zone_1_start = -2 @@ -66,33 +107,81 @@ def test_property_setters_and_getters(): t.zone_1_end = 100 -def test_get_prices_native_uses_raw_data_boundaries(): - tz = make_tz() - t = DummyTariffZones(tz) +# --------------------------------------------------------------------------- +# _get_prices_native — normal schedule +# --------------------------------------------------------------------------- + +def test_get_prices_native_returns_48_hours(): + t = make_tariff() + prices = t._get_prices_native() + assert len(prices) == 48 - # prepare raw data and store it in the provider cache - raw = { - 'tariff_zone_1': 10.0, - 'tariff_zone_2': 20.0, - 'zone_1_start': 7, - 'zone_1_end': 22, - } - t.store_raw_data(raw) +def test_get_prices_native_correct_prices(): + t = make_tariff(zone_1_start=7, zone_1_end=22) + prices = t._get_prices_native() + + now = datetime.datetime.now().astimezone(t.timezone) + current_hour_start = now.replace(minute=0, second=0, microsecond=0) + + for rel_hour, price in prices.items(): + h = (current_hour_start + datetime.timedelta(hours=rel_hour)).hour + is_zone_1 = 7 <= h < 22 + expected = t.tariff_zone_1 if is_zone_1 else t.tariff_zone_2 + assert price == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# _get_prices_native — wrap-around schedule +# --------------------------------------------------------------------------- + +def test_get_prices_native_wraparound(): + """zone_1_start=22, zone_1_end=6 covers hours 22-23 and 0-5.""" + t = make_tariff(zone_1_start=22, zone_1_end=6) prices = t._get_prices_native() assert len(prices) == 48 - # Compute current hour start same way as provider now = datetime.datetime.now().astimezone(t.timezone) current_hour_start = now.replace(minute=0, second=0, microsecond=0) for rel_hour, price in prices.items(): - ts = current_hour_start + datetime.timedelta(hours=rel_hour) - h = ts.hour - if 7 <= 22: - is_day = (h >= 7 and h < 22) - else: - is_day = not (h >= 22 and h < 7) - - expected = raw['tariff_zone_1'] if is_day else raw['tariff_zone_2'] - assert price == expected + h = (current_hour_start + datetime.timedelta(hours=rel_hour)).hour + is_zone_1 = h >= 22 or h < 6 + expected = t.tariff_zone_1 if is_zone_1 else t.tariff_zone_2 + assert price == pytest.approx(expected) + + +def test_get_prices_native_equal_start_end_all_zone_2(caplog): + """When start == end, all hours should be zone 2 and a warning is logged.""" + import logging + t = make_tariff(zone_1_start=10, zone_1_end=10) + with caplog.at_level(logging.WARNING): + prices = t._get_prices_native() + assert all(p == pytest.approx(t.tariff_zone_2) for p in prices.values()) + assert any('0 hours' in msg for msg in caplog.messages) + + +# --------------------------------------------------------------------------- +# Factory integration (DynamicTariff.create_tarif_provider) +# --------------------------------------------------------------------------- + +def test_factory_creates_tariff_zones(): + config = { + 'type': 'tariff_zones', + 'tariff_zone_1': 0.2733, + 'tariff_zone_2': 0.1734, + 'zone_1_start': 5, + 'zone_1_end': 0, + } + provider = DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0) + assert isinstance(provider, TariffZones) + assert provider.tariff_zone_1 == pytest.approx(0.2733) + assert provider.tariff_zone_2 == pytest.approx(0.1734) + assert provider.zone_1_start == 5 + assert provider.zone_1_end == 0 + + +def test_factory_missing_required_field_raises(): + config = {'type': 'tariff_zones', 'tariff_zone_1': 0.27} + with pytest.raises(RuntimeError, match='tariff_zone_2'): + DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0) From b03165d0f0a42eb92284c8f88ba7add3361b27ad Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Mon, 9 Mar 2026 19:39:46 +0100 Subject: [PATCH 09/10] feat(tariffzones): replace start/end with hour-list zones (up to 3) Replace zone_1_start/zone_1_end config parameters with explicit hour-list assignment per zone: Config parameters: - zone_1_hours: comma-separated hours for zone 1 (e.g. 7,8,9,...,22) - zone_2_hours: comma-separated hours for zone 2 (e.g. 0,1,...,6,23) - zone_3_hours: optional third zone hours - tariff_zone_3: price for optional third zone Validation rules (all enforced at price-generation time): - No hour may appear in more than one zone (ValueError) - No duplicate within a single zone (ValueError) - All 24 hours 0-23 must be covered (ValueError) - zone_3_hours and tariff_zone_3 must both be set or both omitted Input formats accepted for *_hours: comma-separated string (as YAML provides), Python list/tuple, or single integer. Updated: - TariffZones: new _parse_hours(), _validate_configuration(), properties - DynamicTariff factory: requires zone_1_hours + zone_2_hours - batcontrol_config_dummy.yaml: updated example comments - Tests: 24 tests covering parse, validation, 2-zone, 3-zone, factory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/batcontrol_config_dummy.yaml | 10 +- src/batcontrol/dynamictariff/dynamictariff.py | 10 +- src/batcontrol/dynamictariff/tariffzones.py | 192 ++++++++++---- .../dynamictariff/test_tariffzones.py | 240 ++++++++++++------ 4 files changed, 311 insertions(+), 141 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index e4e2bbd..6c4605a 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -68,10 +68,12 @@ utility: 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 - # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees - # zone_1_start: 5 # only required for tariff_zones, hour of day when zone 1 tariff starts - # zone_1_end: 0 # only required for tariff_zones, hour of day when zone 1 tariff ends + # tariff_zone_1: 0.2733 # only required for tariff_zones, Euro/kWh incl. vat/fees (peak hours) + # zone_1_hours: 7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22 # hours assigned to zone 1 (0-23, comma-separated) + # tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees (off-peak hours) + # zone_2_hours: 0,1,2,3,4,5,6,23 # hours assigned to zone 2 (must cover remaining hours) + # tariff_zone_3: 0.2100 # optional third zone price + # zone_3_hours: 17,18,19,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/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index 04a3809..030b7d1 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -131,21 +131,25 @@ def create_tarif_provider(config: dict, timezone, selected_tariff.upgrade_48h_to_96h() elif provider.lower() == 'tariff_zones': - required_fields = ['tariff_zone_1', 'tariff_zone_2'] + 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_1_start=int(config.get('zone_1_start', 7)), - zone_1_end=int(config.get('zone_1_end', 22)), + 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: diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 7ace1f9..16bfe81 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -1,15 +1,21 @@ """Tariff_zones provider -Simple dynamic tariff provider that returns a repeating two zone tariff. +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_1_start: hour when tariff zone 1 starts (int, default 7) -- zone_1_end: hour when tariff zone 1 ends (int, default 22) +- 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) -Wrap-around is supported: setting zone_1_start=22 and zone_1_end=6 means zone 1 -covers hours 22-23 and 0-5. When zone_1_start == zone_1_end, zone 1 covers 0 hours. +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 @@ -35,7 +41,7 @@ class TariffZones(DynamicTariffBaseclass): - """Two-tier tariff: zone 1 / zone 2 fixed prices.""" + """Multi-zone tariff with up to 3 zones; each zone owns a set of hours.""" def __init__( self, @@ -44,9 +50,11 @@ def __init__( delay_evaluation_by_seconds=0, target_resolution: int = 60, tariff_zone_1: float = None, + zone_1_hours=None, tariff_zone_2: float = None, - zone_1_start: int = 7, - zone_1_end: int = 22, + zone_2_hours=None, + tariff_zone_3: float = None, + zone_3_hours=None, ): super().__init__( timezone, @@ -56,39 +64,89 @@ def __init__( native_resolution=60, ) - self._zone_1_start = self._validate_hour(zone_1_start, 'zone_1_start') - self._zone_1_end = self._validate_hour(zone_1_end, 'zone_1_end') 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). """ - if self._tariff_zone_1 is None or self._tariff_zone_2 is None: - raise RuntimeError( - '[TariffZones] tariff_zone_1 and tariff_zone_2 must be set ' - 'before generating prices' - ) - - zone_1_start = self._zone_1_start - zone_1_end = self._zone_1_end - - if zone_1_start == zone_1_end: - logger.warning( - 'tariffZones: zone_1_start == zone_1_end (%d): zone 1 covers 0 hours', - zone_1_start - ) + 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) @@ -96,30 +154,42 @@ def _get_prices_native(self) -> dict[int, float]: prices = {} for rel_hour in range(48): ts = current_hour_start + datetime.timedelta(hours=rel_hour) - h = ts.hour - if zone_1_start < zone_1_end: - is_zone_1 = zone_1_start <= h < zone_1_end - elif zone_1_start > zone_1_end: - # wrap-around (e.g., zone_1_start=22, zone_1_end=6) - is_zone_1 = h >= zone_1_start or h < zone_1_end - else: - # zone_1_start == zone_1_end: no zone 1 hours - is_zone_1 = False - - prices[rel_hour] = self._tariff_zone_1 if is_zone_1 else self._tariff_zone_2 + prices[rel_hour] = hour_to_price[ts.hour] logger.debug('tariffZones: Generated %d hourly prices', len(prices)) return prices @staticmethod - def _validate_hour(val, name: str) -> int: - try: - ival = int(val) - except (ValueError, TypeError) as exc: - raise ValueError(f'[{name}] must be an integer between 0 and 23') from exc - if ival < 0 or ival > 23: - raise ValueError(f'[{name}] must be between 0 and 23 (got {ival})') - return ival + def _parse_hours(value, name: str) -> list: + """Parse a comma-separated string, list, or single int into a validated list of hours. + + Raises ValueError if any value is out of range [0, 23] or appears more than once + within the same zone. + """ + if isinstance(value, int): + parts = [value] + elif isinstance(value, str): + parts = [p.strip() for p in value.split(',') if p.strip()] + elif isinstance(value, (list, tuple)): + parts = list(value) + else: + raise ValueError( + f'[{name}] must be a comma-separated string, list, or integer' + ) + + hours = [] + for part in parts: + try: + h = int(part) + except (ValueError, TypeError) as exc: + raise ValueError(f'[{name}] invalid hour value: {part!r}') from exc + 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: @@ -148,17 +218,33 @@ def tariff_zone_2(self, value: float) -> None: self._tariff_zone_2 = self._validate_price(value, 'tariff_zone_2') @property - def zone_1_start(self) -> int: - return self._zone_1_start + 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_1_start.setter - def zone_1_start(self, value: int) -> None: - self._zone_1_start = self._validate_hour(value, 'zone_1_start') + @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_1_end(self) -> int: - return self._zone_1_end + def zone_3_hours(self) -> list: + return self._zone_3_hours - @zone_1_end.setter - def zone_1_end(self, value: int) -> None: - self._zone_1_end = self._validate_hour(value, 'zone_1_end') + @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 index 5df98ca..e29bfc9 100644 --- a/tests/batcontrol/dynamictariff/test_tariffzones.py +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -5,46 +5,66 @@ 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): - defaults = dict(tariff_zone_1=0.27, tariff_zone_2=0.17) + """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) # --------------------------------------------------------------------------- -# _validate_hour +# _parse_hours # --------------------------------------------------------------------------- -def test_validate_hour_accepts_integer(): - assert TariffZones._validate_hour(0, 'zone_1_start') == 0 - assert TariffZones._validate_hour(23, 'zone_1_end') == 23 +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_validate_hour_rejects_out_of_range(): - with pytest.raises(ValueError): - TariffZones._validate_hour(-1, 'zone_1_start') - with pytest.raises(ValueError): - TariffZones._validate_hour(24, 'zone_1_end') +def test_parse_hours_list_of_ints(): + result = TariffZones._parse_hours([7, 8, 9], 'zone_1_hours') + assert result == [7, 8, 9] -def test_validate_hour_accepts_float_by_int_conversion(): - # int() truncates floats, so 7.9 -> 7 - assert TariffZones._validate_hour(7.9, 'zone_1_start') == 7 +def test_parse_hours_single_int(): + result = TariffZones._parse_hours(5, 'zone_1_hours') + assert result == [5] -def test_validate_hour_rejects_string_decimal(): - with pytest.raises(ValueError): - TariffZones._validate_hour('7.5', 'zone_1_start') +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_duplicate_within_zone(): + with pytest.raises(ValueError, match='more than once'): + TariffZones._parse_hours('7,8,7', 'zone_1_hours') -def test_validate_hour_rejects_none(): + +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._validate_hour(None, 'zone_1_start') + TariffZones._parse_hours({'set'}, 'zone_1_hours') # --------------------------------------------------------------------------- @@ -65,100 +85,138 @@ def test_validate_price_rejects_negative(): TariffZones._validate_price(-0.1, 'tariff_zone_1') -def test_validate_price_rejects_non_numeric(): - with pytest.raises(ValueError): - TariffZones._validate_price('abc', 'tariff_zone_1') - - # --------------------------------------------------------------------------- -# Constructor and property setters +# Constructor and _validate_configuration # --------------------------------------------------------------------------- -def test_constructor_sets_prices_and_boundaries(): - t = make_tariff(zone_1_start=6, zone_1_end=21) +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_start == 6 - assert t.zone_1_end == 21 + assert t.zone_1_hours == HOURS_PEAK + assert t.zone_2_hours == HOURS_OFFPEAK -def test_constructor_defaults_boundaries(): - t = make_tariff() - assert t.zone_1_start == 7 - assert t.zone_1_end == 22 +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_prices_unset_raises_on_generate(): - t = TariffZones(make_tz()) - with pytest.raises(RuntimeError, match='tariff_zone_1 and tariff_zone_2'): +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_property_setters_and_getters(): - t = make_tariff() - t.zone_1_start = 5 - t.zone_1_end = 23 - assert t.zone_1_start == 5 - assert t.zone_1_end == 23 +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() - with pytest.raises(ValueError): - t.zone_1_start = -2 - with pytest.raises(ValueError): - t.zone_1_end = 100 + +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 — normal schedule +# _get_prices_native — 2-zone # --------------------------------------------------------------------------- def test_get_prices_native_returns_48_hours(): - t = make_tariff() - prices = t._get_prices_native() + prices = make_tariff()._get_prices_native() assert len(prices) == 48 -def test_get_prices_native_correct_prices(): - t = make_tariff(zone_1_start=7, zone_1_end=22) +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) - current_hour_start = now.replace(minute=0, second=0, microsecond=0) + base = now.replace(minute=0, second=0, microsecond=0) for rel_hour, price in prices.items(): - h = (current_hour_start + datetime.timedelta(hours=rel_hour)).hour - is_zone_1 = 7 <= h < 22 - expected = t.tariff_zone_1 if is_zone_1 else t.tariff_zone_2 + 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 — wrap-around schedule +# _get_prices_native — 3-zone # --------------------------------------------------------------------------- -def test_get_prices_native_wraparound(): - """zone_1_start=22, zone_1_end=6 covers hours 22-23 and 0-5.""" - t = make_tariff(zone_1_start=22, zone_1_end=6) +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) - current_hour_start = now.replace(minute=0, second=0, microsecond=0) + 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 = (current_hour_start + datetime.timedelta(hours=rel_hour)).hour - is_zone_1 = h >= 22 or h < 6 - expected = t.tariff_zone_1 if is_zone_1 else t.tariff_zone_2 - assert price == pytest.approx(expected) + h = (base + datetime.timedelta(hours=rel_hour)).hour + assert price == pytest.approx(zone_map[h]) + +# --------------------------------------------------------------------------- +# CSV string input (as YAML would provide) +# --------------------------------------------------------------------------- -def test_get_prices_native_equal_start_end_all_zone_2(caplog): - """When start == end, all hours should be zone 2 and a warning is logged.""" - import logging - t = make_tariff(zone_1_start=10, zone_1_end=10) - with caplog.at_level(logging.WARNING): - prices = t._get_prices_native() - assert all(p == pytest.approx(t.tariff_zone_2) for p in prices.values()) - assert any('0 hours' in msg for msg in caplog.messages) +def test_csv_string_hours_accepted(): + t = TariffZones( + make_tz(), + tariff_zone_1=0.27, + zone_1_hours='7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22', + tariff_zone_2=0.17, + zone_2_hours='0,1,2,3,4,5,6,23', + ) + prices = t._get_prices_native() + assert len(prices) == 48 # --------------------------------------------------------------------------- @@ -168,20 +226,40 @@ def test_get_prices_native_equal_start_end_all_zone_2(caplog): def test_factory_creates_tariff_zones(): config = { 'type': 'tariff_zones', - 'tariff_zone_1': 0.2733, - 'tariff_zone_2': 0.1734, - 'zone_1_start': 5, - 'zone_1_end': 0, + 'tariff_zone_1': 0.27, + 'zone_1_hours': '7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22', + 'tariff_zone_2': 0.17, + 'zone_2_hours': '0,1,2,3,4,5,6,23', } provider = DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0) assert isinstance(provider, TariffZones) - assert provider.tariff_zone_1 == pytest.approx(0.2733) - assert provider.tariff_zone_2 == pytest.approx(0.1734) - assert provider.zone_1_start == 5 - assert provider.zone_1_end == 0 + assert provider.tariff_zone_1 == pytest.approx(0.27) + assert provider.tariff_zone_2 == pytest.approx(0.17) + + +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} - with pytest.raises(RuntimeError, match='tariff_zone_2'): + 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) From 389fff7e9f5e85391159a5d23e5334aad1fff5db Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Mon, 9 Mar 2026 19:43:16 +0100 Subject: [PATCH 10/10] feat(tariffzones): add range syntax support for zone hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _parse_hours now accepts inclusive range tokens (e.g. '0-5') in addition to single values, enabling compact config like: zone_1_hours: 7-22 zone_2_hours: 0-6,23 zone_3_hours: 17-20 Ranges are expanded inclusively (7-22 → [7,8,...,22]). Single integers and range strings may be freely mixed. List/tuple elements that are Python ints are handled directly (no string conversion) to avoid treating negative integers as ranges. Validation unchanged: inverted ranges (5-3) and out-of-bounds values raise ValueError. Updated config dummy example to use range notation. Added 7 new parse_hours tests covering ranges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config/batcontrol_config_dummy.yaml | 6 +- src/batcontrol/dynamictariff/tariffzones.py | 63 +++++++++++++++---- .../dynamictariff/test_tariffzones.py | 46 ++++++++++++-- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 6c4605a..c938f92 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -69,11 +69,11 @@ utility: 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,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22 # hours assigned to zone 1 (0-23, comma-separated) + # 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,1,2,3,4,5,6,23 # hours assigned to zone 2 (must cover remaining 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,18,19,20 # optional hours for zone 3 (must not overlap with zone 1 or 2) + # 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/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 16bfe81..8e23b88 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -161,34 +161,73 @@ def _get_prices_native(self) -> dict[int, float]: @staticmethod def _parse_hours(value, name: str) -> list: - """Parse a comma-separated string, list, or single int into a validated list of hours. - - Raises ValueError if any value is out of range [0, 23] or appears more than once - within the same zone. + """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): - parts = [value] + raw_ints = [value] + tokens = [] elif isinstance(value, str): - parts = [p.strip() for p in value.split(',') if p.strip()] + raw_ints = [] + tokens = [p.strip() for p in value.split(',') if p.strip()] elif isinstance(value, (list, tuple)): - parts = list(value) + # 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 part in parts: - try: - h = int(part) - except (ValueError, TypeError) as exc: - raise ValueError(f'[{name}] invalid hour value: {part!r}') from exc + 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 diff --git a/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py index e29bfc9..05e2321 100644 --- a/tests/batcontrol/dynamictariff/test_tariffzones.py +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -45,6 +45,37 @@ def test_parse_hours_single_int(): 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') @@ -52,6 +83,11 @@ def test_parse_hours_rejects_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') @@ -211,9 +247,9 @@ def test_csv_string_hours_accepted(): t = TariffZones( make_tz(), tariff_zone_1=0.27, - zone_1_hours='7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22', + zone_1_hours='7-22', tariff_zone_2=0.17, - zone_2_hours='0,1,2,3,4,5,6,23', + zone_2_hours='0-6,23', ) prices = t._get_prices_native() assert len(prices) == 48 @@ -227,14 +263,16 @@ def test_factory_creates_tariff_zones(): config = { 'type': 'tariff_zones', 'tariff_zone_1': 0.27, - 'zone_1_hours': '7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22', + 'zone_1_hours': '7-22', 'tariff_zone_2': 0.17, - 'zone_2_hours': '0,1,2,3,4,5,6,23', + '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():