Skip to content
Merged
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).)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Minor wording: “A zone based pricing” is grammatically awkward; “zone-based pricing” (or “a zone-based tariff”) reads more naturally.

Suggested change
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).)
2. A zone-based tariff, 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).)

Copilot uses AI. Check for mistakes.
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
8 changes: 7 additions & 1 deletion config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,16 @@ 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 (peak hours)
# zone_1_hours: 7-22 # hours assigned to zone 1; supports ranges (7-22), singles (7), or mixed (0-5,6,7)
# tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees (off-peak hours)
# zone_2_hours: 0-6,23 # hours assigned to zone 2 (must cover remaining hours)
# tariff_zone_3: 0.2100 # optional third zone price
# zone_3_hours: 17-20 # optional hours for zone 3 (must not overlap with zone 1 or 2)
# 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
Comment on lines +3 to +5
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The script only activates a virtualenv at ./.venv/..., but run_tests.sh looks for ./venv/activate while the README suggests .venv. This mismatch can lead to tests running outside the intended environment on some setups; consider checking both locations or standardizing on one convention across scripts and docs.

Suggested change
# Activate virtual environment if it exists (Windows path used by venv)
if (Test-Path -Path .\.venv\Scripts\Activate.ps1) {
. .\.venv\Scripts\Activate.ps1
# Activate virtual environment if it exists (prefer .venv, fall back to venv)
if (Test-Path -Path .\.venv\Scripts\Activate.ps1) {
. .\.venv\Scripts\Activate.ps1
} elseif (Test-Path -Path .\venv\Scripts\Activate.ps1) {
. .\venv\Scripts\Activate.ps1

Copilot uses AI. Check for mistakes.
}

# 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
25 changes: 24 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,28 @@ def create_tarif_provider(config: dict, timezone,
if provider.lower() == 'energyforecast_96':
selected_tariff.upgrade_48h_to_96h()

elif provider.lower() == 'tariff_zones':
required_fields = ['tariff_zone_1', 'zone_1_hours', 'tariff_zone_2', 'zone_2_hours']
for field in required_fields:
if field not in config:
raise RuntimeError(
f'[DynTariff] Please include {field} in your configuration file'
)
zone_3_hours = config.get('zone_3_hours')
tariff_zone_3 = config.get('tariff_zone_3')
selected_tariff = TariffZones(
timezone,
min_time_between_api_calls,
delay_evaluation_by_seconds,
target_resolution=target_resolution,
tariff_zone_1=float(config['tariff_zone_1']),
zone_1_hours=config['zone_1_hours'],
tariff_zone_2=float(config['tariff_zone_2']),
zone_2_hours=config['zone_2_hours'],
tariff_zone_3=float(tariff_zone_3) if tariff_zone_3 is not None else None,
zone_3_hours=zone_3_hours,
)

else:
raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}')
raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}')
return selected_tariff
289 changes: 289 additions & 0 deletions src/batcontrol/dynamictariff/tariffzones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
"""Tariff_zones provider

Simple dynamic tariff provider that assigns a fixed price to each hour of the day
using up to three configurable zones.

Config options (in utility config for provider):
- type: tariff_zones
- tariff_zone_1: price for zone 1 hours (float, Euro/kWh incl. VAT/fees, required)
- zone_1_hours: comma-separated list of hours assigned to zone 1, e.g. "7,8,9,10"
- tariff_zone_2: price for zone 2 hours (float, Euro/kWh incl. VAT/fees, required)
- zone_2_hours: comma-separated list of hours assigned to zone 2, e.g. "0,1,2,3,4,5,6"
- tariff_zone_3: price for zone 3 hours (float, optional)
- zone_3_hours: comma-separated list of hours assigned to zone 3 (optional)

Rules:
- Every hour 0-23 must appear in exactly one zone (ValueError if any hour is missing).
- No hour may appear more than once across all zones (ValueError on duplicate).
- zone_3_hours and tariff_zone_3 must both be set or both omitted.

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
Comment on lines +26 to +33
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

Docstring wording is ungrammatical/unclear: “soften_price_difference_on_charging to enabled/disabled”. Consider rephrasing to “set soften_price_difference_on_charging to enabled/disabled” (and similarly for max_grid_charge_rate) so users can follow the configuration guidance.

Suggested change
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
If you prefer a more even distribution during the low price hours, you can set
soften_price_difference_on_charging to enabled
and set
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 set

Copilot uses AI. Check for mistakes.
soften_price_difference_on_charging to disabled
"""
import datetime
import logging
from .baseclass import DynamicTariffBaseclass

logger = logging.getLogger(__name__)


class TariffZones(DynamicTariffBaseclass):
"""Multi-zone tariff with up to 3 zones; each zone owns a set of hours."""

def __init__(
self,
timezone,
min_time_between_API_calls=0,
delay_evaluation_by_seconds=0,
target_resolution: int = 60,
tariff_zone_1: float = None,
Comment on lines +46 to +52
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The __init__ parameter continuation indentation is misaligned with the opening parenthesis and will likely trigger PEP8/pylint continuation-indentation warnings. Please run autopep8 (or align continuation lines under the opening parenthesis / use a 4-space hanging indent) to match the style used in other providers.

Copilot uses AI. Check for mistakes.
zone_1_hours=None,
tariff_zone_2: float = None,
zone_2_hours=None,
tariff_zone_3: float = None,
zone_3_hours=None,
):
super().__init__(
timezone,
min_time_between_API_calls,
delay_evaluation_by_seconds,
target_resolution=target_resolution,
native_resolution=60,
)

self._tariff_zone_1 = None
self._tariff_zone_2 = None
self._tariff_zone_3 = None
self._zone_1_hours = None
self._zone_2_hours = None
self._zone_3_hours = None

if tariff_zone_1 is not None:
self.tariff_zone_1 = tariff_zone_1
if zone_1_hours is not None:
self.zone_1_hours = zone_1_hours
if tariff_zone_2 is not None:
self.tariff_zone_2 = tariff_zone_2
if zone_2_hours is not None:
self.zone_2_hours = zone_2_hours
if tariff_zone_3 is not None:
self.tariff_zone_3 = tariff_zone_3
if zone_3_hours is not None:
self.zone_3_hours = zone_3_hours

def get_raw_data_from_provider(self) -> dict:
"""No external API — configuration is static."""
return {}

def _validate_configuration(self) -> None:
"""Raise RuntimeError/ValueError if the zone configuration is incomplete or invalid."""
if self._tariff_zone_1 is None:
raise RuntimeError('[TariffZones] tariff_zone_1 must be set')
if self._zone_1_hours is None:
raise RuntimeError('[TariffZones] zone_1_hours must be set')
if self._tariff_zone_2 is None:
raise RuntimeError('[TariffZones] tariff_zone_2 must be set')
if self._zone_2_hours is None:
raise RuntimeError('[TariffZones] zone_2_hours must be set')

zone3_hours_set = self._zone_3_hours is not None
zone3_price_set = self._tariff_zone_3 is not None
if zone3_hours_set != zone3_price_set:
raise RuntimeError(
'[TariffZones] zone_3_hours and tariff_zone_3 must both be set or both omitted'
)

# Check for duplicate hours across all zones
seen = {}
for zone_name, hours in [
('zone_1_hours', self._zone_1_hours),
('zone_2_hours', self._zone_2_hours),
('zone_3_hours', self._zone_3_hours),
]:
if hours is None:
continue
for h in hours:
if h in seen:
raise ValueError(
f'Hour {h} is defined in both {seen[h]} and {zone_name}'
)
seen[h] = zone_name

# Check all 24 hours are covered
missing = sorted(set(range(24)) - set(seen))
if missing:
raise ValueError(
f'Hours {missing} are not assigned to any zone; '
'all 24 hours (0-23) must be covered'
)

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).
"""
self._validate_configuration()

hour_to_price = {}
for hours, price in [
(self._zone_1_hours, self._tariff_zone_1),
(self._zone_2_hours, self._tariff_zone_2),
(self._zone_3_hours, self._tariff_zone_3),
]:
if hours is not None:
for h in hours:
hour_to_price[h] = price

now = datetime.datetime.now().astimezone(self.timezone)
current_hour_start = now.replace(minute=0, second=0, microsecond=0)

prices = {}
for rel_hour in range(48):
ts = current_hour_start + datetime.timedelta(hours=rel_hour)
prices[rel_hour] = hour_to_price[ts.hour]
Comment on lines +151 to +157
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

In _get_prices_native, datetime.datetime.now().astimezone(self.timezone) relies on the host local timezone for naive now(), which can misalign indices (especially on Windows where TZ isn’t applied) and can produce incorrect hours around DST changes. Prefer basing calculations on datetime.datetime.now(datetime.timezone.utc) and converting to self.timezone, and avoid adding timedeltas to pytz-localized datetimes (convert each rel_hour from UTC to local instead) so DST transitions map to the correct local hour.

Suggested change
now = datetime.datetime.now().astimezone(self.timezone)
current_hour_start = now.replace(minute=0, second=0, microsecond=0)
prices = {}
for rel_hour in range(48):
ts = current_hour_start + datetime.timedelta(hours=rel_hour)
prices[rel_hour] = hour_to_price[ts.hour]
# Anchor calculations in UTC and convert to the configured timezone
now_utc = datetime.datetime.now(datetime.timezone.utc)
now_local = now_utc.astimezone(self.timezone)
current_hour_start_local = now_local.replace(minute=0, second=0, microsecond=0)
current_hour_start_utc = current_hour_start_local.astimezone(datetime.timezone.utc)
prices = {}
for rel_hour in range(48):
ts_utc = current_hour_start_utc + datetime.timedelta(hours=rel_hour)
ts_local = ts_utc.astimezone(self.timezone)
prices[rel_hour] = hour_to_price[ts_local.hour]

Copilot uses AI. Check for mistakes.

logger.debug('tariffZones: Generated %d hourly prices', len(prices))
return prices

@staticmethod
def _parse_hours(value, name: str) -> list:
"""Parse hour specifications into a validated list of hours.

Accepted formats (may be mixed):
- Single integer: 5
- Comma-separated values: "0,1,2,3"
- Inclusive ranges: "0-5" → [0, 1, 2, 3, 4, 5]
- Mixed: "0-5,6,7" → [0, 1, 2, 3, 4, 5, 6, 7]
- Python list/tuple of ints or range-strings: [0, '1-3', 4]

Raises ValueError if any hour is out of range [0, 23], if a range is
invalid (start > end), or if an hour appears more than once within the
same zone.
"""
def expand_token(token: str) -> list:
"""Expand a single string token (range or integer) to a list of ints."""
if '-' in token:
parts = token.split('-', 1)
try:
start, end = int(parts[0].strip()), int(parts[1].strip())
except (ValueError, TypeError) as exc:
raise ValueError(
f'[{name}] invalid range: {token!r}'
) from exc
if start > end:
raise ValueError(
f'[{name}] range start must be <= end, got {token!r}'
)
return list(range(start, end + 1))
try:
return [int(token)]
except (ValueError, TypeError) as exc:
raise ValueError(
f'[{name}] invalid hour value: {token!r}'
) from exc

if isinstance(value, int):
raw_ints = [value]
tokens = []
elif isinstance(value, str):
raw_ints = []
tokens = [p.strip() for p in value.split(',') if p.strip()]
elif isinstance(value, (list, tuple)):
# split into direct integers (no range parsing) and string tokens
raw_ints = [p for p in value if isinstance(p, int)]
tokens = [str(p).strip() for p in value
if not isinstance(p, int) and str(p).strip()]
else:
raise ValueError(
f'[{name}] must be a comma-separated string, list, or integer'
)

hours = []
for h in raw_ints:
if h < 0 or h > 23:
raise ValueError(f'[{name}] hour {h} is out of range [0, 23]')
if h in hours:
raise ValueError(f'[{name}] hour {h} appears more than once')
hours.append(h)

for token in tokens:
for h in expand_token(token):
if h < 0 or h > 23:
raise ValueError(f'[{name}] hour {h} is out of range [0, 23]')
if h in hours:
raise ValueError(f'[{name}] hour {h} appears more than once')
hours.append(h)

return hours

@staticmethod
def _validate_price(val, name: str) -> float:
try:
fval = float(val)
except (ValueError, TypeError) as exc:
raise ValueError(f'[{name}] must be a positive number') from exc
if fval <= 0:
raise ValueError(f'[{name}] must be positive (got {fval})')
return fval

@property
def tariff_zone_1(self) -> float:
return self._tariff_zone_1

@tariff_zone_1.setter
def tariff_zone_1(self, value: float) -> None:
self._tariff_zone_1 = self._validate_price(value, 'tariff_zone_1')

@property
def tariff_zone_2(self) -> float:
return self._tariff_zone_2

@tariff_zone_2.setter
def tariff_zone_2(self, value: float) -> None:
self._tariff_zone_2 = self._validate_price(value, 'tariff_zone_2')

@property
def tariff_zone_3(self) -> float:
return self._tariff_zone_3

@tariff_zone_3.setter
def tariff_zone_3(self, value: float) -> None:
self._tariff_zone_3 = self._validate_price(value, 'tariff_zone_3')

@property
def zone_1_hours(self) -> list:
return self._zone_1_hours

@zone_1_hours.setter
def zone_1_hours(self, value) -> None:
self._zone_1_hours = self._parse_hours(value, 'zone_1_hours')

@property
def zone_2_hours(self) -> list:
return self._zone_2_hours

@zone_2_hours.setter
def zone_2_hours(self, value) -> None:
self._zone_2_hours = self._parse_hours(value, 'zone_2_hours')

@property
def zone_3_hours(self) -> list:
return self._zone_3_hours

@zone_3_hours.setter
def zone_3_hours(self, value) -> None:
self._zone_3_hours = self._parse_hours(value, 'zone_3_hours')
Loading