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/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..70720fd --- /dev/null +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -0,0 +1,85 @@ +"""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, + ) + + + + 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)) + + 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