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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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