WhatIf: baseline tariff for the counterfactual, run deletion, nav fix, current cap rates - #4393
Conversation
…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>
There was a problem hiding this comment.
🟡 Not ready to approve
There are a couple of concrete UX/validation issues (baseline dropdown selection and baseline-tariff validation error paths) that can mislead users or produce incorrect/misleading behavior until fixed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR updates the Annual “WhatIf” web flow to more accurately price the no-PV/no-battery counterfactual using a separate baseline tariff (defaulting to the Ofgem price cap), adds run deletion from the compare table, fixes menu highlighting for WhatIf sub-pages, and refreshes built-in price-cap constants.
Changes:
- Add
baseline_tariffsupport (config, validation, UI selection, and engine execution) sono_pvbatis priced on a separate tariff while other scenarios remain unchanged. - Add
/annual_deleteroute + storage-backeddelete_run()and a Delete button on the compare table. - Improve navigation highlighting for sub-pages and update price-cap constants to current values.
File summaries
| File | Description |
|---|---|
| docs/annual-prediction.md | Documents baseline tariff semantics and run deletion behavior. |
| apps/predbat/web.py | Registers a new POST route for deleting annual runs. |
| apps/predbat/web_helper.py | Adds a second-pass menu highlight strategy for sub-pages. |
| apps/predbat/web_annual.py | Adds baseline tariff selection to the form and a Delete action/button in compare view. |
| apps/predbat/annual.py | Implements baseline tariff application for no_pvbat and adds caveats/fallback logic. |
| apps/predbat/annual_store.py | Adds delete_run() to remove a run document + plans + index entry. |
| apps/predbat/tariff_catalogue.py | Centralizes updated price-cap/SEG constants and baseline default ID. |
| apps/predbat/tests/test_web_annual.py | Adds UI/config validation checks for baseline tariff selection. |
| apps/predbat/tests/test_annual_store.py | Adds coverage for deletion removing documents, plans, and index entries. |
| apps/predbat/tests/test_annual_integration.py | Adds regression guard to ensure baseline reprices only no_pvbat. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…ng no export The tariff dropdown offered a fixed list of import/export pairs, so only the combinations someone had thought to enumerate could be modelled. Import and export are now separate dropdowns, and a household with no export agreement can say so: "No export payment" prices export at 0p rather than leaving it unpriced. Splitting the dropdowns exposed a latent engine bug that the pairs had been hiding. rates_for gated BOTH sides on one `self.import_url or self.export_url` test, so a tariff mixing a downloaded URL on one side with basic rates on the other took the URL branch and found nothing to stamp for the basic side. No built-in pair was mixed, so it never fired - but "price cap import with Octopus Outgoing Prime export" is now an ordinary selection, and it would have priced the entire year's import at zero. Each side resolves its own source now. Matching a saved tariff back to the dropdown was also import-URL-only, which made "Agile / Prime" and "Agile / Fixed" indistinguishable: a saved config reloaded as whichever came first in the list. Matching each side against its own list removes that by construction. The no-PV/battery baseline keeps an import selector only - that scenario has nothing to export, so an export control there would change no figure. Tests: mixed URL/basic tariffs in both directions, asserting on values rather than presence (verified by mutation - restoring the old gate fails both); a deliberate zero export stamps every minute and raises no unpaid-export caveat; free combinations round-trip through the form; two exports sharing an import stay distinguishable. Full suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pare table The compare table had one Tariff column, fed by a URL-substring match on the import side alone. Once import and export became independent choices and the no-PV/battery scenario gained its own baseline, that column could no longer describe a run: two runs differing only in their export deal looked identical, and the baseline every saving is measured against was invisible everywhere. Replace it with Baseline, Import and Export columns - baseline first, since it is what the other two are judged against. Names come from the tariff catalogue, including the user's own compare_list entries, via a new shared tariff_catalogue.match_entry() that the form's dropdowns now use too, so the two cannot describe the same tariff differently. build_summary carries the tariff dicts rather than a pre-rendered name, because naming needs compare_list and only the web layer can read it; backfill_summaries now also refreshes summaries that predate the tariff fields. "What this run used" gains a Baseline row. nowrap moves from the whole compare table onto its data cells. Applied to the headers it held every column open at its heading set on one line - "PV + battery pays back in" propping open a column reading "4.2 years" - which is what made the table far wider than its figures needed. Also: reset-to-defaults no longer selects the Octopus load source. That source reads the import meter, which on a home with solar or a battery already has that system's self-consumption subtracted from every reading - the form carries a banner warning against it - yet anyone with credentials in apps.yaml is by definition such a home, so the prefill picked the one source that was wrong for them. The credentials still fill the fields; only the radio now defaults to predicted consumption. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two issues raised on #4393. Validation error paths. baseline_tariff goes through the same _validate_tariff as the main tariff, whose four messages hard-coded "annual.tariff" - so every baseline problem sent the user to fix a block that was perfectly valid. The block being validated is now a parameter, and the baseline reports as annual.baseline_tariff. Baseline dropdown selection. A baseline written by hand in YAML matches no catalogue entry, so _selected_import_id returns CUSTOM_ID - which is truthy, so the "or default" fallback never fired, and since this dropdown deliberately offers no Custom option, nothing was marked selected at all. The form relied on the browser showing the first option to display anything. CUSTOM_ID now counts as "no baseline" alongside None, so the price cap is stated rather than implied. Not changed, and worth being explicit about: saving the form still replaces a hand-written baseline with a catalogue one. That is not specific to the baseline - config_from_post builds the config from scratch, so any key the form does not render is dropped - and the catalogue-only baseline is deliberate. Preserving hand-written config through the form is a separate question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four changes to the WhatIf tab.
A separate tariff for the no-PV/battery counterfactual
A household with no system is not on a battery tariff — the cheap overnight rates those offer only pay off once you have somewhere to put the energy. Pricing the counterfactual on the user's own smart tariff therefore credited it with a saving it could never have had, and understated what the system is worth.
The Configure page gains Tariff without PV or a battery, defaulting to the Ofgem price cap. It applies to
no_pvbatonly; every other scenario keeps the main tariff.A subtlety that bit the first version: the rate swap must happen before the
Predictionis constructed.Predictionsnapshots the rates offpredbatat construction (prediction.py:181), so a Prediction built first bills at the main tariff no matter what is installed afterwards — the swap appears to work and changes nothing. The integration test caught it by asserting the counterfactual actually moves.Known simplification, stated rather than hidden: both tariffs share the main tariff's standing charge, so a difference there is excluded from savings and payback. Recorded in the results caveats and the docs.
Sub-pages no longer highlight Dashboard
/annual_viewand/annual_comparematched no menu link, so the highlighting JS fell through to its "first item" default and lit up Dashboard while the user was plainly on WhatIf. A second pass maps a sub-page to its parent entry, and only runs when nothing matched exactly — so/apps_editorkeeps its own highlight rather than being captured by/apps.Delete runs from the Compare page
Each row gets a Delete button that confirms first. Deletion goes through the same discard path eviction uses, so the document and every captured plan go too, leaving no orphaned storage.
Price cap rates brought current
24.86p/kWh and a 60p standing charge were stale. Now 26.11p/kWh and 57.19p/day, the Ofgem cap for 1 July–30 September 2026, as named constants so the next 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.
Testing
Full suite green (297s), including the ~110s annual integration run.
The load-bearing test asserts both halves of the tariff swap: the counterfactual must change, and every other scenario must be bit-for-bit identical. Verified by mutation — removing the restore makes
pv_onlyswing from −297p to −1.2p, which the test catches.🤖 Generated with Claude Code