Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

#--------------------------
Expand Down
28 changes: 27 additions & 1 deletion src/batcontrol/dynamictariff/dynamictariff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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'))
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tariff values are extracted as floats, but there's no validation to ensure they are positive numbers or reasonable values. Negative or extremely large tariff values could cause issues in the battery control logic. Consider adding validation to ensure tariff_zone_1 and tariff_zone_2 are positive floats within a reasonable range.

Suggested change
tariff_zone_2 = float(config.get('tariff_zone_2'))
tariff_zone_2 = float(config.get('tariff_zone_2'))
# validate tariff values to be positive and within a reasonable range
max_allowed_tariff = 10.0
if not (0.0 < tariff_zone_1 <= max_allowed_tariff):
raise RuntimeError(
'[DynTariff] Invalid tariff_zone_1 value '
f'({tariff_zone_1}). Expected a positive value '
f'<= {max_allowed_tariff}.'
)
if not (0.0 < tariff_zone_2 <= max_allowed_tariff):
raise RuntimeError(
'[DynTariff] Invalid tariff_zone_2 value '
f'({tariff_zone_2}). Expected a positive value '
f'<= {max_allowed_tariff}.'
)

Copilot uses AI. Check for mistakes.
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
Comment on lines 144 to 156
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tariff_zones provider lacks input validation for zone_1_start and zone_1_end. These values should be validated to ensure they are within the valid hour range (0-23). Invalid values could lead to unexpected behavior in the price calculation logic. Consider adding validation either in the factory method (create_tarif_provider) or in the Tariff_zones init method.

Copilot uses AI. Check for mistakes.

else:
raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}')
raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}')
return selected_tariff
85 changes: 85 additions & 0 deletions src/batcontrol/dynamictariff/tariffzones.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines 54 to 85
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states this implementation is "already prepared for multiple zones", but the current code is hardcoded for exactly two tariff zones. Extending to more than two zones would require significant refactoring of the data structure (e.g., using lists of zone definitions with start/end times and prices) and the logic in _get_prices_native. Consider whether this claim in the PR description is accurate, or if additional work is needed to truly prepare for multiple zones.

Copilot uses AI. Check for mistakes.
Comment on lines 54 to 85
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tariff_zones provider lacks comprehensive test coverage. All other tariff providers in this codebase have dedicated test files (e.g., test_tibber.py, test_evcc.py, test_energyforecast.py). A test file should be created at tests/batcontrol/dynamictariff/test_tariffzones.py to verify the tariff calculation logic, especially the wrap-around scenario when zone_1_start is greater than zone_1_end.

Copilot uses AI. Check for mistakes.