Skip to content

docs(debug-journal): fold in the 2026-10-11 queue slice (10 candidates); mark GH#5050 and the GH#5221 event-day instance fixed in PR #5163, GH#5461 fixed in PR #5462, and the Victron template gap fixed in PR #5332 - #5481

Merged
springfall2008 merged 1 commit into
mainfrom
bot/debug-journal-2026-10-11
Oct 11, 2026

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Automated journal update — the /journal-update flow folding the 2026-10-11 queue slice. 10 candidates: 10 folded (one of them rewritten as fixed), 0 dropped, 0 left queued. Every claim was re-verified against origin/main d7d8c607 before folding. Heads-up: ./run_pre_commit, pre-commit run --all-files and the node linters are permission-denied for the triage runner, so the exact hooks could not be run — the applicable ones were replicated by hand (trailing whitespace, end-of-file newline, dictionary case-insensitive sort order, and a token scan of every newly introduced term against the workspace dictionary; one genuine new telemetry key, pac, was added). pre-commit.ci runs the real gate on this PR.

Merge sweep — existing entries corrected

Per-candidate

  1. 5454 (iBoost solar + iboost_smart combo) — folded into the iBoost row: the docs-vs-code contradiction (docs/customisation.md "only applies when iboost_solar and iboost battery are both Off" vs the fetch.py gate ((not iboost_solar) and (not iboost_charging)) or iboost_smart, both verified on main), the grid-first combination mechanism, the iboost_rate_threshold -10 floor, the PR iBoost: optional demand-forecast-driven smart planning (iboost_forecast) #4756/Consider solar export value when scheduling EV charging #5384 pointers and the flat-rate test trap.
  2. 5456 (HAHistory prune drops the state-in-force record → Predheat 20.0 °C) — folded into the Predheat row: the hourly EntityHistory.prune_history() cutoff drops the state-in-force record at the cutoff, so an unavailable/unknown blip as the oldest surviving record floors age to 0 and get_historical(default=20.0) subserves for 24 h; the blip-non-empty-store mechanism (the 2-minute incremental refresh only appends newer epochs); the shared-cache distinction (Predbat's own load path floors age use at 1 day); and the fix trap (the one-line retention fix fails two test_hahistory.py assertions encoding the current prune semantics). Still live.
  3. 5458 (Power Down reward assumed unconditional) — folded into the Octopus row, sharpening the existing GH#2090 sentence: Octopus pays the reward only on export above the user's baseline (typical export at that hour), so a plan that already exports in the event hour models a fictional session value; the three-way report split (inflated total = per-minute crediting Account for baseline calculations for Saving Sessions #2090, "I exported but got no reward" = baseline, low-value bookings = octopus_saving_session_min_octopoints_per_kwh Octopus Saving Session value overstated: incentive applied to whole export window, not marginal reduction vs baseline #4893); and the persistence gap (events re-read each cycle) plus the unprobed BottlecapDave Baselines attribute pointer.
  4. 5459 (Sigenergy docs entity-name split) — folded into the Sigenergy row: the docs' predbat_max_*_limit_action automations cite inverter-level names with the plant -ing suffix — matching neither the plant -ing pair nor the inverter non--ing pair — and the template binds battery_rate_max to the plant -ing name; fix in flight on PR docs(sigenergy): fix rate automation sensor names, re-apply mode on restart, stop zeroing import limit in freeze #5464 (verified unmerged against current main).
  5. 5460 (Sunsynk battery-first idle clipping, closed dup of PV clipping protection: proactively create battery headroom using PV forecast/PV90 #4865) — folded into the Sunsynk row: the planner-shaped-then-re-pointed + battery-first-idle-charge mechanism behind "planned export didn't happen / battery charged instead", the clipped-energy accounting sites (clipped_today, the inverter_can_charge_during_export + inverter_support_feedin_first branch — symbols verified on main), and the reusable log-archaeology traps (grid power = −pac, battery sign conventions, ~15-min poll vs 5-min publish stale cache, etoday counters ratchet, spot-sample balance reconciliation fails).
  6. 5461 (IOG low-load test) — rewritten as fixed: PR fix(car): low-load test needs live load power to be low too before cancelling an Intelligent dispatch #5462 merged 2026-10-09 (v9.3.7): the live load_power total must also be under the cut-off (no load_power → the 5-minute figure alone decides; not-low or no-reading → no evidence either way), and the cut-off factor dropped 0.9 → 0.85 (DYNAMIC_LOAD_CAR_LOW_FACTOR, verified on main). Durable mechanism kept: two-consecutive-low-window grace, ramp-window eligibility, confirm/cancel band asymmetry (0.85 vs the full threshold), the car_charging_energy-sensor strip skip; pre-fix(car): low-load test needs live load power to be low too before cancelling an Intelligent dispatch #5462 signature kept for older logs. The candidate's "byte-identical v9.3.5→main" claim is superseded by the fix.
  7. 5471 (GatewayMQTT auto-config drops an unreadable inverter until restart) — folded into the Gateway row: partial visibility proceeds with the readable subset while _configured_inverter_serials records all serials so _needs_reconfigure() never sees the dropped device as new; the fix trap (a serial-set-membership re-run thrashes every frame — gate on the device becoming usable); Gateway MQTT: defer initial auto-configuration while the hub reports more dongles than inverters #5227's deferral does not cover "listed but unusable"; the green gateway suite proves nothing for the partial-outage scenario; fix in flight on PR fix(gateway): keep configured inverters when the hub cannot read them (#5471) #5475 (discovery/auto-config side).
  8. 5478 (GatewayMQTT missing-field fallbacks fabricate 6000 W / unlimited export) — folded into the Gateway row: the or 6000 rate fallbacks, the proto-0 export-limit 99999 sentinel read back as 100.0 kW, literal-0 reserve_soc publishing, and _check_rate_caps() persisting the fabricated cap on the hub; or 6000 dates from the component's first commits (not a regression); fix precedent merge_non_null(); a green gateway suite proves nothing here (no missing-rating case).
  9. 5479 (docs yaml code fences unvalidated) — folded as a new "Traps when investigating" bullet: nothing validates the fenced yaml in docs/inverter-setup.md (check-yaml matches only .yaml/.yml; verified against .pre-commit-config.yaml), the two SolaX script blocks broken since Feb 2026 by a wide neighbouring re-indent (PR Docs: Improve LuxPower Freeze Charge Exit Automation: Fix Trigger/Condition Logic to Handle Predbat Restarts #3389), the two break shapes (hard parse error vs silently-parseable variables: null), and the git blame -L technique.
  10. In addition, one pre-existing malformed symptom-table row (the "Too many API errors" row missing its closing |) was repaired, and the merge-sweep also surfaced the "manual override partially ignored" class gaining a fixed instance (GH#5423, fixed by PR fix(plan): shrink, not grow, the source window in the export swap's partial combine (#5423) #5447) now noted in the web row.

…s); mark GH#5050 and the GH#5221 event-day instance fixed in PR #5163, GH#5461 fixed in PR #5462, and the Victron template gap fixed in PR #5332

Co-Authored-By: Claude Code <noreply@anthropic.com>
@springfall2008
springfall2008 marked this pull request as ready for review October 11, 2026 17:54
Copilot AI balanced review requested due to automatic review settings October 11, 2026 17:54
@springfall2008
springfall2008 merged commit f20ddaf into main Oct 11, 2026
2 checks passed
@springfall2008
springfall2008 deleted the bot/debug-journal-2026-10-11 branch October 11, 2026 17:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The journal misstates reward-baseline semantics, and the PR description omits the GH#5472 candidate rationale.

2 open findings
What changed in this PR

Updates the debugging journal with newly verified issue findings and merged-fix status.

Changes:

  • Adds and refreshes debugging guidance across several integrations.
  • Adds pac to the spelling dictionary.
File Description
tools/​debug-journal.md Updates issue mechanisms, fixes, and investigation traps.
.cspell/​custom-dictionary-workspace.txt Adds the pac telemetry term.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tools/debug-journal.md
| Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices/<id>/ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT<n>`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, `_needs_reconfigure()` treats `""` as a newly discovered inverter and auto-config binds it — note the last-resort branch that used to fall back to `candidate_aios or list(all_inverters)` was removed in `eaef6e61` (2026-10-04), which now defers auto-config entirely with a warn when no battery-capable telemetry has arrived (`_auto_configured` stays False so `_needs_reconfigure()` retries); an empty-serial slot that does report battery telemetry is still bound, so the trap survives on that shape, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). | `gateway` |
| Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. Since PR #5396 (merged 2026-10-05, unreleased at the time of writing) a minute is not marked `io_adjusted` at all when the dispatch price is within `IO_RATE_TOLERANCE` (0.01p) of what it replaced - the unrounded tariff's 5.2314p off-peak receiving a 5.23p dispatch is dp2 rounding, not a dispatch (GH#5392). This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). The bounce symptom itself — the battery flipping Charge↔Demand while a car charges in bursts — is the supervision working as coded (GH#5390, log-verified): a non-"charging" reading counts after `DYNAMIC_LOAD_CAR_START_MINUTES` (3 min) inside the dispatch, the cheap dispatch rate is stripped from the car's still-future slots (house plan re-prices those minutes) and it resumes on the next "charging" reading — ground-truth with the hourly `Last hour: ... car X kWh` log lines against Octopus's own per-slot amounts before calling it a bug. **The completed-dispatch cheap stamp lives only as long as the integration's dispatch feed carries the session (GH#5413, reporter log + yaml, 2026-10-05):** `rate_add_io_slots()` stamps completed dispatches deliberately (`dispatch_billed_off_peak()` exempts `end_minutes <= minutes_now`, and the fetch merges `completed_dispatches` unconditionally), but the integration withdrawing the dispatches when a session closes removes the stamp retroactively (`io_adjusted` reads empty, `rate_import`/`rate_max` all back at the day rate hours later) and the plan-History re-render derives past minutes from the **current** rate tables (`history_to_future_rates(self.rate_import, ...)`, and `today_cost()` re-prices today-so-far), so the flip is the display of the un-stamped arrays - no persistence of applied rates exists (open GH#2785, PR #3340 parked); the narrower fix is persisting the stamped/completed minutes. A tell from the same log worth keeping: those midday dispatches were visible only from their own start minute, so a "cheap slot Octopus never offered" question on an *ad-hoc* dispatch is different from the overnight band, which is visible hours ahead. **Octopus Intelligent devices freeze in place once the account leaves an Intelligent tariff (GH#5412, probe-verified on main 2026-10-05, still live):** `async_update_intelligent_devices()` returns before polling when the import tariffCode is not Intelligent, so the live-set pruning never runs off-IOG and everything built while on the Intelligent tariff persists - `automatic_config()` wires from `self.intelligent_devices` tariff-blind, the storage cache round-trips the devices, `fetch_sensor_data_car_planning()` keeps taking the Intelligent branch and rebuilding `self.octopus_slots` from the frozen entities' dispatch attributes every cycle, and `rate_add_io_slots()` keeps stamping completed dispatches (with the ~96h look-back) - the Ohme half flips `octopus_intelligent_wanted()` false on the new tariff but `charger_slots_wanted()` refuses while `octopus_intelligent_slot` still points at the frozen Octopus entity, so the #5401 charger-schedule mode stays blocked until the wiring is cleared by hand. #5405's owner re-wire (`car_slots_released_to_us()`) treats the not-refreshed device list as a live constraint on purpose - it refuses to re-wire off an Intelligent tariff, which is the same freeze acknowledged, not removed. **A related clobber to read before any "my manual octopus_intelligent_slot keeps reverting" report:** with `octopus_automatic: true`, `set_arg()` overwrites unconditionally and `automatic_config()` re-wires whenever the device dict is non-empty, so a user's own apps.yaml entity is replaced on the first run after every restart; since #5405 only the *empty-set clearing* respects foreign wiring (it checks the wiring is still what Octopus itself last wrote) - "the user is not affected" holds only under `octopus_automatic: false`. **Weekend Happy Hour / Power Up booking is not implemented (GH#5404, enhancement, 2026-10-05):** Predbat reads only the booked/allocated side (the free-session `events` attribute and `joined_events` with `event_type` WEEKEND_HAPPY_HOUR → `octopus_free_slots`); `available_events` is consumed only by the Power Down auto-join loop (`join_octoplus_power_down_session_event` plus the deprecated saving-session fallback, #4593) and WHH entries never reach it - they are skipped at the source (cannot be joined through the API - Octopus allocates them or the user books on the website) and Power Up entries ride in `available_events` at 0 octopoints, skipped by the min-octopoints guard; no WHH join service exists anywhere. If a join half is ever built, the saving-session auto-join block is the template and Power Up needs a slot *choice* among 2-3 candidates, with an explicit rule for weekend slots released Thursday that partly sit beyond the 48h horizon; the `events` attribute carries both available (code set) and auto-joined (`code` null) entries and the free-session path zero-rates them all regardless of booking state. **The vehicle-catalogue lookups match by make+model *name*, last match wins (GH#5438, code-read on v9.3.6):** `async_get_intelligent_devices()` matches account devices against the global GraphQL catalogue (`intelligent_catalogue_query`) by name only — both the charge-point and vehicle loops assign on every match and keep the last, so when a model name lists several variants (zappi "(all models)" is listed at 7.4 and 22.0 — three-phase variants share a name) the last list entry wins and the order is the API's, not Predbat's. The device dict (`charge_point_power_in_kw` / `vehicle_battery_size_in_kwh`) spreads into the component's own `intelligent_dispatch` entity attributes, `automatic_config()` wires `octopus_intelligent_slot` to that entity, and `fetch_sensor_data_car_planning()` reads them back — battery size **replaced outright** when truthy (recalculating the car SoC), and rate via `max(rate, configured)` (the "Take the max as Octopus over reports" comment is an #2855-era line predating the catalogue) — so when the catalogue figure is too high, both the rate and the battery size are unreachable from apps.yaml while the component runs. Symptom: `Car 0 rate from Octopus is 22.0kW and configured rate 7.4` repeating every cycle; on an IOG-wired car `plan_car_charging()` is skipped entirely, so the bad rate skews charging-now modelling and battery-size-derived limits rather than the slot list. Cosmetic but reliable marker for this path: the catalogue returns `powerInKw`/`batterySize` as **strings**, `_clean_number()` refuses strings, so the coordinator drops both car `ratings` entries every poll with `value does not fit the container's type - dropped`. Test gap: `test_octopus_catalogue_cache.py`/`test_octopus_intelligent_devices.py` fixtures have a single variant per name, so ambiguity is never exercised. | `octopus_*`, `saving_session*` |
| Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices/<id>/ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT<n>`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, `_needs_reconfigure()` treats `""` as a newly discovered inverter and auto-config binds it — note the last-resort branch that used to fall back to `candidate_aios or list(all_inverters)` was removed in `eaef6e61` (2026-10-04), which now defers auto-config entirely with a warn when no battery-capable telemetry has arrived (`_auto_configured` stays False so `_needs_reconfigure()` retries); an empty-serial slot that does report battery telemetry is still bound, so the trap survives on that shape, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). **GH#5471 (triaged 2026-10-10, maintainer report, no attachments; all steps verified by reading `gateway.py`):** auto-config drops an inverter that is unreadable at the discovery pass until Predbat restarts: the pool is filtered to battery-capable telemetry (a capacity fallback when no primary has battery), so partial visibility proceeds with the readable subset; success records `_configured_inverter_serials` from all inverters in the status message including the filtered-out ones; and `_needs_reconfigure()` only re-runs for serials outside that set - the only `automatic_config()` call site is gated on it - so a listed-but-unusable inverter is never "new" again: no error anywhere, the component reports healthy (`_auto_configured` True), and capacity/plan run on fewer inverters until a restart. #5227's dongle-count deferral covers "missing from status", not "listed but unusable". Fix in flight on PR #5475 (discovery/auto-config side); fix trap: recording only *bound* serials would make `_needs_reconfigure()` see the still-unusable inverter as new on every telemetry frame - a re-run must be gated on the device *becoming usable*, not serial-set membership. `./run_all --test gateway` passes on main - the partial-outage scenario is uncovered. **GH#5478 (triaged 2026-10-10, maintainer report quoting a real site log):** missing-field fallbacks fabricate limits during a telemetry gap (e.g. right after a hub firmware update): `battery_rate_max = bat.rate_max_w or 6000` and `inverter_rate_max = inv.inverter.rate_max_w or battery_rate_max` make an unpopulated proto field a hard 6000 W on both published sensors, proto-0 `control.export_limit_w` publishes as 99999 (deliberate sentinel - proto-0 cannot be told apart from an explicit 0) which `inverter.py` reads back as 100.0 kW, and unpopulated `control.reserve_soc` publishes literal 0 (only `reserve_soc_max` gets an assumption) so Predbat rewrote its reserve against a wrong current value. The wired args feed `battery_rate_max_charge/discharge = min(inverter_limit_*, battery_rate_max_raw)`, so the plan maximum doubles during the gap and the executor writes the fabricated rates - and `_check_rate_caps()` sends `set_rate_cap` with the fabricated cap, which the hub persists ("The hub stores it"; self-corrects on the next good telemetry since differing caps are re-sent). `or 6000` dates from the component's first commits (`9f7fc558`, 2026-03-23) - not a regression. Fix precedent: `merge_non_null()` (`gecloud.py`); gateway.py has no last-good cache for the rate/limit fields. A green `gateway` suite proves nothing here - no test covers a missing rating. | `gateway` |
| Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()` — the line this entry used to cite at `octopus.py:2786` has moved; grep the symbol) and Octopus pays the reward only on export above the user's baseline (their typical export at that hour), so for a plan that already exports in the event hour the modelled session value is fiction and the real reward can be zero (GH#5458); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. Split the reports three ways: an inflated session total is the per-minute crediting (#2090); "I exported but got no reward" is the baseline eating it — check whether the user's plan already exported in the typical event hour before suspecting event pickup or rate propagation (#5458); a low-value session the plan books into is what `octopus_saving_session_min_octopoints_per_kwh` is for (#4893's workaround, closed NOT_PLANNED - it skips low-value sessions entirely and does not help baseline mechanics). Baseline-aware valuation needs past-event persistence Predbat does not have (events are re-read each cycle from the integration's `joined_events`/`available_events` attributes), so that ask needs Storage-backed persistence or user-configured keep-clear hours; the BottlecapDave integration exposes a joined session's per-half-hour baselines ahead of time via the `octoplus_power_down_baseline` sensors' `Baselines` attribute (reporter-thread evidence; not probed from Predbat's tree, so verify before building a baseline-aware prediction on it). A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. Since PR #5396 (merged 2026-10-05, unreleased at the time of writing) a minute is not marked `io_adjusted` at all when the dispatch price is within `IO_RATE_TOLERANCE` (0.01p) of what it replaced - the unrounded tariff's 5.2314p off-peak receiving a 5.23p dispatch is dp2 rounding, not a dispatch (GH#5392). This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). The bounce symptom itself — the battery flipping Charge↔Demand while a car charges in bursts — is the supervision working as coded (GH#5390, log-verified): a non-"charging" reading counts after `DYNAMIC_LOAD_CAR_START_MINUTES` (3 min) inside the dispatch, the cheap dispatch rate is stripped from the car's still-future slots (house plan re-prices those minutes) and it resumes on the next "charging" reading — ground-truth with the hourly `Last hour: ... car X kWh` log lines against Octopus's own per-slot amounts before calling it a bug. **The low-load test path (no `car_charging_now` sensor).** A cancel via the load test needs two consecutive low 5-minute windows - the first sets `dynamic_load_car_since`, the next satisfies `timed_at − since ≥ DYNAMIC_LOAD_CAR_LOAD_MINUTES` (5 min) - and the evidence guard requires the whole trailing window to lie inside the dispatch, so the dispatch's own ramp window is eligible and starts the clock: one lagged/under-read window beyond the ramp cancels (GH#5461, verified by `git log -L` on plan.py plus the reporter's log). **A genuine cancel was fixed in PR #5462 (v9.3.7):** the live `load_power` total must now also be under the cut-off - with `load_power` unset the 5-minute figure alone decides; live load that is not low or has no reading makes a low window no evidence either way (it neither cancels nor resumes) - and the cut-off factor dropped 0.9 → 0.85 (`DYNAMIC_LOAD_CAR_LOW_FACTOR`, `const.py`) for a load sensor that under-reads while the battery grid-charges. Pre-#5462 signature: a 4.80kW 5-minute read against a 5.4kW (0.9 × 6.0) cut-off at 22:40 while the inverter reported 7410W of live car charging. The confirm/cancel band is asymmetric by design: the cheap-rate confirmation needs load at the full `car_charging_threshold` while the low/cancel classification uses the 0.85 factor of it, so the in-between band keeps the slot trusted but never confirms it. `car_charging_threshold`'s history-strip heuristic is skipped when a real `car_charging_energy` sensor is configured (fetch.py, GH#4717), so "lowering car_charging_threshold corrupts the load forecast" only holds without that sensor. **The completed-dispatch cheap stamp lives only as long as the integration's dispatch feed carries the session (GH#5413, reporter log + yaml, 2026-10-05):** `rate_add_io_slots()` stamps completed dispatches deliberately (`dispatch_billed_off_peak()` exempts `end_minutes <= minutes_now`, and the fetch merges `completed_dispatches` unconditionally), but the integration withdrawing the dispatches when a session closes removes the stamp retroactively (`io_adjusted` reads empty, `rate_import`/`rate_max` all back at the day rate hours later) and the plan-History re-render derives past minutes from the **current** rate tables (`history_to_future_rates(self.rate_import, ...)`, and `today_cost()` re-prices today-so-far), so the flip is the display of the un-stamped arrays - no persistence of applied rates exists (open GH#2785, PR #3340 parked); the narrower fix is persisting the stamped/completed minutes. A tell from the same log worth keeping: those midday dispatches were visible only from their own start minute, so a "cheap slot Octopus never offered" question on an *ad-hoc* dispatch is different from the overnight band, which is visible hours ahead. **Octopus Intelligent devices freeze in place once the account leaves an Intelligent tariff (GH#5412, probe-verified on main 2026-10-05, still live):** `async_update_intelligent_devices()` returns before polling when the import tariffCode is not Intelligent, so the live-set pruning never runs off-IOG and everything built while on the Intelligent tariff persists - `automatic_config()` wires from `self.intelligent_devices` tariff-blind, the storage cache round-trips the devices, `fetch_sensor_data_car_planning()` keeps taking the Intelligent branch and rebuilding `self.octopus_slots` from the frozen entities' dispatch attributes every cycle, and `rate_add_io_slots()` keeps stamping completed dispatches (with the ~96h look-back) - the Ohme half flips `octopus_intelligent_wanted()` false on the new tariff but `charger_slots_wanted()` refuses while `octopus_intelligent_slot` still points at the frozen Octopus entity, so the #5401 charger-schedule mode stays blocked until the wiring is cleared by hand. #5405's owner re-wire (`car_slots_released_to_us()`) treats the not-refreshed device list as a live constraint on purpose - it refuses to re-wire off an Intelligent tariff, which is the same freeze acknowledged, not removed. **A related clobber to read before any "my manual octopus_intelligent_slot keeps reverting" report:** with `octopus_automatic: true`, `set_arg()` overwrites unconditionally and `automatic_config()` re-wires whenever the device dict is non-empty, so a user's own apps.yaml entity is replaced on the first run after every restart; since #5405 only the *empty-set clearing* respects foreign wiring (it checks the wiring is still what Octopus itself last wrote) - "the user is not affected" holds only under `octopus_automatic: false`. **Weekend Happy Hour / Power Up booking is not implemented (GH#5404, enhancement, 2026-10-05):** Predbat reads only the booked/allocated side (the free-session `events` attribute and `joined_events` with `event_type` WEEKEND_HAPPY_HOUR → `octopus_free_slots`); `available_events` is consumed only by the Power Down auto-join loop (`join_octoplus_power_down_session_event` plus the deprecated saving-session fallback, #4593) and WHH entries never reach it - they are skipped at the source (cannot be joined through the API - Octopus allocates them or the user books on the website) and Power Up entries ride in `available_events` at 0 octopoints, skipped by the min-octopoints guard; no WHH join service exists anywhere. If a join half is ever built, the saving-session auto-join block is the template and Power Up needs a slot *choice* among 2-3 candidates, with an explicit rule for weekend slots released Thursday that partly sit beyond the 48h horizon; the `events` attribute carries both available (code set) and auto-joined (`code` null) entries and the free-session path zero-rates them all regardless of booking state. **The vehicle-catalogue lookups match by make+model *name*, last match wins (GH#5438, code-read on v9.3.6):** `async_get_intelligent_devices()` matches account devices against the global GraphQL catalogue (`intelligent_catalogue_query`) by name only — both the charge-point and vehicle loops assign on every match and keep the last, so when a model name lists several variants (zappi "(all models)" is listed at 7.4 and 22.0 — three-phase variants share a name) the last list entry wins and the order is the API's, not Predbat's. The device dict (`charge_point_power_in_kw` / `vehicle_battery_size_in_kwh`) spreads into the component's own `intelligent_dispatch` entity attributes, `automatic_config()` wires `octopus_intelligent_slot` to that entity, and `fetch_sensor_data_car_planning()` reads them back — battery size **replaced outright** when truthy (recalculating the car SoC), and rate via `max(rate, configured)` (the "Take the max as Octopus over reports" comment is an #2855-era line predating the catalogue) — so when the catalogue figure is too high, both the rate and the battery size are unreachable from apps.yaml while the component runs. Symptom: `Car 0 rate from Octopus is 22.0kW and configured rate 7.4` repeating every cycle; on an IOG-wired car `plan_car_charging()` is skipped entirely, so the bad rate skews charging-now modelling and battery-size-derived limits rather than the slot list. Cosmetic but reliable marker for this path: the catalogue returns `powerInKw`/`batterySize` as **strings**, `_clean_number()` refuses strings, so the coordinator drops both car `ratings` entries every poll with `value does not fit the container's type - dropped`. Test gap: `test_octopus_catalogue_cache.py`/`test_octopus_intelligent_devices.py` fixtures have a single variant per name, so ambiguity is never exercised. | `octopus_*`, `saving_session*` |
Comment thread tools/debug-journal.md
| Load ML (`load_ml_component.py`, `load_predictor.py`) | The 2-hourly retrain isn't one pass. `_do_training()` hardcodes `ml_curriculum_step_days = 1` / `ml_curriculum_max_passes = 4` (`load_ml_component.py:94-95` — plain instance attributes, no `config.py` entry, not user-configurable), and `train_curriculum()` caps to the largest N windows (`load_predictor.py:1560-1564`); on ~80 days of history that's roughly five near-full-scale passes back to back, confirmed against a reporter's log as a 16-minute CPU spike every 2 hours (GH#3896). Separately, `threads` only ever reaches the C++ prediction kernel (`plan.py:1497`, `resolve_batch_threads`) — never wired to ML — and there is no `OMP_NUM_THREADS`/`threadpoolctl` anywhere in the repo (confirmed absent, 2026-08-27), so NumPy's BLAS backend is free to fan out across every core on its own during that training window. Whether that actually saturates a host depends on which BLAS backend is linked — worth confirming the platform before assuming a code fix is the right lever. There is a mapped test now: `ml_training_perf` (PR #4912), which asserts the training invariant rather than peak RSS — a peak-memory assertion was too machine-dependent to hold. Frequency is a separate axis (GH#5072): `RETRAIN_INTERVAL_SECONDS` (2h, `load_ml_component.py`) is the only knob of the cadence and is hardcoded, and any "make the retrain interval configurable" implementation hits a silent ceiling at the equally hardcoded `ml_max_model_age_hours = 48` — `LoadPredictor.is_valid()` returns invalid with reason `"stale"` past it and ML predictions then fall back to empty, so exposing the interval without also exposing the staleness cap is a footgun above 48h. Model-status signals (GH#5075, code-verified on main; PR #5112 open draft carries the fix): `is_valid()` can report **"active" for a model that has never been trained** — `_initialize_weights()` sets `model_initialized = True` before the first epoch, and both `validation_mae` and the age check skip `None`, so a training abandoned at epoch 0 publishes `model_status = "active"` / `model_valid = True`; conversely `training_timestamp is None` is *not* a sound "never trained" test, because a legacy saved model that predates the field is trained and must stay valid (`test_load_ml.py` asserts exactly that). And `train()` stamps `training_timestamp`/`validation_mae` at the end of *every* curriculum pass, so a curriculum that aborts part-way leaves the last intermediate window's **fresh** stamps standing — suppressing exactly the `ml_max_model_age_hours` staleness retrain that would otherwise rebuild a complete model. #5112 adds a `model_trained` flag plus stamp snapshot/restore around `train_curriculum()`; until it merges, treat "status active + nonsense forecast on a just-started install, or whose initial training keeps failing" as this, not as a data problem. The normalisation-statistics pairing on an abort (restored weights, stats refit for the abandoned run) is *suspected, not measured*. **"Reverting fixed the CPU" is usually an observation-window artefact (GH#5213, verified from a Pi 5 reporter's log):** load-ML fine-tune ran on *both* versions with identical structure (5 passes, 30 epochs, ~14s/epoch — 20–41 min every 2h ≈ 19% duty of one core), while both of the reporter's short v9.0.3 windows contained a training their next upgrade killed partway, so the panel always read low when they checked after rolling back. On any "CPU regressed after upgrade to X" report with Load ML on, first reconstruct from the log when fine-tunes started/finished under each version and how long the user actually stayed on each — the 2h retrain boundary means a version window shorter than that proves nothing. **Load ML has a hardcoded 48h horizon and the plan's load is zero beyond it (GH#5336, enhancement, read on main 57ec7bf1 2026-10-01):** `PREDICT_HORIZON = 48 * (60 // CHUNK_MINUTES)` (`load_predictor.py:35`) is the only horizon definition, and it feeds only the `predict()` rollout loop, blend schedule, logs and the `save()` metadata — training does not depend on it, and `load()` validates only model version + architecture, so models survive a horizon change. With `load_ml_source` set, `fetch.py` sets `load_forecast_only = True` and the weighted-bucket historical forecast is skipped wholesale, so the plan's future load is `self.load_forecast` alone (via `step_data_history(..., load_forecast=...)` from `fetch_sensor_data()`'s call path) — past minute 2880 there is no data and the plan-HTML load column is exactly 0; the log tell is `Starting autoregressive prediction loop for 576 steps (48.0 hours)`. The "over 48h" in `Generated N predictions (total X kWh over 48h)` (`load_ml_component.py:695`) is hardcoded in the *log string* — the step count is the truth — and `test_load_ml.py` pins 576 steps. The ask (configurable 48/72/96) is architecturally straightforward because little else depends on the constant, and there is no fallback beyond the ML horizon by design of `load_forecast_only`. | `ml_training_perf` |
| Savings & metrics (`output.py`, `predbat.py`) | `savings_total_predbat` accumulates the **unadjusted** `saving` — fed from `self.savings_today_predbat = saving` (`output.py`) into the once-per-date add inside `update_pred()`'s `calculate_savings` block (`predbat.py`) — while `savings_yesterday_predbat` publishes `saving_adjusted` (`output.py`) — the two sensors answer different questions and can disagree in sign on the same day (confirmed from a reporter's dump: `saving_real: +86.14p` vs `saving_adjusted: -25.66p`, GH#3894). Both are still current on `main`. Separately, the battery-value adjustment bills the SoC **level** at day-end against the counterfactual baseline rather than the **change** in SoC over the day — a day that force-exports through midnight can be energy-neutral yet still get charged the full baseline-vs-actual SoC gap as if it were lost value. **The cumulative savings have no plausibility guard and `start_date` is not a data-start date (GH#5450, triaged 2026-10-08 against c073c369; code-read + git archaeology):** the restore/accumulate block under `update_pred()`'s `if self.calculate_savings:` restores each balance via `load_previous_value_from_ha()` (`userinterface.py` — state first, **then the last recorder history entry**, and that history fallback applies to **attribute reads too**), adds `savings_today_predbat` **once per date** (gate `minutes_now > 60` since #3108, so ~01:05) and republishes every cycle; there is **no plausibility check anywhere** on the addition, `calculate_savings` is hard-wired True in `fetch_sensor_data()` (no config item), and **no built-in reset exists** — `gateway.py` reads the totals only for a display monthly average. `start_date`/`last_updated` were introduced by #2844 and default to "today" on first read when missing **without touching the restored balance**, so a "start_date newer than any retained data" report is simply the day the install first ran a #2844 build — not a data reset and not a savings-start date. Recovery for an implausible total (undocumented/unsupported today): a manual HA state edit (Developer Tools → States) of `predbat.savings_total_predbat`/`savings_total_pvbat` to 0 or a trusted figure **re-stating `start_date` explicitly in the same edit** — deleting the entity alone risks the recorder-history fallback resurrecting the stale balance while the state is missing; Predbat re-reads each cycle so the reset sticks. Units archaeology ruled out as inflators: #1114's pounds→pence state move could only shrink a balance, and pre-#2844's counter gate added at most ~2/day — so the original inflation event itself remains unreconstructed (suspected class: a day with mis-scaled or garbage rate data inflating the simulated "Predbat disabled" baseline, made permanent by the unguarded accumulator; do not pin a specific commit as the cause). Once-per-date consumption: the add consumes whatever `savings_today_predbat` stands at ~01:05 (the pre-SoC-roll-forward compute); later same-day recomputes (after the SoC roll-forward, #3676) publish a different final value consumed the following morning — a zero daily result still consumes the date. Same-symptom family: #3292/#3301/#3936; the row's #3894 point is the daily-value asymmetry, a different failure mode. | none |
| GivTCP REST (`inverter.py`) | **Structurally stale as written, and left here for the mechanism only:** PR #4864 moved REST handling out into `givtcp.py`/`givtcp_rest.py`, so `update_status()` (now `inverter.py:1333`) no longer reads `Power.Power` at `inverter.py:1435` at all. What survives is the same trap one layer up - the component's auto-config claims the power keys unless `givtcp_rest_power_ignore` is set, and it now logs an Info line when you opt out (`givtcp.py:766-767`). PR #4959 also lets an apps.yaml-named energy sensor win over auto-configuration for the history-read keys. Historically, with `givtcp_rest` configured `update_status()` read PV/Grid/Load power straight out of the REST `Power.Power` block; the apps.yaml entity lists - including any `0` placeholders put there deliberately to zero a duplicate reading - are only consulted on the non-REST `else` branch, and `execute.py` then sums every inverter's REST readings. On a hybrid + AIO pair that presents as the AIO's hybrid-fed PV port counted as solar at night, and both units' shared-CT grid readings summed (~7.1 kW shown for ~3.6 kW of real export). The per-inverter `givtcp_rest_power_ignore: true` restores the apps.yaml lists and is already documented in `docs/apps-yaml.md` - the gap is that nothing warns when a `0` placeholder is silently bypassed (GH#4883). Two v9.0.x notes since the refactor. **GH#4993 (still live on main, b8996659):** the component's required `rest_urls` arg is resolved straight from `givtcp_rest` (`components.py`) and the stock `config/apps.yaml` ships `givtcp_rest:` uncommented with example URLs, while the registry's `"inverter": True` flag is documentation-only and nothing reads it - so every install using the template, GivEnergy or not, starts a GivTCP REST component that fails discovery (`num_entities: 0`), hammers two dead URLs and shows "GivTCP REST: Error / Never updated" in the Components panel. Discovered inverters stay at zero so `automatic_config()` skips and no Predbat args get hijacked - the damage is log spam and panel error, not wrong control. Workaround: comment out the whole `givtcp_rest:` block, and any uncommented `givtcp_automatic:` line with it, because leaving that set while `givtcp_rest` is absent trips the `Warn: Skipping GivTCP REST interface, missing required configuration: givtcp_rest` gate. **GH#4984 (fixed by the refactor, keep for older logs):** `Warn: Inverter N REST failed to setDischargeRate to <X> got <X>` where *got equals the requested rate* is a zero verification tolerance, not a failed write - pre-#4864 the read-back tolerance was sized from `battery_rate_max_discharge`/`charge`, which an `inverter_limit_discharge`/`inverter_limit_charge` API override of 0 (users reach for it as "manual rate 0"; it is in `CONFIG_API_OVERRIDE`) drives to 0 against a strict `<`, so an exact read-back fails, burns the 5 `INVERTER_MAX_RETRY_REST` posts (~2s apart, the 4-5 runs users see in the GivTCP log) and flags the whole run "Demand with Errors". Only fires when something else set the real rate away from the target first, otherwise `adjust_discharge_rate`'s change-gate skips the write. The refactor re-sized the tolerance from `write_tolerance_watts()` - the max rate GivTCP itself reports (`Invertor_Max_Bat_Rate`), falling back to 2600W - and the entity write path uses `<=`, so an exact match passes; the charge-rate variant #2882 (still open) does not match this trap's signature — it is a ~1 kW read-back miss (3600 written, 2560 got), not got==requested — and maps instead to the GH#5386 family appended below. **GH#5101 (still live):** `initialize()` builds one `InverterRestState(id=n, rest_api=url)` per `givtcp_rest` entry with no URL-shape validation (`givtcp.py`), so a YAML mapping indented into the list during an apps.yaml edit becomes `rest_api=<dict>` and `read_data()`'s first line (`url = inverter.rest_api + "/" + api`, `givtcp_rest.py`) raises `TypeError: unsupported operand type(s) for +: 'dict' and 'str'`, caught by `ComponentBase.start()` and logged with no index or URL. Signature: the `discovered N inverter(s) from M configured REST endpoint(s)` line never appears anywhere in the log (discovery settles only after the poll loop completes), and the malformed entry itself produces no `Errno 22` retries because it dies before `get_data()` — valid-but-dead string entries ahead of it produce the full ladder, so Errno-22 count = 5 attempts × completed poll cycles. The component never reports started; the main loop keeps working off the user's own apps.yaml entities. The whole class is new with the v9 component — v8.55 read `givtcp_rest` with `index=self.id`, so stray extra entries were inert on single-inverter installs. Also from GH#5099: nothing in the repo sets `battery_calibration` automatically — the component publishes the sensor from GivTCP's own detection but expects an apps.yaml mapping — and Predbat reads the arg's state as calibrating only when it is literally `on`/`On`/`true`/`True` (`inverter.py`), while GivTCP's detection is `Battery_Calibration != "Off"` (`givtcp_rest.py`); anyone wiring a multi-valued select into `battery_calibration` must reconcile the two. and because `refresh_config()` re-samples the arg every cycle (the objects persist since PR #5126, but the per-cycle re-read survives — see the args trap in "Traps when investigating"), `in_calibration` is re-sampled per cycle, not init-only. Another manual-vs-auto split (GH#5132): the `discharge_target_soc` write is the force-export hardware backstop - always `int(self.reserve_percent)`, never the plan's per-window export limit (that floor is software-only in `execute.py`) - and the #4517 unsupported-model guard (`DISCHARGE_TARGET_UNSUPPORTED_MODELS`/`_TYPES`, givtcp.py) runs only in the **auto-config** path, where it stops Predbat publishing the entity at all. A user on the **manual** entity-list path gets the entity from the GivTCP add-on instead, so the guard never engages: on an AIO whose add-on write handler for `number.givtcp_*_discharge_target_soc_1` is a silent no-op (diagnosed live - manual `number.set_value` did nothing and produced zero GivTCP log lines), Predbat warned `didn't complete got 4.0` every cycle for 14h. The write is skipped when the arg is absent by design, so **removing `discharge_target_soc` from apps.yaml is the first workaround on any version and either path**. Setup tell: manual-path apps.yaml lists `discharge_target_soc: - number.givtcp_*`; auto-config users don't. (Version trap from the same issue: the issue body said v8.54.3, the attached log's own version lines said v9.0.3 - the log wins.) **GH#5178 (enhancement, live on main):** whenever `givtcp_rest` is configured the component publishes its **full** entity surface unconditionally — ~44 entities per inverter — regardless of whether the GivTCP HA integration already provides equivalents; deliberate, so REST-only setups have entities at all. A "Predbat created N duplicate GivTCP entities" report maps here and is a feature request: the count is checkable as (GIVTCP_SENSORS + GIVTCP_CONTROLS − per-fleet withheld) × inverter count (44×3 = 132 in #5178, matched), the user-side workaround is an HA recorder exclusion glob `*.predbat_givtcp_*` plus hiding (safe — the four history keys keep the user's own sensors, USER_WINS), and `docs/components.md` has no GivTCP REST section yet. Related live gap (code-read, #5178's reporter's debug yaml): `automatic_config()` claims `discharge_target_soc` gated on rest_v3 alone (`givtcp.py`), while `publish_data()` additionally withholds the entity for `DISCHARGE_TARGET_UNSUPPORTED_MODELS`/`_TYPES` — on an unsupported-model fleet the arg points at `number.predbat_givtcp_N_discharge_target_soc` entities that are never created; benign so far (inverter.py leaves an unreadable discharge target alone, `No current discharge target to read`), fix shape = consult the same unsupported-model set or `published_discovery` like the discovery keys do. **GH#5209 (fixed in PR #5216; mechanism kept for pre-#5216 logs):** pre-#5216 `automatic_config()` filled every per-inverter arg list **positionally** from `discovered`, so an endpoint down at the discovery pass shifted every later endpoint down one slot, and `rediscover()`'s deliberate append-only recovery turned the shift into a rotation on recovery — recovery did not heal. Writes route by the index embedded in the entity id (`_parse_entity`), so the misroute reaches hardware; log signature `Inverter 1 current charge rate is 3000W and new target is 2600W` paired with `GivTCP: Inverter 2 set charge rate 2600 via REST successful` alternating each cycle, plus `battery_calibration ... has 2 entries, expected 3` / `Out of range index 2` for claimed-but-user-unconfigured keys (`_keep_configured_tail()` returned the short list unchanged when `_configured_value()` was None — only those keys warned while the user's own apps.yaml lists got their tail preserved). Debug-yaml diagnosis: any `predbat_givtcp_<n>_*` entry whose list position is not `<n>`. Workarounds at any version: restart Predbat once every endpoint answers, or `givtcp_automatic: False`. Design note on the fix: at startup a dead leading endpoint cannot be told apart from a leading placeholder URL, and #5216 chooses identity-by-index — `givtcp_rest: [dead, live]` becomes two inverters, not one (`num_inverters` cannot arbitrate; the GivTCP template tells REST users to delete it). **One design rule left by #5216's own development:** during the PR's cleanup it was found that the re-probe adoption pass judged every `published_discovery` gate before the adopted endpoint had published anything — `run()` calls `publish_data()`, then `rediscover()` appends the newly answering index, then `automatic_config()` re-runs because `discovered != configured_for`, so each `all(key in self.published_discovery.get(n, set()) for n in discovered)` gate failed on the adoption cycle and discovery-gated keys (`soc_max`, `battery_calibration`, `inverter_limit`, …) were skipped or handed back while the gates reading `rest_data` directly (`rest_v3`, `pause_mode_supported`, …) judged correctly. The merged PR re-publishes after a fleet-growing `rediscover()` (`givtcp.py`, the `if rediscover:` block) and adds a claim hand-back (`self.claimed_from`), and that ordering is the rule for anyone touching the path: **any gate over `published_discovery` must not be judged in the cycle where `rediscover()` appended an index.** On pre-#5216 builds (no hand-back, no wait) a *tail* endpoint adopted late never has its discovery keys claimed at all — suspected, unverified against a build without the PR — so a "late GivTCP inverter shows 8 kWh / no calibration sensor until restart" report on an older build maps here. **A missing inverter-details block silently stales the published sensors and fakes a clock-skew alarm (GH#5334, code-read on main 57ec7bf1):** `inverter_details()` (givtcp_rest.py) has no log and returns `{}` when `Invertor_Details` is empty (normal on v3) and `raw.invertor.serial_number` is null, which is exactly the Gateway/EMS shape upstream omits from `/readData` (britkat1980/giv_tcp#597 is the upstream fix, unmerged at triage time); `publish_data()` then gates each detail-block sensor on presence — `inverter_time`, `soc_max`, `battery_rate_max`, `inverter_limit` — so HA keeps the entity's *last* state frozen and stale looks identical to live, while `serial_number` itself is published unconditionally: **the discriminating tell is the serial sensor reading unknown while inverter_time stays frozen**. Predbat reads that frozen `inverter_time` and `check_clock_skew()` (inverter.py) crosses the ≥30-min restart threshold every cycle — repeating `Warn: Inverter time is <old>, Predbat computer time <now>, this is <growing> minutes skewed` and `Warn: Inverter control auto restart trigger: Clock skew >= 30 minutes command []` (empty `command` = no `auto_restart` configured) with the inverter clock actually fine — not the clock, not HA time sync. Fix shapes discussed, maintainer's call: a per-cycle warn when the details block resolves empty; a fallback lookup over top-level dict blocks for `Invertor_Serial_Number`/`Invertor_Time` (unambiguous only at exactly one candidate); or the cheapest anti-misdiagnosis — publish an explicit `unknown`/`unavailable` `inverter_time` instead of skipping, which `Inverter` already treats as "no reading" and skips skew detection for without a restart trigger. Any "Predbat-published entity looks frozen but claims to be live" report on a REST-backed component is a candidate for this same `if value:` publish-gate class. **GH#5386 (2026-10-04, open) — a read-back miss larger than the REST verify tolerance never converges:** the REST verify compares within `write_tolerance_watts()/12` (charge) and `/25` (discharge), where `write_tolerance_watts()` is the max rate GivTCP itself reports (`Invertor_Max_Bat_Rate`, 2600 W fallback); the reporter's 6000 W write read back 5100 while GivTCP's own log said the POST succeeded, so every ~2s retry and every cycle re-warns, and the entity-level deadband (`rate_tolerances()`'s one-step `fuzzy_below`) cannot cover a ~9-step miss either. Predbat's REST mirror entities **are** the read-back, so the debug yaml alone (mirror state vs the target) proves non-convergence without GivTCP logs. Why the register sits at 5100 is open — a stale cached Control read vs a firmware/BMS cap (the mirror's own max attribute says 6000, inconsistent with a pure cap) — and the suspected fix shape (accept GivTCP's POST success, a Solis-style `verify_settle_seconds` re-read, or sizing the tolerance to the firmware cap) is untested. Family so far: #5324 (exactly one rounding step; fixed by PR #5348's `fuzzy_below`), #2882 (~1 kW miss, open), #5386 (900 W miss, open). | `inverter` |
| Compare (`compare.py`) | `apply_hardware_overrides()` overrode four attributes and not `battery_rate_max_export`, which is the one the export prediction path actually uses (`prediction.py:906`), so an `override_battery_rate_max_discharge_kw` left force-export slots pinned at the real hardware rate - probe-verified against the reporter's debug yaml, fixed in PR #4897 with an explicit fifth key (GH#4895). `battery_rate_max_export` itself is `min(inverter_limit_export, battery_rate_max_raw)` (`inverter.py:431`), and the prediction still clamps export draw at the un-overridden `inverter_limit`, so a "model a bigger inverter" scenario needs `override_inverter_limit_kw` as well or it is inert. A negative "True cost" alongside zero import is **not** phantom export revenue: compare reports `metric_end - metric_start`, and `compute_metric()` credits the end-of-scenario battery at the *replacement import* rate (`plan.py:1769`), deliberately ignoring the export rate - so a scenario ending 25 kWh fuller on free PV scores negative by construction. Likewise a non-zero Export column under `rates_export: 0` is forced PV-clipping export, not a planned export slot; banning export outright is a missing feature (GH#2446), not a compare bug (GH#4881, confirmed twice, the second time against the reporter's own detailed plans). GH#2033 (bumped 2026-09-18, code-verified): the table's Import and Export columns are whole-horizon totals, not plan-slot sums — `run_prediction()` accumulates `import_kwh_battery`/`import_kwh_house`/`export_kwh` for every modelled minute of grid flow and seeds `export_kwh` from `export_today_now`, so PV export outside slots, scenario-plan arbitrage and today-so-far export all count, and a planned discharge slot exports regardless of load vs solar. Deliberate: compare/annual pass `include_manual_api=False` to `basic_rates()` so live manual_api overrides never leak into simulated tariffs, while a tariff's own `rates_import`/`rates_export` replaces the live rates wholesale (no `prev` passed) — per-tariff `rates_*_override` blocks are the workaround; and #2033's "identical friendly names" premise is contradicted by the code (`"Compare " + name`), so duplicate names likely come from duplicate `name:` values in `compare_list`. GH#5133 (fixed on **open PR #5143, unmerged** - keep the mechanism for logs on stock main): `publish_data()` built the entity id by plain concatenation with no slug sanitising, so a `compare_list` id containing `/` produced `POST /api/states/predbat.compare_tariff_IGO/Prime` - and that 404 is the route not matching, not a missing entity. The warns arrive **in pairs** - `set_state()` does the POST and then a read-back `update_state()` GET, and both warn - so two identical 404 warns per cycle anywhere on `/api/states/...` means one bad write, not two problems. And `comparisons.yaml` was never pruned against `compare_list`, so a renamed or deleted tariff id kept republishing for as long as the file survived: the bad id existed only in the persisted yaml (the reporter's current apps.yaml looked fine), so grepping the attached config proves nothing - ask for `comparisons.yaml`, delete the stale entry and restart. The PR adds `tariff_entity_slug()` and `prune_comparisons()`. From the same PR's review (still unmerged, so observations about stock main): the tree has four ad-hoc entity-id slugify helpers with different semantics - `gateway.py`'s `_ev_suffix` drops non-`[a-z0-9_]` characters, `sigenergy.py`'s `_system_slug` folds only `-`, web.py's `re.sub` neither collapses runs nor strips, the PR's `tariff_entity_slug` collapses and strips - and `dashboard_item()` validates nothing, so on any "invalid entity id / 404 on publish" report check which slugify helper built the id; none caps length, and HA rejects object ids over 64 characters. A history lookup that passes an unknown entity id returns `None` silently - a wasted round-trip, **not** a 404 - so don't accept "it 404s" about the history path. With `compare_list` commented out, stored tariffs are republished **once per Predbat start** (only `load_yaml` reaches `publish_data` without the `compare_list` early return), so the tell for "sensors for a tariff I deleted keep coming back" is a restart, not the 5-minute cycle; and stored compare results are looked up by the raw `compare_list` id everywhere (`run_all`'s prior-SoC carry-forward, `select_best`, web.py's Compare page), so a fix that normalises tariff identity must *move* the stored entry to the configured id - keeping the old key preserved nothing, because nothing looked it up under the new id. **GH#5180 (code-read on main, still live):** the /compare tab's `Existing` badge is broken by any event stamped into the live rate tables at the moment compare runs — `Compare.run_all()` snapshots its baseline from the live `self.pb.rate_import`/`rate_export` (`compare.py:598-599`), which by then carry the IO/saving/free/Axle stamps, while `fetch_rates()` refetches the compared tariff's plain rate card and never re-applies any event — so the minute compare differs at every event minute and `result["existing_tariff"]` goes False, withholding the badge (web.py) for that whole run. Compare fires daily at midnight or on the `compare_active` switch, so an event active at that moment is enough. Cost figures are unaffected: every scenario runs on its own refetched plain card. In-tree fix baselines already exist: `rate_import_no_io` (also excludes manual overrides — but the compared refetch never applies manual rates either, so override users already fail this comparison today; the new base makes that consistent rather than opening a gap) and `rate_export_base` (comment says saving sessions and overrides stay out of it). `test_compare` has zero setup for saving/free/IO slots and never asserts `existing_tariff` — a green compare test says nothing about the event-active path. Suspected, not verified: the `run_all()` finally block restores everything it saved **except** `rate_import`/`rate_export`, so the live tables may hold the last compared tariff's rates until the next `fetch_sensor_data()` rebuild — if "the live plan used a different tariff's rates right after running a comparison" is reported, start there. **The compared cost excludes the standing charge, and compare's `config:` block overrides only CONFIG_ITEMS (GH#5452, code-read on c073c369, reporter's premise confirmed):** `enable_standing_charge` (`prediction.py`) is gated on a save-mode list that omits `"compare"` — never in it from day one (#1952's birth commit shipped the list without it) — so the `run_prediction(..., save="compare")` call never adds `metric_standing_charge` at the once-per-midnight step; `enable_save_stats` *does* include `"compare"` (that is why the compared tariff's plan HTML renders at all). `select_best` ranks on the blended metric with no independent standing-charge term, so ranking inherits the gap. Traps for a fix or a future report: (a) **any `get_arg`-read apps.yaml arg that is not a CONFIG_ITEM is silently not overridable via a compare_list `config:` block** — `fetch_config` (`compare.py`) looks keys up in `config_index`, which `load_user_config()` builds solely from CONFIG_ITEMS — so a `metric_standing_charge` override is dropped with `Warn: Compare, config item metric_standing_charge not found` despite compare.md promising "any standard Predbat configuration setting" (check for "X not found" warns before assuming the override took); (b) a naive fix flipping the cost10 run to `save="compare"` would ALSO set `enable_save_stats` and repopulate `predict_*_best` from the PV10 scenario, rendering the compared tariff's plan HTML as the PV10 plan — the cost10 call (passes no save) needs its own treatment; (c) the compare page's "Actual" daily-cost line includes the standing charge while every projection excludes it, so projections under-read the Actual line by one day's standing charge — expect "Actual is always above the forecasts" reports; (d) two comment sites in `annual.py` claim `save="best"` (or `"compare"`/`"yesterday"`) switches on `enable_standing_charge` — only "best"/"yesterday" are true, "compare" is not; treat those comments as wrong until the gate changes. Sibling: #3775 (open enhancement, covers strictly less). | `compare` |
| Compare (`compare.py`) | `apply_hardware_overrides()` overrode four attributes and not `battery_rate_max_export`, which is the one the export prediction path actually uses (`prediction.py:906`), so an `override_battery_rate_max_discharge_kw` left force-export slots pinned at the real hardware rate - probe-verified against the reporter's debug yaml, fixed in PR #4897 with an explicit fifth key (GH#4895). `battery_rate_max_export` itself is `min(inverter_limit_export, battery_rate_max_raw)` (`inverter.py:431`), and the prediction still clamps export draw at the un-overridden `inverter_limit`, so a "model a bigger inverter" scenario needs `override_inverter_limit_kw` as well or it is inert. A negative "True cost" alongside zero import is **not** phantom export revenue: compare reports `metric_end - metric_start`, and `compute_metric()` credits the end-of-scenario battery at the *replacement import* rate (`plan.py:1769`), deliberately ignoring the export rate - so a scenario ending 25 kWh fuller on free PV scores negative by construction. Likewise a non-zero Export column under `rates_export: 0` is forced PV-clipping export, not a planned export slot; banning export outright is a missing feature (GH#2446), not a compare bug (GH#4881, confirmed twice, the second time against the reporter's own detailed plans). GH#2033 (bumped 2026-09-18, code-verified): the table's Import and Export columns are whole-horizon totals, not plan-slot sums — `run_prediction()` accumulates `import_kwh_battery`/`import_kwh_house`/`export_kwh` for every modelled minute of grid flow and seeds `export_kwh` from `export_today_now`, so PV export outside slots, scenario-plan arbitrage and today-so-far export all count, and a planned discharge slot exports regardless of load vs solar. Deliberate: compare/annual pass `include_manual_api=False` to `basic_rates()` so live manual_api overrides never leak into simulated tariffs, while a tariff's own `rates_import`/`rates_export` replaces the live rates wholesale (no `prev` passed) — per-tariff `rates_*_override` blocks are the workaround; and #2033's "identical friendly names" premise is contradicted by the code (`"Compare " + name`), so duplicate names likely come from duplicate `name:` values in `compare_list`. GH#5133 (fixed on **open PR #5143, unmerged** - keep the mechanism for logs on stock main): `publish_data()` built the entity id by plain concatenation with no slug sanitising, so a `compare_list` id containing `/` produced `POST /api/states/predbat.compare_tariff_IGO/Prime` - and that 404 is the route not matching, not a missing entity. The warns arrive **in pairs** - `set_state()` does the POST and then a read-back `update_state()` GET, and both warn - so two identical 404 warns per cycle anywhere on `/api/states/...` means one bad write, not two problems. And `comparisons.yaml` was never pruned against `compare_list`, so a renamed or deleted tariff id kept republishing for as long as the file survived: the bad id existed only in the persisted yaml (the reporter's current apps.yaml looked fine), so grepping the attached config proves nothing - ask for `comparisons.yaml`, delete the stale entry and restart. The PR adds `tariff_entity_slug()` and `prune_comparisons()`. From the same PR's review (still unmerged, so observations about stock main): the tree has four ad-hoc entity-id slugify helpers with different semantics - `gateway.py`'s `_ev_suffix` drops non-`[a-z0-9_]` characters, `sigenergy.py`'s `_system_slug` folds only `-`, web.py's `re.sub` neither collapses runs nor strips, the PR's `tariff_entity_slug` collapses and strips - and `dashboard_item()` validates nothing, so on any "invalid entity id / 404 on publish" report check which slugify helper built the id; none caps length, and HA rejects object ids over 64 characters. A history lookup that passes an unknown entity id returns `None` silently - a wasted round-trip, **not** a 404 - so don't accept "it 404s" about the history path. With `compare_list` commented out, stored tariffs are republished **once per Predbat start** (only `load_yaml` reaches `publish_data` without the `compare_list` early return), so the tell for "sensors for a tariff I deleted keep coming back" is a restart, not the 5-minute cycle; and stored compare results are looked up by the raw `compare_list` id everywhere (`run_all`'s prior-SoC carry-forward, `select_best`, web.py's Compare page), so a fix that normalises tariff identity must *move* the stored entry to the configured id - keeping the old key preserved nothing, because nothing looked it up under the new id. **GH#5180 (code-read on main, still live):** the /compare tab's `Existing` badge is broken by any event stamped into the live rate tables at the moment compare runs — `Compare.run_all()` snapshots its baseline from the live `self.pb.rate_import`/`rate_export` (`compare.py:598-599`), which by then carry the IO/saving/free/Axle stamps, while `fetch_rates()` refetches the compared tariff's plain rate card and never re-applies any event — so the minute compare differs at every event minute and `result["existing_tariff"]` goes False, withholding the badge (web.py) for that whole run. Compare fires daily at midnight or on the `compare_active` switch, so an event active at that moment is enough. Cost figures are unaffected: every scenario runs on its own refetched plain card. In-tree fix baselines already exist: `rate_import_no_io` (also excludes manual overrides — but the compared refetch never applies manual rates either, so override users already fail this comparison today; the new base makes that consistent rather than opening a gap) and `rate_export_base` (comment says saving sessions and overrides stay out of it). `test_compare` has zero setup for saving/free/IO slots and never asserts `existing_tariff` — a green compare test says nothing about the event-active path. Suspected, not verified: the `run_all()` finally block restores everything it saved **except** `rate_import`/`rate_export`, so the live tables may hold the last compared tariff's rates until the next `fetch_sensor_data()` rebuild — if "the live plan used a different tariff's rates right after running a comparison" is reported, start there. **The compared cost excludes the standing charge, and compare's `config:` block overrides only CONFIG_ITEMS (GH#5452, code-read on c073c369, reporter's premise confirmed):** `enable_standing_charge` (`prediction.py`) is gated on a save-mode list that omits `"compare"` — never in it from day one (#1952's birth commit shipped the list without it) — so the `run_prediction(..., save="compare")` call never adds `metric_standing_charge` at the once-per-midnight step; `enable_save_stats` *does* include `"compare"` (that is why the compared tariff's plan HTML renders at all). `select_best` ranks on the blended metric with no independent standing-charge term, so ranking inherits the gap. Traps for a fix or a future report: (a) **any `get_arg`-read apps.yaml arg that is not a CONFIG_ITEM is silently not overridable via a compare_list `config:` block** — `fetch_config` (`compare.py`) looks keys up in `config_index`, which `load_user_config()` builds solely from CONFIG_ITEMS — so a `metric_standing_charge` override is dropped with `Warn: Compare, config item metric_standing_charge not found` despite compare.md promising "any standard Predbat configuration setting" (check for "X not found" warns before assuming the override took); (b) a naive fix flipping the cost10 run to `save="compare"` would ALSO set `enable_save_stats` and repopulate `predict_*_best` from the PV10 scenario, rendering the compared tariff's plan HTML as the PV10 plan — the cost10 call (passes no save) needs its own treatment; (c) the compare page's "Actual" daily-cost line includes the standing charge while every projection excludes it, so projections under-read the Actual line by one day's standing charge — expect "Actual is always above the forecasts" reports; (d) two comment sites in `annual.py` claim `save="best"` (or `"compare"`/`"yesterday"`) switches on `enable_standing_charge` — only "best"/"yesterday" are true, "compare" is not; treat those comments as wrong until the gate changes. Sibling: #3775 (open enhancement, covers strictly less). **GH#5472 (triaged 2026-10-10, reporter's log timeline):** an id-less dict entry in `compare_list` crashes `run_all()` at `results.get(tariff["id"], {})` with `KeyError: 'id'` every 5 minutes, and one `Scenario complete for tariff:` line always precedes the first crash - the id-less entry never appears in the log. The crash converts once-a-day compare into an all-day loop: the sequence is `expose_config("compare_active", True)` → `run_all()` → `expose_config("compare_active", False)`, a crash inside `run_all()` skips the False, the switch persists True (`restore: False` in config.py), and every following cycle's gate (`(scheduled and minutes_now < RUN_EVERY) or compare_active`) re-enters - while a *successful* midnight compare is only 1-2 cycles. Each crash also skips the `load_minutes_step` cleanup, gc/malloc_trim and inverter quick-update scheduling - plan computation and execution already ran before compare, so live control is unaffected. Log fingerprint: `Scenario complete for tariff:` (only from `run_all()`) on every cycle = the re-arm loop, not the midnight window. Hand-edited apps.yaml is unvalidated at load (`_validate_compare_list()` is wired only into the web apps save); `run_single()` reads the id defensively (`tariff.get("id", "")`) while `run_all()` does not - fix direction: skip+warn on an id-less entry and/or validate at load, and reset `compare_active` in a `finally`. Open PR #5465 covers the same midnight comparison window for a different defect. | `compare` |
chalfontchubby added a commit that referenced this pull request Oct 11, 2026
Conflicted files:
- tools/debug-journal.md: the "Too many API errors" symptom row. Main's
  only change to that row was adding the missing trailing `|` cell
  terminator; the PR side rewrites the row for the outage-timer fix and
  already ends with ` |`. Kept the PR's row; every other journal change
  from main (the 2026-10-11 queue slice, #5481) merged in unchanged.

Main's gateway.py / test_gateway.py / cspell changes merged cleanly and
do not touch ha.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants