Skip to content

Annual electricity cost prediction tool (engine, CLI and web UI) - #4315

Merged
springfall2008 merged 119 commits into
mainfrom
feat/annual-prediction-tool
Jul 30, 2026
Merged

Annual electricity cost prediction tool (engine, CLI and web UI)#4315
springfall2008 merged 119 commits into
mainfrom
feat/annual-prediction-tool

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

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.

  • Solar comes from Open-Meteo's ERA5 archive, converted GTI→kW with the SAPM/PVWatts cell-temperature model. Predbat plans against the forecast archive and is billed against actuals, so it never gets perfect foresight.
  • Load is either a synthetic profile (annual kWh + night/day/flat shape) or the user's real Octopus consumption history.
  • Car charging is episodic, not smeared: weekly sessions, with each sampled day planned both with and without a session and the results blended by frequency.
  • Tariffs come from basic rates or Octopus URLs, with a curated dropdown merged from the user's own Compare list.

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:

  • A month with no rate data, no usable weather, or all-failed samples is unavailable and excluded from the annual total — never counted as a free month.
  • export_credit_p_estimate is flagged as already inside cost_p; adding them double-counts.
  • Self-consumption is deliberately not reported: it cannot be derived from these totals, since pv_generated − export assumes all export comes from PV, which is false whenever the battery exports grid-charged energy.
  • predbat_vs_baseline_p is 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)

  • The Open-Meteo forecast archive only reaches back to ~2021; earlier years plan against actuals and are flagged as likely overstating savings.
  • The current-rates fallback repeats a single daily shape — correct for fixed and banded tariffs, but it would flatter Predbat on a genuinely dynamic one. Not reachable today, as every dynamic tariff in the catalogue has history.
  • Pre-existing and unrelated: chart axis text does not follow dark mode, and fetch_month anchors to UTC, which leaves 1 January unpriced in UTC+ timezones (no effect at the default Europe/London).

🤖 Generated with Claude Code

springfall2008 and others added 30 commits July 25, 2026 18:39
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.
springfall2008 and others added 14 commits July 28, 2026 08:53
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when config['solar'] is present but the arrays sum to 0 kWp (e.g. missing/zero kwp values). 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_RUNS is 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 reduce MAX_RUNS to 5 to match the stated behaviour (this also affects Storage growth for debug runs, even with plan-splitting).
MAX_RUNS = 20

springfall2008 and others added 11 commits July 28, 2026 23:03
…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>
The Solar section rendered one block per configured array with no way to change
how many, so a house with panels facing two ways could not be described through
the form at all.

Add and remove go through the server rather than cloning markup in JavaScript.
An array block is a mode select, two toggled rows and a matching efficiency
field over in Advanced; a JS clone would have to be kept in step with all of
that. Rebuilding from config_from_post keeps everything already typed and
renumbers the arrays for free.

Removing every array is now possible and gives a battery-only run, which the
docs already described but the form could not express: render_form treated an
absent solar key and an explicitly empty list alike, so it always forced at
least one array back on screen.

Neither button saves. A newly added array has no size yet, and writing that to
disk would persist a configuration that cannot be run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.annual-array had no styling at all, so one array's Remove button sat directly
against the next array's heading - reading as though it belonged to the array
below it rather than the one above.

Each array now has room around its heading and button, and a dashed rule
separating it from the next. The last one drops the rule, since there is
nothing after it to divide from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 4da3dd0 into main Jul 30, 2026
2 checks passed
@springfall2008
springfall2008 deleted the feat/annual-prediction-tool branch July 30, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants