Skip to content

Commit 389fff7

Browse files
MaStrCopilot
andcommitted
feat(tariffzones): add range syntax support for zone hours
_parse_hours now accepts inclusive range tokens (e.g. '0-5') in addition to single values, enabling compact config like: zone_1_hours: 7-22 zone_2_hours: 0-6,23 zone_3_hours: 17-20 Ranges are expanded inclusively (7-22 → [7,8,...,22]). Single integers and range strings may be freely mixed. List/tuple elements that are Python ints are handled directly (no string conversion) to avoid treating negative integers as ranges. Validation unchanged: inverted ranges (5-3) and out-of-bounds values raise ValueError. Updated config dummy example to use range notation. Added 7 new parse_hours tests covering ranges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b03165d commit 389fff7

3 files changed

Lines changed: 96 additions & 19 deletions

File tree

config/batcontrol_config_dummy.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,11 @@ utility:
6969
fees: 0.015 # only required for awattar and energyforecast
7070
markup: 0.03 # only required for awattar and energyforecast
7171
# tariff_zone_1: 0.2733 # only required for tariff_zones, Euro/kWh incl. vat/fees (peak hours)
72-
# zone_1_hours: 7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22 # hours assigned to zone 1 (0-23, comma-separated)
72+
# zone_1_hours: 7-22 # hours assigned to zone 1; supports ranges (7-22), singles (7), or mixed (0-5,6,7)
7373
# tariff_zone_2: 0.1734 # only required for tariff_zones, Euro/kWh incl. vat/fees (off-peak hours)
74-
# zone_2_hours: 0,1,2,3,4,5,6,23 # hours assigned to zone 2 (must cover remaining hours)
74+
# zone_2_hours: 0-6,23 # hours assigned to zone 2 (must cover remaining hours)
7575
# tariff_zone_3: 0.2100 # optional third zone price
76-
# zone_3_hours: 17,18,19,20 # optional hours for zone 3 (must not overlap with zone 1 or 2)
76+
# zone_3_hours: 17-20 # optional hours for zone 3 (must not overlap with zone 1 or 2)
7777
# apikey: YOUR_API_KEY # MANDATORY for energyforecast and tibber. Uncomment and set if using those providers.
7878

7979
#--------------------------

src/batcontrol/dynamictariff/tariffzones.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,34 +161,73 @@ def _get_prices_native(self) -> dict[int, float]:
161161

162162
@staticmethod
163163
def _parse_hours(value, name: str) -> list:
164-
"""Parse a comma-separated string, list, or single int into a validated list of hours.
165-
166-
Raises ValueError if any value is out of range [0, 23] or appears more than once
167-
within the same zone.
164+
"""Parse hour specifications into a validated list of hours.
165+
166+
Accepted formats (may be mixed):
167+
- Single integer: 5
168+
- Comma-separated values: "0,1,2,3"
169+
- Inclusive ranges: "0-5" → [0, 1, 2, 3, 4, 5]
170+
- Mixed: "0-5,6,7" → [0, 1, 2, 3, 4, 5, 6, 7]
171+
- Python list/tuple of ints or range-strings: [0, '1-3', 4]
172+
173+
Raises ValueError if any hour is out of range [0, 23], if a range is
174+
invalid (start > end), or if an hour appears more than once within the
175+
same zone.
168176
"""
177+
def expand_token(token: str) -> list:
178+
"""Expand a single string token (range or integer) to a list of ints."""
179+
if '-' in token:
180+
parts = token.split('-', 1)
181+
try:
182+
start, end = int(parts[0].strip()), int(parts[1].strip())
183+
except (ValueError, TypeError) as exc:
184+
raise ValueError(
185+
f'[{name}] invalid range: {token!r}'
186+
) from exc
187+
if start > end:
188+
raise ValueError(
189+
f'[{name}] range start must be <= end, got {token!r}'
190+
)
191+
return list(range(start, end + 1))
192+
try:
193+
return [int(token)]
194+
except (ValueError, TypeError) as exc:
195+
raise ValueError(
196+
f'[{name}] invalid hour value: {token!r}'
197+
) from exc
198+
169199
if isinstance(value, int):
170-
parts = [value]
200+
raw_ints = [value]
201+
tokens = []
171202
elif isinstance(value, str):
172-
parts = [p.strip() for p in value.split(',') if p.strip()]
203+
raw_ints = []
204+
tokens = [p.strip() for p in value.split(',') if p.strip()]
173205
elif isinstance(value, (list, tuple)):
174-
parts = list(value)
206+
# split into direct integers (no range parsing) and string tokens
207+
raw_ints = [p for p in value if isinstance(p, int)]
208+
tokens = [str(p).strip() for p in value
209+
if not isinstance(p, int) and str(p).strip()]
175210
else:
176211
raise ValueError(
177212
f'[{name}] must be a comma-separated string, list, or integer'
178213
)
179214

180215
hours = []
181-
for part in parts:
182-
try:
183-
h = int(part)
184-
except (ValueError, TypeError) as exc:
185-
raise ValueError(f'[{name}] invalid hour value: {part!r}') from exc
216+
for h in raw_ints:
186217
if h < 0 or h > 23:
187218
raise ValueError(f'[{name}] hour {h} is out of range [0, 23]')
188219
if h in hours:
189220
raise ValueError(f'[{name}] hour {h} appears more than once')
190221
hours.append(h)
191222

223+
for token in tokens:
224+
for h in expand_token(token):
225+
if h < 0 or h > 23:
226+
raise ValueError(f'[{name}] hour {h} is out of range [0, 23]')
227+
if h in hours:
228+
raise ValueError(f'[{name}] hour {h} appears more than once')
229+
hours.append(h)
230+
192231
return hours
193232

194233
@staticmethod

tests/batcontrol/dynamictariff/test_tariffzones.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,49 @@ def test_parse_hours_single_int():
4545
assert result == [5]
4646

4747

48+
def test_parse_hours_range_string():
49+
result = TariffZones._parse_hours('0-5', 'zone_1_hours')
50+
assert result == [0, 1, 2, 3, 4, 5]
51+
52+
53+
def test_parse_hours_range_full_day():
54+
result = TariffZones._parse_hours('7-22', 'zone_1_hours')
55+
assert result == list(range(7, 23))
56+
57+
58+
def test_parse_hours_mixed_range_and_singles():
59+
result = TariffZones._parse_hours('0-5,6,7', 'zone_1_hours')
60+
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
61+
62+
63+
def test_parse_hours_range_single_element():
64+
# "5-5" is a valid range that yields just [5]
65+
result = TariffZones._parse_hours('5-5', 'zone_1_hours')
66+
assert result == [5]
67+
68+
69+
def test_parse_hours_list_with_range_strings():
70+
result = TariffZones._parse_hours(['0-3', '4', '5-6'], 'zone_1_hours')
71+
assert result == [0, 1, 2, 3, 4, 5, 6]
72+
73+
74+
def test_parse_hours_rejects_inverted_range():
75+
with pytest.raises(ValueError, match='start must be <= end'):
76+
TariffZones._parse_hours('5-3', 'zone_1_hours')
77+
78+
4879
def test_parse_hours_rejects_out_of_range():
4980
with pytest.raises(ValueError, match='out of range'):
5081
TariffZones._parse_hours('0,24', 'zone_1_hours')
5182
with pytest.raises(ValueError, match='out of range'):
5283
TariffZones._parse_hours([-1], 'zone_1_hours')
5384

5485

86+
def test_parse_hours_rejects_range_out_of_bounds():
87+
with pytest.raises(ValueError, match='out of range'):
88+
TariffZones._parse_hours('20-25', 'zone_1_hours')
89+
90+
5591
def test_parse_hours_rejects_duplicate_within_zone():
5692
with pytest.raises(ValueError, match='more than once'):
5793
TariffZones._parse_hours('7,8,7', 'zone_1_hours')
@@ -211,9 +247,9 @@ def test_csv_string_hours_accepted():
211247
t = TariffZones(
212248
make_tz(),
213249
tariff_zone_1=0.27,
214-
zone_1_hours='7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22',
250+
zone_1_hours='7-22',
215251
tariff_zone_2=0.17,
216-
zone_2_hours='0,1,2,3,4,5,6,23',
252+
zone_2_hours='0-6,23',
217253
)
218254
prices = t._get_prices_native()
219255
assert len(prices) == 48
@@ -227,14 +263,16 @@ def test_factory_creates_tariff_zones():
227263
config = {
228264
'type': 'tariff_zones',
229265
'tariff_zone_1': 0.27,
230-
'zone_1_hours': '7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22',
266+
'zone_1_hours': '7-22',
231267
'tariff_zone_2': 0.17,
232-
'zone_2_hours': '0,1,2,3,4,5,6,23',
268+
'zone_2_hours': '0-6,23',
233269
}
234270
provider = DynamicTariff.create_tarif_provider(config, make_tz(), 0, 0)
235271
assert isinstance(provider, TariffZones)
236272
assert provider.tariff_zone_1 == pytest.approx(0.27)
237273
assert provider.tariff_zone_2 == pytest.approx(0.17)
274+
assert provider.zone_1_hours == list(range(7, 23))
275+
assert provider.zone_2_hours == list(range(0, 7)) + [23]
238276

239277

240278
def test_factory_three_zones():

0 commit comments

Comments
 (0)