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
23 changes: 23 additions & 0 deletions run_tests.ps1
Original file line number Diff line number Diff line change
@@ -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
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
118 changes: 118 additions & 0 deletions src/batcontrol/dynamictariff/tariffzones.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +58 to +93
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 +58 to +93
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.

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')
98 changes: 98 additions & 0 deletions tests/batcontrol/dynamictariff/test_tariffzones.py
Original file line number Diff line number Diff line change
@@ -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