Skip to content

Merge upstream v8.54.3 - #57

Merged
dandwhelan merged 67 commits into
mainfrom
claude/merge-batpred-fixes-2v73ca
Sep 4, 2026
Merged

dandwhelan merged 67 commits into
mainfrom
claude/merge-batpred-fixes-2v73ca

Conversation

@dandwhelan

Copy link
Copy Markdown
Owner

Merges upstream springfall2008/batpred v8.54.3 (66 commits since the v8.54.1 base) into the fork.

Conflicts and how they were resolved

  • apps/predbat/predbat.py — kept the fork's version scheme and bumped THIS_VERSION to v712.30.
  • apps/predbat/annual.py — kept both sides: the fork's HEADLESS_APPS_YAML_NAME constant and upstream's new _solar_entries / _numeric_or_none / config_warnings helpers.
  • apps/predbat/web_annual.py — unioned the annual import line: the fork's RESULTS_SCHEMA_VERSION plus upstream's SOLAR_KWP_SOFT_LIMIT and config_warnings.
  • apps/predbat/tests/test_web_mcp.py — the fork rewrote this filename to hold the MCP OAuth/JWT/PKCE suite and moved upstream's tool tests to tests/test_web_mcp_tools.py. Kept the fork's file, and applied upstream's 404 new lines (get_log byte budget, line-number read-back, trailing-newline counting) to test_web_mcp_tools.py where those tests actually live.

Fork features verified intact

  • FIT support across config.py, fetch.py, prediction.py, plan.py, plus the prediction_kernel.py fallback gate and tests/test_fit.py
  • Custom /dash_entities dashboard in web.py / web_helper.py
  • Fork release workflow and DEFAULT_PREDBAT_REPOSITORY = "dandwhelan/batpred"

Upstream touched no kernel source (prediction_kernel.cpp, prediction_kernel.py, prediction.py hot loop) between v8.54.1 and v8.54.3, so no ABI bump or .so rebuild was needed.

Testing

  • unit_test.py --quick — all pass (5 slow tests skipped, 375s)
  • pre-commit run --all-files — all hooks pass

🤖 Generated with Claude Code

https://claude.ai/code/session_016mak6yDBWtLnwGGN92NG5w


Generated by Claude Code

springfall2008 and others added 30 commits August 29, 2026 21:58
…ort risk

The setting's step was 0.1, but the margin that stops Predbat exporting the
battery at a loss is the ratio of what exporting earns to what replacing the
energy costs - only about 1% on a flat export tariff near the cheapest import
rate (11.174p / 11.319p on the 10.54p import, 12p export case in springfall2008#4840).

One step below the 1.0 default therefore landed at 0.9, well past the ~0.987
flip point, so the planner force-exported the battery for a real loss of about
0.2p/kWh with no way for the user to nudge the setting gently. 0.02 is not
enough either - 0.98 is still below the flip point - so the step becomes 0.01.

Every value reachable on the old 0.1 step remains a valid multiple of the new
one, so existing settings are unaffected, and all step consumers branch only on
whether the step is integral, which is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 403 carrying a CDN block page is already handled for GraphQL queries, which
keep their cached JWT rather than discarding it. The token mint sits behind the
same CDN but had no such handling, and unlike a query it has no cached result to
fall back on: once the JWT expires, every authenticated call needs a new one.

The result is a lockout that cannot self-heal. One edge block near token expiry
is enough - the mint is refused, so is the retry, and the component re-mints on
every poll indefinitely (~50 attempts an hour observed over 26 hours, with no
successes). Rates keep downloading over REST throughout, so the integration
looks healthy while every dispatch, settings and saving-session query silently
returns nothing.

Detect the block in async_refresh_token with the existing is_edge_block_body
check and back off exponentially instead, 5 minutes doubling to a 1 hour cap,
cleared on the next successful mint. A block that lifts is still picked up
within the hour, but a block that does not is no longer hammered.

The block is also now logged as an edge block rather than as the generic
"Unauthenticated request: 403", which reads like a revoked API key and sends
anyone diagnosing this down the wrong path. A 403 that is not identifiable as a
CDN page keeps the existing refresh-and-retry behaviour, so genuinely revoked
credentials still recover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
… off

Review catch on the previous commit. The backoff guard sat after the five minute
proactive-refresh check, so a token with four minutes of genuine life left was
treated as unusable the moment a mint was refused: one transient CDN 403 could
cost up to five minutes of queries that would otherwise have succeeded. Before
the backoff existed the next poll would simply re-mint and recover, so this was
a regression the block itself did not cause.

Decode the expiry once and, while backing off, hand back a token that has not
actually expired. An expired or undecodable token still returns None. Callers
that hit a genuine auth failure clear graphql_token before re-minting, so this
can only revive a token we have not been able to renew yet, never a revoked one.

Also from review:

- Repeat the backoff reason at most every 10 minutes while suppressed. The mint
  makes no request during the window and so logged nothing, leaving anyone
  reading a short log window unable to tell a deliberate cooldown from a bad API
  key - every caller only says "token refresh failed".
- Clamp the doubling exponent so a long block cannot evaluate 2 ** block_count
  without bound. The delay was already capped; this bounds the arithmetic.
- Extract the schedule into token_mint_backoff_seconds().

Tests: assert the exact backoff schedule rather than mere monotonicity (a
1, 2, 2, 2... implementation passed the old check), and add two cases the
previous ones could not catch - an elapsed deadline reopening the mint with the
deadline left set, so a guard that tests the field for presence rather than
against the clock fails, and a near-expiry token still being served during a
backoff. Both were confirmed to fail against the corresponding mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
Second review pass. The previous commit only handled the backoff guard, so the
call that actually met the CDN 403 still returned None even when the token it
held had not expired - the fallback started one call late, for no reason. The
guard would have handed that same token to the very next caller anyway.

Return it from the edge-block branch too, re-reading the clock rather than
reusing the timestamp sampled before the request went out.

Tests: Test 14 asserted the old behaviour, so it was pinning the defect - it now
requires the token from the triggering call. Adds an undecodable token during an
active backoff (no provable life left, so it must not be served) and a throttle
test proving the reason is restated once per interval rather than per call.
Both new assertions were confirmed to fail against the corresponding mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
is_edge_block_body, the CDN markers and the token-mint backoff schedule are not
Octopus-specific: every Kraken-based provider (Octopus, EDF, E.ON) mints its JWT
through the same CDN-fronted endpoint and can be blocked the same way. Move them
to utils so the Kraken auth mixin can use them too.

No behaviour change. EDGE_BLOCK_REFRESH_AFTER stays in octopus.py - it is policy
for that component's query path, not a shared helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
The Kraken auth mixin fails in the opposite direction to the Octopus component,
from the same trigger. _kraken_token_request returned None for any non-200
without reading the body or logging anything, so a CDN 403 was indistinguishable
from a 500 or a rejected credential. check_and_refresh_oauth_token then set
oauth_failed, which is a latch: its first line is `if self.oauth_failed: return
False`, and nothing clears it outside _init_kraken_auth.

So a single transient edge block at refresh time disables the provider for the
lifetime of the process - silently, and with no retry - until a restart. That
is not a credential problem and should not be treated as one.

Read the body on a non-200 and use is_edge_block_body to tell an edge block from
a real rejection. On a block: back off on the shared schedule, keep any token we
still hold that has not actually expired, and leave oauth_failed alone. Do not
retry immediately with primary credentials either - that is a second request
into the same block. Genuine failures keep the existing behaviour: retry once
with primary credentials, then latch.

Other non-200s are now logged rather than swallowed; previously every failure
path in _kraken_token_request returned None without a word.

Also bind the three new backoff helpers onto the instance in KrakenAPI. When
OAuthMixin is the class base only the auth entry points are bound, so a helper
called by check_and_refresh_oauth_token has to be bound alongside it or it
raises AttributeError at runtime - caught here by an existing test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
…ng tests

Second review pass. None of the seven tests added by the previous commit
actually ran: run_kraken_auth_mixin_tests() carried a hardcoded list and the new
tests were defined below it, so unit_test.py - which calls that runner, not
pytest discovery - skipped every one. CDN detection, the non-latching behaviour,
suppression, the valid-token fallback and the elapsed-deadline path could all
have regressed with the suite green.

Both Kraken runners now discover module-level test_* functions instead of
listing them, so the next test added cannot go unrun either.

Also from review: check_and_refresh_oauth_token compared the retained token
against a timestamp sampled BEFORE the request. A token with ten seconds left
when a fifteen second edge-blocked request started was reported as usable.
Re-read the clock before answering.

New coverage for the two gaps the review named:

- An edge block while a refresh token is held: it must not burn the refresh
  token, must not recurse into the same block, and must not latch.
- A real KrakenAPI driven through block, suppression and recovery. KrakenAPI
  binds mixin methods onto the instance one at a time, so every helper reached
  from a bound method has to be bound alongside it; the direct-mixin tests
  cannot see that. Confirmed to fail when any one binding is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
…y-value-scaling-step-4840

Reduce metric_battery_value_scaling step to 0.01 and document the export risk
Fixes springfall2008#4851.

Octopus publishes Power Up and Power Down through the same savingSessions feed
and distinguishes them with `eventType`:

  TURN_DOWN           reduce consumption, rewarded in octopoints
  WEEKEND_HAPPY_HOUR  an earned free import hour, reward 0
  TURN_UP             increase consumption, rewarded in octopoints

Predbat's query never asked for that field, so a booked Weekend Happy Hour
arrived indistinguishable from a saving session at 0 p/kWh and was dropped by
`saving_rate > 0`. The free hour never reached the plan: the import rate stayed
at the standard tariff rate, no charge window was planned, and the only way to
use it was to enter it by hand as a manual import rate override.

Request `eventType`, carry it through to the joined and available event lists,
and route a joined WEEKEND_HAPPY_HOUR into octopus_free_slots at rate 0.

Deliberately NOT keyed on the reward value. A zero reward looks like a free hour
but a TURN_DOWN reporting 0 - an unknown reward published as 0 rather than null -
would then be zeroed on IMPORT, making Predbat grid-charge through a session
where the user is meant to be reducing import: full price, believing it free, and
the participation lost. eventType is the only sound discriminator.

TURN_UP stays on the saving path. It means import more, but it is rewarded in
octopoints rather than free, so folding it in here would misprice it.

An absent eventType falls through to the existing behaviour rather than guessing,
so the BottleCapDave feed and older API responses are unaffected.

Tests cover all four: a joined WEEKEND_HAPPY_HOUR becomes a free slot while a
rewarded TURN_DOWN in the same joined_events list stays a saving slot, a
TURN_DOWN reporting 0 does not become free, TURN_UP does not become free, and a
missing eventType does not become free. Reverting to the reward-value test fails
two of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
…indow

01e3982 started deriving a meter's off-peak windows from the measurements
API's TOU bucket labels, taking whichever label was most common for each
half-hour across the last 7 days. That premise holds for Economy 7, where the
labels really do describe a fixed schedule, but not for Intelligent Octopus Go.

On IOG the off-peak buckets cover the guaranteed overnight window plus whatever
ad-hoc dispatch slots Octopus granted that night, and dispatches are decided
night by night. A bonus slot that happened to land on a plurality of the sampled
nights was therefore promoted to a permanent recurring window applied to every
future day. On one user's account (IOG-SMB-VAR) that invented a nightly
22:00-22:30 off-peak period alongside the genuine 23:30-05:30 one, and Predbat
planned a battery charge into it believing the rate was 6.9p while the meter
billed the day rate of 30.3p. The plurality vote has no consistency threshold,
so four nights out of seven was enough.

Skip the inference for Intelligent GO tariffs and use the guaranteed window,
which is the only fixed part of an IOG day. octopus_night_times still overrides
everything for a meter whose real bands the API does not report.

Also anchor TOU-derived windows to whichever clock the tariff's off-peak period
is actually fixed to, rather than assuming UTC. The labels are UTC instants, so
a local wall-clock tariff sampled during BST reports its window shifted by an
hour; holding that offset fixed ran it an hour early once the clocks went back -
the same class of bug 01e3982 set out to fix, reintroduced for local tariffs.

Verified against a reporting user's live account: 6.9p now applies only across
23:30-05:30 local and 22:00-22:30 is back to the 30.3p day rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The distinguishing symptom is a cheap slot Octopus has never heard of while the
dispatch feeds are empty, which points at the day/night rate path rather than
phantom dispatches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…s-token-mint-edge-block-backoff

fix(octopus): back off when the CDN blocks the token mint
…bels

For a local-anchored tariff, the log printed tou_windows (raw UTC minutes)
while windows (converted to local wall-clock minutes) was what actually got
applied, making the log line misleading during debugging.
…-auth-edge-block-handling

fix(kraken): do not treat a CDN block as a permanent auth failure
…s-iog-tou-phantom-window

fix(octopus): stop IOG dispatch slots becoming a permanent off-peak window
…s-zero-reward-power-up-free-slot

fix(octopus): treat a joined zero-reward session as free electricity
get_log could only narrow by severity, a literal substring and an age in
hours. Alternation or anchoring was impossible, and there was no way to ask
for a specific window - which is exactly what diagnosing "what happened at
17:06 yesterday" needs.

Adds three optional arguments:

  pattern  a case-insensitive Python regex, ANDed with filter and search
  start    lower bound, accepting a date, a time today, or both
  end      upper bound, same formats; a bare date covers the whole day

All are additive - filter, search, hours and max_lines keep their existing
meaning, so calls that work today behave identically. hours still works and
intersects with start, the narrower bound winning.

Two supporting changes. compile_filter_argument() gains an optional flags
argument (default 0, so the other four regex tools are unaffected) so get_log
can match case-insensitively the way its literal search already does. And the
scan moves out of _execute_get_log into scan_predbat_log(), which now runs on
the executor in full rather than only the file read - a caller-supplied regex
walking a 10MB log on the web loop would stall the SSE stream mid-token.

Fixes a latent bug the time bounds exposed: a line with no stamp of its own
was inheriting the timestamp of the newer entry below it in the file rather
than the entry it continues, so a traceback belonged to the wrong error. It
was invisible while only the hours filter existed, because both stamps were
recent, but a start/end window would detach a traceback from its error.
Timestamps are now resolved in a forward pass before the newest-first
selection walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
max_lines bounded how many lines came back but not how big they were, and
some Predbat log lines are enormous - one Octopus GraphQL response is a
single 20KB line. 500 of those made a 953KB tool result, roughly 238k
tokens, which was 97% of one conversation. It overflowed the model's
context, so the provider truncated the request from the front, dropped the
only user message with it, and answered "no user query found in messages" -
a failure that reads as nothing to do with the log tool that caused it.

Three guards now, not one:

  max_lines               default drops from 500 to 200
  MCP_LOG_MAX_LINE_CHARS  any single line over ~1000 chars is cut
  MCP_LOG_DEFAULT_MAX_BYTES  the response as a whole stops at 64KB

The per-line cap alone is not enough - 500 lines of 1000 chars is still
about 50k tokens - which is why the total budget backstops it.
truncated_reason says which guard ended the response so a caller knows
whether to narrow the filter or raise the line count.

Nothing is lost, only deferred: a cut line carries how many characters were
dropped and names its own line number in the marker, and a new line_number
argument returns that line in full, with optional context either side. That
mirrors search_source pointing read_source at the part worth reading. The
filters are deliberately ignored on a read-back, since the caller already
knows which line it wants and applying them could only refuse it.

Also corrects the comment claiming the line count "keeps a response small
enough for an AI context window", which springfall2008#4840 proved it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Review follow-up. f2c2ce8 moved read_predbat_log_lines() to splitlines()
but left scan_predbat_log() on split("\n"), which is worse than either
choice applied consistently: the two paths index into the same numbering, so
a marker saying "get_log line_number=26793" could read back a different line
than the one it was pointing at.

Both now go through split_predbat_log(). It uses splitlines(), which also
drops the phantom final entry the trailing newline every log ends with left
behind - total_lines counted a line that did not exist and the last line
number read back as "".

splitlines() does also split on \v, \f, \x85 and 
, which a logged
third-party API payload could in principle carry. Measured against a real
57,400-line predbat.log the difference from split("\n") was exactly the one
trailing empty, so that risk is theoretical while the phantom line was not.

The test now asserts the property that actually matters rather than the
splitting mechanism: every line the scan returns must read back as the same
line by its own number. That is the regression guard for the inconsistency
above, which no existing test would have caught.

Also fixes scan_predbat_log's docstring, which still described four return
values after truncated_reason made it five, and pins one clock reading per
test - _dated_log() and _fat_log() took their own datetime.now() while the
tests built bound strings from another, so a run crossing midnight could
disagree by a day.

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

A sent message sometimes never appeared in the transcript until the user
switched conversation and back, which rebuilt it from history.

The bubble was drawn solely by handleUser(), from the server's 'user' SSE
event, so a single missed event lost the message from the view for good.
Two ways it goes missing:

  createAndSend() called doSend() immediately after selectConversation(),
  but openStream() only runs at the end of that load - so every send on a
  new conversation raced the stream being opened, and recovery depended
  entirely on the cursor still being behind the event.

  _snapshot_and_cursor() takes the message snapshot under the store's lock
  and the cursor under the agent's, while a turn appends from the component
  thread. Interleave there and the message is absent from the history with
  its event already below the cursor: not rendered, not replayed.

Rather than chase either race, sendMessage() now draws the bubble itself and
handleUser() adopts it instead of drawing a second one. Your message appears
the instant you press Enter, and no lost event can cost you it. The echo
still renders normally when nothing is pending, which is what a second
browser watching the same conversation needs.

Supporting changes: renderHistory() clears the pending marker, since it
throws away the transcript the marker points into and the rebuilt history
already contains the message. doSend() takes the bubble back down if the
send fails, so a 409 for a turn already running cannot leave the transcript
claiming something was said that was never stored. selectConversation()
returns its load promise so createAndSend() can wait for the stream to be
open before sending - defence in depth rather than the primary fix.

_snapshot_and_cursor()'s docstring claimed no gap existed because no await
separates its two statements. That holds for this loop but not across
threads, so it now describes the window it actually leaves and why closing
it properly is not worth it here.

Verified by running the real generated script in a DOM shim: the bubble
appears on send, the echo does not duplicate it, an echo with nothing
pending still appends, renderHistory clears the marker, and an echo after
that wipe renders rather than being wrongly adopted.

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

/api/show returns the context length baked into the model - 262144 for
Qwen3.8 27B - and the picker reported that. The server caps every model at
OLLAMA_CONTEXT_LENGTH, which on macOS is an Ollama app setting rather than an
environment variable, so the same model is loaded with 131072 behind a 128k
cap. Two Qwen variants advertised 256k while the server would only ever give
128k.

That is worse than a wrong number in a dropdown. The Chat tab's context
counter measures fullness against it, so a doubled limit reads half-full at
the point the window is genuinely full - no warning before a turn overflows,
which is the failure GH#4840 ran into from the other direction.

/api/ps carries context_length per loaded model, which is the effective
value. It is read once per enrichment pass and used in preference to the
ceiling. A model the server has never loaded is not listed there and keeps
its own figure: that is the only answer available and still better than
reporting the ceiling for everything.

Only the raw /api/tags response is cached; _catalogue_to_models() and this
enrichment run on every list_models() call, so the reported figure tracks
what is loaded rather than being frozen for a day.

A server too old to serve /api/ps, or one with nothing loaded, returns an
empty map and every model keeps its ceiling - the behaviour before this
change, so nothing degrades.

Verified against a live Ollama 0.33.2: the helper returns
{'qwen3.8:27b': 131072} where /api/show reports 262144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ollama Cloud refuses /api/ps. Verified against ollama.com: a key good enough
for /v1/models and /api/tags (both 200) still gets 401 there, because
"loaded" is a local-server idea and there is nothing to report for a shared
fleet. Sending the key does not help.

The failure was already ignored - a non-200 leaves every model on its own
ceiling - but nothing tested that, and a cloud provider spent a guaranteed
150ms round trip on every enrichment pass to learn it. Only a locally loaded
model can have been capped, so a catalogue whose models are all remote now
skips the call entirely.

Two tests, both mutation-checked. The first pins the fallback: a 401 and a
transport error each leave the ceiling in place. Its 401 body deliberately
carries a plausible models array, because the first version of this test
passed even with the status check deleted - a refusal with an empty body is
indistinguishable from a refusal that is handled, and the downstream shape
check was quietly doing the work. With a poisoned body the mutation is caught
(the model came back as 4096).

The second pins the skip: a cloud-only catalogue must make no /api/ps call at
all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up. The byte budget broke out of the scan, so matched_lines
tallied only the lines that fitted. The description reports "the most recent
X of Y matching lines", and a Y that stops at the cap understates exactly
what that sentence exists to convey - measured at 71 against a true 400.

max_lines already continued past its cap and kept counting, so the two
truncation paths disagreed. Both now walk to the end of the window and append
nothing further.

Also fixes the accounting either side of the check: `used` was incremented
before testing the budget, so the line that pushed the response over was
billed for despite never being appended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up, keeping the behaviour and documenting why.

The suggestion was to use `is not None` so a valid zero is not treated as
missing. No model has a zero-token window, so a 0 from either endpoint is a
placeholder or a bug rather than a value worth preserving, and taking it
would replace a usable figure with one that tells the user nothing.

Applying the suggestion verbatim makes a model with a 262144 ceiling report
a context_length of 0, which the new test catches. The whole chain already
agrees that falsy means "no answer": _ollama_loaded_context() keeps only
truthy values, contextLengthForModel() returns `context_length || null`, and
renderContextUsage() shows the token count alone when the limit is falsy -
its comment says so explicitly.

Nothing changes except a characterisation test pinning the choice, and
comments at both sites so the intent is not read as accidental truthiness
again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…og-pattern-and-date-range

Add regex and start/end time bounds to the get_log tool
Co-Authored-By: Claude Code <noreply@anthropic.com>
mgazza and others added 28 commits August 30, 2026 21:30
Review nit: the new backoff/token-request logs read "Warn: Kraken token ..."
while the rest of the component uses "Warn: Kraken: ...". The odd one out
breaks grep and log-parsing on the usual prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
Review follow-up (@gcoan). A Weekend Happy Hour cannot be joined through the
API - Octopus either allocates one or the user books it on the website - so
listing it in available_events only produces join attempts the API rejects
(springfall2008#4593/springfall2008#4595) and fills the join selector with options that cannot be selected.
BottleCapDave's integration stopped passing these through in v19.0.1 for the
same reason; match that now that eventType makes them identifiable.

Ordering matters and is easy to get wrong: the reward/code/type maps are built
from this same events list, and a joined Happy Hour looks its event type up
there. Skipping before those maps are filled leaves the joined event with no
type and the injected default reward, so the planner prices a free hour as an
80p/kWh saving session. The skip therefore sits after the maps are populated,
and the test fails if it is moved above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnzhfR8uDgGUtYLNpDA4ka
… reporting them

/pr-cleanup previously only gathered review feedback and CI status - a PR
that was merely stale/conflicting against main (no comments, no CI) fell
through as "nothing to address", even when GitHub reported it CONFLICTING
(observed on PR springfall2008#4036). Adds a new step 2 that merges origin/main into the
branch and resolves any conflicts by hand, aborting and asking for a manual
rebase instead of guessing when the conflict is too large to resolve safely.
Grants the BOT_CLEANUP flow the git-merge permission it needs to act on this
(no other flow checks out an existing branch that can be behind).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Issue springfall2008#4881: the first triage attempt against glm-5.3-flash:cloud completed
its real work (comment posted, BOT_TRIAGED applied), then kept running until
Claude Code's Anthropic-priced cost estimate crossed the $10 cap and aborted
with a non-zero exit - which made the daemon retry an already-finished issue.
claude_budget_args() omits --max-budget-usd entirely for whichever flows are
running against an Ollama model; --max-turns remains the circuit-breaker there.

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

- Narrow the BOT_CLEANUP git-merge permission from an unscoped "git merge*"
  to enumerated origin/main-scoped spellings (bare, --no-edit, --abort),
  matching pr-cleanup/SKILL.md's own "only ever merge origin/main" guardrail
  and this file's established pattern for the flag-before-target literal-
  prefix-matching footgun.
- Switch the skill's example commands to --no-edit, both to stay covered by
  the narrowed allowlist and to avoid hanging on an interactive editor
  prompt with no one there to dismiss it.
- Fix SKILL.md wording that implied a fast-forward merge always creates a
  merge commit, and a test docstring's awkward "reach without a use" phrasing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eanup-resolve-merge-conflicts

Triage bot: have BOT_CLEANUP resolve merge conflicts against main
…-bot-comment-disclosure

Triage bot: disclose bot authorship on every comment it posts
…, tighten the tests

fetch.py:
- The subtract now respects load_baseline. The floor is applied in the fill loop and the
  modulation walked straight back through it - only reachable at all since the load
  arrays started being modulated. The new non-flat test catches it: 144 of 290 steps
  fell below a binding floor before this.
- Key the phase off the minutes_now parameter rather than self.minutes_now. Every other
  minute in the function comes from the parameter and output.py already calls it with 0;
  this also makes the result a pure function of its inputs, which the replay and test
  paths need.
- Express the grid in step units instead of a hardcoded 5, so a future step=10 caller
  gets alternating parity rather than a constant one and a .get that always misses.
- Don't borrow on the final step: the fill loop runs a plan interval past
  forecast_minutes, so the last on-step could borrow from a bucket this loop never
  reaches to repay, leaking it into the total.
- Reword the "keeps the same total" comment, which was not true of the code below it.
  Repaying exactly needs the outstanding debt tracked separately from the next borrow,
  which is a rewrite of the model rather than a fix here.

Tests:
- New non-flat fixture. On a flat series the model is net-zero by construction, so the
  original fixture could not see under-repayment, a breached baseline floor or a
  negative step. This asserts the total never rises, no step goes negative, and the
  floor holds.
- CLOUD_FACTOR_PV10 promoted to const.py and imported by plan.py, test_single_debug and
  test_random_scenarios, which each spelled the 0.2 increment separately.
- Dropped the cloud_factor=0 assertion: it takes the identical guarded path as None, so
  it could never fail independently.
- test_pv90 gains a companion asserting the pv90 < nominal < pv10 ordering holds with
  divergence active, restoring coverage of the default-on modulated path the clamp test
  has to disable. Its saved-dict now uses a getattr default so changed seeding reports
  rather than raising.

Baselines: debug_cases goldens re-verified end-to-end against the fixed tree, each case
run individually - both unchanged. random_results regenerated (runtime_s preserved).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GivEnergy answers a rejected setting write with HTTP 200 and puts the failure
in the body, e.g. {"value": -7, "success": false, "message": "Inverter Locked"}.
Predbat took the 200 at face value: it refreshed the component health timestamp
and recorded a successful API call for a write that never reached the inverter,
discarded both the code and the message, and then retried codes GivEnergy
documents as non-retryable for the full budget (~55s per failing setting).

async_get_inverter_data now classifies a success=false body: it counts the
failure, records it under a specific metrics reason (-7 becomes inverter_locked),
leaves last_success_timestamp untouched so the component health check sees the
outage, and logs GivEnergy's own code and message. Terminal codes (-3, -4, -7)
raise GECloudTerminalError so the caller aborts on the first response; retryable
codes (-1, -2, -5, -6) return None and keep the existing retry behaviour.

The read, write, EVC command and generic retry paths all handle the exception,
and the read and write paths also treat a terminal code as final when it arrives
in a body that did not set success=false.

Fixes springfall2008#4896

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

Keying classify_ge_failure on the success flag alone missed the other shape the
setting endpoints use, where the negative code arrives in "value" with no
success=false alongside it. Those bodies still refreshed last_success_timestamp
and were recorded as successful API calls - the exact defect this PR set out to
fix, in the response shape it did not cover.

A known code is now a failure in its own right, but only on the setting read and
write endpoints, where "value" carries a result code; anywhere else a negative
"value" could be a genuine reading, so an explicit success=false is still
required there.

A retryable failure now hands the body back to the caller rather than returning
None, so the read and write paths keep classifying the code themselves and keep
their request-spreading jitter for the timeout codes - retry behaviour is
unchanged from before the PR, only the metrics and health timestamp move. That
in turn makes the terminal-code branches in those two paths unreachable, since
async_get_inverter_data now always raises for them, so they are removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Updated automation steps for mid-window time updates and added instructions for configuring the soc_kw sensor and apps.yaml.
PR springfall2008#4500 added self.args_from_apps_yaml, a deep copy of self.args taken at the top of
initialize() before Predbat's own defaulting runs, so set_arg_auto() can tell "the user
configured this in apps.yaml" apart from "Predbat defaulted it" and warn when
auto-discovery overrides an explicit setting.

Both dump paths that walk self.__dict__ special-cased only the field literally named
"args" for masking, and is_debug_excluded_key() tests the attribute *name* (which
contains none of _key/password/secret/token). So this second, unredacted copy of
apps.yaml went out in the clear alongside the properly-masked one:

- create_debug_yaml() - the predbat_debug.yaml users attach to bug reports, and every
  rolling debug_history snapshot
- the get_state agent/MCP tool - handed to whichever model asked

Real api keys, tokens and passwords were included in all of them.

Fixed at the source: the snapshot is now built with mask_secret_args() rather than
copy.deepcopy(), so no secret ever enters the structure regardless of how it is later
dumped. This costs nothing functionally - set_arg_auto() is only ever called with
auto-discovery targets (battery_scaling, soc_max, car_charging_energy, sensor entity
ids) across alphaess, sunsynk, solis, myenergi, ohme and gecloud, never a
credential-shaped key, so the values it compares and logs are unaffected.

Both dump sites also mask the field explicitly. That is belt-and-braces given the
source fix, but keeps the two paths consistent about which fields are credential-bearing
so a future field added next to it is not silently exempt.

Adds test_args_from_apps_yaml_snapshot_is_redacted, which plants an unmasked snapshot
and asserts neither create_debug_yaml() nor get_state emits it. Verified by mutation:
reverting either masking tuple fails it on all ten leak assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… them

SECRET_KEY_SUBSTRINGS can only catch what a key *name* reveals (_key, password, secret,
token), so credentials named after what they identify were served in the clear: account
numbers, meter point numbers, site/system/plant ids, login identifiers, and the id half
of an id/secret pair.

Adds a "secret": True flag to the component registry's arg definitions, so a credential
is declared next to the arg that supplies it rather than inferred from its spelling.
components.secret_config_names() reads the live COMPONENT_LIST (picking up the
conditionally registered gateway component) and feeds utils.is_secret_key().

23 credentials that previously went out unredacted are now covered:

  octopus_api_account, kraken_account_id, kraken_export_account_id, kraken_mpan,
  kraken_export_mpan, kraken_email, myenergi_hub_serial, ohme_login, deye_username,
  deye_app_id, deye_company_id, sunsynk_username, alphaess_app_id, enphase_username,
  enphase_site_id, axle_site_id, axle_partner_username, sigenergy_system_id,
  sigenergy_client_pem, teslemetry_site_id, solax_client_id, solax_plant_id,
  gateway_device_id

myenergi_hub_serial is included because it is the digest auth username, not just a
device id (myenergi.py:393). The 36 args the substrings already caught are flagged too,
so the registry is the single source of truth and a test asserts the two agree.

Redaction and the '!secret' advice are deliberately separated. is_secret_key() takes
registry=True by default - the strict answer to "must this be redacted?" - so a new
caller fails safe. find_unmasked_secret_paths(), which drives the "stored in plain text,
consider !secret" banner, passes registry=False: that advice is about values which grant
access, and an inline octopus_api_account is the documented normal setup, so warning
every user about one would be noise rather than advice. Note this scope also binds
find_redacted_secret_overwrite(), which must agree with the redactor or saving the
apps.yaml editor would write 'xxx' over a real account number.

Device serial numbers (solis_inverter_sn, ge_cloud_serial, solax_plant_sn and friends)
are deliberately left readable: they address hardware rather than authenticate it, and
they are what makes an integration bug report diagnosable - the same trade-off
SECRET_KEY_EXEMPT_SUFFIXES already makes for token expiry times.

utils cannot import components at module scope (every component module imports utils),
so the registry is imported on first use. An empty or failed lookup is not cached, so a
redaction running while components is still importing resolves properly on the next call
instead of silently losing these names for the life of the process.

Adds test_registry_secret_flags_drive_redaction, asserting every flagged arg redacts,
that the newly covered names redact end to end through mask_secret_args and get_apps,
that serials and other non-credentials stay readable, and that the !secret banner keeps
its narrower scope. Mutation-verified: removing the registry consultation fails 50
assertions, dropping one "secret": True flag fails 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The /debug_apps_live masking test only injected octopus_api_key, which the key-name
substrings catch on their own, so it could not tell whether the component registry's
"secret" flag reaches this route. Adds octopus_api_account alongside it - an account
number reads like ordinary config, so before the flag existed this download served it
in the clear.

Asserts it is redacted on both a bare request and masked=1, and still returned under
the deliberate masked=0 opt-out. Mutation-verified: removing the registry consultation
from is_secret_key() fails both masked assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d-inverter-locked-4896

GE Cloud: surface write rejections instead of counting them as successes
The "apps.yaml (file)" link served apps.yaml verbatim, with no redaction and no warning,
while the "apps.yaml (live)" link beside it defaulted to masked and offered the unmasked
copy behind a confirm dialog. Someone grabbing a file to attach to a bug report had a
fair chance of clicking the one that hands over every credential in their config.

/debug_apps now takes ?masked= and defaults to masked, matching /debug_apps_live. The
default matters more than the dialog: the config-errors page carries its own plain
'./debug_apps' link (web.py:3673), so defaulting to masked covers that one too rather
than only fixing the link the dialog is attached to. downloadFileApps() gives the
Download row's file link the same OK/Cancel choice as the live one.

Redaction is applied to the file text, not the parsed args - mask_secret_yaml_text()
round-trips through ruamel so the download still reads like the user's own apps.yaml,
comments, ordering and quoting intact, with only credential values replaced. A
'!secret name' reference is left exactly as written: it holds no credential, only the
name of one, and which secret a key resolves to is what makes a misconfigured
integration diagnosable. Unparseable YAML raises, and the route fails closed with an
explanatory comment block rather than falling back to the raw file - serving unredacted
text because the parse failed is the leak this exists to prevent.

The editor is deliberately left alone, on both halves: /apps_editor shows the real file
(a credential you cannot see is one you cannot edit) and writes back exactly what was
submitted. Redaction applies only to downloads, which produce a file for someone else;
the editor produces the file Predbat runs on, so masking it would save 'xxx' over real
credentials. test_web_if now pins that invariant - mutation-verified by wrapping the
editor's write in mask_secret_yaml_text(), which fails both new assertions.

Also asserts unknown keys still redact on their name alone (supabase_key,
whatever_password and friends): the registry adds to the substring heuristic rather than
replacing it, and collapsing is_secret_key() to registry-only fails those assertions
plus the existing nested-credential test.

The /debug_apps test writes its credentials into the file on disk rather than into
my_predbat.args. The shipped test apps.yaml carries no credential, and an earlier
POST /apps_editor in the same test has already ruamel-round-tripped the file, so
comparing raw against redacted text passed whether or not anything was redacted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…modulation-precedence

fix(fetch): make the cloud/load divergence model actually modulate
test_training_does_not_copy_the_dataset_per_epoch failed on every macOS machine,
including on clean main, reporting ~280MB of growth against a 143MB budget and
concluding "the matrix is being materialised". The matrix is not being materialised.

What the guard actually measured was ru_maxrss, a high-water mark of pages *ever
touched* rather than memory held at any instant. Training frees each batch buffer
immediately - measured here it retains 0.0MB of live numpy arrays after a pass and
peaks at 67MB of Python-tracked memory - but the mark accumulates every page those
thousands of short-lived buffers ever dirtied. The mark is also page-granular, so a
16KB-page host (Apple Silicon) inflates it several-fold against a 4KB-page Linux one.
That made the budget, which was calibrated against a matrix size in bytes, compare two
quantities that are not commensurable.

It went unnoticed because this is a slow test and CI runs --quick, so it has never been
exercised upstream - neither green nor red.

Assert the invariant directly instead: WindowedFeatures.__array__ is the only path that
builds every row at once, so count its calls, and separately check that no single
allocation approaches the notional matrix. Both are properties of the code rather than of
the host. Verified the guard still fails when it should by forcing a materialisation:
it reports materialised=1 and a 65.9MB allocation, tripping both checks. In normal
operation the largest single allocation is 5.9MB against a 71.6MB matrix.

Peak RSS is still reported as a diagnostic, just not asserted on, and benchmark() still
returns it for manual profiling.

The module docstring previously suggested MallocLargeCache=0 to get a Linux-comparable
figure on macOS; measured, it changes nothing (279.8MB vs 282.9MB), because allocator
caching is not what the mark records. Replaced with what the number actually means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rom-apps-yaml-secret-leak

fix(security): stop apps.yaml downloads and debug dumps leaking credentials
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…mid-window-automation

docs: add Hanchu iESS mid-window charge/discharge time update automation
…ining-perf-rss-guard

fix(test): assert the ML training invariant instead of peak RSS
Conflicts resolved:
- predbat.py: keep the fork version scheme, bump to v712.30
- annual.py: keep both HEADLESS_APPS_YAML_NAME and upstream's new
  config_warnings helpers
- web_annual.py: union the annual imports (RESULTS_SCHEMA_VERSION plus
  upstream's SOLAR_KWP_SOFT_LIMIT/config_warnings)
- tests/test_web_mcp.py: the fork's OAuth suite owns this filename and
  upstream's tool tests live in test_web_mcp_tools.py, so upstream's new
  get_log byte-budget and line-number tests were applied there instead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mak6yDBWtLnwGGN92NG5w
@dandwhelan
dandwhelan marked this pull request as ready for review September 4, 2026 09:02
@dandwhelan
dandwhelan merged commit 434f685 into main Sep 4, 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.

6 participants