Repository navigation
Merge upstream v8.54.1 (AI Chat, annual rework) into the fork - #56
Merged
Merged
Conversation
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>
…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.
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.
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.pyand their suites) — new files, no conflict.Conflicts of substance
annual.py— upstream refactored the loop the fork's heat pump lived in. Upstream extractedrun()'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 onself.heat_modelin one line. It takestariffexplicitly for the same reason upstream's does: readingself.tariffwould silently re-price every swept tariff against the main one. It also now passes upstream's newsamplingconfig through toselect_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 needsdate(), the fork's temperature fetch needsdatetime.strptimeandpytz. Taking either side alone would have been aNameErrorat import.web_mcp.py— taken from upstream, fork fix confirmed obsolete. The fork's only local line routedset_state_externalthroughself.base.ha_interfacebecause the method also runs bound toMCPServerWrapper. Upstream moved that tool toagent_tools.pyand independently arrived at the same call, so nothing is lost.test_web_mcp.py— upstream's new contract broke the fork's mock.set_confignow validates the target againstCONFIG_ITEMSand 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 theset_configtest registers the setting it writes.test_web_if.py— the endpoint-coverage guard vs 18 new chat routes./chatjoins the blanket sweep like any other tab. The other 17 are exempted with the reason recorded:/chat/streamis 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_chatdrives them properly.Duplicate filenames again —
test_web_mcp_tools.pycarries 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.pyregisters 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 exceptplot, which fails only against the local matplotlib stub (it monkeypatchesplt.show);test_plot.pyis untouched by this merge and CI has real matplotlibannual_integration,annual_heat_integration,load_ml,ml_training_perf,plan_preclip— passannual_heat,annual_export_sweep,kernel_parity,web_mcp,web_mcp_tools,web_if— passannual_heat_integrationis the guard that matters most here: it pins the heat leg's extrano_pvbatimport 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.