Annual electricity cost prediction tool (engine, CLI and web UI) - #4315
Open
springfall2008 wants to merge 117 commits into
Open
Annual electricity cost prediction tool (engine, CLI and web UI)#4315springfall2008 wants to merge 117 commits into
springfall2008 wants to merge 117 commits into
Conversation
Spec for a standalone tool that projects a year of household electricity costs using the real Predbat planning engine, reporting each month under three scenarios: no PV/battery, PV+battery without Predbat, and with Predbat. Prediction engine only; the web UI is separate later work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the flat 0.7 P10 derate with a value derived from real day-ahead forecast error: download both the Open-Meteo ERA5 actuals archive and the archived short-range forecast for the same dates, and take the 10th percentile of the per-month actual/forecast daily energy ratio. Predbat now also plans against the forecast series and is billed against actuals, rather than being handed perfect foresight. Because the plan is costed against a fixed actuals series, hedging can only add cost, so P10 is not a free parameter and must not come from climatological spread. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteen TDD tasks covering the shared solar model extraction, load profile sources, the Open-Meteo actuals and forecast archives, historical tariff resolution, the headless PredBat bootstrap, sample selection, the three-scenario day runner, orchestration, CLI and documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uite The golden-fixture generator re-implemented the arithmetic it was meant to verify, so a transcription error would have produced a green parity test that proved nothing. tests/test_open_meteo.py already exercises the real download_open_meteo_data() end to end; that is the parity guard, and the task now forbids editing it. The new unit test asserts hand-derived values instead of a snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the Open-Meteo cell-temperature derate and trapezoidal hourly integration out of solcast.py into solar_model.py so the upcoming annual prediction tool can reuse the identical conversion instead of duplicating it. Also updates the two call sites (test_solcast.py and the standalone open_meteo_live.py script) that referenced the removed SolarAPI.convert_azimuth instance method, and re-exports pvwatts_cell_temperature from solcast.py for test_open_meteo.py's existing import.
The script is at coverage/run_pre_commit, not the repo root, and a run that rewrites files via the black or file-contents-sorter hooks has not passed. Two tasks reported pre-commit clean when it was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nary Task 1 follow-up: derate, COEFF, and trapezoidally were flagged by the cspell pre-commit hook on apps/predbat/solar_model.py. All three are legitimate technical terms from the PVWatts/SAPM model description, so they're added to the workspace dictionary rather than reworded away.
pre-commit --all-files enumerates via git ls-files, so a brand-new module is never checked and the run reports a false pass. This is what let two tasks ship black and cspell violations while reporting clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 4 of the annual electricity-cost prediction tool: downloads a year of real half-hourly Octopus meter readings and serves them as daily load profiles via OctopusConsumptionLoadProfile, discarding any day that doesn't have all 48 half-hour slots (a partial day would look like genuinely low consumption). Falls back to a synthetic profile, or None, for missing days. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 4's review found that a mid-pagination failure was cached as a complete year. The same break-then-cache pattern appears in Task 5's weather fetch and Task 6's rate fetch; fix both in the plan rather than rediscovering it in two more review cycles. A partial cache with no expiry is worse than no cache: it pins wrong data permanently and reads back as genuine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d DST days Three failure-visibility gaps in the Octopus consumption source, all in tension with the design goal that failures must be visible, never silent: - fetch() treated a mid-pagination request failure identically to reaching the end of the pages, so a transient network blip could permanently cache a partial year. It now tracks whether the loop ended by exhausting `next` links versus a failed request (or the page safety cap), and only caches on a genuine natural end. - daily_kwh() silently returned 0.0 for a missing day without recording it in missing_days, disagreeing with minute_profile() for the same case. Both now record the date consistently. - parse_consumption_results() let the autumn clock-change day's duplicate 01:00-02:00 half-hour readings silently overwrite each other, producing an undercounted "complete" day. A day with any slot written twice is now discarded like a partial day, logged when a logger is supplied. Adds one covering test per fix in test_annual_load_octopus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry array to parse Fixes review findings on the Open-Meteo weather module: the truncation guard previously ran after the cache write, so a bad response was cached and then rejected forever with no retry. A shared _payload_problem() check now gates both the cache write and the cache read, so a poisoned cache entry is discarded and re-fetched rather than trusted permanently. Also, _fetch_series() previously counted a series as available if any one array parsed, so a failed array silently produced an overly optimistic actual/forecast ratio (capped at 1.0, i.e. no P10 derate at all). Every configured array must now parse for a series to count; a single failure abandons the whole series. Adds covering tests: the P10 order statistic on a month with differing daily ratios, the Storage cache path (write/hit/poisoned-entry recovery), the truncation guard, and the multi-array partial-failure path for both the forecast and actual series. Tightens the deterministic January P10 assertion to the exact measured value and replaces a 48-hour-window assertion that could never fail with one that exercises a non-hour-aligned cutoff.
Resolves import/export rates for a specific historical date from an Octopus product URL (period_from/period_to, paginated, cached per month) or a static basic-rates structure, for the annual electricity cost prediction tool.
- Treat a hit page-cap the same as a failed page: stop caching a
truncated Octopus download instead of silently persisting it.
- Fix month_available disagreeing with rates_for for an export-only
configuration (no import URL).
- Refuse to report a month available when neither an Octopus URL nor
rates_import is configured, instead of silently pricing at zero.
- Only append page_size to the Octopus URL when the caller didn't
already specify one, so an explicit page_size is no longer
overridden.
- Stop mutating predbat.args for {dno_region} substitution (use
resolve_arg's extra_args instead), so a live Octopus component's
own region is never clobbered.
- Warn and ignore day_of_week/date basic-rate entries, which cannot
be honoured during a historical replay (they anchor to today's
date, not the sampled historical date).
- Cache the computed basic rate table instead of recomputing and
re-logging it on every rates_for call.
Adds covering tests for all of the above, including a December to
January month-boundary merge case.
…nfig validation
Address code review findings on the config validator: route every
float()/int() conversion through a _require_number() helper that catches
TypeError/ValueError and re-raises AnnualConfigError, add range checks
(size_kwh/kwp/inverter_kw > 0, efficiency and pv10_derate_fallback in
(0, 1], year within [1940, current year], etc.), close the octopus:{}
loophole in the load exclusivity check, name every templated tariff
field missing dno_region rather than just the first, and drop the
redundant deepcopy ahead of scrub_secrets. Add covering tests for each.
…v-var leak in headless bootstrap Fixes 6 defects found in review of Task 8's headless bootstrap: - battery_rate_max_export is now owned solely by apply_hardware(), not clobbered back to a hardcoded default by reset_sample_state() - soc_kw is reset per sample and set deterministically by apply_hardware() instead of being clamped against inherited state - the full rate-derived family (low_rates, high_export_rates, rate_import, rate_export, thresholds, min/max/average) is now cleared alongside the replicated rates that fed them - reset_sample_state() now only clears per-sample leaks; the deliberate offline-mode choices moved to a new configure_offline_mode(), called once from create_headless_predbat() - PREDBAT_APPS_FILE is saved and restored around PredBat() construction so it cannot leak into a later PredBat() in the same process - the no-battery branch sums kwp across all solar arrays (not just the first) and zeroes battery_rate_min, so PV-only and battery scenarios are compared against the same export cap
- _percentile_indices(0, samples) now returns [] instead of a meaningless [-1]; add direct tests for count 0/1, samples > count and samples == count. - Document why the has_solar=False branch deliberately skips the following-day guard, and cover that a battery-only run still samples the last day of the month. - Add a December -> following-January boundary test through select_samples() to pin the timedelta roll-over. - Drop the redundant set() dedup in select_samples() (indices are already distinct) and soften the weight-sum docstring wording to acknowledge ordinary floating-point rounding.
Runs no-PV/battery, PV+battery-on-a-timer, and Predbat-planned scenarios for one sampled day through the real Predbat engine and returns their billed figures. Predbat plans on the forecast PV series and is costed against the actuals series so it is never given perfect foresight; only the Predbat scenario gets a smart car, the other two use a fixed off-peak timer. Fixes a gap in the plan: calculate_plan() never calls plan_car_charging() itself (that only happens in the live fetch cycle or Compare's helper, both bypassed here), so scenario 3 now calls plan_car_charging() directly to populate car_charging_slots -- otherwise the smart car would silently never charge and would look free in the Predbat scenario.
… in Task 10 Coordinator review of the three-scenario day runner found several bugs that biased the comparison, most critically that the headless bootstrap's Monitor-mode default left calculate_best_charge/calculate_best_export/ set_charge_window/set_export_window all False, so calculate_plan() never produced a charge or export window and "with Predbat" scored no better than demand-only. - Enable the four planner flags in configure_offline_mode(); assert them both on the mock fixture and on a real create_headless_predbat() instance. - Fix add_car_to_load() billing half the car's daily energy per day instead of the full amount (it divided by len(windows) when car_kwh was already a per-day figure). - Set car_charging_from_battery = True in scenario 3 so the battery is free to serve the car exactly as it implicitly is in scenarios 1/2. - Warn when plan_car_charging() cannot fit the car's full daily energy before its ready time, so a shortfall is visible rather than silently under-billed. - Set num_cars from config before _apply_rates() runs in prepare_sample(), removing an order-dependent leak into set_rate_thresholds() from the previous sample's scenario 3. - Match Compare.run_scenario()'s zeroed end-of-period compute_metric() call exactly, so the optimiser's internal metric_keep heuristic cannot leak into the reported billed cost. - Round the car's timer window to a 5 minute grid so step_data_history()'s sampling cannot quantise its billed duration. - Rename forecast_load_step to predbat_load_step (there is no forecast/actual split for load, only PV). Verified end-to-end with the planner enabled: with_predbat now costs less than without_predbat, which costs less than no_pvbat, on a synthetic day with a cheap overnight band.
… fixes Fixes 9 issues found in review of the AnnualPredictor orchestrator: - Critical: an all-unavailable year fabricated a zero-cost, zero-saving result instead of reporting no result (dead truthiness guard). - Critical: run()/_build_results()/_month_scenarios()/average_rate() had no tests; added a fast, non-slow test_annual_results covering all four. - One failing sample no longer aborts the whole run (logged, sample dropped, month marked degraded or unavailable as appropriate). - The forecast/actuals integration test now asserts the with-Predbat cost is materially worse under an inflated forecast, not merely "not cheaper"; its misleading comment about pv_generated_kwh is corrected. - export_credit_p renamed to export_credit_p_estimate (not additive with cost_p); self_consumed_kwh's clamp now carries a self_consumed_kwh_meaningful flag plus a caveat when it fires. - A failed next-month tariff fetch is now logged and caveated instead of discarded; a docstring corrected to match its code; a dead ternary collapsed.
…e, fix flaky swap test - Reweight surviving samples in a degraded month (days_in_month / len(survivors)) so a partially-failed month still represents a full month in the annual total, instead of silently under-counting by the dropped samples' share. - Document export_credit_p_estimate at its assignment site and in a results-document caveat as an approximation already reflected inside cost_p, so a consumer does not double-count it. - Replace the annual_integration forecast/actuals cost-threshold assertion (which measured plan-search noise and could flip sign depending on suite order) with a structural check that predbat.prediction.pv_forecast_minute_step holds the actuals, not the inflated forecast, after run_day() returns. - Carry forward the test_annual_bootstrap fix that stopped debug_enable leaking across the shared suite fixture and forcing every later prediction off the C++ kernel.
…on; dedupe payback caveat Adds two test_annual_config.py cases that put a bad annual.costs block (negative value, unrecognised key) through validate_config() and assert AnnualConfigError specifically, via the existing expect_config_error_type() helper - so a future refactor that calls resolve_costs() outside _validated_costs()'s try/except would fail a test instead of leaking a raw ValueError past the CLI/web layer's catch. Verified by temporarily removing the try/except: both new assertions failed as expected, confirming they discriminate. Also makes the payback caveat dedupe against self.caveats before appending, matching the no_result_message pattern two lines above it in _build_results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the validated pv_only chart colour (#9439ef) and label to the existing three-colour palette, and renders annual.costs/annual.payback as an install-cost note and a three-row payback table on the Annual results page - showing "does not pay back" rather than a number when a row never breaks even, and the unavailability reason when a full year was not modelled. Documents both in docs/annual-prediction.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both checks searched the whole page for a label that appears in more than one place, so each passed against broken code: - "PV only" and "With Predbat" are SCENARIO_LABELS values rendered in the chart and month table regardless of the payback table, so deleting a payback row left the test green. - Conversely "PV only" also appears in the payback table's own label list, so dropping pv_only from SCENARIO_ORDER - removing it from the chart AND the month table - also left the test green. Each assertion now runs against _render_payback / _render_chart / _render_month_table directly. Verified both discriminate: removing the pv_battery_predbat row fails the payback test, and removing pv_only from SCENARIO_ORDER fails all three chart/month-table assertions. Also corrects the docs, which said the seven cost settings were "the three PV band rates and their two anchoring costs" - there is only one, pv_minimum_gbp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final-review fix wave. A no-battery run rendered "PV + battery" and "With Predbat" rows at the PV-only capital, reading as though the battery were free; a no-PV run showed a PV row for a system never modelled. Rows are now gated on what the config actually contains, and a run with neither says so. A run stored before the pv_only scenario existed charted it at £0.00 for all twelve months, because a missing scenario defaulted to zero - the same "a zero bar reads as free electricity" failure _render_chart's docstring exists to prevent. A scenario absent from a month is now a gap, and one absent from every month is dropped from the payload rather than shipped as an all-zero series. Same fix for the month table's fabricated 0 kWh cells. The panel-count field raised a bare ValueError (a 500) instead of leaving a bad value for validate_config to reject as the kwp branch does, and silently truncated 13.7 panels to 13 - about £480 of capital - defeating the integer validation added in this branch. Also: guard a None year count, reject NaN/Infinity cost settings that rendered as "nan years", and replace a vacuous test that asserted 0 != 0 and would have passed with the whole feature deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s none Reported: the Octopus API key and account boxes were empty despite being configured. The prefill was working - the cause is that it almost never runs. load_config() returns the saved annual.yaml whenever one exists and only falls back to prefill_config() when none does, so the prefill applies on a FIRST visit only. Anyone who had already pressed Save or Run - and especially anyone who saved while on the manual load source, which stores no octopus block at all - saw those two boxes blank for good. The same trap would swallow any field added to the prefill in future. The two credential FIELDS now fall back to the live instance's values when the config being rendered carries none. Deliberately not a merge of the prefill into the saved config: _validate_load treats the octopus and manual sources as mutually exclusive, so injecting an octopus block into a saved manual config would make it invalid outright. The saved source choice is the user's and is left untouched - only the boxes are filled, ready for if they switch. An incomplete pair is still ignored, since a key without an account downloads nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Viewing a stored run showed its figures with nothing saying what system or tariff produced them, and ran straight on from the settings form with no break - so the form's current values read as though they described the run on screen. They often do not: the selector can show a run from a completely different system. Adds a Results heading with a divider, and a "What this run used" table covering solar size, battery, load source, car charging, both tariffs, standing charge and sample count. Read from the run's OWN stored config, never the live form or instance - otherwise every figure below would be misattributed to today's settings. A run saved before this existed says so rather than rendering an empty table. Tariffs show the Octopus product code rather than 130 characters of URL, with the full endpoint on hover. An Octopus-sourced run names its account instead of inventing an annual kWh figure it never used. Secrets were already redacted by scrub_secrets before the document was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…torage Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comparing twenty runs on the annual compare page must not read twenty results documents - a debug run's document is several megabytes. build_summary() lifts cost, saving, payback and system-size figures once at save time onto the index entry; backfill_summaries() fills runs stored before summaries existed, writing the index back at most once and only when something changed.
…ackfill annual_costs.py already establishes isinstance(..., (int, float)) as this codebase's convention for reading cost_p, because a missing scenario must not be silently treated as a zero saving. build_summary() read the same field without that guard, so a malformed cost_p raised TypeError inside backfill_summaries' loop and aborted the index write for every other run already filled - one corrupt document blocked the compare page for all twenty. Apply the same guard, and wrap the per-entry build_summary() call in backfill so one bad document is skipped rather than fatal.
save_run now strips a debug run's plans out of the results document and writes one storage key per leg, recording the keys as plan_keys on the index entry. The results document - read to render totals that ignore the plans, and previously several megabytes on a debug run - stays small as a result. load_plan reads a single leg's own key (~177 KB) rather than the whole document, falling back to a document that still has its plans embedded so a run stored before this split stays viewable. _discard_run expires plan keys alongside the run document on eviction. web_annual.py's /annual_plan route now calls load_plan instead of re-reading the whole results document per click of the plan viewer's day/scenario selectors; the now-redundant _find_plan is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not the document
save_run() strips a debug run's captured plans out of the stored results
document (previous commit), but _render_plan_viewer still built its
day/scenario selector from results.get("months")[i]["plans"] - the very
field that was just stripped. Every run saved since that change rendered
a permanently empty plan viewer, and /annual_plan became unreachable in
practice even though the plans were safely stored under their own keys.
save_run now also records a small plan_index on the index entry - one
{month, index, day, leg} entry per leg, no payload - and
_render_plan_viewer builds its options from that. A run stored before
plan_index existed has no such key on its index entry, so the viewer
falls back to reading plans out of the document exactly as before,
which still has them embedded.
Also: the "load_plan never raises" test was vacuous - it reused a
storage fixture holding only a legacy run, so every case in it
short-circuited on "run not found" before reaching the logic it
claimed to exercise, and non-integer/negative index cases weren't
tested at all. Pointed it at storage that actually holds the run under
test and added those cases, plus dedicated coverage for the legacy
fallback's own bounds check and for a corrupt leg blob (a string, a
list, a dict with no "scenarios") - none of which was previously
proven, despite an earlier claim that it was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config form and a stored run's results shared one long page, which let the form's current values read as though they described the run shown below - often a run from a different system entirely. Split into three navigable pages (/annual, /annual_view, /annual_compare) with a shared tab strip whose end arrows disable rather than wrap. The progress area now renders on all three pages so navigating away mid-run doesn't lose sight of it, and a finished run navigates only the tab that started it to /annual_view. /annual_compare is a placeholder pending a later task. Deliberately repoints test_web_annual_results' old divider/heading assertion (meaningless now the pages are separate) at the nav marking the viewer page as current.
Fills in the compare page with a nine-column table, one row per stored run, read entirely from each run's own cached summary. Distinguishes payback never computed (partial year, shown as a dash with the reason) from payback computed but not achieved (shown as "does not pay back"). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d off-by-100 The original "own figures" test only checked substrings against the whole rendered table, so a swapped or shifted row-to-summary mapping would still pass (every value was present somewhere). Split into per-row assertions instead, and add explicit assertions on the rendered pound strings for cost_with_predbat_p and saving_vs_none_p, since nothing previously checked that the pence-to-pounds conversion was applied at all. Verified both discriminate: swapping each row's summary with another run's made the per-row test fail while the old whole-table test still passed; removing _pounds' /100.0 made the money assertions fail. Both mutations were reverted before committing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update docs/annual-prediction.md to match what shipped: the Annual tab is now Configure/Results/Compare pages sharing a tab strip with non-wrapping arrows, the progress area appears on all three, the compare table's dash-vs-"does not pay back" distinction, and that captured debug plans are stored per leg rather than embedded in the results document (so downloaded JSON excludes them). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orrupt years
- Point the run selector's form at ./annual_view instead of ./annual: since the
page split, ./annual is the Configure form and drops the ?run= parameter
entirely, so switching runs from the viewer's dropdown silently stranded the
visitor on the config page with the results gone (critical).
- Guard payback "years" in the compare table's per-cell renderer against
non-numeric values, rendering a dash instead of letting
"{:.1f}".format(years) raise and 500 the whole compare page for every run.
- Add coverage: the selector's form action, unknown Solar/Battery/Cost/Saving
compare figures rendering as a dash (not 0/n-a), a non-numeric payback
"years" value, and html_annual_view actually rendering a stored run's own
totals, selector and plan viewer round-tripped through real storage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 49 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
apps/predbat/annual_store.py:66
build_label()can emit a label with no solar information whenconfig['solar']is present but the arrays sum to 0 kWp (e.g. missing/zerokwpvalues). That produces confusing run labels like “9.5kWh battery · Agile” even though a solar section exists. Consider explicitly adding “no solar” when the computed total is 0.
solar = config.get("solar")
if solar:
total_kwp = sum(array.get("kwp", 0) for array in solar if isinstance(array, dict))
if total_kwp:
parts.append("{}kWp".format(round(total_kwp, 2)))
else:
parts.append("no solar")
apps/predbat/annual_store.py:17
MAX_RUNSis set to 20, but the PR description says “the last five runs are stored and switchable.” Either update the description/docs to match 20, or reduceMAX_RUNSto 5 to match the stated behaviour (this also affects Storage growth for debug runs, even with plan-splitting).
MAX_RUNS = 20
…ctopus import The dropdown only ever populated the URL boxes, and config_from_post read the boxes - so the two could disagree, and for a basic-rates entry they always did. Selecting "Eon Next Drive" or "Price cap import / SEG export" cleared both boxes, and the no-URL fallback then silently ran the whole year at the flat price-cap default: a different tariff to the one named on screen, and different money out. A stale URL left in a box from an earlier selection leaked through the same way. The dropdown is now what runs. A built-in entry is taken from the catalogue, URL or fixed rates alike; the URL boxes are shown only for Custom, which is the only case where what is typed in them is used. Re-selection also matches on rates, so a basic-rates config reopens on its own entry rather than the first in the list. Second issue: an import meter records what was BOUGHT, not what the house used. On a home that already has solar or a battery, that generation and discharge have already been subtracted from every reading, so modelling a system on top counts the same saving twice and overstates it. The Octopus option now says so, and says it more loudly when the live instance already reports an array or a battery. Documented alongside, with the guidance to enter total household consumption instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Predbat's page stylesheet sets `p { white-space: nowrap }` (web_helper.py),
which suits the short single-line paragraphs elsewhere but meant every sentence
of prose on this tab ran straight off the right of the page - the new Octopus
warning most visibly, but equally the caveats, the payback note and the
run-details note.
Re-enables wrapping for this tab's own paragraphs only. Deliberately not by
changing the global rule, which every other page depends on, and scoped so the
compare table keeps the nowrap that holds its columns together while it scrolls
sideways. Prose is capped at 80ch, since a sentence set across an ultra-wide
monitor is hard to track from one line to the next.
Nothing about the markup reveals this, so a test pins the override.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fieldset insets its content by its own border plus 0.75rem of padding, but the Advanced block and the two submit buttons are bare children of the form - so they hung to the left of everything above them, and the buttons sat flush against the bottom of the page with nothing beneath them. Both now carry the same inset, and the button row has room above and below it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bottom The buttons still sat hard against the bottom of the page: a bottom margin on the last child collapses straight out through its container and produces no gap at all. Padding on the page containers instead, which does not collapse. Adds "Reset to defaults", which rebuilds the configuration from prefill_config() - so on a configured instance it resets to the user's OWN solar, battery, tariff and Octopus details, filling only the gaps with the example system, rather than to a stranger's setup. It saves the result: a reset the user had to remember to confirm with Save would leave the old configuration on disk and still running, which is not what the button says. It confirms first, since there is no undo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… setup inverter_limit and export_limit are sensor_lists in APPS_SCHEMA, so their value is a LIST even on a single-inverter system - a 3.6 kW inverter reads as [3600]. Without combine=True, get_arg()'s float-default coercion fails on the list, is caught internally, and returns the 0.0 default, so the prefill saw "not set" and silently substituted the example 5 kW. Every real system was modelled with the wrong inverter and export limits while appearing to have been read from the user's own configuration. soc_max was fixed for exactly this reason; the loop directly beneath it was missed. Also stops inferring the hybrid flag from inverter_type being present, which is true of every configured system - so an AC-coupled setup was modelled as hybrid, letting the plan charge the battery straight from DC PV that really has to make a round trip out through the inverter and back. It now reads the inverter_hybrid the planner itself runs on. That value lives in an entity rather than apps.yaml, so get_arg() ignores args for it entirely and a test written against args would have passed while testing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… estimate The compare page showed payback periods with no sign of the outlay they repay, which is half the picture. build_summary now caches total_gbp, so a System cost column can sit beside the payback columns; existing runs pick it up through the same one-time backfill. The configuration page gains a live install-cost estimate and two optional quote fields. A quote replaces the modelled cost for that part of the system, and the two sides are independent: quoted for a battery but not for panels, the battery uses the real figure and the solar stays modelled. That split is not cosmetic - the PV-only payback needs the PV capital on its own, and a single whole-system figure cannot be divided back into its parts. Zero means "no quote" rather than "free", matching how the inverter limits treat 0. Anything priced from a quote is labelled as such, so an estimate is never passed off as a real price. The live estimate is fetched from a new ./annual_cost_preview endpoint rather than recomputed in JavaScript: the band interpolation, the minimum price and the quote overrides would otherwise exist in two places and drift, the same hazard the prediction kernel carries a parity rule about. It is debounced, and tolerates the half-typed numbers that arrive on every keystroke. Also makes the compare tests locate cells by column heading rather than a fixed index - adding this column shifted every index and left two assertions checking the wrong cells. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The web nav entry is now "WhatIf", and all three pages carry the title "What If Annual Prediction". The title lives in render_nav rather than in each of the three handlers, so they cannot drift into titling two pages out of three. Browser titles and the documentation follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two quote fields were solar and battery separately, which is not the shape real quotes come in: an installer prices the installation, and nobody is handed a battery-only figure to copy out. They are now "Quoted price for solar only" and "Quoted price for solar & battery", with the battery taken as the difference between them - so a single whole-system quote is entered as it stands, with no arithmetic asked of the user. Either still works alone. A whole-system price on its own leaves the solar modelled, which is what the PV-only payback rests on; a solar-only price leaves the battery modelled. A whole-system quote coming in under the solar figure beside it holds the battery at zero rather than going negative, so the total never disagrees with its own parts. quoted_battery_gbp is accepted and ignored rather than rejected: a config saved against the previous revision must still load, and reading a battery price as if it covered the solar too would understate the system rather than fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run, Save settings and Reset now sit directly under the tabs rather than beneath every fieldset, so starting a run does not mean scrolling past the whole form first. The progress bar follows them, inside the form, so the bar appears immediately under the control that reveals it rather than below the fold. The buttons stay inside the form element - moved out, they would no longer submit it - and a test pins both the ordering and that containment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an annual electricity cost prediction tool: a projection of a full year of household costs, run through Predbat's real planning engine rather than a simplified model. Available both as a CLI and as a new Annual tab in the web UI.
It answers "what would this system actually save me?" under three scenarios — no PV/battery, PV and battery on a dumb timer, and PV and battery with Predbat — month by month.
How it works
For each month, a couple of days are sampled (configurable), stratified by irradiance percentile so the sample represents the month's real spread of solar rather than an average day. Each sampled day is planned and costed, then projected across the month.
Costs use the same battery-value correction as Compare (
metric_end − metric_start), so a scenario cannot look cheap by finishing on an empty battery.Web UI
The Annual tab prefills from whatever the live instance has configured — read via the args dictionary, never by re-reading
apps.yaml, which may not exist — and falls back to a typical UK system for the rest, so it works on an unconfigured instance and eventually for unregistered Predbat.com visitors. Runs happen in a subprocess with a progress bar; the last five runs are stored and switchable.Debug mode
An opt-in flag retains each sampled day's plan JSON inside the results document (no extra files), viewable in the browser through Predbat's existing client-side plan renderer — same columns and debug toggle as the live plan page. Every scenario and both car legs are kept, so a suspicious monthly figure can be traced to the plan that produced it.
Tariffs with no history
A newly launched tariff has no rates for the replayed year. Octopus returns an empty result rather than an error, and an empty export download was previously treated as "export unpaid" — which would have produced a complete, plausible year with export silently priced at zero.
Each side now falls back to the tariff's current rates repeated across the month, keyed by local minute-of-day so an evening peak does not slide an hour across DST. The pattern is refused entirely unless it covers a full local day, so an inadequate pattern fails loudly instead of leaving minutes at 0p. Months priced this way are marked and named in a caveat. Adds Prime Outgoing export pairings alongside the existing Fixed ones (verified live across four DNO regions).
Honesty about the numbers
The tool reports what it cannot know, rather than presenting a confident wrong figure:
unavailableand excluded from the annual total — never counted as a free month.export_credit_p_estimateis flagged as already insidecost_p; adding them double-counts.pv_generated − exportassumes all export comes from PV, which is false whenever the battery exports grid-charged energy.predbat_vs_baseline_pis documented as easier to beat on half-hourly tariffs than banded ones, because of how the baseline window is chosen.Testing
Full suite green. New tests cover the solar model, load profiles, weather sampling, tariff handling, the results assembly, the CLI, the web page, the job runner, the run store, and an end-to-end integration run.
Several defects were caught and fixed during review that would each have produced plausible-but-wrong money figures — among them a standing charge leaking into every billed cost, an open-ended tariff row leaving 47 of 48 daily slots priced at 0p, and rate-cache keys without tariff identity, which made a second run return the first tariff's numbers.
Known limitations (documented, not hidden)
fetch_monthanchors to UTC, which leaves 1 January unpriced in UTC+ timezones (no effect at the default Europe/London).🤖 Generated with Claude Code