Skip to content

Commit 0b99343

Browse files
feat(annual): baseline tariff, run deletion, nav fix and current cap rates
Four changes to the WhatIf tab. The no-PV/battery counterfactual can now be priced on its own tariff, defaulting to the Ofgem price cap. A household with no system is not on a battery tariff - the cheap overnight rates only pay off once there is somewhere to put the energy - so pricing the counterfactual on the user's own smart tariff credited it with a saving it could never have had, and understated the system. It applies to no_pvbat only; every other scenario keeps the main tariff. Both still share the main tariff's standing charge, so any difference there is excluded from savings - stated in the results caveats and the docs rather than left to be discovered. The rate swap has to happen BEFORE the Prediction is constructed, because Prediction snapshots the rates off predbat at construction time - built first, it bills at the main tariff whatever is installed afterwards, so the swap looks like it works and changes nothing. That is exactly what the first version did. Sub-pages now highlight their parent menu entry. /annual_view and /annual_compare matched no menu link, so the JS fell through to its default of "first item" and lit up Dashboard while the user was plainly on WhatIf. The second pass only runs when nothing matched exactly, so /apps_editor keeps its own highlight rather than being captured by /apps. Runs can be deleted from the Compare page, with confirmation. Deleting discards the document and every captured plan through the same path eviction uses, so it leaves no orphaned storage behind. Price cap figures brought to the July 2026 cap: 26.11p/kWh and 57.19p/day, from Ofgem, replacing 24.86p and a 60p default. Named constants now, so the next cap change is a one-line edit. The 4.1p SEG export rate still sits inside the typical 3-8p band for fixed export offers and is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7fd6b1b commit 0b99343

10 files changed

Lines changed: 335 additions & 34 deletions

apps/predbat/annual.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from annual_weather import AnnualWeather, resolve_postcode
2626
from const import MINUTE_WATT, PREDICT_STEP
2727
from prediction import Prediction
28+
from tariff_catalogue import PRICE_CAP_IMPORT_P, SEG_EXPORT_P
2829

2930
VALID_SHAPES = ["night", "day", "flat"]
3031

@@ -215,6 +216,13 @@ def _validate_load(raw):
215216
}
216217

217218

219+
# What a household with no PV and no battery actually pays: the Ofgem price cap for
220+
# import, and a typical fixed Smart Export Guarantee rate for the export they cannot
221+
# have without generation. Rates come from tariff_catalogue so the cap figure is stated
222+
# in exactly one place.
223+
DEFAULT_BASELINE_TARIFF = {"rates_import": [{"rate": PRICE_CAP_IMPORT_P}], "rates_export": [{"rate": SEG_EXPORT_P}]}
224+
225+
218226
def _validate_tariff(raw):
219227
"""Normalise the tariff block, requiring at least one import rate source.
220228
@@ -297,6 +305,12 @@ def validate_config(config, today=None):
297305
"battery": battery,
298306
"load": _validate_load(raw.get("load")),
299307
"tariff": _validate_tariff(raw.get("tariff")),
308+
# The counterfactual bill is what the household would pay with no system at all,
309+
# and such a household is not on a battery tariff: the smart import tariffs are
310+
# only worth having once you have something to shift load into. Pricing the
311+
# no-PV/battery scenario on the same tariff as the battery scenarios therefore
312+
# understates what the system is worth. Defaults to the Ofgem price cap.
313+
"baseline_tariff": _validate_tariff(raw.get("baseline_tariff") or DEFAULT_BASELINE_TARIFF),
300314
"samples_per_month": samples_per_month,
301315
"costs": _validated_costs(raw.get("costs")),
302316
"debug": _coerce_bool(raw.get("debug", False)),
@@ -1079,7 +1093,7 @@ def _capture_plan(predbat, pv_step, pv_step10, load_step, load_step10, end_recor
10791093
return raw_plan
10801094

10811095

1082-
def _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh, car_rate_kw, plans=None):
1096+
def _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh, car_rate_kw, plans=None, baseline_tariff=None):
10831097
"""Run all four scenarios against one sampled day at a fixed car charging energy.
10841098
10851099
``car_kwh`` is the actual energy this leg charges - either a full weekly charging
@@ -1119,11 +1133,32 @@ def _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_
11191133
predbat.charge_window_best = []
11201134
predbat.export_window_best = []
11211135
predbat.export_limits_best = []
1136+
# Price the counterfactual on its OWN tariff. A household with no PV and no battery is
1137+
# not on a smart import tariff - those only pay off once there is something to shift
1138+
# load into - so charging the baseline at the battery tariff's overnight rate credits
1139+
# it with a saving it could never have had, and understates the system.
1140+
#
1141+
# The rates are restored immediately afterwards, from the tariff's own output rather
1142+
# than from predbat.rate_import: _apply_rates REPLICATES what it is given, so reusing
1143+
# the installed dict would re-replicate an already-replicated series. Every later
1144+
# scenario in this leg therefore runs on exactly what prepare_sample() installed.
1145+
if baseline_tariff is not None:
1146+
baseline_import, baseline_export = baseline_tariff.rates_for(midnight_utc, PLAN_MINUTES)
1147+
_apply_rates(predbat, baseline_import, baseline_export)
1148+
1149+
# Constructed AFTER the rate swap, never before: Prediction snapshots the rates off
1150+
# predbat at construction time (prediction.py, `self.rate_import = base.rate_import`),
1151+
# so a Prediction built first would be billed at the main tariff no matter what was
1152+
# installed afterwards - the swap would appear to work and change nothing.
11221153
predbat.prediction = Prediction(predbat, zero_step, zero_step, load_step, load_step, soc_kw=0, soc_max=0)
11231154
results["no_pvbat"] = _billed_result(predbat, DAY_MINUTES, zero_step)
11241155
if plans is not None:
11251156
plans["no_pvbat"] = _capture_plan(predbat, zero_step, zero_step, load_step, load_step, DAY_MINUTES)
11261157

1158+
if baseline_tariff is not None:
1159+
main_import, main_export = tariff.rates_for(midnight_utc, PLAN_MINUTES)
1160+
_apply_rates(predbat, main_import, main_export)
1161+
11271162
# Scenario 1b: PV but no battery. apply_hardware gives the array its real inverter
11281163
# and export limits - a PV-only system still has an inverter, and clipping matters -
11291164
# while soc_max=0 leaves it with nowhere to store surplus, so everything the house
@@ -1223,7 +1258,7 @@ def _blend_results(with_car, without_car, fraction):
12231258
return {key: {field: fraction * with_car[key][field] + (1 - fraction) * without_car[key][field] for field in SCENARIO_FIELDS} for key in SCENARIO_KEYS}
12241259

12251260

1226-
def run_day(predbat, config, weather, tariff, load_source, day, midnight_utc, plans=None):
1261+
def run_day(predbat, config, weather, tariff, load_source, day, midnight_utc, plans=None, baseline_tariff=None):
12271262
"""Run all four scenarios against one sampled day and return their billed figures.
12281263
12291264
A configured car charges in weekly sessions, not a daily smear (see
@@ -1245,7 +1280,7 @@ def run_day(predbat, config, weather, tariff, load_source, day, midnight_utc, pl
12451280

12461281
if car_charging_kwh <= 0:
12471282
leg_plans = {} if plans is not None else None
1248-
result = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=leg_plans)
1283+
result = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=leg_plans, baseline_tariff=baseline_tariff)
12491284
if plans is not None:
12501285
plans.append({"leg": "single", "scenarios": leg_plans})
12511286
return result
@@ -1257,12 +1292,12 @@ def run_day(predbat, config, weather, tariff, load_source, day, midnight_utc, pl
12571292
sessions_per_week, session_kwh = car_charging_schedule(car_charging_kwh, car_rate_kw)
12581293

12591294
with_car_plans = {} if plans is not None else None
1260-
with_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=session_kwh, car_rate_kw=car_rate_kw, plans=with_car_plans)
1295+
with_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=session_kwh, car_rate_kw=car_rate_kw, plans=with_car_plans, baseline_tariff=baseline_tariff)
12611296
if plans is not None:
12621297
plans.append({"leg": "with_car", "scenarios": with_car_plans})
12631298

12641299
without_car_plans = {} if plans is not None else None
1265-
without_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=without_car_plans)
1300+
without_car = _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_utc, car_kwh=0.0, car_rate_kw=car_rate_kw, plans=without_car_plans, baseline_tariff=baseline_tariff)
12661301
if plans is not None:
12671302
plans.append({"leg": "without_car", "scenarios": without_car_plans})
12681303

@@ -1423,6 +1458,9 @@ async def run(self, progress=None):
14231458
self.caveats.append("Months {} had too few forecast/actual day pairs, so their P10 used the flat {} derate.".format(sorted(self.weather.fallback_months), self.config["pv10_derate_fallback"]))
14241459
if has_solar:
14251460
self.caveats.append("The forecast-versus-ERA5 gap includes systematic model bias as well as forecast error, so measured solar uncertainty is slightly overstated.")
1461+
self.caveats.append(
1462+
"The no_pvbat counterfactual is priced on its own baseline tariff, since a household with no PV or battery would not be on a battery tariff. Both scenarios still use ONE standing charge - the main tariff's - so any difference in standing charge between the two tariffs is NOT included in the reported savings or payback."
1463+
)
14261464
self.caveats.append("export_credit_p_estimate is money ALREADY included inside cost_p (which prices every export minute at its real rate); it is informational only - adding it to cost_p double-counts export income.")
14271465
self.caveats.append(
14281466
"The without_predbat baseline charges in the single cheapest contiguous band of each day, mirroring Predbat's own savings baseline. On a half-hourly tariff such as Agile the cheapest band is often one 30 minute slot, so the baseline is a more pessimistic comparator there than on a banded tariff (Economy 7, Cosy, Flux) where it covers the whole cheap period. Compare predbat_vs_baseline_p across tariffs with that in mind."
@@ -1439,7 +1477,11 @@ async def run(self, progress=None):
14391477
self.predbat = create_headless_predbat(self.work_dir, self.config["timezone"], self.log)
14401478
self.load_source = await self._build_load_source()
14411479
self.tariff = AnnualTariff(self.config["tariff"], log=self.log, predbat=self.predbat, storage=self.storage, timezone=self.config["timezone"])
1480+
# Prices the no-PV/battery counterfactual only. Its own AnnualTariff because it
1481+
# may be a completely different product with its own rate downloads and cache.
1482+
self.baseline_tariff = AnnualTariff(self.config["baseline_tariff"], log=self.log, predbat=self.predbat, storage=self.storage, timezone=self.config["timezone"])
14421483

1484+
baseline_fallback_months = []
14431485
zone = pytz.timezone(self.config["timezone"])
14441486
months = []
14451487
total_units = 12
@@ -1452,6 +1494,11 @@ async def run(self, progress=None):
14521494
days_in_month = calendar.monthrange(year, month)[1]
14531495
standing_charge_p = self.tariff.standing_charge_p_per_day * days_in_month
14541496

1497+
baseline_ready = await self.baseline_tariff.fetch_month(year, month)
1498+
if not baseline_ready:
1499+
# Falls back to the main tariff for this month rather than losing it, but
1500+
# that silently changes what no_pvbat means, so it is recorded.
1501+
baseline_fallback_months.append(month)
14551502
if not await self.tariff.fetch_month(year, month):
14561503
months.append({"month": month, "status": "unavailable", "reason": "no rate data available", "days": days_in_month, "standing_charge_p": standing_charge_p})
14571504
completed += 1
@@ -1478,7 +1525,7 @@ async def run(self, progress=None):
14781525
midnight_utc = zone.localize(datetime(day.year, day.month, day.day)).astimezone(pytz.utc)
14791526
day_plans = [] if self.config["debug"] else None
14801527
try:
1481-
result = run_day(self.predbat, self.config, self.weather, self.tariff, self.load_source, day, midnight_utc, plans=day_plans)
1528+
result = run_day(self.predbat, self.config, self.weather, self.tariff, self.load_source, day, midnight_utc, plans=day_plans, baseline_tariff=self.baseline_tariff if baseline_ready else None)
14821529
except Exception as exc: # noqa: BLE001 - one bad sample must not abort the whole year
14831530
self.log("Warn: Annual: {} in month {} failed to plan/cost ({}: {}); excluding it from this month's total".format(day.isoformat(), month, type(exc).__name__, exc))
14841531
failed_days.append(day.isoformat())
@@ -1535,6 +1582,13 @@ async def run(self, progress=None):
15351582

15361583
self.caveats.extend(self._tariff_fallback_caveats(self.tariff.fallback_months, self.tariff.unpaid_export_months, year))
15371584

1585+
if baseline_fallback_months:
1586+
# Falling back to the main tariff keeps the month rather than losing it, but it
1587+
# silently changes what no_pvbat means there, so it has to be said out loud.
1588+
self.caveats.append(
1589+
"No baseline-tariff rates were available for month(s) {}, so the no-PV/battery counterfactual there was priced on the main tariff instead, which understates what the system is worth in those months.".format(sorted(baseline_fallback_months))
1590+
)
1591+
15381592
if progress:
15391593
progress(total_units, total_units, "Complete")
15401594

apps/predbat/annual_store.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,28 @@ async def backfill_summaries(storage, runs):
309309
if filled:
310310
await storage.save(STORAGE_MODULE, INDEX_NAME, runs, format="json")
311311
return runs
312+
313+
314+
async def delete_run(storage, run_id):
315+
"""Remove one run entirely - its document, its captured plans and its index entry.
316+
317+
Reuses the same discard path eviction uses, so a run the user deletes leaves no more
318+
behind than one that aged out of the ring: the document and every plan key are
319+
expired, not merely unlinked from the index. Storage has no ``delete`` method on the
320+
real component, which is why discarding is overwrite-with-expiry rather than removal.
321+
322+
Returns True when a run was found and removed, False when the id matched nothing -
323+
so the caller can tell "deleted" from "already gone" rather than reporting success
324+
for a run that was never there.
325+
"""
326+
if not storage or not run_id:
327+
return False
328+
329+
index = await list_runs(storage)
330+
entry = next((existing for existing in index if existing.get("id") == run_id), None)
331+
if entry is None:
332+
return False
333+
334+
await _discard_run(storage, run_id, entry.get("plan_keys"))
335+
await storage.save(STORAGE_MODULE, INDEX_NAME, [existing for existing in index if existing.get("id") != run_id], format="json")
336+
return True

apps/predbat/tariff_catalogue.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@
2424
# The dropdown's escape hatch: leaves the URL fields blank for a hand-entered tariff
2525
CUSTOM_ID = "custom"
2626

27+
# What a household with no PV and no battery is assumed to be on. Named here so the form
28+
# and the engine's own default cannot drift apart.
29+
BASELINE_DEFAULT_ID = "cap_seg"
30+
31+
# Ofgem price cap, 1 July - 30 September 2026, direct debit, England/Scotland/Wales,
32+
# including VAT. Named rather than repeated so the next cap change is a one-line edit.
33+
# Source: https://www.ofgem.gov.uk/information-consumers/energy-advice-households/energy-price-cap-unit-rates-and-standing-charges
34+
PRICE_CAP_IMPORT_P = 26.11
35+
PRICE_CAP_STANDING_CHARGE_P = 57.19
36+
37+
# A typical FIXED Smart Export Guarantee rate. Fixed SEG offers sit around 3-8p/kWh in
38+
# mid-2026, so this is deliberately at the conservative end - the smart export tariffs
39+
# that pay far more are separate entries in this catalogue, and a household on the price
40+
# cap is not on one of them.
41+
SEG_EXPORT_P = 4.1
42+
2743
_OCTOPUS = "https://api.octopus.energy/v1/products"
2844

2945
# The two Octopus flat-rate export products offered against each import tariff below.
@@ -42,11 +58,11 @@
4258
_OUTGOING_PRIME = "{}/OUTGOING-PRIME-FIX-12M-26-06-23/electricity-tariffs/E-1R-OUTGOING-PRIME-FIX-12M-26-06-23-{{dno_region}}/standard-unit-rates/".format(_OCTOPUS)
4359

4460
BUILTIN_TARIFFS = [
45-
{"id": "cap_seg", "name": "Price cap import / SEG export", "rates_import": [{"rate": 24.86}], "rates_export": [{"rate": 4.1}]},
61+
{"id": "cap_seg", "name": "Price cap import / SEG export", "rates_import": [{"rate": PRICE_CAP_IMPORT_P}], "rates_export": [{"rate": SEG_EXPORT_P}]},
4662
{
4763
"id": "eon_next_drive",
4864
"name": "Eon Next Drive import / Fixed export",
49-
"rates_import": [{"rate": 6.7, "start": "00:00:00", "end": "07:00:00"}, {"rate": 24.86, "start": "07:00:00", "end": "00:00:00"}],
65+
"rates_import": [{"rate": 6.7, "start": "00:00:00", "end": "07:00:00"}, {"rate": PRICE_CAP_IMPORT_P, "start": "07:00:00", "end": "00:00:00"}],
5066
"rates_export": [{"rate": 16.5}],
5167
},
5268
{

apps/predbat/tests/test_annual_integration.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,30 @@ def _run_car_config():
329329
print(" ERROR: {}.{} = {} without capture but {} with capture - save is leaking into the billed numbers".format(key, field, without_capture[key][field], with_capture[key][field]))
330330
failed = True
331331

332+
print("Test: the baseline tariff prices no_pvbat only and does not leak into the other scenarios")
333+
# A flat price-cap-style baseline against the banded main tariff. Two properties
334+
# matter and they pull in opposite directions: the counterfactual MUST change (or the
335+
# feature does nothing), and every other scenario must be bit-for-bit identical (or
336+
# the swap has leaked and silently repriced the system being evaluated). _apply_rates
337+
# mutates predbat in place and replicates what it is given, so a leak here is a very
338+
# live possibility rather than a theoretical one.
339+
reset_inverter(my_predbat)
340+
baseline_midnight = pytz.utc.localize(datetime(day.year, day.month, day.day))
341+
baseline_load_source = SyntheticLoadProfile(annual_kwh=config["load"]["annual_kwh"], shape=config["load"]["shape"], year=config["year"])
342+
flat_cap = StubTariff(cheap=26.11, normal=26.11, peak=26.11, export=4.1)
343+
without_baseline = _run_scenarios(my_predbat, config, weather, StubTariff(), baseline_load_source, day, baseline_midnight, car_kwh=0.0, car_rate_kw=DEFAULT_CAR_RATE_KW)
344+
reset_inverter(my_predbat)
345+
with_baseline = _run_scenarios(my_predbat, config, weather, StubTariff(), baseline_load_source, day, baseline_midnight, car_kwh=0.0, car_rate_kw=DEFAULT_CAR_RATE_KW, baseline_tariff=flat_cap)
346+
347+
if abs(with_baseline["no_pvbat"]["cost_p"] - without_baseline["no_pvbat"]["cost_p"]) < 1.0:
348+
print(" ERROR: a flat baseline tariff should reprice no_pvbat away from the banded main tariff, got {} vs {}".format(without_baseline["no_pvbat"]["cost_p"], with_baseline["no_pvbat"]["cost_p"]))
349+
failed = True
350+
for key in ["pv_only", "without_predbat", "with_predbat"]:
351+
for field in SCENARIO_FIELDS:
352+
if without_baseline[key][field] != with_baseline[key][field]:
353+
print(" ERROR: {}.{} changed from {} to {} - the baseline tariff has leaked past no_pvbat".format(key, field, without_baseline[key][field], with_baseline[key][field]))
354+
failed = True
355+
332356
print("Test: capturing plans does not leak predbat.debug_enable on")
333357
# The annual "debug" flag means "save the plan info", nothing more - it must never be
334358
# wired to Predbat's own debug_enable, which kernel_supported() requires False to use

0 commit comments

Comments
 (0)