Skip to content

Merge upstream v8.54.1 (AI Chat, annual rework) into the fork - #56

Merged
dandwhelan merged 159 commits into
mainfrom
merge/upstream-v8.54.1
Aug 30, 2026
Merged

dandwhelan merged 159 commits into
mainfrom
merge/upstream-v8.54.1

Conversation

@dandwhelan

Copy link
Copy Markdown
Owner

Catches the fork up to upstream v8.54.1. PR #53 was labelled "merge upstream v8.54.0" but its second parent is upstream fe8feb9c, about three hours short of the v8.54.0 tag — so 143 commits of v8.54.0 plus the 14 of v8.54.1 were still outstanding. 157 commits, 72 files, +32,474 / −1,176. Fork version v712.29.

The bulk is upstream's AI Chat feature (web_chat.py, chat.py, chat_tools.py, chat_store.py, agent_tools.py and their suites) — new files, no conflict.

Conflicts of substance

annual.py — upstream refactored the loop the fork's heat pump lived in. Upstream extracted run()'s month loop into _plan_months() so an export-tariff sweep can replan the same days against each export product. The fork's heat-pump branch was inline in that loop.

Rather than reinstate the inline loop, the heat leg became _plan_one_month_heat() — the exact parallel of upstream's extracted _plan_one_month() — and _plan_months() branches on self.heat_model in one line. It takes tariff explicitly for the same reason upstream's does: reading self.tariff would silently re-price every swept tariff against the main one. It also now passes upstream's new sampling config through to select_samples, so the weekday-spread selector applies to a heat-pump run rather than being silently ignored on that path.

The fork's "a heat pump forces a full run" gate survives on fast_mode (fast mode interpolates from anchors that never modelled the heat curve), and the post-loop residual check moved out of the loop body with it.

annual_weather.py — imports resolved as the union, not either side: upstream's month-window bounding needs date(), the fork's temperature fetch needs datetime.strptime and pytz. Taking either side alone would have been a NameError at import.

web_mcp.py — taken from upstream, fork fix confirmed obsolete. The fork's only local line routed set_state_external through self.base.ha_interface because the method also runs bound to MCPServerWrapper. Upstream moved that tool to agent_tools.py and independently arrived at the same call, so nothing is lost.

test_web_mcp.py — upstream's new contract broke the fork's mock. set_config now validates the target against CONFIG_ITEMS and reads the value back to confirm the write landed (it used to report success for writes that went nowhere). The fork's mock recorded writes without applying them, so every write came back "did not take effect". The mock now lands the value the way a real service call does, and the set_config test registers the setting it writes.

test_web_if.py — the endpoint-coverage guard vs 18 new chat routes. /chat joins the blanket sweep like any other tab. The other 17 are exempted with the reason recorded: /chat/stream is a long-lived SSE response that never completes, and the rest act on a conversation id or model choice a bare request cannot supply — sweeping them would only exercise argument validation. test_web_chat drives them properly.

Duplicate filenames again — test_web_mcp_tools.py carries upstream's same-named suite forward under the rename PR #53 established, updated to their current version rather than frozen at the old one. unit_test.py registers both, with no key collision.

Tests

Run in a throwaway container (--rm, --network none) so nothing touches the live deployment:

  • unit_test.py --quick — all pass except plot, which fails only against the local matplotlib stub (it monkeypatches plt.show); test_plot.py is untouched by this merge and CI has real matplotlib
  • All five slow tests: annual_integration, annual_heat_integration, load_ml, ml_training_perf, plan_preclip — pass
  • annual_heat, annual_export_sweep, kernel_parity, web_mcp, web_mcp_tools, web_if — pass

annual_heat_integration is the guard that matters most here: it pins the heat leg's extra no_pvbat import to exactly one day of heat, and explicitly not the two-day figure.

The kernel source is unchanged in this range, so the checked-in binaries stay valid.

springfall2008 and others added 30 commits August 26, 2026 22:23
validate_config() gains an optional annual.months key: a sorted, de-duplicated
list of month numbers 1-12, defaulting to the full year. run() now only
downloads rates for and plans the requested months. An explicit month subset
forces fast_mode off (there is nothing to interpolate against and no
seasonal curve to fit for a subset), and _fast_mode_viable is skipped
whenever the effective month list is not the full twelve, avoiding wasted
anchor-month rate downloads.

Deviates from the brief's Step 3 snippet in one respect: out-of-range month
entries (annual.months = [0] or [13]) get a dedicated 'must be between 1 and
12' message instead of _require_number's generic 'at least/at most' wording,
because the brief's own Step 1 test asserts the error contains 'between',
which _require_number's shared minimum/maximum message never produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without an explicit annual.months subset the year cap stays at
today.year - 1, unchanged. With an explicit subset, each named month
must have ended at least WEATHER_ARCHIVE_LAG_DAYS (6) days ago, so the
sampler never silently plans against an incomplete Open-Meteo archive
month.

Tests hardcode today=date(2026, 8, 26) rather than depending on the
real clock, since expect_error() cannot pass a today argument.
…f it

Two runs differing only in roof orientation shared a cache entry, so the
second was served the first's irradiance with no warning and no failed run.
…ardcoding it

The pre-existing cache-poisoning-discard test seeded its cache with a literal
old-format key string. Under the new hash-based cache key, that literal no
longer matched what _fetch_series computes, so the seeded entry was a cache
miss rather than a poisoned cache hit - the test still passed, but for the
wrong reason, and stopped exercising the _payload_problem(cached) is None
discard path it exists to guard. Derive the seeded key from the same
_build_url + hash the production code uses so it can never drift from it
again.
Closes the gap in springfall2008#2830: predbat.carbon_today resets to 0 at midnight, so a
user's own attempt to total it in a Utility Meter helper failed (they don't
accept the value going backwards). savings_total_predbat already solves exactly
this for cost, so this mirrors that pattern for carbon.

predbat.carbon_yesterday reads predbat.carbon_today's own recorded HA history at
23:59 the previous day - the same technique cost_yesterday already uses for
cost_today - rather than re-running any simulation. predbat.carbon_total is a
genuine persisted running total: loaded from HA on startup, incremented once a
day at the same 1am rollover gate savings_total_predbat uses, with start_date/
last_updated attributes so it can be reset the same way.

Both are gated on carbon_enable and add nothing for users who don't have it on.

Caught two things while building this:

- calculate_yesterday() temporarily zeroes self.carbon_enable for ~300 lines
  while it runs a no-PV/battery baseline simulation (so that inner sim doesn't
  needlessly compute carbon too). The first version of carbon_yesterday's
  publish sat inside that window, so with carbon genuinely enabled the entity
  silently never got published. Caught by the new test, fixed by capturing the
  real flag before the temporary disable.
- test_faq_recorder_config (built for springfall2008#4583) flagged that the new
  get_history_wrapper(".carbon_today") call left docs/faq.md's recorder
  allowlist example stale. Added predbat.carbon_today there - a real fix
  independent of this feature, since anyone using that documented recorder
  config would otherwise have had it silently break.

Tests: new _test_carbon_yesterday in test_calculate_yesterday.py, covering both
the enabled path (value read back correctly, entity published) and the disabled
path (neither happens) - deliberately the case that would have caught the bug
above. ./run_all --quick and pre-commit both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
select_samples() gains sampling="weekday_spread" alongside the default
"percentile": each target weekday (from the same _percentile_indices(7, n)
helper) takes the day with that weekday whose week block is least used,
spreading samples across the month without a short final week block
forcing a duplicate weekday. Wired through validate_config()'s new
"sampling" key and _plan_one_month's select_samples() call.

Default-off: sampling defaults to "percentile" and existing
test_annual_sampling.py is unchanged and still passes.
Moves run()'s per-month loop into AnnualPredictor._plan_months(tariff, ...),
parameterised on the tariff instead of reading self.tariff directly. Also
threads tariff through _plan_one_month (called from inside the loop), which
itself read self.tariff in three places - without that, a future caller
passing a different tariff into _plan_months would silently still price
every day against self.tariff.

_plan_months returns the interpolatable accumulator alongside months,
baseline_fallback_months and completed, since run()'s fast-mode assembly
(kept in run(), unmoved) still reads it for the single-tariff path.

Pure refactor: the single-tariff call site passes self.tariff through
explicitly, so behaviour is unchanged. Full annual suite (38 suites) is
green before this commit.
Adds annual.export_tariffs: an optional list of {id, name, export_octopus_url|
rates_export} entries, each naming an export product to evaluate against the
same import side, standing charge, weather and sampled days as the rest of
the run. Validated and normalised by _validate_export_tariffs, defaulting to
[] (an absent or empty list is unchanged behaviour - the existing single
annual.tariff export side).

AnnualPredictor.run() gains a by_export branch: when export_tariffs is set,
it builds one AnnualTariff per entry (import side and standing charge copied
from config["tariff"], export keys replaced rather than merged so a sweep
entry that omits export_octopus_url/rates_export cannot inherit the main
tariff's), calls _plan_months once per tariff, and returns
{year, months_requested, by_export: {id: {name, annual, months, caveats,
rates_synthesised}}, caveats} instead of the single-tariff document.
rates_synthesised flags a tariff whose rates came from the current-rates
fallback (e.g. a product with no rate history yet), so a synthetic result
sitting beside two real ones isn't mistaken for a like-for-like comparison.

Default-off: with no export_tariffs, run() takes the untouched single-
tariff path (test_annual_integration is unaffected). Full annual suite
green (39 suites) after adding test_annual_export_sweep.py.
validate_config already forced fast_mode off for an explicit annual.months
subset (nothing to interpolate). A sweep has the same problem for a
different reason: _plan_months only plans the four anchor months in fast
mode, and AnnualPredictor.run()'s by_export branch always builds results
with fast_mode=False/months_interpolated=0 - there is no interpolation step
in that branch to fill in the other eight. Left unguarded, annual.fast_mode:
true combined with annual.export_tariffs and no explicit months would
silently plan a 4-month sample per swept tariff and report it as a complete
year, with nothing in the document to reveal the shortfall.

export_tariffs is now validated ahead of the fast_mode expression (moved out
of the returned dict into a local so both the "export_tariffs" key and the
"fast_mode" key can see the same validated, final value), and fast_mode is
now `... and not explicit_months and not export_tariffs`. Forced off rather
than rejected, mirroring how an explicit month subset is handled, so a
config that sets both keeps working instead of failing outright.

Adds three cases to test_annual_export_sweep: export_tariffs forces
fast_mode off with no months, still forces it off alongside an explicit
month subset, and a negative control (fast_mode survives with no
export_tariffs at all) so the first two can't pass against a
hardcoded-False implementation.

Full annual suite (39 suites) green.
… caveat; harden tests

Review round 2 (1 critical, 4 important, 1 elevated minor):

CRITICAL: _validate_export_tariffs accepted a templated {dno_region} export
URL with no dno_region set. AnnualTariff._resolve_url then failed to
substitute the placeholder, leaving export_url effectively unusable - and
because every export-pricing path is gated on `if self.export_url:`, that
tariff silently priced every export minute at zero with no fallback and no
caveat. Both Octopus Outgoing Fixed and Outgoing Prime in tariff_catalogue.py
are {dno_region}-templated, so this was the primary intended input. Fixed by
threading dno_region (read off annual.tariff, the way _validate_tariff
already does) into _validate_export_tariffs and rejecting the same way
annual.tariff itself would.

IMPORTANT: the sweep path dropped the baseline-tariff-fallback caveat -
baseline_fallback_months was unpacked per iteration and never read, so a
sweep could silently price the no_pvbat counterfactual on the main tariff
(understating pv_battery_vs_none_p on every card) with nothing in the
document to say so. Now accumulated as a union across every sweep iteration
and reported once, run-wide. Extracted the message itself (previously
duplicated verbatim between the single-tariff and sweep paths) into
AnnualPredictor._baseline_fallback_caveat().

IMPORTANT: added regression tests at the _plan_one_month/_plan_months seam.
No test anywhere had executed AnnualPredictor.run() or called these methods
directly, so reverting _plan_one_month's tariff parameter back to
self.tariff (collapsing every sweep card onto one number) would have left
the suite green. New tests monkeypatch annual.run_day/select_samples with
recorders and assert differentially - identity (`is`) checks that run_day
and rates_for were invoked on the EXACT stub tariff object passed in, not
merely "a tariff was passed".

IMPORTANT: extracted the by_export[id] card assembly out of run()'s sweep
loop into AnnualPredictor._export_card(), so a stub-tariff test can exercise
the five-key card shape (name, annual, months, caveats, rates_synthesised)
without a real network run. Also fixed the existing test block mislabelled
"the results document gains by_export ..." that never actually touched
by_export (it duplicated an earlier config["export_tariffs"] check) -
renamed to describe what it does, with real by_export coverage added
alongside it.

IMPORTANT (stopgap): annual_cli.format_table crashed with KeyError on a
sweep document (no top-level months/annual). Added an `if "by_export" in
results:` branch rendering a minimal, honest summary per tariff - Task 8
replaces this with the real renderer.

ELEVATED MINOR: rates_synthesised was true for unpaid-export months
(priced at zero, not synthesised), true when only the shared IMPORT side
fell back (flagging every card regardless of that tariff's own export
rates), true for the December (year+1, 1) spill-fetch month, and (before
the critical fix above) false in exactly the case where export figures
were fake. Narrowed to export-side-only, scoped to the modelled year -
its one job is flagging a card whose EXPORT rates were synthesised
(Outgoing Prime has no history before June 2026).

Deferred per reviewer instruction, not touched here: duplicate payback
caveat text; sweep progress lacking a tariff name/final tick; import side
re-fetched per tariff; entry["name"] not type-checked; the "id" test
fragment being loose.

New/updated test suites, all green: annual_export_sweep,
annual_export_sweep_dno_region, annual_export_sweep_tariff_threading,
annual_export_sweep_card_shape, annual_export_sweep_rates_synthesised.
Full `-k annual` suite: 43/43 green.
Adds apply_cli_overrides(config, months, export_compare, fast), a copy-not-mutate
merge of CLI flags onto the loaded config, matching how --fast already behaved.
--months plans only the given comma-separated months; --export-compare sweeps
the three Octopus export products (tariff_catalogue.export_compare_tariffs(),
via new EXPORT_COMPARE_TARIFF_IDS), forces weekday_spread sampling at 5
samples/month, and disables fast_mode - reproducing the SaaS export-comparison
page from a single CLI invocation.

apply_fast_override is left in place (still directly tested) but is no longer
called from main(), which now goes through apply_cli_overrides for all three
flags.
…shape configs

Fix round 1 on the --months/--export-compare flags, addressing review findings:

- CRITICAL: merged.setdefault("annual", {}) raised a bare AttributeError on a
  non-mapping config (an empty/malformed YAML file loads as None, a list, or a
  bare string). Since main() now routes every invocation through this
  function, that was a universal regression, not one scoped to the new flags.
  Fixed by adopting apply_fast_override's own isinstance guard.

- CRITICAL: the same setdefault() call injected a brand-new, near-empty
  "annual" key into a bare-shape config (validate_config's other supported
  shape: the inner mapping passed directly) even with no flags at all,
  silently discarding the rest of that config on the next validate_config
  call. Fixed with the same "annual = merged['annual'] if ... else merged"
  idiom apply_fast_override already used.

- IMPORTANT: a malformed --months (e.g. "abc") raised a bare ValueError
  outside any of main()'s exception handlers, dumping a traceback instead of
  the "Config error: ..." + exit 2 every other bad-config path produces.
  Fixed by catching it and re-raising as AnnualConfigError, and by wrapping
  the apply_cli_overrides call in main() with the same try/except pattern
  used around config loading and predictor.run().

Added apps/predbat/tests/test_annual_cli.py::test_annual_cli_apply_cli_overrides_config_shapes
covering all four cases from review: non-dict passthrough, bare-shape no-op
equality, bare-shape flag writes landing on the bare mapping (not nested),
and --months errors surfacing as AnnualConfigError all the way through
main(). Also strengthened the existing "no flags" check in
test_annual_cli_export_compare_flags to assert full equality (for both
config shapes) rather than checking only for the two keys that happened to
be named.
… summary

Replaces the KeyError-avoidance stopgap in format_table (added purely to stop
--export-compare crashing the CLI after a multi-minute run) with the real
by-export renderer: one _format_single_table per swept tariff (using that
tariff's own caveats, not the run-wide list) plus a comparison summary that
names the cheapest tariff with and without Predbat and calls out when they
differ. Single-tariff output is unchanged (existing annual_cli tests are the
byte-identical guard).
Review round 1 fixes for the export-sweep table renderer:

- CRITICAL: cost(), the comparison row loop and both min() rankings crashed
  with TypeError when any swept tariff had annual["scenarios"] = None (a
  documented per-tariff failure mode - each export product is planned
  independently). Now that card is skipped from ranking and from the
  "(vs baseline)" delta, but still listed with an explicit "no usable
  result" marker rather than silently vanishing.
- IMPORTANT: a deliberate single-month sweep (--months 7) reported "Based
  on 1 of 12 months.", implying eleven months had failed. Per-tariff
  sub-documents now carry months_requested through from the run-wide
  document, and _format_single_table renders "Based on N of M requested
  month(s)." when present, leaving the plain single-tariff path (which
  never carries months_requested) byte-identical.
- MINOR: a negative delta (a swept tariff worse than the baseline) rendered
  as "£-8.00"; added _format_pence_delta as a call-site-only wrapper so
  _format_pence itself, shared by every other caller, is untouched.
- MINOR: added a baseline-fallback test (a sweep with no "outgoing_fixed"
  entry, anchoring on order[0] instead).

New tests: test_annual_cli_export_compare_table_partial_failure,
test_annual_cli_export_compare_table_baseline_fallback,
test_annual_cli_export_compare_table_months_requested_wording.
…th doc, sign format

Review round 1 of the export-comparison acceptance run surfaced three issues no unit
test could catch:

- The worked example's azimuth comment had east and west swapped versus
  solar_model.convert_azimuth's actual (anticlockwise) convention. Replaced the
  restated mapping with a citation to the docstring so it cannot drift again.

- A deliberate single-month run (annual.months: [7]) had its P10 and payback
  caveats describe the other eleven unrequested months as failures ("had too few
  forecast/actual day pairs" / "could not be modelled ... missing months are
  named in the caveats"), because AnnualWeather always derives P10 fallback over
  the full calendar and build_payback only ever compared against 12. Both are
  now scoped to what was actually requested: the P10 caveat (extracted to
  AnnualPredictor._p10_fallback_caveat, mirroring the existing
  _baseline_fallback_caveat/_tariff_fallback_caveats helpers) is filtered to
  annual.months and omitted entirely when nothing requested fell back; the
  payback caveat (build_payback's new months_requested parameter, default 12
  for every existing caller) says the run "deliberately covered N of 12
  month(s)" instead of misreporting a subset run as a shortfall.

- The export-sweep comparison summary's With/Without columns rendered a
  net-credit annual cost (the common case for an export-heavy July) as
  "£-57.13" instead of "-£57.13", inconsistent with the Saving column next to
  it. Switched those two columns to the same _format_pence_delta call already
  used for Saving; _format_pence itself is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s bar

- _export_card no longer lets _build_results' side-effect caveats ("no
  month produced a usable result", the payback message) leak onto the
  run-wide caveats list: self.caveats is swapped for a scratch list for
  the duration of the call, so those messages land on the failing
  card alone instead of telling every reader nothing was modelled.
- The export sweep now emits the terminal (total, total, "Complete")
  progress event; previously it only ever reported up to total - 1 and
  never closed.
- _build_results carries a months_requested key when annual.months named
  an explicit subset, so a plain --months run's table no longer prints
  the self-contradictory "Based on 1 of 12 months." next to a payback
  caveat that (correctly) says the run deliberately covered 1 month.
- WEATHER_ARCHIVE_LAG_DAYS raised from 6 to 8: the archive's ~5 day lag
  plus the weather window's own 2-day end-of-month buffer plus a day of
  margin, so the guard actually covers the window it gates.
- _weekday_spread_days drops its unused year/month parameters.
- Reworded a stale "still cover all twelve months" comment now that a
  month subset takes the same code path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--year lets a CLI run plan a specific year (parsed and validated the
same way --months is), wired through apply_cli_overrides and main().
Without it, --months alone silently plans last year, which is wrong
for a tariff that only exists this year (Outgoing Prime, launched 23
June 2026 - a month before that falls back to synthesised current
rates on the export side).

apply_fast_override is deleted: main() has called apply_cli_overrides
for every flag for a while now, so the older function was called only
by its own test and duplicated apply_cli_overrides' two config-shape
guards. Its test coverage is folded into apply_cli_overrides' own
tests instead of kept as a second, narrower path through the same
guards.

Also: moved the module-local `from tariff_catalogue import
export_compare_tariffs` to the file header (no circular-import reason
for it), and fixed a comment on the months_requested branch that
claimed a plain single-tariff document never carries that key - it
does now, for an explicit --months run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eep regression test

- test_annual_results: assert a full 12-month _build_results() carries
  no months_requested key, and a --months [7] document carries
  months_requested == [7].
- test_annual_config: move the pinned archive-lag boundary test from 6
  to 8 days (WEATHER_ARCHIVE_LAG_DAYS), fix a comment that miscounted
  "2 September is 1 day past 31 August" as 1 day rather than 2, and add
  the missing invalid-sampling rejection test.
- test_annual_cli: cover --year (wrapped/bare config shape, no-op when
  omitted, malformed value raises AnnualConfigError, end-to-end main()
  exit code); rewrite test_annual_cli_fast_flag to exercise
  apply_cli_overrides directly now that apply_fast_override is gone
  (using value equality for the non-mapping check, since
  apply_cli_overrides deepcopies its input before checking its shape,
  unlike the deleted function).
- test_annual_export_sweep: add test_annual_export_sweep_run, a fully
  stubbed (AnnualTariff/run_day/select_samples/create_headless_predbat
  patched, no solar so weather stays None) test of
  AnnualPredictor.run()'s sweep branch - nothing else in the suite
  calls run() itself. Asserts a failed tariff's "no usable result"
  caveat lands on its own card, never the run-wide list or the other
  cards, and that the sweep emits a terminal (3, 3, "Complete")
  progress event. Registered in unit_test.py.
- Reworded internal "Task 8" references in test docstrings/prints to
  describe what they test rather than a project-tracking label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…year/--export-compare

docs/annual-prediction.md previously covered none of annual.months,
annual.sampling, annual.export_tariffs, --months, --export-compare or
--year: added two new subsections (Planning a month subset, Comparing
export tariffs), extended the config-file example YAML, and documented
the three CLI flags with runtime-scaling notes. Links
docs/annual/export-compare.yaml as a complete runnable example.

docs/annual/export-compare.yaml's "same run from flags" example now
includes --year (its omission meant the equivalent flag-only command
silently modelled last year, before Outgoing Prime existed), and the
header comment's archive-lag text is updated from six to eight days
to match the WEATHER_ARCHIVE_LAG_DAYS change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the documented docs/annual/export-compare.yaml command from
the repo root leaves an untracked predbat_config.json behind:
create_headless_predbat() sets config_root to --work-dir only after
load_user_config()/fetch_config_options() have already run against
PredBat's own "./" default, and load_user_config() saves its config
there. Every user who follows the docs was getting a dirty tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the design for a Chat tab in the Predbat web UI backed by an
OpenRouter model, presenting Predbat's existing tools directly to the
LLM as function-calling tools rather than speaking MCP.

Covers the shared tool layer extracted from web_mcp.py, the new chat
component and its apps.yaml gating, SSE streaming, the write
confirmation gate, the model picker, and the credential-redaction rule
that keeps secrets from reaching a third-party model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the single shared conversation with a saved list you can switch
between. One turn still runs at a time across the component; while it
does, every other conversation stays readable and only sending is locked.

Which conversation a browser is viewing is client state rather than a
server cursor, so two browsers do not fight. Conversation ids are hex
tokens because storage sanitises identifiers and two titles could
otherwise collide onto one filename. Adds storage.delete(), without
which a deleted conversation's file could not be removed without
bypassing the Storage abstraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the storage.delete() extension with a 30-day rolling expiry on
every conversation save plus a deleted flag that hides a conversation and
lets its file age out. Storage keeps its current surface.

Titles now come from the model via a set_chat_title tool, with a
first-user-message fallback for models that ignore the instruction.

Adds documentation search over the published MkDocs index, source search
and read over the installed .py files, an allowlisted fetch_url, and an
optional OpenRouter web-search switch. OpenRouter offers search but no
URL fetch, so reading a specific page needs our own tool; source is read
locally because the installed files are the exact version running.

The source tools are gated on file extension rather than directory
because config_root can resolve to the source directory, where a
directory rule would expose apps.yaml and secrets.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.py alone missed prediction_kernel.cpp, the two .proto files and the
build scripts. The allowlist is now .py, .cpp, .h, .hpp, .proto, .sh.

Records why this stays an allowlist rather than becoming "everything
except .yaml": CONFIG_ROOTS falls back to "./", the repo ships
config/apps.yaml inside the source tree, and the Storage cache sits at
config_root/cache - so the tree can hold token-bearing .json, a 10MB
.log, .bak backups and .so binaries, none of which a .yaml-only rule
would stop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Allowlist is now .py, .cpp, .h, .hpp, .proto, .sh, .md, with a reason
recorded per entry.

Records why documentation stays a cached fetch of the published MkDocs
index rather than shipping with the installer: match_archive_member
accepts only files directly in apps/predbat and staging is flat, so
shipping docs/ would mean nested paths, directory creation, wider
rollback and a second GitHub listing inside the SHA1-verified update
path. .md is on the allowlist regardless, so a local docs/ could be
preferred later without moving anything else.

Also corrects the earlier claim that config/apps.yaml is present on an
install - it is in a checkout only, though config_root can still resolve
to the source directory and put the Storage cache there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t spec

main has seven MCP tools and an unredacted _execute_get_apps, so the
chat agent's credential-safety argument only holds on top of springfall2008#4775.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve tasks, 71 steps, each with its test written before the code.
Ordered so the tool extraction, the conversation store, the chat tools
and the agent loop are each independently testable before anything is
visible to a user in Task 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A turn runs on the web component's loop, not the chat component's, so
search_source, read_source and get_log's 10MB synchronous read would
freeze the web server and stall the turn's own SSE stream. All three are
now offloaded via loop.run_in_executor, with a regression test that a
0.3s scan does not stop the loop ticking.

Records that base.run_in_executor must not be used: it blocks the loop
until the callback finishes and returns the Future rather than the
result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008 and others added 29 commits August 29, 2026 16:00
…hat-agent

feat(chat): AI chat agent in the Predbat web UI
…-e712

chore(lint): enable ruff E712 (comparison to True/False)
…l-export-compare

feat(annual): export tariff comparison, and fix a weather-cache orientation collision
Ollama's /v1/models publishes no pricing, and the picker worked out
whether a model was free from the quoted price - so every local model
came out not-free and "show only free models" emptied the list. The box
is ticked by default, so that was an Ollama user's first sight of the
picker, on a server where nothing costs anything.

The rule cannot live in the browser: an absent price means "free" on
hardware you own and "unknown, so assume not" on a metered endpoint, and
only the server knows which it is talking to. PROVIDERS gains a metered
flag, is_free_model() applies it, and each catalogue entry carries the
answer. Metered behaviour is unchanged - a quoted zero is free, an
absent price is not, and the routing models quoting -1 stay out, since
offering those as free would bill someone who asked not to be.

The catalogue is cached for a day, so entries written before this have
no such flag and would have gone on filtering to nothing. The cache name
now carries a schema version, which retires them immediately rather than
letting the fix arrive silently up to a day later.
…n-running-total-2830

Add carbon_yesterday and a running carbon_total sensor
Every stop check sat between tool rounds or between tool calls. The
completion itself is the longest part of a turn - minutes on a reasoning
model - and is exactly when somebody reaches for Stop, so pressing it
while the model was thinking did nothing at all until the model finished
on its own, with the timer visibly climbing and the button apparently
dead.

It is now checked on every streamed chunk, which is the only code that
runs during a completion. What the model had already said is kept: the
browser has rendered those deltas, and dropping them would make them
vanish on the next reload with nothing to explain it. Text only - any
half-built tool_calls from the interrupted stream are discarded, since
an assistant message whose tool_calls were never answered makes the
provider reject every later turn in the conversation.

Raised as TurnStopped rather than a ChatRequestError so the retry
wrapper does not catch it: retrying a turn the user just stopped is the
opposite of what they asked for.

A deliberate Stop also no longer reports itself as a timeout. The three
existing checkpoints all said "this turn took longer than 1800 seconds",
which is untrue of a button the user pressed on purpose; stop_reason()
tells the two apart and they now say so.
Ollama runs models on its own hardware as well as yours, reached as an
ordinary provider at https://ollama.com/v1. Verified end to end against
the live service: catalogue, streaming completions with usage, and tool
calling all work through the existing code path.

What did not work was the pricing. Ollama was marked unmetered on the
strength of being Ollama, so every model at ollama.com - a paid service -
came back free, and "show only free models" would have handed someone a
billable model under a filter asking for the opposite. Where the endpoint
is now decides that, not what the entry is called: the same provider type
is free on a private address and metered on a public one.

The model list for an Ollama provider now comes from its own /api/tags
rather than the OpenAI-compatible shim. Not for visibility - both list
the same models, checked against a live server with a cloud model pulled -
but because remote_model and remote_host appear only there, and they are
what marks a cloud model pulled into a local server as something somebody
bills for.

Two smaller things this turned up. A local catalogue was cached for a day
like a hosted one, so a model you just pulled would not appear until
tomorrow; local endpoints now refresh in minutes, which is the whole
reason their list changed. And a provider where nothing is free hit the
free filter with no search term and said 'No free model matches ""',
which reads as a broken picker rather than a ticked box - it now says so,
and how many models are behind it.
… when adding one

The apps.yaml example showed two half-specified providers. It is now a
complete working block covering all three kinds of endpoint - a hosted
aggregator, a server of your own, and Ollama's paid cloud - with a note
that every field except api_key is optional, so the short form is still
obviously available.

The Settings dialog now prefills an Ollama provider with ollama.com
rather than localhost. localhost is the right reading of an existing
entry that names no url, and PROVIDER_DEFAULT_URLS still says so - a
change there would silently repoint installs working today - but it is
almost never right for a new one, because Predbat runs inside its Home
Assistant container where localhost is the container rather than the
machine the user has in mind. The cloud works from there.

A prefilled default nobody can question is a trap, so it comes with a
note under the URL field saying what it is and how to point it at your
own server instead, shown only when adding rather than over a URL the
user has already chosen.

That note also fixed a contradiction it created: the key field said "not
usually needed" on the strength of the type, while the URL above it now
holds an endpoint that does need one. Both cases are covered in one
sentence rather than reimplementing the server's is-this-local rule in
the browser to guess between them.
Copilot spotted that the local provider had gained ollama_details, which
I changed by accident while adding the metered flag beside it. The
consequence is worse than the review said: ollama_details now selects
the catalogue endpoint as well as the per-model enrichment, so a local
provider would have had its model list fetched from /api/tags. 'local'
is the generic OpenAI-compatible option - llama.cpp, LM Studio and the
rest - which serve neither that nor /api/show, so every one of those
endpoints would have 404d and offered no models at all, rather than
merely making some pointless requests.

Reverted, with a test pinning the flag for all four providers, since it
now governs two behaviours rather than one and is easy to set by
accident again.

CI was failing on the files added by "Git nexus": cspell did not know
gitnexus, interprocedural, ladybugdb, Kuzu or unregisters, the SKILL.md
fenced blocks carried no language (MD040), and the GitNexus section
arrived as a second top-level heading in AGENTS.md and CLAUDE.md, which
both already have one (MD025). Words added, blocks labelled as text, and
the heading demoted to a section of the document it was appended to.
…script-update

docs: update Hanchu iESS bridge script to use Predbat planned charge/discharge times
…mode-follow-os-preference-4800

Dark mode follows OS/browser prefers-color-scheme when unset
…ree-filter-local-models

fix(chat): free-model filter on local endpoints, and Stop during a streaming turn
…008#4835)

fetch_octopus_sessions() required a truthy code for octopus_free_session
events, but auto-joined events like Weekend Happy Hours publish code:
null - only "available to join" events carry one. Two sibling code
paths already gate on start/end alone; this one now matches. code was
never used downstream (the resulting slot only carries start/end/rate),
so dropping the requirement is safe - kept for the diagnostic log only,
falling back to the event id when absent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Updated the script example in the inverter setup documentation to include YAML formatting.
…input

The field rendered as plain text, exposing the key on screen (e.g. in a
screen share or over someone's shoulder). It still accepts paste and
selection like a normal text input; the account id field is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review feedback on springfall2008#4838 noted it was previously applied to every field
built by _text_field (postcode, account id, tariff URLs), not just the
masked API key, contradicting the PR description that other fields are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-yaml-fence

fix: add yaml language tag to Hanchu iESS bridge script code block
…-octopus-api-key-password-field

fix: mask the Octopus API key on the What If tab as a password field
…s-free-session-null-code-4835

Accept Octopus free-session events with code: null
- The model dropdown opened upward with a fixed max-height and no
  viewport awareness, so on a short window it rendered above the page's
  fixed top nav (.menu-bar, z-index: 1000) and was clipped/painted over.
  openModelList() now clamps the list's height to the space actually
  free below that bar, using its live rendered position rather than a
  hardcoded offset.
- set_config accepted its value as an opaque string (the tool schema
  has no way to know a setting's real type) and handed it straight to
  set_state_external(), whose switch branch picks turn_on/turn_off by
  Python truthiness - so a request to turn a switch off (the string
  "False") always evaluated truthy and turned it on instead, while
  still reporting success. Values are now coerced to the target
  CONFIG_ITEMS entry's real type (bool for switch, float/int for
  input_number/number) before writing, and the tool refuses to report
  success unless the write actually landed.
- Raised the plain completion retry budget from 3 to 10 attempts, with
  the backoff settling at 5s after the first two retries (1s, 3s, then
  5s repeating) via the existing retry_delay_for() schedule mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Flooring a fractional value via int() would silently write something
other than what was requested (2.9 becomes 2) while still reporting
success against the read-back - the same class of bug as the switch
truthiness issue this PR already fixes, just for numbers. Reject
instead of guessing.

Addresses Copilot review feedback on PR springfall2008#4839.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ingfall2008/batpred into fix/chat-model-picker-and-set-config
…odel-picker-and-set-config

fix(chat): keep the model picker on-screen and make set_config type-safe
…nnual rework

The previous merge (PR #53) stopped at upstream fe8feb9, about three hours short
of the v8.54.0 tag, so this catches up 157 commits: the whole AI Chat feature and
twelve commits of upstream work on annual.py.

Conflicts of substance:

annual.py - upstream extracted run()'s month loop into _plan_months() so an export
sweep can replan the same days per export tariff. The fork's heat-pump branch lived
inside that loop. Rather than reinstate the inline loop, the heat leg becomes
_plan_one_month_heat(), the exact parallel of upstream's extracted _plan_one_month(),
and _plan_months() branches on self.heat_model. It takes `tariff` explicitly for the
same reason upstream's does: reading self.tariff would silently re-price every swept
tariff against the main one. It also now honours the new `sampling` config, so the
weekday-spread selector applies to a heat-pump run too. The fork's "a heat pump forces
a full run" gate survives on fast_mode, and the post-loop residual check moved out of
the loop body with it.

annual_weather.py - imports resolved as the union: upstream's month-window bounding
needs date(), the fork's temperature fetch needs datetime.strptime and pytz.

web_mcp.py - taken from upstream. The fork's one local line (routing set_state_external
through self.base.ha_interface) is obsolete: upstream moved that tool to agent_tools.py
and independently arrived at the same call.

test_web_mcp.py - upstream's set_config now validates the target against CONFIG_ITEMS
and reads the value back to confirm the write landed. The fork's mock recorded writes
without applying them, so every write reported "did not take effect". The mock now
lands the value like a real service call does, and the set_config test registers the
setting it writes.

test_web_if.py - the fork's endpoint-coverage guard failed on upstream's 18 new chat
routes. /chat joins the blanket sweep; the other 17 are exempted with the reason, since
/chat/stream is an SSE response that never completes and the rest need a conversation id
a bare request cannot supply. test_web_chat covers them properly.

test_web_mcp_tools.py carries upstream's same-named suite forward (the rename PR #53
established), and unit_test.py registers both. Version bumped to v712.29.

Tests: unit_test.py --quick plus all five slow tests and kernel_parity pass in a
throwaway container. The kernel source is untouched by this range, so the checked-in
binaries stay valid.
'sweepable' is a coinage; the dictionary is for real vocabulary the project uses,
not for words invented in one comment.
@dandwhelan
dandwhelan merged commit 3a23d4d into main Aug 30, 2026
2 checks passed
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.

4 participants