diff --git a/README.MD b/README.MD index 458a7a7..73b6871 100644 --- a/README.MD +++ b/README.MD @@ -10,7 +10,7 @@ To integrate batcontrol with Home Assistant, use the following repository: [batc ## Prerequisites: 1. A PV installation with a BYD Battery and a Fronius Gen24 inverter. -2. An EPEX Spot based contract with hourly electricity pricing, like Awattar, Tibber etc. (Get a €50 bonus on sign-up to Tibber using this [link](https://invite.tibber.com/x8ci52nj).) +2. A zone based pricing, like Octopus, or an EPEX Spot based contract with hourly electricity pricing, like Awattar, Tibber etc. (Get a €50 bonus on sign-up to Tibber using this [link](https://invite.tibber.com/x8ci52nj).) 3. Customer login details to the inverter. **OR** use the MQTT inverter driver to integrate any battery/inverter system - see [MQTT Inverter Integration](#mqtt-inverter-integration) below. diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 18ec92a..e4e2bbd 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, tariff_zones] vat: 0.19 # only required for awattar and energyforecast fees: 0.015 # only required for awattar and energyforecast markup: 0.03 # only required for awattar and energyforecast + # tariff_zone_1: 0.2733 # only required for tariff_zones, Euro/kWh incl. vat/fees + # 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/run_tests.ps1 b/run_tests.ps1 new file mode 100644 index 0000000..2f5e9ff --- /dev/null +++ b/run_tests.ps1 @@ -0,0 +1,23 @@ +# PowerShell version of run_tests.sh + +# Activate virtual environment if it exists (Windows path used by venv) +if (Test-Path -Path .\.venv\Scripts\Activate.ps1) { + . .\.venv\Scripts\Activate.ps1 +} + +# Ensure pytest and helpers are installed +python -m pip install --upgrade pip +python -m pip install pytest pytest-cov pytest-asyncio + +# Run pytest with coverage and logging options +$params = @( + 'tests/', + '--cov=src/batcontrol', + '--log-cli-level=DEBUG', + '--log-cli-format=%(asctime)s [%(levelname)8s] %(name)s: %(message)s', + '--log-cli-date-format=%Y-%m-%d %H:%M:%S' +) + +python -m pytest @params + +exit $LASTEXITCODE diff --git a/src/batcontrol/dynamictariff/dynamictariff.py b/src/batcontrol/dynamictariff/dynamictariff.py index b67cae1..c62a8e8 100644 --- a/src/batcontrol/dynamictariff/dynamictariff.py +++ b/src/batcontrol/dynamictariff/dynamictariff.py @@ -19,6 +19,7 @@ from .tibber import Tibber from .evcc import Evcc from .energyforecast import Energyforecast +from .tariffzones import TariffZones from .dynamictariff_interface import TariffInterface @@ -129,6 +130,31 @@ def create_tarif_provider(config: dict, timezone, if provider.lower() == 'energyforecast_96': 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(): + 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 + ) + # 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] Unkown provider {provider}') + raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}') return selected_tariff diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py new file mode 100644 index 0000000..3d0fac5 --- /dev/null +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -0,0 +1,118 @@ +"""Tariff_zones provider + +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) +- 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 +15min if the target resolution is 15. + +Note: +The charge rate is not evenly distributed across the low price hours. +If you prefer a more even distribution during the low price hours, you can adjust the +soften_price_difference_on_charging to enabled +and +max_grid_charge_rate to a low value, e.g. capacity of the battery divided +by the hours of low price periods. + +If you prefer a late charging start (=optimize efficiency, have battery only short +time at high SOC), you can adjust the +soften_price_difference_on_charging to disabled +""" +import datetime +import logging +from .baseclass import DynamicTariffBaseclass + +logger = logging.getLogger(__name__) + + +class TariffZones(DynamicTariffBaseclass): + """Two-tier tariff: zone 1 / zone 2 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, + ) + + # default zone boundaries + self._zone_1_start = 7 + self._zone_1_end = 22 + + + + 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_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)) + + # 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) + + 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 zone_1_start <= zone_1_end: + is_day = (h >= zone_1_start and 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) + + prices[rel_hour] = tariff_zone_1 if is_day else tariff_zone_2 + + 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') 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