Skip to content

Commit 7f5ebad

Browse files
fix(plan): value left-over battery on the base tariff, not saving-session prices
battery_value_rate ceilinged the end-of-plan battery credit on rate_max and took its export-recovery ratio from rate_export_max, both whole-horizon figures that include saving sessions. On a flat tariff running a session that put the credit above what discharging a stored kWh can realise, so the planner scored freeze charge as profit and froze every window up to end_record while the battery sat nearly full. A reported case had a flat 29.2p import with a 2p session bonus the next evening (rate_max 31.2 vs rate_max_base 29.2) and a 100p export session expiring 19 minutes after minutes_now (rate_export_max 100, export 0p for the rest of the horizon). The session bonus raised the ceiling while the expiring export event switched the discount off, leaving freeze charge worth +2.00p per kWh of load - exactly the session bonus - and 5 of 6 charge windows frozen. Read the base tariff for both terms instead. rate_max_base is already captured before sessions inflate rate_max; rate_export_max_forward is new, built by fetch from rate_export_base at the point that copy is taken, and forward-looking so an export price that has passed stops counting. On the reported case the credit drops from 29.05p to 21.75p per kWh, freeze charge goes to -5.84p per kWh of load, and no window freezes. Both terms fall back to their whole-horizon equivalents when the base data is absent, so replaying an older debug file is unchanged. run_single_debug now resets both fields alongside the existing dynamic_load_baseline resets: the planner consumes them now, debug files written before they existed carry neither, and inside the full suite they leaked from earlier tests into the replay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent abf09a2 commit 7f5ebad

8 files changed

Lines changed: 322 additions & 12 deletions

File tree

apps/predbat/fetch.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,9 @@ def fetch_sensor_data(self, save=True):
10011001
self.rate_scan_export(export_rates, print=False)
10021002
export_rates, self.rate_export_replicated = self.rate_replicate(export_rates, is_import=False)
10031003
self.rate_export_base = export_rates.copy()
1004+
# Built here, from the base rates, so the saving session and overrides applied below stay
1005+
# out of it - battery_value_rate needs the tariff's own export price, not an event price
1006+
self.rate_export_max_forward = self.rate_export_max_forward_calc(self.rate_export_base)
10041007
# For export tariff only load the saving session if enabled
10051008
if self.rate_export_max > 0:
10061009
self.load_saving_slot(self.octopus_saving_slots, export_rates, export=True, rate_replicate=self.rate_export_replicated)
@@ -1946,6 +1949,34 @@ def rate_scan(self, rates, print=True):
19461949
self.rate_min_forward = self.rate_min_forward_calc(rates)
19471950
self.log("Import rates: min {}{}, max {}{}, average {}{}".format(self.rate_min, curr, self.rate_max, curr, self.rate_average, curr))
19481951

1952+
def rate_export_max_forward_calc(self, rates):
1953+
"""
1954+
Work out the highest export rate from each minute forwards
1955+
1956+
The export mirror of rate_min_forward_calc. Fed from the base export tariff (see
1957+
rate_export_base) so a saving session cannot stand in for the tariff's general ability to sell
1958+
surplus - that question is what the export haircut in battery_value_rate asks, and answering it
1959+
with a one-off event price makes stored energy look fully recoverable on a system that in fact
1960+
cannot sell a single kWh.
1961+
"""
1962+
rate_array = []
1963+
rate_export_max_forward = {}
1964+
rate = 0.0
1965+
1966+
for minute in range(self.forecast_minutes + self.minutes_now + 48 * 60):
1967+
if minute in rates:
1968+
rate = rates[minute]
1969+
rate_array.append(rate)
1970+
1971+
# Work out the max rate going forward — O(n) right-to-left scan avoids O(n²) slice allocations
1972+
running_max = rate_array[-1] if rate_array else 0.0
1973+
for minute in range(len(rate_array) - 1, self.minutes_now - 1, -1):
1974+
running_max = max(running_max, rate_array[minute])
1975+
if minute < self.forecast_minutes + 24 * 60 + self.minutes_now:
1976+
rate_export_max_forward[minute] = running_max
1977+
1978+
return rate_export_max_forward
1979+
19491980
def rate_scan_gas(self, rates, print=True):
19501981
"""
19511982
Scan the gas rates and work out min/max

apps/predbat/plan.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,14 @@ def battery_value_rate(self, minute):
15971597
what the grid would ever charge for it, and floored at the export arbitrage margin and at
15981598
1p/kWh.
15991599
1600+
Both the cap and the export recovery ratio read the base tariff (rate_max_base,
1601+
rate_export_max_forward), captured before saving sessions and overrides are layered on. A
1602+
session is a one-off event, not evidence about what the tariff charges for a kWh or pays for a
1603+
surplus one, and letting one set either term values stored energy above what discharging it can
1604+
realise - which the planner spends as profit by freeze charging every window up to end_record.
1605+
Both fall back to their whole-horizon equivalents when the base data is absent, so replaying an
1606+
older debug file behaves as it did before.
1607+
16001608
Note `rate_export_min` here is not the export rate - it is the export earnings less the
16011609
replacement cost, so it only raises the value when exporting beats re-importing. It can
16021610
never lower it, which is why a zero export rate does not reduce the credit.
@@ -1605,8 +1613,9 @@ def battery_value_rate(self, minute):
16051613
attributes and the savings report, so all three agree on what a stored kWh is worth.
16061614
"""
16071615
rate_min_raw = self.rate_min_forward.get(minute, self.rate_min)
1616+
rate_max = self.rate_max_base or self.rate_max
16081617
rate_min = rate_min_raw / self.inverter_loss / self.battery_loss + self.metric_battery_cycle
1609-
rate_min = max(min(rate_min, self.rate_max * self.inverter_loss * self.battery_loss - self.metric_battery_cycle), 0)
1618+
rate_min = max(min(rate_min, rate_max * self.inverter_loss * self.battery_loss - self.metric_battery_cycle), 0)
16101619

16111620
# Replacement cost assumes the energy can always be redeployed. That holds while surplus can
16121621
# be sold, but if export pays less than it cost to import then anything the house cannot use
@@ -1615,7 +1624,11 @@ def battery_value_rate(self, minute):
16151624
# matches or beats it, down to metric_battery_value_export_scaling when export is worthless.
16161625
# Setting that to 1.0 disables this entirely.
16171626
if rate_min_raw > 0 and self.metric_battery_value_export_scaling < 1.0:
1618-
recovery = min(max(self.rate_export_max / rate_min_raw, 0.0), 1.0)
1627+
# Forward-looking like rate_min_raw, so an export price that has already passed stops
1628+
# counting once it has - a saving session ending in ten minutes says nothing about what
1629+
# the energy still in the battery can be sold for over the rest of the plan.
1630+
export_max = self.rate_export_max_forward.get(minute, 0.0) if self.rate_export_max_forward else self.rate_export_max
1631+
recovery = min(max(export_max / rate_min_raw, 0.0), 1.0)
16191632
rate_min *= self.metric_battery_value_export_scaling + (1.0 - self.metric_battery_value_export_scaling) * recovery
16201633

16211634
rate_export_min = self.rate_export_min * self.inverter_loss * self.battery_loss_discharge - self.metric_battery_cycle - rate_min

apps/predbat/predbat.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ def reset(self):
432432
self.rate_export_min_minute = 0
433433
self.rate_export_max = 0
434434
self.rate_export_max_minute = 0
435+
self.rate_export_max_forward = {}
435436
self.rate_export_average = 0
436437
self.rate_gas_min = 0
437438
self.rate_gas_max = 0

apps/predbat/tests/test_compute_metric.py

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
# pylint: disable=line-too-long
99
# pylint: disable=attribute-defined-outside-init
1010

11+
# Everything battery_value_rate reads - snapshotted and restored around each test that pokes at it
12+
VALUE_RATE_STATE = (
13+
"rate_min_forward",
14+
"rate_min",
15+
"rate_max",
16+
"rate_max_base",
17+
"inverter_loss",
18+
"battery_loss",
19+
"battery_loss_discharge",
20+
"rate_export_min",
21+
"rate_export_max",
22+
"rate_export_max_forward",
23+
"metric_battery_cycle",
24+
"metric_battery_value_export_scaling",
25+
)
26+
1127

1228
def compute_metric_test(
1329
my_predbat,
@@ -61,6 +77,11 @@ def compute_metric_test(
6177
my_predbat.metric_self_sufficiency = metric_self_sufficiency
6278
my_predbat.rate_min = rate_min
6379
my_predbat.rate_max = rate_max
80+
# battery_value_rate prefers the session-free base rate for its ceiling, so pin it alongside
81+
# rate_max or a value left behind by an earlier test would decide these metrics instead.
82+
my_predbat.rate_max_base = rate_max
83+
# Empty means "no forward export data", which sends battery_value_rate back to rate_export_max
84+
my_predbat.rate_export_max_forward = {}
6485
if not end_record:
6586
end_record = my_predbat.forecast_minutes
6687

@@ -114,9 +135,11 @@ def save_state(my_predbat):
114135
"metric_self_sufficiency",
115136
"rate_min",
116137
"rate_max",
138+
"rate_max_base",
117139
"carbon_enable",
118140
"carbon_metric",
119141
"rate_min_forward",
142+
"rate_export_max_forward",
120143
]
121144
for item in save_items:
122145
state_dict[item] = getattr(my_predbat, item)
@@ -136,21 +159,20 @@ def battery_value_rate_test(my_predbat):
136159
re-splitting the formula would reintroduce that divergence, and only the ceiling case catches it.
137160
"""
138161
failed = False
139-
saved = {
140-
name: getattr(my_predbat, name) for name in ("rate_min_forward", "rate_min", "rate_max", "inverter_loss", "battery_loss", "battery_loss_discharge", "rate_export_min", "rate_export_max", "metric_battery_cycle", "metric_battery_value_export_scaling")
141-
}
162+
saved = {name: getattr(my_predbat, name) for name in VALUE_RATE_STATE}
142163
try:
143164
my_predbat.inverter_loss = 0.96
144165
my_predbat.battery_loss = 0.97
145166
my_predbat.battery_loss_discharge = 0.97
146167
my_predbat.metric_battery_cycle = 0.0
147168
my_predbat.rate_export_min = 0.0
148169
my_predbat.rate_export_max = 6.9
170+
my_predbat.rate_export_max_forward = {}
149171
my_predbat.metric_battery_value_export_scaling = 1.0
150172
my_predbat.rate_min = 6.9
151173

152174
# Normal spread: the gross-up applies and the ceiling does not bind
153-
my_predbat.rate_max = 28.85
175+
my_predbat.rate_max = my_predbat.rate_max_base = 28.85
154176
my_predbat.rate_min_forward = {10: 6.9}
155177
expected = 6.9 / 0.96 / 0.97
156178
got = my_predbat.battery_value_rate(10)
@@ -160,15 +182,15 @@ def battery_value_rate_test(my_predbat):
160182

161183
# Flat tariff: rate_min_forward == rate_max, so the ceiling MUST bind and pull the value
162184
# below the gross-up. Without the ceiling this returns 1.0739*R instead of 0.9312*R.
163-
my_predbat.rate_max = 6.9
185+
my_predbat.rate_max = my_predbat.rate_max_base = 6.9
164186
expected_capped = 6.9 * 0.96 * 0.97
165187
got = my_predbat.battery_value_rate(10)
166188
if abs(got - expected_capped) > 1e-6:
167189
print("ERROR: battery_value_rate is {} on a flat tariff, expected the capped {} - the rate_max ceiling is not being applied".format(got, expected_capped))
168190
failed = True
169191

170192
# The 1p floor applies when the forward rate is negative (plunge pricing)
171-
my_predbat.rate_max = 28.85
193+
my_predbat.rate_max = my_predbat.rate_max_base = 28.85
172194
my_predbat.rate_min_forward = {10: -5.0}
173195
got = my_predbat.battery_value_rate(10)
174196
if abs(got - 1.0) > 1e-6:
@@ -180,6 +202,52 @@ def battery_value_rate_test(my_predbat):
180202
return failed
181203

182204

205+
def battery_value_rate_ceiling_base_test(my_predbat):
206+
"""Pin the ceiling to the base import tariff, not the session-inflated one.
207+
208+
load_saving_slot writes the session price into the import rates too, so rate_max can be a one-off
209+
event price rather than the tariff's real peak. Ceiling on that and a stored kWh gets credited far
210+
above anything the tariff will ever pay for it, which is what makes freeze charge look profitable
211+
on a flat tariff. rate_max_base is captured before the session is layered on.
212+
"""
213+
failed = False
214+
saved = {name: getattr(my_predbat, name) for name in VALUE_RATE_STATE}
215+
try:
216+
my_predbat.inverter_loss = 0.96
217+
my_predbat.battery_loss = 0.97
218+
my_predbat.battery_loss_discharge = 0.97
219+
my_predbat.metric_battery_cycle = 0.0
220+
my_predbat.rate_export_min = 0.0
221+
my_predbat.rate_export_max = 0.0
222+
my_predbat.rate_export_max_forward = {}
223+
my_predbat.metric_battery_value_export_scaling = 1.0
224+
my_predbat.rate_min = 29.2
225+
my_predbat.rate_min_forward = {10: 29.2}
226+
227+
# A 100p saving session sets rate_max, but the real tariff is flat at 29.2p. The ceiling must
228+
# come from the base rate, so the value stays at the flat tariff's 0.9312*R.
229+
my_predbat.rate_max = 100.0
230+
my_predbat.rate_max_base = 29.2
231+
expected = 29.2 * 0.96 * 0.97
232+
got = my_predbat.battery_value_rate(10)
233+
if abs(got - expected) > 1e-6:
234+
print("ERROR: battery_value_rate is {} with a session-inflated rate_max, expected the base-capped {}".format(got, expected))
235+
failed = True
236+
237+
# Unset base (an older debug file being replayed) falls back to rate_max so nothing regresses
238+
my_predbat.rate_max = 31.2
239+
my_predbat.rate_max_base = 0
240+
expected = 31.2 * 0.96 * 0.97
241+
got = my_predbat.battery_value_rate(10)
242+
if abs(got - expected) > 1e-6:
243+
print("ERROR: battery_value_rate is {} with rate_max_base unset, expected the rate_max fallback {}".format(got, expected))
244+
failed = True
245+
finally:
246+
for name, value in saved.items():
247+
setattr(my_predbat, name, value)
248+
return failed
249+
250+
183251
def battery_value_export_scaling_test(my_predbat):
184252
"""Pin the export-risk haircut: full value when export can recover the import cost, discounted when it cannot.
185253
@@ -189,17 +257,17 @@ def battery_value_export_scaling_test(my_predbat):
189257
plans that have no export problem at all.
190258
"""
191259
failed = False
192-
names = ("rate_min_forward", "rate_min", "rate_max", "inverter_loss", "battery_loss", "battery_loss_discharge", "rate_export_min", "rate_export_max", "metric_battery_cycle", "metric_battery_value_export_scaling")
193-
saved = {name: getattr(my_predbat, name) for name in names}
260+
saved = {name: getattr(my_predbat, name) for name in VALUE_RATE_STATE}
194261
try:
195262
my_predbat.inverter_loss = 0.96
196263
my_predbat.battery_loss = 0.97
197264
my_predbat.battery_loss_discharge = 0.97
198265
my_predbat.metric_battery_cycle = 0.0
199266
my_predbat.rate_export_min = 0.0
200267
my_predbat.rate_min = 6.9
201-
my_predbat.rate_max = 28.85
268+
my_predbat.rate_max = my_predbat.rate_max_base = 28.85
202269
my_predbat.rate_min_forward = {10: 6.9}
270+
my_predbat.rate_export_max_forward = {}
203271
my_predbat.metric_battery_value_export_scaling = 0.8
204272
full = 6.9 / 0.96 / 0.97
205273

@@ -231,14 +299,82 @@ def battery_value_export_scaling_test(my_predbat):
231299
return failed
232300

233301

302+
def battery_value_export_forward_test(my_predbat):
303+
"""Pin the export-recovery ratio to the forward base export tariff.
304+
305+
The haircut asks "can I sell surplus?", which is a property of the tariff. Answering it with
306+
rate_export_max - the highest export price anywhere in the horizon, saving sessions included -
307+
let a single 2-hour 100p session switch the haircut off for a whole 48-hour plan on a system whose
308+
export tariff pays nothing. The value then sat above what a stored kWh can realise, and the planner
309+
freeze charged every window up to end_record because holding energy scored better than using it.
310+
311+
Scoping to the forward base tariff fixes both halves: sessions never enter it, and a session that
312+
has already passed cannot keep counting.
313+
"""
314+
failed = False
315+
saved = {name: getattr(my_predbat, name) for name in VALUE_RATE_STATE}
316+
try:
317+
my_predbat.inverter_loss = 0.96
318+
my_predbat.battery_loss = 0.97
319+
my_predbat.battery_loss_discharge = 0.97
320+
my_predbat.metric_battery_cycle = 0.0
321+
my_predbat.rate_export_min = 0.0
322+
my_predbat.rate_min = 6.9
323+
my_predbat.rate_max = my_predbat.rate_max_base = 28.85
324+
my_predbat.rate_min_forward = {10: 6.9}
325+
my_predbat.metric_battery_value_export_scaling = 0.8
326+
full = 6.9 / 0.96 / 0.97
327+
328+
# A saving session pushes rate_export_max to 100p, but the export tariff itself pays nothing,
329+
# so the forward base view is 0 and the full haircut must still apply.
330+
my_predbat.rate_export_max = 100.0
331+
my_predbat.rate_export_max_forward = {10: 0.0}
332+
expected = full * 0.8
333+
got = my_predbat.battery_value_rate(10)
334+
if abs(got - expected) > 1e-6:
335+
print("ERROR: battery_value_rate is {} with a saving session in rate_export_max, expected the haircut {} - a one-off event is standing in for the tariff".format(got, expected))
336+
failed = True
337+
338+
# Base tariff that genuinely recovers half the import cost takes half the haircut
339+
my_predbat.rate_export_max_forward = {10: 3.45}
340+
expected = full * 0.9
341+
got = my_predbat.battery_value_rate(10)
342+
if abs(got - expected) > 1e-6:
343+
print("ERROR: battery_value_rate is {} with a base export of 3.45, expected {}".format(got, expected))
344+
failed = True
345+
346+
# A real export tariff matching the cheapest import still takes no haircut - the forward view
347+
# must not be read as "export is always worthless"
348+
my_predbat.rate_export_max_forward = {10: 6.9}
349+
got = my_predbat.battery_value_rate(10)
350+
if abs(got - full) > 1e-6:
351+
print("ERROR: battery_value_rate is {} with a base export matching import, expected the undiscounted {}".format(got, full))
352+
failed = True
353+
354+
# No forward data at all (an older debug file being replayed) falls back to rate_export_max
355+
my_predbat.rate_export_max = 3.45
356+
my_predbat.rate_export_max_forward = {}
357+
expected = full * 0.9
358+
got = my_predbat.battery_value_rate(10)
359+
if abs(got - expected) > 1e-6:
360+
print("ERROR: battery_value_rate is {} with no forward export data, expected the rate_export_max fallback {}".format(got, expected))
361+
failed = True
362+
finally:
363+
for name, value in saved.items():
364+
setattr(my_predbat, name, value)
365+
return failed
366+
367+
234368
def run_compute_metric_tests(my_predbat):
235369
"""
236370
Test the compute metric function
237371
"""
238372
failed = False
239373
print("**** Running compute metric tests ****")
240374
failed |= battery_value_rate_test(my_predbat)
375+
failed |= battery_value_rate_ceiling_base_test(my_predbat)
241376
failed |= battery_value_export_scaling_test(my_predbat)
377+
failed |= battery_value_export_forward_test(my_predbat)
242378
state_dict = save_state(my_predbat)
243379
failed |= compute_metric_test(my_predbat, "zero", assert_metric=0)
244380
failed |= compute_metric_test(my_predbat, "cost", cost=10.0, assert_metric=10)

0 commit comments

Comments
 (0)