Skip to content

Commit 2139b06

Browse files
CIclaude
andcommitted
Stamp day_of_week rules across the whole rate horizon, not just day+2 (#5168 review)
basic_rates() now runs its per-day loop out to the end of the horizon rate_replicate() covers, instead of modelling two days and seeding a third with a separate write. Every day is filtered against its own weekday, so days past the third no longer copy the previous day's pattern, a midnight-spanning rule's early hours land on the right day, and rate_increment and load_scaling behave the same on every day. The weekday offset uses floor division, so yesterday's minutes are checked against yesterday's weekday. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2789c4d commit 2139b06

2 files changed

Lines changed: 96 additions & 33 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2123,8 +2123,9 @@ def apply_manual_rates(self, rates, manual_items, is_import=True, rate_replicate
21232123

21242124
def basic_rates(self, info, rtype, prev=None, rate_replicate=None, include_manual_api=True):
21252125
"""
2126-
Work out the energy rates based on user supplied time periods
2127-
works on a 24-hour period only and then gets replicated later for future days
2126+
Work out the energy rates based on user supplied time periods. Weekly rules on the base
2127+
tariff are stamped across the whole horizon, each day against its own day_of_week; any
2128+
minute no rule claims is left for rate_replicate() to fill later
21282129
21292130
include_manual_api should be False for callers (e.g. tariff comparison, annual replay)
21302131
that simulate a tariff other than the live one - the live system's manual API overrides
@@ -2286,16 +2287,28 @@ def basic_rates(self, info, rtype, prev=None, rate_replicate=None, include_manua
22862287

22872288
day_of_week_midnight = self.midnight_utc.weekday()
22882289

2290+
# A weekly rule on the base tariff is stamped over the whole horizon rate_replicate()
2291+
# goes on to cover, not just the days modelled above, so every day is filtered against
2292+
# its own weekday. rate_replicate() fills a missing minute from the same time of day 24
2293+
# hours earlier and knows nothing about day_of_week, so a day left to it takes the
2294+
# previous day's pattern - a weekend rule flattening the weekday peaks (batpred#5168).
2295+
# Overrides (prev) already span the replicated horizon via max_minute.
2296+
if not date and not prev:
2297+
stamp_end = max(max_minute + 24 * 60, self.forecast_minutes + 48 * 60)
2298+
else:
2299+
stamp_end = max_minute
2300+
22892301
# Store rates against range
22902302
if end_minutes >= (-48 * 60) and start_minutes < max_minute:
22912303
for minute in range(start_minutes, end_minutes):
22922304
minute_mod = minute % max_minute
22932305
if (not date) or (minute >= (-24 * 60) and minute < max_minute):
22942306
minute_index = minute_mod - 24 * 60
22952307
# For incremental adjustments we have to loop over 24-hour periods
2296-
while minute_index < max_minute:
2308+
while minute_index < stamp_end:
22972309
if not date or (minute_index >= start_minutes and minute_index < end_minutes):
2298-
current_day_of_week = (day_of_week_midnight + int(minute_index / (24 * 60))) % 7
2310+
# Floor division, so yesterday's minutes are checked against yesterday's weekday
2311+
current_day_of_week = (day_of_week_midnight + minute_index // (24 * 60)) % 7
22992312
if not day_of_week or (current_day_of_week in day_of_week):
23002313
if rate_increment:
23012314
rates[minute_index] = rates.get(minute_index, 0.0) + rate
@@ -2308,20 +2321,6 @@ def basic_rates(self, info, rtype, prev=None, rate_replicate=None, include_manua
23082321
if date:
23092322
break
23102323
minute_index += 24 * 60
2311-
if not date and not prev:
2312-
# Seed the slot past the days modelled above so rate_replicate() has
2313-
# something to copy forward. This has to honour the rule's day filter
2314-
# in the same way the loop just did, or the last rule processed for a
2315-
# given time of day wins out there whatever the weekday - a weekend
2316-
# rule flattening the weekday peaks two days out (batpred#5168).
2317-
# Minutes left unwritten here are no loss: rate_replicate() fills them
2318-
# from the same time of day 24 hours earlier.
2319-
extend_minute = minute_mod + max_minute
2320-
extend_day_of_week = (day_of_week_midnight + int(extend_minute / (24 * 60))) % 7
2321-
if not day_of_week or (extend_day_of_week in day_of_week):
2322-
rates[extend_minute] = rate
2323-
if load_scaling is not None:
2324-
self.load_scaling_dynamic[extend_minute] = load_scaling
23252324
else:
23262325
self.log("Warn: Bad rate data provided in energy rates type {} {}".format(rtype, this_rate))
23272326

‎apps/predbat/tests/test_basic_rates.py‎

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,8 @@ def test_basic_rates(my_predbat):
117117
my_predbat.midnight_utc = my_predbat.local_tz.localize(datetime.strptime("2025-07-05T00:00:00", "%Y-%m-%dT%H:%M:%S")) # Saturday (day 6)
118118
old_minutes_now = my_predbat.minutes_now
119119
my_predbat.minutes_now = 14 * 60 # 14:00 Saturday afternoon
120-
# Tests 6 and 7 also assert the third day, which basic_rates() leaves to rate_replicate() where
121-
# no rule seeds it - pin the forecast length so that day is replicated, and the future rate
122-
# offset so the replicated values are comparable against the tariff's own rates
120+
# Tests 6 and 7 also assert the third day - pin the forecast length so it lies inside the horizon,
121+
# and the future rate offset so any replicated minute is comparable against the tariff's own rates
123122
old_forecast_minutes = my_predbat.forecast_minutes
124123
old_rate_offset_import = my_predbat.metric_future_rate_offset_import
125124
my_predbat.forecast_minutes = 24 * 60
@@ -139,11 +138,8 @@ def test_basic_rates(my_predbat):
139138
failed |= assert_rates(results, 24 * 60, 48 * 60, 8) # All day Sunday = 8
140139

141140
# Day 3 (Monday): the weekday pattern must survive 48 hours out (issue #5168). basic_rates()
142-
# seeds the slot past the days it models so rate_replicate() has something to copy forward,
143-
# and that seeding write used to ignore day_of_week - so the weekend rule, being processed
144-
# last, flattened Monday to the weekend rate and erased the weekday peak. 00:00-07:00 is not
145-
# seeded by any rule here (the off-peak window seeds the fourth day from 22:00 onwards), so it
146-
# comes from rate_replicate() copying the same time of day 24 hours earlier
141+
# used to seed that day with a write that ignored day_of_week, so the weekend rule, being
142+
# processed last, flattened Monday to the weekend rate and erased the weekday peak
147143
failed |= assert_rates(results, 48 * 60, 48 * 60 + 7 * 60, 8) # 00:00-07:00 off-peak
148144
failed |= assert_rates(results, 48 * 60 + 7 * 60, 48 * 60 + 22 * 60, 14) # 07:00-22:00 peak
149145
failed |= assert_rates(results, 48 * 60 + 22 * 60, 72 * 60, 8) # 22:00-24:00 off-peak
@@ -293,10 +289,10 @@ def test_basic_rates(my_predbat):
293289
my_predbat.manual_api = old_manual_api
294290
my_predbat.had_errors = old_had_errors
295291

296-
# Test 13: the export config from issue #5168 - weekday rules tiling the whole day plus a
297-
# weekend catch-all. The weekend rule is processed last, and before the fix its seeding write
298-
# for the day beyond the ones basic_rates() models ignored day_of_week, so it flattened that
299-
# day to the weekend rate and the weekday peaks vanished 48 hours out
292+
# Test 13: the export config from issue #5168 - weekday rules covering the day plus a weekend
293+
# catch-all. The weekend rule is processed last, and before the fix its seeding write for the
294+
# day beyond the ones basic_rates() modelled ignored day_of_week, so it flattened that day to
295+
# the weekend rate and the weekday peaks vanished 48 hours out
300296
print("*** Running test: Weekend day_of_week rule must not flatten future weekdays (issue #5168)")
301297
saved_midnight_utc = my_predbat.midnight_utc
302298
my_predbat.midnight_utc = my_predbat.local_tz.localize(datetime.strptime("2025-07-06T00:00:00", "%Y-%m-%dT%H:%M:%S")) # Sunday, so the third day is Tuesday
@@ -312,9 +308,9 @@ def test_basic_rates(my_predbat):
312308
results = my_predbat.basic_rates(weekend_catch_all, "export")
313309
results, results_replicated = my_predbat.rate_replicate(results, is_import=False, is_gas=False)
314310

315-
# Day 3 (Tuesday) must keep the weekday peaks. The rules end on :59:59 so each leaves the
316-
# final minute of its window unset - assert the interiors, which are the ones the rules
317-
# actually write
311+
# Day 3 (Tuesday) must keep the weekday peaks. The rules end on :59:59, which floors to the
312+
# :59 minute, and the window excludes its end minute - so each rule leaves its final minute
313+
# (06:59, 08:59, ...) to rate_replicate(). Assert the interiors, which the rules write
318314
failed |= assert_rates(results, 48 * 60, 48 * 60 + 6 * 60 + 59, 11.5) # 00:00-07:00 off-peak
319315
failed |= assert_rates(results, 48 * 60 + 7 * 60, 48 * 60 + 8 * 60 + 59, 23.0) # 07:00-09:00 peak
320316
failed |= assert_rates(results, 48 * 60 + 9 * 60, 48 * 60 + 16 * 60 + 59, 11.5) # 09:00-17:00 off-peak
@@ -323,4 +319,72 @@ def test_basic_rates(my_predbat):
323319
finally:
324320
my_predbat.midnight_utc = saved_midnight_utc
325321

322+
# Test 14: every day out to the end of the horizon rate_replicate() covers keeps its own weekday's
323+
# pattern, whichever order the rules are listed in. rate_replicate() fills a missing minute from
324+
# the same time of day 24 hours earlier and knows nothing about day_of_week, so any day left to
325+
# it takes the previous day's pattern - Monday copying Sunday's flat rate, say. The weekday
326+
# off-peak differs from the weekend rate so the early hours of the midnight-spanning window are
327+
# checked too, and the peak carries a load_scaling to check that write follows the same days
328+
print("*** Running test: day_of_week honoured on every day of the horizon, in either rule order (issue #5168)")
329+
saved_midnight_utc = my_predbat.midnight_utc
330+
saved_forecast_minutes = my_predbat.forecast_minutes
331+
saved_rate_offset_import = my_predbat.metric_future_rate_offset_import
332+
saved_load_scaling_dynamic = my_predbat.load_scaling_dynamic
333+
# rate_replicate() then runs to the end of the fifth day, and replicated minutes stay comparable
334+
my_predbat.forecast_minutes = 72 * 60
335+
my_predbat.metric_future_rate_offset_import = 0
336+
try:
337+
weekday_rules = [
338+
{"start": "07:00:00", "end": "22:00:00", "rate": 14.0, "day_of_week": "1,2,3,4,5", "load_scaling": 1.5},
339+
{"start": "22:00:00", "end": "07:00:00", "rate": 12.0, "day_of_week": "1,2,3,4,5"},
340+
]
341+
weekend_rule = [{"rate": 8.0, "day_of_week": "6,7"}]
342+
for midnight in ("2025-07-04", "2025-07-05"): # Friday and Saturday
343+
for rules in (weekday_rules + weekend_rule, weekend_rule + weekday_rules):
344+
my_predbat.midnight_utc = my_predbat.local_tz.localize(datetime.strptime(midnight, "%Y-%m-%d"))
345+
my_predbat.load_scaling_dynamic = {}
346+
results = my_predbat.basic_rates(rules, "import")
347+
results, results_replicated = my_predbat.rate_replicate(results, is_import=True, is_gas=False)
348+
for day in range(5):
349+
day_start = day * 24 * 60
350+
if (my_predbat.midnight_utc.weekday() + day) % 7 >= 5:
351+
failed |= assert_rates(results, day_start, day_start + 24 * 60, 8.0)
352+
expect_scaling = None
353+
else:
354+
failed |= assert_rates(results, day_start, day_start + 7 * 60, 12.0)
355+
failed |= assert_rates(results, day_start + 7 * 60, day_start + 22 * 60, 14.0)
356+
failed |= assert_rates(results, day_start + 22 * 60, day_start + 24 * 60, 12.0)
357+
expect_scaling = 1.5
358+
scaling = my_predbat.load_scaling_dynamic.get(day_start + 12 * 60)
359+
if scaling != expect_scaling:
360+
print("ERROR: load_scaling at noon on day {} from midnight {} should be {} got {}".format(day, midnight, expect_scaling, scaling))
361+
failed = 1
362+
finally:
363+
my_predbat.midnight_utc = saved_midnight_utc
364+
my_predbat.forecast_minutes = saved_forecast_minutes
365+
my_predbat.metric_future_rate_offset_import = saved_rate_offset_import
366+
my_predbat.load_scaling_dynamic = saved_load_scaling_dynamic
367+
368+
# Test 15: yesterday's minutes are filtered against yesterday's weekday. The day offset was
369+
# int(minute / 1440), which truncates towards zero, so every minute of yesterday bar its first
370+
# was checked against today's weekday instead
371+
print("*** Running test: yesterday's rates follow yesterday's day_of_week")
372+
saved_midnight_utc = my_predbat.midnight_utc
373+
my_predbat.midnight_utc = my_predbat.local_tz.localize(datetime.strptime("2025-07-07T00:00:00", "%Y-%m-%dT%H:%M:%S")) # Monday, so yesterday is Sunday
374+
try:
375+
results = my_predbat.basic_rates([{"rate": 14.0, "day_of_week": "1,2,3,4,5"}, {"rate": 8.0, "day_of_week": "6,7"}], "import")
376+
failed |= assert_rates(results, -24 * 60, 0, 8.0)
377+
failed |= assert_rates(results, 0, 24 * 60, 14.0)
378+
finally:
379+
my_predbat.midnight_utc = saved_midnight_utc
380+
381+
# Test 16: a rate_increment rule adds to the rate already there on every day it is stamped on,
382+
# including the days past the two modelled explicitly - the seeding write for the third day
383+
# used to assign the bare increment there
384+
print("*** Running test: rate_increment adds to the base rate on every day")
385+
results = my_predbat.basic_rates([{"rate": 10.0}, {"start": "17:00:00", "end": "19:00:00", "rate_increment": 5.0}], "import")
386+
for day in range(3):
387+
failed |= assert_rates(results, day * 24 * 60 + 17 * 60, day * 24 * 60 + 19 * 60, 15.0)
388+
failed |= assert_rates(results, day * 24 * 60 + 19 * 60, day * 24 * 60 + 20 * 60, 10.0)
389+
326390
return failed

0 commit comments

Comments
 (0)