diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index a3fe095ad..13d61391e 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -233,6 +233,7 @@ foxcloud foxess foxesscloud freephase +fromisoformat fronius funcs futurerate @@ -713,6 +714,7 @@ vvwwxxabcdefyyzzuu waf waitpid Wallbox +weakref weblink welink Werror diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 561944d6e..1255526e3 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -58,7 +58,7 @@ A cheap way to settle these: replay the debug file once per suspect setting, cha Two `get_arg()` boolean traps affect every component boolean arg, and either alone can look like a feature silently not running (both pre-existing and generic, surfaced reviewing PR #4997): -- **An explicit empty value defeats the default.** `key:` with nothing after it is YAML null; `self.args.get(arg, default)` only applies the default when the key is *absent* (`userinterface.py:287`), and `validate_is_boolean()` (`predbat.py:1446`) passes `None` silently — so the component stores `None` and `not value` treats it as off. A default-True flag with `key:` in the yaml is off. +- **An explicit empty value defeats the default.** `key:` with nothing after it is YAML null; `self.args.get(arg, default)` only applies the default when the key is *absent* (`userinterface.py:287`), and `validate_is_boolean()` (`predbat.py:1446`) passes `None` silently — so the component stores `None` and `not value` treats it as off. A default-True flag with `key:` in the yaml is off. **Narrowed 2026-09-24 (PR #5188):** for component args declared in `COMPONENT_LIST`, `Components.initialize()` now resolves a YAML null back to the registry default (`components.py`, comment cites GH#5186), so this bullet no longer reaches component registry args — it survives for the larger population of direct `get_arg()` callers. The obvious global fix (a None arm in `get_arg`'s bool branch, which unlike its int/float branches has none) was deliberately left as a follow-up: `impact()` on `get_arg` rates CRITICAL, and since `None` and `False` are both falsy only bool-True defaults would change. The quoted-string bullet below is unaffected either way — `get_arg` returns a real `False` for it, so no resolver can distinguish it from an explicit opt-out. - **A quoted truthy string coerces to False.** `'1'` or `'y'` passes validation (anything `bool()`-able) but the coercion list `['on','true','yes','enabled','enable','connected']` (`userinterface.py:319`) turns it into `False`. Unquoted `1` parses as an int and stays truthy — the trap is specifically quoted strings. Bool defaults are safe from the int-default truncation pattern (`userinterface.py:307` excludes bool from `int(float())`) — that one (GH#4925) bites component args with float defaults like `solcast_poll_hours: 4.8`. Check both paths first on any "flag silently off" report. @@ -71,6 +71,8 @@ Bool defaults are safe from the int-default truncation pattern (`userinterface.p Residue a "fails in the suite, passes alone" report should still be checked against: hand-rolled snapshot/restore of `args` in `test_inverter.py` and `test_agent_tools.py`, and the restore-to-default shape (the #5061 pattern) `soc_kwh_history = {}` at entry and cleanup in `test_calculate_yesterday.py` — currently latent (nothing else populates it), but fragile by construction. Throwaway-instance precedents (`run_debug_cases`' per-case instances and the `*_isolated` runners in `unit_test.py`) remain the pattern for anything that replays a full dump. +Two more shapes from the GH#5169/PR #5171 round (code-read plus one observed red run): pollution through **module-level and class-level** state can still cross the per-test instances — `test_secrets.py`'s race test replaces `collect_log_secret_values` in another module's namespace (restoring it in a `finally`), and the log-redaction pattern lock is a class attribute on `LogRedaction` — so `run_shuffle` remains the detector for that class even though the instance half is fixed. And a test that **raises aborts the whole run**: the registry loop's per-test call is a bare `test_failed = func(my_predbat)` with no try/except (`unit_test.py`), so an exception stops later tests in the same module and every later module — observed during #5169's red run, where a test importing the module under test raised `ModuleNotFoundError` and the tests registered after it never ran. Put a new module's test before siblings that import it, and catch the expected `ImportError`/`AttributeError` inside the test so it reports a readable ERROR line instead of aborting. Related flow trap: `coverage/run_pre_commit` is `python3 -m pre_commit run --all-files && ./run_all --quick`, so when the markdownlint hook fails (in triage flows, on the uncommitted GitNexus edits to `.claude/skills/` and `CLAUDE.md`), the quick suite silently never runs — a log that ends at cspell with no test output is **not** a green suite; run `./run_all --quick` separately from `coverage/` when the suite result is what you are claiming. + **A found pair does not by itself say whether the fix belongs in the culprit's test cleanup or in the production code the victim exercises.** The first pair found (`calculate_yesterday` → `prediction_batch`, crashing on `self.car_charging_battery_size[car_n]` in `prediction.py:768` with no bounds check against `self.num_cars`) initially looked like it might be a real latent gap, since a genuine divergence between those two in production would crash the same way — but `calculate_yesterday()` turned out not to touch `car_charging_battery_size` at all, so that one traced back to test-fixture state, not a live code path. Check both before writing a fix. Unknown apps.yaml keys are **silently ignored** — `validate_config()` (`predbat.py:1459`) iterates over `APPS_SCHEMA` checking required-ness and type and never the reverse, so a typo'd or obsolete key produces no diagnostic at all. The nearest thing to a net is `unmatched_args` (`userinterface.py:1209`), which only captures args whose `re:` regex matched nothing (GH#4999 is the enhancement request for a warning). If that warning ever gets implemented, the allowlist cannot be `APPS_SCHEMA` alone: component yaml blocks (`octopus:`, `web:`, …) are validated via `COMPONENT_LIST` (`components.py:56`), Predbat injects derived args at runtime via `set_arg()` (so the check must read the startup snapshot `args_from_apps_yaml`, not live `self.args`), and dict-valued schema entries such as `predheat` have un-enumerated inner keys. @@ -97,22 +99,22 @@ Grep for the named symbol rather than trusting a line number. | Area | What past debugging found | Targeted test | |------|---------------------------|---------------| | Fox (`fox.py`) | The cloud API returns errno 42015/44096 for settings a given device does not support (`FOX_SETTINGS_UNSUPPORTED_ERRNO`); those are marked unavailable and never polled or written again. Entity type matters — WorkMode is a select, ExportLimit a number. Two later capacity/schedule traps, both still live: `publish_data()` sums every `batteryList` entry unconditionally (`fox.py:1779-1781`), and an AIO ESS returns one physical pack as four `bmu` entries all carrying the inverter's own `batterySN`, so `soc_max` comes out at 4x the correct `batteryDesignCapacity` and `soc_kw` with it (GH#4919, read out of the reporter's own API response). `fox_automatic: true` re-`set_arg`s `soc_max` every cycle and `soc_max` is not in `CONFIG_API_OVERRIDE`, so apps.yaml cannot override it - the escape hatch is `fox_automatic: false`. With the Mode Scheduler deleted the scheduler `enable` flag reads 0 and `compute_schedule()` (`fox.py:1080`) derives the displayed charge window from the legacy `forceChargeTime` read, which Predbat cannot clear: `set_battery_charging_time()` (`fox.py:1009`), the only writer of that endpoint, has zero production callers - re-verified on main, where it is referenced only by `test_fox_api.py` (GH#4939). Note this is the *residual* half of that issue: the blocker the reporter actually hit was HA event routing filtering on the literal string `predbat` rather than the configured entity prefix, **fixed in PR #4962**. Unlike the self-referential HA verify trap below, a Fox `didn't complete got X` is a genuine failure - the read-back polls entities the component republishes from Fox Cloud. A separate trap on the **HA/modbus path** (inverter type `FoxESS`, not `FoxCloud`): the stock template binds `reserve:` to `number.foxess_min_soc_on_grid` (`templates/fox.yaml`), and that file's own mode table ties both Freeze charging and Hold charging to that entity. A reporter had `reserve:` bound to `number.foxess_inverter_min_soc` instead - a name that appears nowhere in `templates/` or `docs/` - so Predbat never read or wrote `min_soc_on_grid`; when the inverter stranded it at 100 (observed sitting there six days) Predbat could neither see it nor clear it, and `min_soc_on_grid` at 100% stops the battery discharging while grid-connected. `FoxESS` is `has_idle_time: False` (`config.py`), so the `idle time is ...` line is bookkeeping only and never programs a demand period - discharge outside a charge window depends entirely on the inverter's own settings, which is what makes a wrong reserve binding silent (GH#4961). FoxCloud freeze export (GH#5015, **fixed in PR #5038**, v9.0.2 - keep the mechanism for pre-v9.0.2 logs): `support_feedin_first: True` makes `prediction.py`'s freeze branch model freeze export as genuine Feed-in-First — load exports up to the limit, only the surplus beyond it charges the battery — but the cloud path used to never select the `Feedin` work mode: `apply_battery_schedule()` (`fox.py`) only emitted `SelfUse`/`ForceCharge`/`ForceDischarge` groups and `adjust_inverter_mode()` hardcodes `SelfUse` for Fox (`inverter.py`), so the inverter sat in SelfUse where surplus PV charges the battery *before* exporting and the plan's freeze-export revenue didn't match the hardware. #5038 makes `apply_battery_schedule()` select `Feedin` as the baseline work mode whenever freeze export is requested; note the cloud API's verify read is slow there — a write can log success, read back pre-write 3s later and read back the new mode after ~18s (recorded in a `fox.py` comment), so a quick verify read is not evidence of failure. `support_feedin_first` therefore means two different things depending on connection method: the modelling is correct on the FoxESS HA/modbus path, where `discharge_freeze_service` genuinely selects Feed-in First (#4207, fixed by #4425), while #4582 extended the flag to the cloud defs with no equivalent execution path. Log check: a freeze-export day shows hundreds of full-day `SelfUse` groups in `Fox: New schedule` lines and never a `ForceDischarge` or `Feedin` group — and `ForceDischarge` appearing in a `Fetch scheduler V1 returned` enum list is **not** evidence a discharge window was written. Workaround: turn `set_export_freeze` off so the planner stops selecting freeze-export slots. #4182 is the neighbouring open issue. | `fox_api`, `fox_oauth` | -| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` now remembers `last_export_schedule_committed` and re-commits only on a real schedule change (the every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); a commit is only recorded when the writes *and* the button press both succeeded; `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup. Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. Two more. **PR #5189** (merged 2026-09-24, GH#5187) additionally caps every per-slot current at the inverter's **rated current** — the sysCommand.max clamp alone never involved the AC rating — and distrusts implausible recovery-SoC read-backs. And the hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) | `solis` | +| Solis (`solis.py`) | `SOLIS_CID_STORAGE_MODE = 636` is Modbus 43110, a bit mask. On firmware "4B and above" (what `is_tou_v2_mode()` detects: CID 6798 reads 43605) the timed charge/discharge enable moved to the per-slot registers — `SOLIS_CID_CHARGE_ENABLE_BASE`/`..._DISCHARGE_ENABLE_BASE`, Modbus 43707, six slots not three — and every mode value carrying TOU bit 1 (3/35/43/51/98) was dropped; 35 became 33, 98 became 96. Such an inverter answers a CID 636 write with code 0 and reads back without bit 1, so a log full of CID 636 verification warnings is a refused bit, not a failed write (GH#4707). `set_storage_mode_if_needed()` decides the bit from `is_tou_v2_mode()`: never asked for on V2, always asked for and retried on V1, where bit 1 still *is* the timed charge/discharge enable. Do not try to learn it from the read-back instead — GH#4710 did, and GH#4774 showed why that cannot work: on six inverters the same write of 179 verifies minutes before and minutes after the one that reads back 177, so a single post-write read is not evidence of a firmware property. Latching it was also silent rather than noisy: the verify read refreshes `cached_values`, so after a stripped write the cache held the stripped value, the suppressed computed value matched it, and `set_storage_mode_if_needed()` stopped writing CID 636 at all for the whole 8-hour verdict — across an overnight charge window. GH#4239's "only retained while a window is configured" is not the whole story — it was refused with slot 1 enabled and its window in force. `read_and_write_cid()` re-reads once after `verify_settle_seconds` before calling a mismatch a failure, because the immediate verify read is taken about half a second after the write. Do not "stop the failed verify poisoning the cache" — `write_cid()` caches the value it *requested* and the post-write `read_cid()` deliberately overwrites it with what the inverter actually reports, so the cache mirrors the inverter rather than Predbat's intent; skipping that update would leave the cache agreeing with the write that just failed, and change detection would then never retry it. The two control paths choose the storage mode differently and it matters: V1 picks from `in_charge_slot`/`in_discharge_slot`, which are clock tests, so CID 636 changes value and gets written the moment a window opens; V2 picks from `slot1_active`, which is only "slot 1 has a window configured", so the mode is written when the slot is *programmed* and nothing at all happens at the window boundary. On the GH#4774 night the last CID 636 write was 22 minutes before the window opened, and the comparison night that worked had one near the window - so `claim_window_mode_assertion()` now asserts the mode once per window on both paths. Slot registers are polled hourly, plus every 5 minutes while `is_inside_active_window()` is true, so drift during the window that matters is caught in the same cycle it happens. GH#4774 also left a useful negative result: that inverter grid-charged from 20% to 91% overnight with bit 1 clear throughout, confirming the per-slot enables alone drive timed charging on V2 — so a "charge window never started" report on V2 firmware is not a CID 636 problem and needs looking at elsewhere. Separately, an inverter reporting `batteryType 'No Battery'` stays in `self.inverter_sn` for polling; `is_battery_inverter()` keeps control writes off it, which was most of the warning volume in GH#4707. That explanation doesn't cover every CID 636 report, though: a later GH#4707 comment showed zero `setting storage mode to` idle-mode log lines, and both V1 mode-decision idle paths always log that line — so a report with neither line cannot be coming from `write_time_windows_if_changed()` at all. The only remaining write path is the HA select handler, `set_storage_mode_value()` at `solis.py:2574`. Check which write path actually ran before assuming this entry's explanation applies. Separately, on inverters using `H M` time format (`GS_fb00`, most cloud subtypes) `adjust_force_export()` used to re-commit a stable export window every cycle — `is_hm_format` alone made `changed_start_end` true, so `press_and_poll_button()` fired on every cycle of an unchanged export window (GH#4709, confirmed live on main at the time) — and PR #4713 fixed the twin idle-cycle press where the times being managed came back as `None` and never compared equal to what the inverter still reported (GH#4712, #2328). **Both merged together in PR #4711 (`bc853a0e`, 2026-09-12, post-v9.0.2)**: `adjust_force_export()` now remembers `last_export_schedule_committed` and re-commits only on a real schedule change (the every-cycle `H M` time-entity rewrite remains, deliberately — #1529 write reliability); a commit is only recorded when the writes *and* the button press both succeeded; `press_and_poll_button()` takes a `side` argument so a split-button config no longer presses the unrelated side's button (which is what cleared `timed_charge_current` alongside the intended one); and `adjust_inverter_mode()` now sleeps 30s after a real window change as a GivTCP settle workaround, so expect a 30s pause per real window change on those types. Keep the mechanism for pre-merge logs, where the signature is a button press logged every 5 minutes inside a window. Away from the CID work, `automatic_config()` has two arg-binding defects. It binds `battery_rate_max` - which feeds *both* the charge and the discharge cap - to the per-device `max_charge_power` entity (`solis.py:1516`), while the `max_discharge_power` entity it also creates is bound to nothing; `inverter.py:429`'s `min(inverter_limit_discharge, battery_rate_max_raw)` then caps discharge at the charge limit on an asymmetric inverter, and `inverter_limit_discharge` cannot lift it because the raw value is the binding constraint (GH#4940, confirmed against the reporter's debug yaml, still live). Auto-discovery also always beats a manual apps.yaml value here (`component_base.py:96-110`). Separately, on Solis Cloud/TOU-V2 the per-slot current write is clamped by `write_time_windows()` (`solis.py`, `new_current = min(slot_current, max_discharge_current_amps)`), where the cap is the *minimum* `sysCommand.max` metadata across **all six** slot CIDs from `cached_infos` — deliberately, because a slot 1 value above the others is not accepted. On GH#5068 that bound slot 1 at 60A against a ~93A plan, and the trap is that the number entity is republished from the local `charge_discharge_time_windows` cache, so the write verify reads back the *clamped* value converted at the **live** battery voltage (60A × ~51.6V ≈ 3094W, not 48V) and logs `didn't complete got 3094.0` every cycle — while a "Wrote N successfully now N" line can also be self-referential (cached 60 == clamped 60 skips the CID write entirely). Grep `CID 5967 ... is set to` to see what actually went to the inverter. The planner models `battery_rate_max_discharge` from apps.yaml/entity max and knows nothing of the clamp, so the plan is systematically optimistic while it binds; workaround is `inverter_limit_discharge` at 60A × pack voltage. #4220 is the same 60A clamp from the user's angle. Separately it built every arg list - PV, load and grid included - from the battery-filtered `devices` list (`solis.py:1456`, narrowed by `cd6e7c79`), dropping a PV-only inverter's generation from `pv_today`/`pv_power`; the PV half was fixed in PR #4923, load/grid deliberately left battery-only because those registers can overlap on a shared-CT install. One more shape worth remembering: the entity-event queue used to drain at the top of `run()`, before the `first` block created the `ClientSession` and discovered inverters, so an event queued during startup executed against `session=None` with an empty `inverter_sn` - and was popped before execution, making it a silently lost write (fixed in `9fe1f7e0`). Three September fixes and one still-live trap. **PR #5089 (merged 2026-09-14)** closed the SolisCloud API-allowance family (GH#5087/#5091): `B0115` ("Datalogger offline or disconnected") and `R0000` ("Daily API request allowance exhausted") are now classified in `SOLIS_API_CODES` and excluded from retry (`SOLIS_API_CODES_NO_RETRY`) - retrying cannot change the answer and every attempt still spends one of the 200 daily requests - with a per-inverter datalogger-offline cooldown (`datalogger_offline()`) so one offline datalogger backs off alone rather than the fleet; and `automatic_config()` is no longer first-cycle-only: `run()` retries it every cycle until it returns True (`automatic_config_done`), so an API outage at startup no longer leaves `load_today` unset until a Predbat restart (the ValueError-at-fetch symptom this produced has its own row in the symptom table). Keep the mechanism for pre-#5089 logs: B0115 was treated as rate limiting (10s sleep + retry, the `ad26f95a` "Quick rate limit hack") and any R-series code fell through to `Unknown code` with the full retry ladder - an offline datalogger drained the 200/day allowance and R0000 arrived hours later. `automatic_config()` makes no API calls of its own - it reads only `self.inverter_details` - and returns False when details are still missing, which is the retryable case. **PR #5095 (merged 2026-09-15)** fixed the amp↔watt conversion voltage (GH#5090): `get_nominal_voltage()` now prefers `solis_nominal_voltage` from apps.yaml (the only stated physical property), then a live reading above `SOLIS_HV_BATTERY_VOLTAGE` (HV packs keep the live voltage as they have since #4493), then BMS charge voltage classification (16S at/above 55V charge voltage, 15S below - settings rather than measurements, so the result holds across polls), then the 48V fallback; the live voltage had swung 10-14% over a month on every sampled system, dragging `battery_rate_max`, the write tolerance computed from it and the read-back of every rate setpoint along. No workaround existed pre-#5095 - `solis_nominal_voltage` fed only the capacity path (`get_capacity_voltage()`), so telling a user to set it fixed capacity but not the rate paths. Diagnostic tell for pre-#5095 logs: failed-write readbacks at a constant ratio of the target (e.g. exactly 66/70) are consistent with write-time vs read-time voltage difference alone. **Still live (GH#5093):** `SolisAPI.run()` folds per-inverter failures into one `poll_success` bool - discovery failure, any inverter's detail fetch, TOU window reads, the in-window re-read - and `ComponentBase.start()` gates startup on that return: False → `api_started` never set, and the startup burst (discovery, details, `startup_reset_registers()`, the infrequent poll) re-runs on a doubling backoff from 60s to a 128-minute cap, so a multi-inverter fleet is held hostage to the least reachable device and every retry re-spends quota. Per-inverter success booleans already exist at every `poll_success` site, so the fix is contained to `run()`; the interim workaround is listing only working SNs in `solis_inverter_sn`. PR #5185 (merged 2026-09-23) also backs off `startup_reset_registers()` per-inverter with a persistent B0600 pause, and a refused startup register read no longer aborts startup. Three Modbus-vs-component bindings and a mode-domain split (GH#5127/#5128/#5129, triaged 2026-09-17). On `charge_time_format: "H M"` types (`GS_fb00`, most cloud subtypes) `adjust_charge_window()` rewrites the charge start/end time entities on every 5-minute cycle of a programmed window (the format check in its write condition, inverter.py) - deliberately, the same #1529 write-reliability workaround the export side has - so "Predbat hammers my Solis with writes" is this first, not SoC-hold micromanagement: `adjust_battery_target()` is change-gated (logs `already at target` and writes nothing when equal) and the hold path's `disable_charge_window()` writes nothing unless the enable switch was on. The Modbus-path types (`GS`, `GS_fb00`, `SX4`, Sofar) have **no Predbat component at all** - every control arg is bound only by the hand-edited template; `create_missing_arg()` only ever *dummies* absent capabilities (a dummy `charge_limit` 100 when `has_target_soc` is False), so no INVERTER_DEF default binds a real entity, and a GS_fb00 user who misses the template's `charge_limit` uncomment gets a silent 100.0 target plus `No entity_id for charge_limit to write N` on every target change while the inverter keeps charging to its own default (GH#5129 - the signature is the repeated `No entity_id` line, not a startup error). On the Solis Cloud component the opposite applies: `automatic_config()` binds `charge_limit` with `set_arg_auto(..., overwrite=True)` (solis.py), so an apps.yaml `charge_limit:` pointing at the inverter's own timed-charge-SoC entity is replaced and logged `auto-discovery wins for this setting` - the first thing to grep a reporter's log for when #2328's guidance "didn't help" (GH#5127). Separately, Solis has **two mode-switch control paths whose mode domains do not overlap** (GH#5128): the HA `solax_modbus` integration (`inverter_type: "GS"`) writes a mode *name* from `SOLAX_SOLIS_MODES`/`SOLAX_SOLIS_MODES_NEW` through `alt_charge_discharge_enable()` and only ever computes 33/35 - the 64/96/98 "Feed-in priority" entries are dead in that table - while the SolisCloud API path above does use feed-in priority in its idle paths; so a "why doesn't Predbat use feed-in priority" report is GH#5128's gap on modbus and already-covered on the API component. Trap for any fix there: 33/35 mean different things in the two tables (`SOLAX_SOLIS_MODES` vs `_NEW`, the `solax_modbus_new` switch), so writes must go through the table-derived name, never a bare number; the reusable pattern is `support_feedin_first` (PR #5038, Fox). Check `fox.py`/`gecloud.py` for the same fold-everything-into-one-bool shape - GE Cloud still runs its own auto-config under `if first:` only, with no retry, so its self-heal after an outage is the pre-#5089-era slow one. **GH#5187, fixed in PR #5189 (merged 2026-09-24) - keep the mechanism for pre-#5189 logs.** Slot currents are now capped at the inverter's rated power (from the `inverter_size` sensor, logged `Capping slot currents on ... at the most the inverter can deliver at its rated power`); pre-#5189 the bound was only CID 7226 × count and the per-slot `sysCommand.max` metadata, so a 3.6 kW AC inverter reading 100 A at CID 7226 refused every 100 A slot-current write and left the slot enabled at 0 A, while the planner side (auto-config binds `battery_rate_max` to `max_charge_power`, GH#4940, and sets no `inverter_limit_discharge`) never saw the AC rating — `inverter_limit_discharge: 3600` remains the plan-side workaround where the modelling matters. Implausible recovery SoC (CID 7229) no longer defeats the #4706 clamp: 0/1/≤over-discharge/>100 now falls back to `over_discharge_soc + 1` with a log line; pre-#5189 a 65521 read passed the guard and made Predbat attempt to write the recovery register itself down every cycle. **Still live:** the write-skip on a never-read slot current CID — `cached_values.get(cid, cap)` defaults to the cap, so a register the poll has never returned reads as "no change" and **no write is attempted at all** (`solis.py`, both charge and discharge paths); if a fixture shows no write of the slot CID, check whether the fixture seeded it in `cached_values` before assuming a bug. Test notes from #5177: `test_solis.py`'s first-cycle tests mock `startup_reset_registers` via instance attribute (`del api.startup_reset_registers` restores the real method); a canned `api.session` set before `run(0, True)` reaches the real request path; and do not patch `asyncio.sleep` to speed up a retried error — `_with_retry` budgets with `time.monotonic()`, so a no-op sleep just burns the real ~30 s budget across ~8 attempts. The hold lever on Solis modbus is window-scoped (GH#5152, code-verified): the only discharge-suppression write is `set_current_from_power()`'s `timed_{charge,discharge}_current` (re-asserted on every rate adjustment, #4415); the standing `battery_discharge_current` registers are never driven anywhere in the repo, and GS makes the timed write the *only* lever (`has_reserve_soc: False`, `has_timed_pause: False`) — so a hold's 0A binds only inside the timed slot, and a "hold/freeze doesn't stop discharge" report on Solis-modbus points at GH#5152 before any plan logic: the execute-side hold gates fire correctly, the lever they pull is what is window-scoped. (The "only binds during the slot" firmware semantics is read off the register names in the solax_modbus integration, not yet live-log confirmed.) | `solis` | | SolaX (`solax.py`) | Code `10402` is a token/auth failure, retried in-request rather than waiting for the next cycle. SolaX clamps battery minimum SOC at 10% (`SOLAX_MIN_RESERVE_PERCENT`), so `battery_min_soc` is auto-configured to stop Predbat writing limits the inverter will reject. **Follow-up on the GH#4993 thread (2026-09-21, from a 23-hour reporter log):** `10402` repeating every cycle with data still flowing is **server-side token invalidation between cycles**, not broken auth or lost data — Predbat's in-request refresh recovers every cycle. The tell: the same token value succeeds mid-cycle and is rejected next cycle while Predbat holds it untouched in memory between cycles (the auth-error branch of `_request_get_impl()` clears it, and `request_wrapper()` retries with a fresh token instead of failing the fetch), and a stated ~30-day `expires_in` against a real ~1-cycle token lifetime. Prime suspect is a second client sharing the same `solax_client_id`/`solax_client_secret` — the reporter ran the separate SolaX Developer API HA integration — **suspected, not proven** (one-active-token-per-app is common but could not be confirmed against SolaX docs); the decisive test is disabling the second client for an hour or registering each client its own app. Count `error 10402` lines against `Fetched ... successfully` lines before calling data lost, and note each 10402 also increments the component's lifetime error counter (`solax.py` auth-error branch), which is what accumulates in the Components panel — do not "fix" this by removing the in-request retry. Secondary, same thread: `Warn: SolarAPI: No solar forecast data was returned from HA sensors.` with the SolaX Cloud template means **no PV forecast source is configured at all** — with `forecast_solar`/`open_meteo`/`solcast_host`+`solcast_api_key` all absent, `fetch_pv_forecast()` falls through to the HA-sensor branch (`solcast.py` sets `active_source = "ha_sensors"`) and the template's `pv_forecast_today`-style `re:` regexes (`templates/solax_cloud.yaml`) match nothing unless the Solcast HA integration is installed. Check which source the log names ("Obtaining solar forecast from Forecast Solar API" vs "Using Solcast integration from inside HA") — a working source dropped during a template migration looks exactly like this. | `solax` | | Sigenergy (`sigenergy.py`) | Lifetime/history totals are cumulative server-side and reset around EU midnight, which showed up as an overnight dip; `fetch_history_totals` applies a monotonic clamp. "Energy totals went backwards overnight" starts here. Separately, a user `apps.yaml` override of `inverter.has_reserve_soc: true` (stock `SIG` default is `False`, deliberately — reverted in `5bfc5c80` after #2873/#3124) bypasses two guards that normally make reserve writes inert for Sigenergy (`execute.py:947-950`, `inverter.py:547-549`), so `adjust_reserve()` writes straight to `number.sigen_plant_ess_discharge_cut_off_state_of_charge`. Confirmed from a reporter's log: zero occurrences of the "Inverter does not support reserve" disable message the first guard would normally log, plus 56 reserve writes landing on that register (GH#4728). The stock template's `reserve:` line also points at that same entity rather than `ess_backup_state_of_charge`, which is what makes the override immediately destructive rather than merely inert. `Applying mode=eco ... duration=720min` is emitted only by the no-window fall-through branch (`sigenergy.py:2199`), so seeing it while a plan export window is live means the component's control snapshot never saw the window. Two code-verified routes to that, both still live: with `sigenergy_automatic: False`, `automatic_config()` - the only wiring of the component's control entities into Predbat - never runs, so Predbat's plan writes land on its own simulated dummy entities (`inverter.py:539-545`) and a `True == True` read-back is Predbat comparing against itself rather than reading a register; and `fetch_controls` runs on the first tick only (`if first:`, `sigenergy.py:2658`), so a single missed `call_service` event leaves the component's internal state permanently out of step while Predbat's write-skip comparison guarantees the healing write is never re-issued (GH#4894, probe-verified in `test_sigenergy.py`). `_publish_mqtt` logged the live `accessToken` on every command until PR #4926 added `SigenergyAPI.redact()`. The docs template still writes `number.sigen_plant_grid_import_limitation` to 0 in both freeze branches (`docs/inverter-setup.md:1936-1940` and `:1971-1975`), and Sigenergy's own EVAC/EVDC chargers are EMS-throttled to the plant import limit - so the block lands exactly when Predbat asks for Freeze Charging to hold for a car (GH#4911). | `sigenergy` | | AlphaESS (`alphaess.py`) | Once any discharge window is configured (`ctrDis=1`), AlphaESS firmware allows discharge *only* inside that window — outside it the battery is charge-from-PV-only, with no self-consumption fallback (AlphaESS's own documented behaviour, per GH#4701). Predbat's generic handling of what happens outside a configured discharge window (`execute.py`) has no AlphaESS-specific case and assumes normal demand mode resumes there, which holds for other brands but not this one — the next best export window Predbat schedules can be hours away, leaving the battery locked out of discharge until then (GH#4723). A separate write-ordering bug, **fixed in PR #4776 (merged 2026-08-27)**: the periodic API commits a schedule in stages — window, then enable switch, then target SoC — pressing the write button after each, so the *first* commit of a cycle always carried the *previous* cycle's target while `alphaess_min_write_interval` (default 300s) held the correcting write back for the rest of that pacing window; a manual charge on an already-live slot could run at the stale target for up to five minutes (GH#4769, confirmed from the reporter's log, not inferred). Also: the legacy `/updateDisChargeConfigInfo` endpoint's `batUseCap` field (the reserve/export-SoC value) has no floor on the Predbat side (`alphaess.py:1103`/`:1143`), while the periodic path clamps it to AlphaESS's documented `[10,100]` range — a reserve/export-SoC entity set below 10 gets every legacy write rejected with an undocumented `10001` (GH#4748); that errno is the signature to recognise this class by. **The single most important thing to know here, documented on `main` since PR #4744: the AlphaESS Open API cannot control export at all, so Predbat cannot make it export.** There is no forced-export, working-mode or dispatch endpoint - only a grid-charge window with a target SoC, a discharge window with an SoC floor, or both together on entitled systems. Force Export therefore exports nothing beyond genuine solar surplus, and Freeze Export comes out byte-identical to Demand mode because `gridCharge` gates only *timed grid charging* and nothing can stop the battery charging from solar. The documented answer is to set `select.predbat_mode` to `Control charge` on AlphaESS; left on `Control charge & discharge` Predbat plans exports that never happen *and* programs the discharge window up to `plan_interval_minutes` early, which on permission-window semantics bars the battery from covering house load until the window opens (that is GH#4723's mechanism). Forced export is reachable over local Modbus, never from the cloud API. Separately, PR #4867 changed how a hold is expressed: Predbat now writes a synthetic **enabled 10% target at 100 W** on a stable daily window (`ALPHAESS_HOLD_SOC`/`ALPHAESS_HOLD_POWER`, `alphaess_const.py:183-184`) rather than the old zero-rate hold, because zero power disables charging in Predbat and AlphaESS may discard such a profile. A genuine grid charge is not replaced by it (`alphaess.py:1106`). Note the consequence, raised in review and not tested on hardware: unlike a zero-rate hold this profile is not energy-neutral by construction, so an install sitting below 10% SoC has a standing 100 W charge request while the hold is in force. | `alphaess_control` | | Ohme (`ohme.py`) | A vendored, version-pinned copy of `dan-r/ohmepy` (`ohme.py:33`, `VERSION`). GH#4719 (2026-08-25) found it several minor versions behind upstream with the control routes (max-charge, session rule) pointing at a withdrawn `/v1/chargeSessions/{id}/rule` — a 404 with Spring's `NoResourceFoundException` — while GET/pause/resume/approve kept working since those stayed on v1 upstream. **Fixed on `main` since** (confirmed 2026-08-27): the vendored client now calls the v2 routes upstream moved those endpoints to. A reporter on an older Predbat version seeing control-only failures (GETs/pause/resume fine, target time/percent/preconditioning silently not applying) is hitting this, not a new bug. Separately, and **still live** (GH#4952): `slot_list()` derives slot energy as `watts * hours` (`ohme.py:124`), treating `watts` as a sustained rate when the observed 16A -> 10A -> 6A taper shows it is a per-slot cap - a reporter measured 48.49 kWh modelled against 19.02 kWh of real charge, 2.55x. Ohme publishes `estimatedSoc`, which would give the true delta, and `ohme.py` never reads it anywhere. The same slots are published as dispatches with `location: "AT_HOME"` and no `source` (`ohme.py:670`), so `rate_add_io_slots()` stamps the whole plug-in-to-target block at the low rate. Note the existing tests assert the current `watts * hours` semantics, and `estimatedSoc` is itself extrapolated when the car has no SoC telemetry - so neither side is a drop-in swap. See the car-charging row for why the `predict()` clamp has to be trimmed *after* this, not before. | `ohme` | | Solcast (`solcast.py`) | `max_kwh` — the array-capacity ceiling used to sanity-check the forecast — is initialised to `9999` (`solcast.py:1339`) and is only ever reassigned on the Forecast.Solar and Open-Meteo branches. On the direct-API and HA-sensor Solcast paths it stays `9999`, so the "Raw forecast exceeds the array ceiling" warning (`solcast.py:1191`) can never fire for Solcast users — confirmed by reading the assignments (GH#4730). That warning is exactly the diagnostic an "impossible PV predicted" report needs, so its absence isn't evidence the ceiling wasn't exceeded. Branch order at `solcast.py:1368` also means a configured `solcast_api_key` always wins over `pv_forecast_*` HA sensors, so comparing Predbat's number against the HA entity compares against the wrong source when both are set. Two more. `solcast_poll_hours: 4.8` is silently truncated to 4: component args come from `COMPONENT_LIST`'s int defaults via `get_arg()`, which does `int(float(value))` (`userinterface.py:266`), and GH#4441's fix lives on the CONFIG_ITEMS route that component args bypass - the reporter's log showed real fetches at exactly 4h and 18x `code 429` against a 10/day hobbyist limit, while `docs/apps-yaml.md` recommends 4.8 for two-array accounts (GH#4925). And `publish_pv_stats()` reads the shared, briefly-rewound `midnight_utc` live (GH#4804) - see "The shared clock is rewound for ~0.8s every hour" above, which has since been confirmed as a real bug class by a separate merged fix. PV calibration self-reference (GH#5116, **fixed in PR #5121** — mechanism kept for older logs): `sensor._pv_forecast_h0` publishes the **calibrated** `power_nowCL` as its state whenever calibration is on, with the raw value only in the `now` attribute, so anything reading the state history gets calibrated output — and calibration itself used to read that history as its "past forecast" while applying its factors to the raw series, which settles the factor at **√(actual/raw)** and corrects only ~half the bias (logged 0.9073x against a measured 0.82). #5121 replaced the read with a dedicated `sensor._pv_forecast_h0_uncalibrated`, published unconditionally and never scaled by `pv_scaling`; `pv_forecast_history()` reads it (scaled by the *current* `pv_scaling`, so a change takes effect over the whole window at once), falls back per-point to the h0 `now` attribute and then the h0 state for points predating the sensor (`now` was added in the same commit that made the state calibrated, so a point without `now` recorded the raw state), and the web chart plots the uncalibrated series. The h0 state-vs-`now` distinction stays live for other consumers. Two general facts from the same PR's review: `history_attribute(attributes=True)` drops any point whose attributes lack the key unless `fallback_to_state=True` (the state path filters `unavailable`/`unknown`; the attribute path only skips them inside the fallback read — `utils.py`), and the DB mirror returns rows with `attributes = {}` on JSON decode failure (`db_engine.py` — the row is still appended), so an empty attribute read on a `db_primary` history does not prove the data predates the attribute. Anything comparing h0 attributes against Predbat's internal PV series must also account for `pv_scaling`, which sits on one side only (`now` is pre-scaling, the internal series post-scaling). Test-fidelity trap: a calibration test that patches `history_attribute_to_minute_data` cannot see which series calibration learned from — the real path needs a `get_history_wrapper` mock with HA-shaped entries wrapped in `patch_now_utc_exact()`, because `now_utc_exact` is real wall-clock while `minutes_now`/`midnight_utc` are frozen; `prune_today`'s default `group=15` also drops history points closer than 15 minutes apart. | `solcast` | | HA write/verify (`ha.py`, `inverter.py`) | `get_state(refresh=True)` is a no-op in a normal HA add-on install: it only re-reads when `not self.ha_key`, and `ha_key` is set in that case, so verification reads the websocket cache, never a fresh value (`ha.py:798`, since PR #2342). Separately, `write_and_poll_value()` treats `domain == "sensor"` as Predbat-owned and POSTs `/api/states/` directly (`inverter.py:2155`, `ha.py:1071-1077`) instead of calling the integration. Probed empirically across all four write-path/domain combinations (GH#4738): a control entity that resolves to a `sensor.*` domain by mistake gets a direct state overwrite that reads back as success, with zero corresponding lines in the real integration's log. `call_service_wrapper()`'s return value used to be discarded at every `inverter.py` write site, so a rejected HA service call (e.g. a Modbus "Illegal Function" error) never reached the verify logic. That is no longer true everywhere: PR #4878 made `call_service_template()` check it (`inverter.py:2817`) and log "was not accepted, it will be retried next cycle". The other eleven `call_service_wrapper()` call sites in that file still discard it, so check the specific write path before repeating the general claim. Later triage extended this row rather than replacing it. `write_and_poll_switch()` builds its service name as `domain + "/turn_..."` straight from the entity's own domain with no validation (`inverter.py:2102`), and `adjust_inverter_mode()` routes every `has_ge_eco_toggle` inverter through it - so an `inverter_mode` pointed at a `select.*` entity produces `select.turn_on`, an HA-invalid service retried 10x at 10s per attempt, and the string coercion just below (`inverter.py:2110-2111`) maps any select state to False, so the force-export direction reports success having written nothing (GH#4909, still live — though on 3-phase GEC the real problem is one stage deeper: no eco register exists at all, and the write path is skipped with `No entity_id for ECO Toggle`; see the GE Cloud row). `adjust_force_export`/`adjust_charge_window` format their times at plan precision with no snapping (`TIME_FORMAT_HMS`, `inverter.py:2608`/`:2615`), so against an integration whose select only offers 15-minute options (Solar Assistant/Deye) Predbat writes e.g. `20:55` and `write_and_poll_option` retries the impossible value 10x every cycle; nothing anywhere reads a select's `options` attribute, and `charge_time_entity_is_option` only decides *how* an entity is written, never which values are legal. The fix precedent is already in tree - AlphaESS's `snap_time_grid()` (`alphaess_const.py:215`), snap start forward and end backward with a collapse check (GH#4947). A second, independent way plan-precision times fail on the same select path: `write_and_poll_option()` only strips the seconds from an 8-char `HH:MM:SS` write when the *previous* state is a healthy 5-char `HH:MM:SS` read — the read feeding it returns `None` for an entity missing from the state cache (`ha.py:817`) or the literal `"unknown"`/`"unavailable"` when degraded (`ha.py:815`), either skips the strip and the raw `HH:MM:SS` goes to `select_option`, which HA rejects; all 10 retries re-read and fail identically, and the log reads `didn't complete got unknown` (GH#5009, probe-verified on main). The fix that serves both is reading the select's `options` attribute — nothing in the codebase does today. Six of the eight `adjust_*` writers discard the write helper's boolean and fire `call_notify()`/`mqtt_message()` regardless, and `rest_setReserve()` has the same gap (GH#4845). `call_service_template()` used to record its dedup hash *before* the call, so a silently failed `select_option` was skipped as "previously called" on every later cycle; fixed (it now records only on success and drops the record on failure), though it still deliberately returns True so callers cannot downgrade a freeze into a stop (GH#4876). From that same report, a false-alarm class worth recognising: a `didn't complete got X` where X is exactly the *previous* cycle's target, on an integration that republishes its entities slowly, is the verify window reading HA's cache - the write had already landed by the next cycle. That window is arithmetic, not a constant: up to `INVERTER_MAX_RETRY` (10) writes, each polled through one `inv_write_and_poll_sleep` budget with a doubling read interval - **~40s on GS, ~100s on GE/GEC/GEE** (`write_and_poll_sleep` is 10 for those types, 4 for GS). A settling bounce that outlasts it is a second false-alarm shape (GH#5130): a HA `number.*` entity can step through intermediates (0 → a float → the settled int) for tens of seconds after a timed-slot write (~44s observed on solax_modbus), so a `didn't complete got ` whose value sits *between* the old and target values is a settling read-back, not a failed write - it self-heals next cycle when the initial read matches. The bounce reads feed only `value_matched()` (keep polling vs rewrite) and only the final read reaches the control ledger (`record_write` on match, `clear` on failure), so the worst case is a redundant same-value rewrite plus the false warning; the in-repo precedent for re-reading after settling is Solis Cloud's `verify_settle_seconds` re-read. And the discharge-stop fallback (GH#5094, still live): `adjust_charge_immediate()` issues a "stop discharge" service step before the charge/freeze step, and when `discharge_stop_service` is not configured it falls back to `charge_stop_service` with `domain="discharge"` - a documented fallback dating to #1614. On Tesla service-hook setups `charge_stop_service` maps to `self_consumption` + backup reserve 0, which on a Powerwall is **active discharge to load** (the EV charger counts as load), not a stop - so every freeze/hold cycle Predbat sends "discharge normally" and then re-commands "hold" ~2s later, and Tesla occasionally applies the first and drops the second, so the battery discharges into the car until the next 5-minute cycle. Verified from the reporter's log: the exact pair fires from the carHolding branch ("Disabling battery discharge whilst car 0 is charging" → `adjust_charge_immediate(soc_percent, freeze=True)`); the reporter had commented out `discharge_stop_service` (and `discharge_start_service`) when disabling export control, which is what exposed the fallback. Workaround: define `discharge_stop_service` in apps.yaml with commands that actually stop discharge (for a no-export setup, the same commands as `charge_freeze_service`). Caveat on the reporter's own `repeat: False` idea: enabling the service dedup means accepted calls are not re-sent (see the GH#4876 entry above - HA accepts the call but Tesla firmware can still drop it), so dropped freeze commands would never be retried. Both this and the fixed export clobber below stem from the same property of the Tesla template set: `charge_stop_service` and the freeze/discharge templates all write the same `operation_mode` select, and the templates mean opposite things on this hardware. | `inverter` | -| GE Cloud (`gecloud.py`) | Gateway fields can come back null. `merge_non_null` stops nulls overwriting good values, and the publish path guards null containers — GH#4656 was a null crash in that area. `number_event` clamps an out-of-range write silently and does not report the clamped value back, while `write_and_poll_value()` keeps comparing against the original pre-clamp target - a permanent false `had_errors` and an unbounded retry (GH#4826). PR #4828 clamped inside `adjust_reserve()` only; the shared write helper still has no min/max awareness and the other `inverter.py` write sites still pass unclamped targets (GH#4831). The error-code path has since been reworked (`9560c61a`, `d6e5998c`) after GH#4896 showed `success: false` nulled `data` before the code branch could read it, so codes GivEnergy documents as never succeeding on a repeat (-3/-4/-7) were retried the full budget and `message` was never surfaced. Separately, **fixed in PR #4954 (merged 2026-09-06)** - keep the mechanism for anyone holding an older log: for `GE`/`GEC`/`GEE` the max battery rate was taken solely from the `charge_rate` entity's `max` attribute, and the `elif "battery_rate_max"` branch below it was unreachable for those types. Percentage-rated models (the 3-phase units) have no absolute charge power register, so `ge_cloud_automatic` left `charge_rate` unset and the attribute read returned the 2600 W default; both the correct rate GECloud writes into `battery_rate_max` and the user's own apps.yaml value went unread, and every rate was clamped to 2600 W. On those models it also corrupted the writes, since the percentage registers are set as `new_rate / battery_rate_max_raw * 100` against a denominator 3.8x too small. The fix falls back to `battery_rate_max` only when `charge_rate` resolves to nothing (GH#4908). GH#4198 is the same code shape on SolaX and is not covered by that fix. A model-string parser gap sits above that path (GH#5136, still live): `get_max_inverter_rate_from_model()` (gecloud.py) matches only decimals (`\d+\.\d+`), so an integer-rated model such as `GIV-3HY-11` falls back to `max_charge_rate` - which the GE Cloud API can change independently (11000 → 9984 W observed) - and `publish_info()` reads only `max_charge_rate`, never the `max_discharge_rate` in the same payload. Distinct from GH#4908 (the absent-`charge_rate` 2600 W default). Start on any "GEC inverter rate is wrong" report by checking the model string for a decimal point; the test covers only decimal cases. `automatic_config()` binds `inverter_limit` to the resulting `_max_inverter_rate` sensors and `prediction.py` clips PV against `inverter_limit`, so the error reaches the plan. One API-shape trap on the site endpoint (`GE_API_SITE`, `site/{uuid}`, added for PR #4978): the published OpenAPI spec types `limits.import`/`limits.export` as a string with a null example and never shows a populated one, so the shape had to be learned from live accounts. A populated limit is `{'enabled': bool, 'power': {'watts': int, 'amps': float}}`, and **`enabled` is the thing that matters** - a site describes its connection's declared capacity in exactly the same structure as a curtailment, marked disabled, so `power.watts` alone tells you nothing about whether anything is being restricted. Site 64744 gave `{'import': {'enabled': False, 'power': {'watts': 46000, 'amps': 200}}, 'export': {'enabled': True, 'power': {'watts': 4500, 'amps': 19.6}}}` - a 200A supply whose export really is curtailed to 4.5kW - while site 46714's disabled 6kW export is its declared capacity and no restriction at all. A fleet survey confirmed the flag across accounts, so only an enabled limit is applied; an enabled zero is a real zero-export connection. A site with no limit reports a null import/export. For a fleet survey use `GET /v1/site`, the list endpoint, which returns every site's `limits` and the inverters under it (with `info.max_discharge_rate`) in one response - no second call per site. Two GEC triage tools and one clobber trap (GH#5017). The component publishes an entity for every `/settings` entry with no name filter (`async_get_inverter_settings()`/`publish_registers()`, `regname_to_ha()` is plain lowercasing), so **absence of an entity in the reporter's debug yaml is proof the API never listed the register** — "the app can control it" does not imply the settings API exposes it, because the app/GivTCP write via local control paths rather than the `/settings` list (a GIV-3HY-20 had slot 1/2 lower-SoC registers absent while 3..10 existed). Caveat: the settings list is fetched once per process start (`register_list` cache) and only the parsed flags are logged, so the debug yaml is the only observable. To capture the raw register list, `python3 apps/predbat/gecloud.py --api-key --write-entity number.probe_nothing 0` makes `test_gecloud_direct()` print the full "Available entities/registers" list with raw cloud names and setting IDs and performs no write — but the harness's startup pass also runs `enable_default_options()` against the real inverter (floors reset, unused windows zeroed), so say so when asking a user to run it. Clobber trap: the missing-feature branch `set_arg("discharge_target_soc", None)` *deletes* the key, including an explicit apps.yaml entry the user wrote; `set_arg_auto(..., overwrite=False)` (component_base.py) is the in-repo pattern for letting an explicit value win — check the `has_*` detection branch on any "automatic config deleted my setting" report. The `inverter_mode` half of this class was fixed in PR #5059 (`885c637d`) — it now binds via `set_arg_auto(..., overwrite=False)` like `export_limit` — but the other missing-feature deletes are still plain `set_arg(..., None)` on main: `pause_mode`, `pause_start_time`, `pause_end_time`, `discharge_target_soc`, `charge_rate_percent`, `discharge_rate_percent`, `givtcp_rest`. A companion test-fidelity trap (GH#5056, verified on main): the component test mocks (`MockBase.set_arg` in `test_ge_cloud.py`; same shape in `test_ohme.py` and `test_alphaess_api.py`, whose mock `set_arg_auto` also writes unconditionally) store the value instead of mirroring the real `set_arg()`'s None-deletes, and their `args_from_apps_yaml` is empty so `set_arg_auto()` falls through to the plain path — so the "feature not detected" assertions (`config_args.get(...) is None`) pass identically whether the code stored None or deleted the key, and a green suite cannot distinguish fixed from unfixed. Any test for this class must populate `args_from_apps_yaml` and assert the manual value survives — #5059's Test 10 in `test_ge_cloud.py` is now the in-repo precedent. And the deeper half of GH#4909 (still live): the 3-phase GEC class exposes **no** eco/demand/mode register in `inverter/{sn}/settings` — verified by enumerating the `predbat_gecloud_*` entities in the reporter's debug dump — yet the `GEC` def sets `has_ge_eco_toggle: True` unconditionally (`config.py`) and auto-config maps `inverter_mode` via `build_entities("switch", ["enable_eco_mode"])` (gecloud.py), which emits nothing when the register is missing, so `adjust_inverter_mode()` logs `No entity_id for ECO Toggle` and returns without writing every plan cycle and freeze export silently no-ops (force-discharge paths work fine). Fix wrinkle for the maintainer: the flag is one per-inverter-type bool while `build_entities` works per-device. The freeze-export half of the same class (GH#5082, follow-up to GH#4909, code-verified on main): `inv_has_timed_pause` is probed at runtime (inverter.py flips it False when the `pause_mode` entity is absent/unavailable) but `inv_support_discharge_freeze` is read only from the static `INVERTER_DEF` entry (`config.py` sets it True for GEC), so `execute.py` never force-disables `set_export_freeze` for the eco-less/pause-less 3-phase class and a freeze-export plan can never be executed. Check both flags on any "freeze silently didn't work on GEC" report - and do not grep only for `No entity_id for ECO Toggle`: that warning comes from the eco branch, while freeze export on this class fails primarily through the pause/rate branch, so the eco warning being absent says nothing about the freeze path. Fix direction: mirror the `inv_has_timed_pause` presence-probe; the manual-freeze-export override path already degrades correctly when `set_export_freeze` is False (plan.py drops to demand). Hardware-reported on the GIV-3HY class: `charge_power_rate` limits AC (grid) charging only - `battery_power` stays at the full discharge figure while the charge register reads 1% - so the `adjust_charge_rate(0)` fallback in the freeze branch is a no-op for PV charging there; note `charge_discharge_with_rate` is False for GEC, so the first `adjust_charge_rate(0)` in each branch is skipped and the write only happens via the `has_timed_pause`-else fallback. Fix precedent for building a freeze from registers the device actually has: `inv_support_feedin_first` + Fox Cloud freeze export (PR #5038); the GIV-3HY exposes `enable_force_discharge` + `discharge_down_to_percent` + `battery_reserve_percent`, the raw material for the #4909/#5082 option-2 freeze. And since #5056 a manual `inverter_mode` apps.yaml override is silently deleted by automatic config, so "just map the eco toggle manually" is not currently viable either. The neighbouring **charge-side** gap (GH#5040: `scheduled_charge_enable` had no `enable_force_charge` candidate, so a 3-phase GEC that gates timed grid charge behind both charge switches logged real charge states while importing nothing) is **fixed in PR #5042** (`a7121135`, v9.0.2): auto-config now binds `scheduled_charge_enable` to `enable_force_charge` with `ac_charge_enable`/`enable_ac_charge` as fallbacks, and `enable_default_options()` holds `enable_ac_charge` on as a static enable when the force register exists. Keep the mechanism for pre-v9.0.2 logs, where the signature is a 3-phase GEC showing plan charge states against flat grid power with no warning anywhere. GE Cloud EMS plants (GH#5103, **fixed in PR #5109, merged 2026-09-24** — keep the mechanism for pre-#5109 logs): with an EMS discovered `polling_mode` is set False (`gecloud.py`), and the settings refresh used to re-read each battery inverter's `/settings` only on the first tick of the process — so the AC3 registers, including the DC-discharge slot windows, were read once and published stale for the rest of the run, while live status/meter polling kept looking normal. #5109 re-reads settings hourly (`SETTINGS_SLOW_REFRESH_SECONDS`, `gecloud.py`), refreshes the EMS and gateway devices every cycle, and runs `check_ems_inverter_slots()` on every snapshot — which now *warns* when a battery inverter's slot 1 is not the full-day window (the #3781 rule: it will override the EMS and can stop charging or discharging early), so the non-00:00–23:59 slot 1 fault is a visible warning on current main and silent only pre-#5109 (pre-fix signature: plan/status look right but the batteries hold 0 W past a fixed wall-clock time every night). Under EMS auto-config Predbat still writes mode/schedule-enable/rates to the AC3s. Nuance: the startup read itself can be skipped when the storage settings cache is under 10 minutes old (`settings_from_cache`), so even a restart is not guaranteed to re-snapshot the registers. | `ge_cloud` | -| Teslemetry / Powerwall (`teslemetry.py`) | Battery model is inferred from site `nameplate_power / battery_count`. A nameplate fallback combined with `inverter_hybrid: True` once produced a false 5 kW inverter limit and spurious morning export. Tariff writes push a whole TOU schedule every cycle, so a setting changed by hand in the Tesla app is reverted on the next cycle (GH#4600, GH#4610). Three later findings. `build_tariff()`/`_render_side` carve a *single* ON_PEAK interval priced with one scalar (`teslemetry.py:1117`/`:1036`/`:1074`), so no intra-window price gradient can exist and Tesla's own TBC is free to defer the whole export to the end of the window; the no-rate-data fallback branch also skips the boost entirely (GH#4887). The charge path sends no rate at all - `evaluate_schedule` (`teslemetry.py:539`) asserts mode `backup` plus reserve = charge target plus grid charging - so a slow charge ramp is Tesla firmware, not a Predbat write bug (GH#4892); Tesla now snaps a backup reserve of 81-99% down to 80% while Predbat still forwards any 0-100 target, and `automatic_config()` overwrites a manually set `battery_rate_max` whenever `teslemetry_automatic` is on. Since PR #4976 there is a Predbat-side lever for exactly that slow ramp: the `teslemetry_tbc_control` setting (**default on** since PR #5188; single source of truth in `teslemetry.py`, registry default in `components.py`) switches `evaluate_schedule()` to `evaluate_schedule_tbc()`, which pushes a **signal tariff** (`build_signal_tariff()` - fixed 0/50/100p bands over the committed charge and export windows) so Tesla's own optimiser runs the charge and reaches the full rate reserve-driven charging cannot; mode goes to `autonomous` in every state, reserve becomes an actual reserve rather than a charge signal, and `_settable_reserve()` maps requests onto reserves the Powerwall honours - an 81-99% request now rounds **up** to 100 rather than being forwarded raw into the snap-to-80 band. The same PR stopped `plan.py` planning a manual freeze export on inverters that cannot do it. A related pricing bug in the same `build_tariff()` family was fixed in PR #4977: an export window ending at midnight priced the whole of tomorrow at peak. And `optimization_strategy: "economics"` *is* pushed with `tariff_content_v2` on every `set_tariff()`, unconditionally, since v8.49.0 / PR #4603 - a request to "add" it is asking for something already shipped (GH#4918). **GH#5157** (reporter's evidence, not independently probed — Powerwall 2, fw 26.26.4): the unit sat in the idle branch of `evaluate_schedule` and simply stopped acting on a standing device tuple for ~4h while `site_info` read back the *correct* configuration — drift invisible to every field the API exposes, with only a Predbat restart recovering it; the stall state is the one branch Predbat legitimately holds for hours, which is why transition-based self-heal never fires. A "Powerwall plateaued / stopped discharging until I restarted Predbat" report maps here first. **Implemented on main** (2026-09-19): `run()` re-asserts the whole device tuple — export rule, grid charging, reserve, mode, not the tariff — every `FORCED_ASSERT_SECONDS` (2h) with the write-on-change dedupe bypassed (`force=True`), the timer advancing only on a fully successful forced assert; the Info line `Teslemetry control drift-correction is transition-based ..., backed by a forced re-assert of the full device tuple every 120 minutes` names it, so on current main the stall is bounded at ~2h. | `teslemetry` | +| GE Cloud (`gecloud.py`) | Gateway fields can come back null. `merge_non_null` stops nulls overwriting good values, and the publish path guards null containers — GH#4656 was a null crash in that area. `number_event` clamps an out-of-range write silently and does not report the clamped value back, while `write_and_poll_value()` keeps comparing against the original pre-clamp target - a permanent false `had_errors` and an unbounded retry (GH#4826). PR #4828 clamped inside `adjust_reserve()` only; the shared write helper still has no min/max awareness and the other `inverter.py` write sites still pass unclamped targets (GH#4831). The error-code path has since been reworked (`9560c61a`, `d6e5998c`) after GH#4896 showed `success: false` nulled `data` before the code branch could read it, so codes GivEnergy documents as never succeeding on a repeat (-3/-4/-7) were retried the full budget and `message` was never surfaced. Separately, **fixed in PR #4954 (merged 2026-09-06)** - keep the mechanism for anyone holding an older log: for `GE`/`GEC`/`GEE` the max battery rate was taken solely from the `charge_rate` entity's `max` attribute, and the `elif "battery_rate_max"` branch below it was unreachable for those types. Percentage-rated models (the 3-phase units) have no absolute charge power register, so `ge_cloud_automatic` left `charge_rate` unset and the attribute read returned the 2600 W default; both the correct rate GECloud writes into `battery_rate_max` and the user's own apps.yaml value went unread, and every rate was clamped to 2600 W. On those models it also corrupted the writes, since the percentage registers are set as `new_rate / battery_rate_max_raw * 100` against a denominator 3.8x too small. The fix falls back to `battery_rate_max` only when `charge_rate` resolves to nothing (GH#4908). GH#4198 is the same code shape on SolaX and is not covered by that fix. A model-string parser gap sits above that path (GH#5136, still live): `get_max_inverter_rate_from_model()` (gecloud.py) matches only decimals (`\d+\.\d+`), so an integer-rated model such as `GIV-3HY-11` falls back to `max_charge_rate` - which the GE Cloud API can change independently (11000 → 9984 W observed) - and `publish_info()` reads only `max_charge_rate`, never the `max_discharge_rate` in the same payload. Distinct from GH#4908 (the absent-`charge_rate` 2600 W default). Start on any "GEC inverter rate is wrong" report by checking the model string for a decimal point; the test covers only decimal cases. `automatic_config()` binds `inverter_limit` to the resulting `_max_inverter_rate` sensors and `prediction.py` clips PV against `inverter_limit`, so the error reaches the plan. One API-shape trap on the site endpoint (`GE_API_SITE`, `site/{uuid}`, added for PR #4978): the published OpenAPI spec types `limits.import`/`limits.export` as a string with a null example and never shows a populated one, so the shape had to be learned from live accounts. A populated limit is `{'enabled': bool, 'power': {'watts': int, 'amps': float}}`, and **`enabled` is the thing that matters** - a site describes its connection's declared capacity in exactly the same structure as a curtailment, marked disabled, so `power.watts` alone tells you nothing about whether anything is being restricted. Site 64744 gave `{'import': {'enabled': False, 'power': {'watts': 46000, 'amps': 200}}, 'export': {'enabled': True, 'power': {'watts': 4500, 'amps': 19.6}}}` - a 200A supply whose export really is curtailed to 4.5kW - while site 46714's disabled 6kW export is its declared capacity and no restriction at all. A fleet survey confirmed the flag across accounts, so only an enabled limit is applied; an enabled zero is a real zero-export connection. A site with no limit reports a null import/export. For a fleet survey use `GET /v1/site`, the list endpoint, which returns every site's `limits` and the inverters under it (with `info.max_discharge_rate`) in one response - no second call per site. Two GEC triage tools and one clobber trap (GH#5017). The component publishes an entity for every `/settings` entry with no name filter (`async_get_inverter_settings()`/`publish_registers()`, `regname_to_ha()` is plain lowercasing), so **absence of an entity in the reporter's debug yaml is proof the API never listed the register** — "the app can control it" does not imply the settings API exposes it, because the app/GivTCP write via local control paths rather than the `/settings` list (a GIV-3HY-20 had slot 1/2 lower-SoC registers absent while 3..10 existed). Caveat: the settings list is fetched once per process start (`register_list` cache) and only the parsed flags are logged, so the debug yaml is the only observable. To capture the raw register list, `python3 apps/predbat/gecloud.py --api-key --write-entity number.probe_nothing 0` makes `test_gecloud_direct()` print the full "Available entities/registers" list with raw cloud names and setting IDs and performs no write — but the harness's startup pass also runs `enable_default_options()` against the real inverter (floors reset, unused windows zeroed), so say so when asking a user to run it. Clobber trap: the missing-feature branch `set_arg("discharge_target_soc", None)` *deletes* the key, including an explicit apps.yaml entry the user wrote; `set_arg_auto(..., overwrite=False)` (component_base.py) is the in-repo pattern for letting an explicit value win — check the `has_*` detection branch on any "automatic config deleted my setting" report. The `inverter_mode` half of this class was fixed in PR #5059 (`885c637d`) — it now binds via `set_arg_auto(..., overwrite=False)` like `export_limit` — but the other missing-feature deletes are still plain `set_arg(..., None)` on main: `pause_mode`, `pause_start_time`, `pause_end_time`, `discharge_target_soc`, `charge_rate_percent`, `discharge_rate_percent`, `givtcp_rest`. A companion test-fidelity trap (GH#5056, verified on main): the component test mocks (`MockBase.set_arg` in `test_ge_cloud.py`; same shape in `test_ohme.py` and `test_alphaess_api.py`, whose mock `set_arg_auto` also writes unconditionally) store the value instead of mirroring the real `set_arg()`'s None-deletes, and their `args_from_apps_yaml` is empty so `set_arg_auto()` falls through to the plain path — so the "feature not detected" assertions (`config_args.get(...) is None`) pass identically whether the code stored None or deleted the key, and a green suite cannot distinguish fixed from unfixed. Any test for this class must populate `args_from_apps_yaml` and assert the manual value survives — #5059's Test 10 in `test_ge_cloud.py` is now the in-repo precedent. And the deeper half of GH#4909 (still live): the 3-phase GEC class exposes **no** eco/demand/mode register in `inverter/{sn}/settings` — verified by enumerating the `predbat_gecloud_*` entities in the reporter's debug dump — yet the `GEC` def sets `has_ge_eco_toggle: True` unconditionally (`config.py`) and auto-config maps `inverter_mode` via `build_entities("switch", ["enable_eco_mode"])` (gecloud.py), which emits nothing when the register is missing, so `adjust_inverter_mode()` logs `No entity_id for ECO Toggle` and returns without writing every plan cycle and freeze export silently no-ops (force-discharge paths work fine). Fix wrinkle for the maintainer: the flag is one per-inverter-type bool while `build_entities` works per-device. The freeze-export half of the same class (GH#5082, follow-up to GH#4909, code-verified on main): `inv_has_timed_pause` is probed at runtime (inverter.py flips it False when the `pause_mode` entity is absent/unavailable) but `inv_support_discharge_freeze` is read only from the static `INVERTER_DEF` entry (`config.py` sets it True for GEC), so `execute.py` never force-disables `set_export_freeze` for the eco-less/pause-less 3-phase class and a freeze-export plan can never be executed. Check both flags on any "freeze silently didn't work on GEC" report - and do not grep only for `No entity_id for ECO Toggle`: that warning comes from the eco branch, while freeze export on this class fails primarily through the pause/rate branch, so the eco warning being absent says nothing about the freeze path. Fix direction: mirror the `inv_has_timed_pause` presence-probe; the manual-freeze-export override path already degrades correctly when `set_export_freeze` is False (plan.py drops to demand). Hardware-reported on the GIV-3HY class: `charge_power_rate` limits AC (grid) charging only - `battery_power` stays at the full discharge figure while the charge register reads 1% - so the `adjust_charge_rate(0)` fallback in the freeze branch is a no-op for PV charging there; note `charge_discharge_with_rate` is False for GEC, so the first `adjust_charge_rate(0)` in each branch is skipped and the write only happens via the `has_timed_pause`-else fallback. Fix precedent for building a freeze from registers the device actually has: `inv_support_feedin_first` + Fox Cloud freeze export (PR #5038); the GIV-3HY exposes `enable_force_discharge` + `discharge_down_to_percent` + `battery_reserve_percent`, the raw material for the #4909/#5082 option-2 freeze. And since #5056 a manual `inverter_mode` apps.yaml override is silently deleted by automatic config, so "just map the eco toggle manually" is not currently viable either. The neighbouring **charge-side** gap (GH#5040: `scheduled_charge_enable` had no `enable_force_charge` candidate, so a 3-phase GEC that gates timed grid charge behind both charge switches logged real charge states while importing nothing) is **fixed in PR #5042** (`a7121135`, v9.0.2): auto-config now binds `scheduled_charge_enable` to `enable_force_charge` with `ac_charge_enable`/`enable_ac_charge` as fallbacks, and `enable_default_options()` holds `enable_ac_charge` on as a static enable when the force register exists. Keep the mechanism for pre-v9.0.2 logs, where the signature is a 3-phase GEC showing plan charge states against flat grid power with no warning anywhere. GE Cloud EMS plants (GH#5103, **fixed in PR #5109, merged 2026-09-24** — keep the mechanism for pre-#5109 logs): with an EMS discovered `polling_mode` is set False (`gecloud.py`), and the settings refresh used to re-read each battery inverter's `/settings` only on the first tick of the process — so the AC3 registers, including the DC-discharge slot windows, were read once and published stale for the rest of the run, while live status/meter polling kept looking normal. #5109 re-reads settings hourly (`SETTINGS_SLOW_REFRESH_SECONDS`, `gecloud.py`), refreshes the EMS and gateway devices every cycle, and runs `check_ems_inverter_slots()` on every snapshot — which now *warns* when a battery inverter's slot 1 is not the full-day window (the #3781 rule: `Inverter X slot 1 is not 00:00-23:59 ... it will override the EMS and can stop charging or discharging early`), once per episode — recorded in `ems_slot_warned` so a standing misconfiguration does not inflate the error counter on every refresh, so the non-00:00–23:59 slot 1 fault is a visible warning on current main and silent only pre-#5109 (pre-fix signature: plan/status look right but the batteries hold 0 W past a fixed wall-clock time every night). Under EMS auto-config Predbat still writes mode/schedule-enable/rates to the AC3s. Nuance: the startup read itself can still be skipped when the storage settings cache is under 10 minutes old (`settings_from_cache`), so even a restart is not guaranteed to re-snapshot the registers. | `ge_cloud` | +| Teslemetry / Powerwall (`teslemetry.py`) | Battery model is inferred from site `nameplate_power / battery_count`. A nameplate fallback combined with `inverter_hybrid: True` once produced a false 5 kW inverter limit and spurious morning export. Tariff writes push a whole TOU schedule every cycle, so a setting changed by hand in the Tesla app is reverted on the next cycle (GH#4600, GH#4610). Three later findings. `build_tariff()`/`_render_side` carve a *single* ON_PEAK interval priced with one scalar (`teslemetry.py:1117`/`:1036`/`:1074`), so no intra-window price gradient can exist and Tesla's own TBC is free to defer the whole export to the end of the window; the no-rate-data fallback branch also skips the boost entirely (GH#4887). The charge path sends no rate at all - `evaluate_schedule` (`teslemetry.py:539`) asserts mode `backup` plus reserve = charge target plus grid charging - so a slow charge ramp is Tesla firmware, not a Predbat write bug (GH#4892); Tesla now snaps a backup reserve of 81-99% down to 80% while Predbat still forwards any 0-100 target, and `automatic_config()` overwrites a manually set `battery_rate_max` whenever `teslemetry_automatic` is on. Since PR #4976 there is a Predbat-side lever for exactly that slow ramp: `teslemetry_tbc_control` — **on by default since PR #5188** (GH#5186; `DEFAULT_TBC_CONTROL = True` in `teslemetry.py`, mirrored in `COMPONENT_LIST`, with `teslemetry_tbc_control: False` as the opt-out) — switches `evaluate_schedule()` to `evaluate_schedule_tbc()`, which pushes a **signal tariff** (`build_signal_tariff()` - fixed 0/50/100p bands over the committed charge and export windows) so Tesla's own optimiser runs the charge and reaches the full rate reserve-driven charging cannot; mode goes to `autonomous` in every state, reserve becomes an actual reserve rather than a charge signal, and `_settable_reserve()` maps requests onto reserves the Powerwall honours - an 81-99% request now rounds **up** to 100 rather than being forwarded raw into the snap-to-80 band. The same PR stopped `plan.py` planning a manual freeze export on inverters that cannot do it. **Consequences of the default flip:** a Teslemetry user who never set the key is on the signal tariff with autonomous mode, so "the Tesla app shows 0p/50p/100p instead of my rates" or "my charge target isn't honoured" is the default path working, not a bug; and the 81-99% `set_reserve_min` limitation becomes default-on behaviour with it. (`MockTeslemetryAPI` still defaults `tbc_control = False` on purpose, so a green teslemetry test run exercises the real-rate path unless a test opts in.) And a separate scoping fact from the GH#5186 triage: `teslemetry_automatic` gates only `automatic_config()` — the scheduler emulator (`sync_tariff()` + `assert_device_state(evaluate_schedule(...))`) runs for every non-read-only user with no `automatic` check, so "Predbat changed my Tesla tariff/mode but I never turned on automatic" is that scoping, not a bug; `set_read_only` is the only switch that stops the writes. A related pricing bug in the same `build_tariff()` family was fixed in PR #4977: an export window ending at midnight priced the whole of tomorrow at peak. And `optimization_strategy: "economics"` *is* pushed with `tariff_content_v2` on every `set_tariff()`, unconditionally, since v8.49.0 / PR #4603 - a request to "add" it is asking for something already shipped (GH#4918). **GH#5157** (reporter's evidence, not independently probed — Powerwall 2, fw 26.26.4): the unit sat in the idle branch of `evaluate_schedule` and simply stopped acting on a standing device tuple for ~4h while `site_info` read back the *correct* configuration — drift invisible to every field the API exposes, with only a Predbat restart recovering it; the stall state is the one branch Predbat legitimately holds for hours, which is why transition-based self-heal never fires. A "Powerwall plateaued / stopped discharging until I restarted Predbat" report maps here first. **Implemented on main** (2026-09-19): `run()` re-asserts the whole device tuple — export rule, grid charging, reserve, mode, not the tariff — every `FORCED_ASSERT_SECONDS` (2h) with the write-on-change dedupe bypassed (`force=True`), the timer advancing only on a fully successful forced assert; the Info line `Teslemetry control drift-correction is transition-based ..., backed by a forced re-assert of the full device tuple every 120 minutes` names it, so on current main the stall is bounded at ~2h. | `teslemetry` | | Sunsynk / DEYE (`sunsynk.py`, `deye.py`) | Freeze export is gated by the per-slot power register (`sellTime{n}Pac` on Sunsynk), confirmed on live hardware — setting the energy mode alone had no effect, and with slot power at zero the battery still charged. `read_only` was ignored by the reconcile loops until `_is_read_only()` gated `_reconcile_control()` (GH#4436). DEYE cloud telemetry keys are one hardcoded spelling per metric — `DEYE_TELEMETRY_KEYS["pv_power"] = "TotalSolarPower"` (`deye_const.py`), confirmed against a single live 3-phase hybrid — and a SUN-8K-SG05LP1-EU-AM2-P instead exposes `TotalDCInputPower` + `DCPowerPV1/2/3`, neither anywhere in the tree. Because `pv_power` is in `DEYE_TELEMETRY_REQUIRED`, the miss fires the `device/latest missing expected keys [...] - telemetry will read zero` warning every cycle and the Cloud PV sensor publishes 0.0 while a manual `pv_power` arg to a local sensor may still protect the planner. Reproduced by swapping the test fixture's single PV key (`tests/test_deye_api.py` `LIVE_DATA_LIST`). Whether the alternate key is unit-equivalent (vs summing `DCPowerPV1-3`) needs a live capture from an affected model — suspected, not verified. CONFIRMED live (Sunsynk 2211093089, 2026-09-19, via `sunsynk.py --tou-test`) that the settings object exposes the TOU register block a SECOND time under unrelated names, so several keys that read like independent settings are the same memory at a different scale: `volt1`-`volt4` are `sellTime3`-`sellTime6` as minutes-since-midnight / 50, `volt5`-`volt10` are `sellTime1Pac`-`sellTime6Pac` / 10, and `volt11`, `volt12`, `current1`-`current4` are `sellTime1Volt`-`sellTime6Volt` x 10 — a contiguous 16-value window, matched 16/16 in both the before and after reads of one write. Two consequences. It is independent proof a TOU write reached the real registers rather than a cloud-side echo, which is the cheapest hardware verification available. And it is a trap: anything that wrote `volt*`/`current*` would silently corrupt the charge programme. Predbat is safe today only because `SUNSYNK_SYSTEM_MODE_FIELDS` excludes them, so treat that exclusion as load-bearing rather than incidental. The alias does NOT extend to what the write payload must contain — `time{n}On` (capital O) is a derived echo of `time{n}on` and is correctly excluded via `SUNSYNK_DERIVED_SLOT_FIELDS`. **GH#5138**: the `control cache is X minutes old (limit 15)` forced-rewrite check is **startup-only** - it lives in `restore_state()`, called only from `run()`'s `if first:` block - so a reporter reading `limit 15` as "rechecks every 15 minutes" is wrong; it never runs again after the first cycle. The issue's core premise (idle slots never clear `sellTime{n}En`) was disproved: every slot `_owned_payload` write carries an explicit sell flag, idle slots are written with `sell: 0`, and the TOU build places a sell-0 baseline segment at each window's end (probe-verified; a `sellTime5En: 1` read-back just after an export window can be *correct*). The real gap it exposed - a write the cloud API acknowledged but the dongle never collected was trusted forever, because the owned-field change gate suppresses identical re-sends and `note_settle` only warned - is **fixed in PR #5142 (merged 2026-09-19)**: sustained settle divergence now clears `applied_payload[sn]` so the next write cycle re-applies. Keep the mechanism for pre-#5142 logs, where a stuck export slot needed a restart to clear. No debug yaml was ever attached, so the reporter's exact overrun is unconfirmed; the `has not applied Predbat's settings after N settings polls` warning is the tell. Two component-side traps since. **PR #5150** (merged 2026-09-19): `control_active` is now persisted and restored alongside `applied_payload` under the same 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`), so a restart can no longer strand an armed inverter in silence; on the upgrade path a cache carrying `applied_payload` with no `control_active` key infers the missing half from the payload keys — a safe lower bound, since `applied_payload[sn]` is written only by `apply_settings`/`apply_dynamic_control`, whose production callers arm `control_active` first (the CLI paths apply with `force=True` and never save). And the in-code comment on the Sunsynk write-button handler claiming Predbat "presses this on every cycle" is wrong about frequency: both press sites in `inverter.py` are change-gated (target SoC presses only when `current_soc != soc`, the export window only when `schedule_changed`), and the one unconditional post-restart re-commit is `is_hm_format`-only, which excludes these types' `HH:MM:SS` — so after a restart with the plan already matching the inverter, nothing is pressed for hours. Read the press gates in `inverter.py` before using that comment to dismiss a "component stopped writing after a restart" report. **GH#5156** (padding truncating any TOU window crossing 04:00/08:00/12:00, `build_tou_slots()` — now shared by both components in `tou_schedule.py`) is **fixed**: padding slots now carry the state of the slot they follow and sit just after it, an inert interval that cannot end a window early (`_padding_segments()` documents the issue). The pre-fix tell was "Deye stops charging at 00:00/04:00/08:00/12:00/16:00/20:00 while the Plan page still shows charging", freshly generated each cycle — not a stale default slot. | `sunsynk_control`, `deye_control` | | Grid sign / arrow direction | `grid_power_invert` is owned by some integrations and not others. With two systems configured, one integration setting it `True` bleeds into the other's entities and inverts the arrows. The fix is an explicit `False` in the automatic config of both. | `sunsynk_config`, `teslemetry` | | Enphase (`enphase.py`) | Unofficial Enlighten endpoints. Accounts with MFA cannot log in at all. Discharge-to-grid schedules are required for export control. Writes need a double-submit CSRF token or return 403. Using the Enphase app at the same time can trip session limits. | `enphase_api` | | myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `control_active` is never set. `release_zappis()` has exactly one caller — `control_tick()` — reached only under `if self.control_active:` in `run()`, and `control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | | 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). `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. `eventType` is now carried through from the flexibility feed (`octopus.py:1210`, `:1227`, `:3529`) and is 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. 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 integration flagged the adjusted minutes, which `rate_replicate()` already refuses to copy into future days. 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). 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. | `octopus_*`, `saving_session*` | | Kraken / EDF (`kraken.py`) | EDF Kraken answers a day/night-structured tariff with **HTTP 400** on `standard-unit-rates/` (`{"detail": "This tariff has day and night rates, not standard."}`) while `day-unit-rates`/`night-unit-rates` return 200 and standing charges 200 — where Octopus four-rate products (GH#4921) answer the same-shaped URL **200 with empty results**. So the "empty results, then data empty" signature belongs to `octopus.py` and a hard 400 to `kraken.py`; do not carry the expectation across (GH#5166, probed against the live API). A nonexistent product still 404s, so 404 and 400 are both live "REST can't serve this tariff" signals on EDF — probe the exact URL from the reporter's log before assuming either. **Fixed in PR #5167** (`async_fetch_rates()`): the GraphQL `applicableRates` fallback now gates on `KRAKEN_REST_RATES_UNAVAILABLE_STATUSES` = (400, 404, 410), per direction (404 covers both a private product and one retired from the REST API; the authenticated retry stays 404/410 because auth cannot change a 400), and only counts a failure when the fallback comes back empty without counting one — `async_graphql_query()` counts its own failures, so a caller that also counts scores 2 per down cycle; snapshot the counter around the call. The standing-charge fallback stays deliberately 404/410-only (`kraken.py` — "400 is a rates-endpoint-only answer"). Two traps from the fix's review: `_fetch_rates_rest()` returns `(None, None)` on a network error, so `err is None` does **not** mean success — success is `(results, None)`; the merged code stashes the public attempt's status and restores it when the authenticated retry hits a network error (pre-fix symptom: a private-product tariff's rates vanish for one cycle with no fallback log line, while an identical 404 one cycle later recovers). The day/night endpoints' rows carry the same `payment_method` variant overlap as GH#5144 — suspected, no probe; check `minute_data`'s handling before consuming them directly. Also benign: `Warn: Kraken: Auth not available for find-tariffs` repeating on early restarts before the first `Tariff discovered` line is setup-phase, not a second bug. | `kraken` | -| Axle (`axle.py`) | Export sessions have to boost the import rate as well as the export rate. State is published unconditionally from `run()` so a fetch failure does not freeze the sensor at a stale value. `load_axle_slot()` has no lower time bound - it checks only `start_minutes < forecast_minutes + minutes_now`, not the `start_minutes >= 0` guard `load_free_slot()` has (octopus.py) - so a closed Axle event writes dead keys at negative minutes through `rate_dict.get(minute, 0)` (GH#5036, replay-verified against the reporter's dump: the +100 boosts sit only at the two closed events' actual UTC start/end times). The dead keys are inert in the plan - `rate_minmax()` and the window scans start at `minutes_now` - so "a dead event projected forward as free export" is not what the code does. The comment at octopus.py:2859 saying `load_saving_slot()` and `load_axle_slot()` "both bound themselves that way" is half-wrong: axle bounds only its end, and `load_saving_slot()` also lacks the start guard but is harmless there because its inner loop writes only `if minute in rate_dict`. Two September reports extend this row. **GH#5060** (closed as a duplicate of #4277, the same defect reported a year earlier and never root-caused — #4277 covers Octopus saving sessions too): event boosts are applied *before* user rate overrides on both sides — `load_axle_slot(..., export=True)` runs ahead of `basic_rates(rates_export_override, ...)` (import mirrors it: `load_saving_slot`/`load_axle_slot` then the import override) — so a full-horizon `rates_export_override` rewrites the event minutes and re-marks them `user`. That reporter had no `rates_export` key in apps.yaml at all, so the override was their only export source, which is why the import-side boost survived while the export-side +100 did not. Workaround (maintainer's suggestion on #4277, checked viable against the code): move the fixed schedule from `rates_export_override` into `rates_export` — it becomes the base tariff via `basic_rates` before the boosts run, so the event stacks on top and nothing downstream rewrites it (assuming manual export rates are empty, since `apply_manual_rates` also runs after the boosts). Forensic technique: in a debug yaml, the `rate_*_replicated` dict discriminates the two failure modes in one step — mark `user` through the event window ⇒ the boost ran and a later override clobbered it; the `saving` marks missing while the boost is missing ⇒ the boost call never ran (or, per GH#5036 above, wrote dead keys). Compare against `rate_*_base` for the tariff's own price; same idea as the GH#5012 base/no_io/adjusted comparison in the Octopus row. **GH#5050** (shared with the Octopus row): the same boosts run before `rate_scan()`, so the automatic low-rate threshold classifies the whole day low — see the symptom-table row on `set_rate_thresholds()`. | `axle` | +| Axle (`axle.py`) | Export sessions have to boost the import rate as well as the export rate - introduced deliberately by PR #4520 (first release v8.48.2, mirrored on Octopus saving sessions), documented in docs/energy-rates.md, and upheld by the maintainer on #5060. Two reports of the same design now exist (#5060 closed as dup-of-#4277 with the by-design answer given; #5175 triaged as enhancement, no regression - `tools/triage_test.sh axle` asserts the dual boost), and **no config knob disables just the import side** (`axle_pence_per_kwh` scales both directions; config.py has only `axle_api_key`/`axle_pence_per_kwh`/`axle_automatic`/`axle_control`). The "textual plans / colour coding skewed" half of such reports belongs to the #5050 threshold row (boosts before `rate_scan()`), not here. State is published unconditionally from `run()` so a fetch failure does not freeze the sensor at a stale value. `load_axle_slot()` has no lower time bound - it checks only `start_minutes < forecast_minutes + minutes_now`, not the `start_minutes >= 0` guard `load_free_slot()` has (octopus.py) - so a closed Axle event writes dead keys at negative minutes through `rate_dict.get(minute, 0)` (GH#5036, replay-verified against the reporter's dump: the +100 boosts sit only at the two closed events' actual UTC start/end times). The dead keys are inert in the plan - `rate_minmax()` and the window scans start at `minutes_now` - so "a dead event projected forward as free export" is not what the code does. The comment at octopus.py:2859 saying `load_saving_slot()` and `load_axle_slot()` "both bound themselves that way" is half-wrong: axle bounds only its end, and `load_saving_slot()` also lacks the start guard but is harmless there because its inner loop writes only `if minute in rate_dict`. Two September reports extend this row. **GH#5060** (closed as a duplicate of #4277, the same defect reported a year earlier and never root-caused — #4277 covers Octopus saving sessions too): event boosts are applied *before* user rate overrides on both sides — `load_axle_slot(..., export=True)` runs ahead of `basic_rates(rates_export_override, ...)` (import mirrors it: `load_saving_slot`/`load_axle_slot` then the import override) — so a full-horizon `rates_export_override` rewrites the event minutes and re-marks them `user`. That reporter had no `rates_export` key in apps.yaml at all, so the override was their only export source, which is why the import-side boost survived while the export-side +100 did not. Workaround (maintainer's suggestion on #4277, checked viable against the code): move the fixed schedule from `rates_export_override` into `rates_export` — it becomes the base tariff via `basic_rates` before the boosts run, so the event stacks on top and nothing downstream rewrites it (assuming manual export rates are empty, since `apply_manual_rates` also runs after the boosts). Forensic technique: in a debug yaml, the `rate_*_replicated` dict discriminates the two failure modes in one step — mark `user` through the event window ⇒ the boost ran and a later override clobbered it; the `saving` marks missing while the boost is missing ⇒ the boost call never ran (or, per GH#5036 above, wrote dead keys). Compare against `rate_*_base` for the tariff's own price; same idea as the GH#5012 base/no_io/adjusted comparison in the Octopus row. **GH#5050** (shared with the Octopus row): the same boosts run before `rate_scan()`, so the automatic low-rate threshold classifies the whole day low — see the symptom-table row on `set_rate_thresholds()`. | `axle` | | History fetch / memory (`ha.py`) | History is fetched in `HISTORY_CHUNK_DAYS`-sized chunks with boundary dedup — records landing exactly on a chunk start inside a data gap corrupted smoothing before that was fixed. The largest memory peak in a run is ML load-predictor training (`load_predictor.py`), not the plan. | `history_chunking` | | Minute data (`utils.py`) | The gap-fill in `minute_data()` writes `newest_state` into every minute beyond the data, and `newest_state` is the value of the sample *closest to now* — which for a **forward** series (`backwards=False`) is the **earliest** sample's value, not the last. A consumer that converts a cumulative series into per-minute deltas *after* the fill therefore manufactures a phantom positive delta at the minute right after the last sample whenever the series ends lower than it started (internal reset: `6.0 first … 2.0 last → +4.0 kWh`), and when several such series are summed before delta conversion a shorter-horizon sensor's reset drop can mask another sensor's genuine same-minute delta. On any "forecast/demand series is wrong near the horizon" report, check whether the path calls `minute_data()` with `backwards=False` and then takes deltas — convert deltas per-sensor before summing, or fill the tail with the last sample, at the call site (PR #4756 review, verified by reading the fill loop). | `minute_data` | | Charge/discharge curve (`inverter.py`) | The curve is evaluated per target minute; tapering near ~93% SOC is expected behaviour, not a fault. An `IndexError: list index out of range` at `predbat_status_data[0]` entering curve detection right after a history outage (often preceded by the `Empty history passed to minute_data` warning) was a typo'd guard — `charge_rate_data` checked twice, `predbat_status_data` never — fixed in PR #2814 and first released in v8.26.2; the four-way guard is correct on main. Any new find_charge_curve IndexError report against ≥ v8.26.2 is a stale-version report (GH#2689). | `find_charge_curve`, `battery_curve_keys` | @@ -122,21 +124,23 @@ Grep for the named symbol rather than trusting a line number. | Predheat (`predheat.py`) | GH#4670: with `predheat_enable` set, Predheat still did not activate after startup because of lazy flag initialisation. There is no registered Predheat test module, so there is nothing to run here — investigate by reading. GH#4848 mapped the surface for the recurring "make Predheat drive my heat pump" requests: Predheat has **no control path at all** - its only outbound service call is a read-only `weather/get_forecasts`, everything else is `set_state` publishing prediction sensors. `smart_thermostat` looks like control but only pre-empts an already-scheduled setpoint rise *inside the simulation*. And Predheat is instantiated as its own object with its own timer loop rather than as a `PredBat` mixin, so `plan.py` never sees a heat decision variable. `load_forecast: - predheat.heat_energy$external` does surface heat consumption as the plan's Xload column, but as a *fixed* load the battery plans around, never a shifted one. **GH#5153** (code + repro verified): `get_weather_data()` passes the forecast's `temperature` raw into `minute_data()` with no unit conversion — a °F-native weather entity feeds ~55-66 into a °C model, the loss term `heat_loss_watts * (internal - external)` then *heats* the house toward the modelled outside temperature and the internal forecast runs away and levels just below it while energy/cost stay 0 because the thermostat correctly never fires. One-glance check: the weather entity's `temperature_unit` attribute. Same issue, second bug: `minute_data_age` includes the age of `heating_energy`, which is optional — unconfigured, its age is 0, `minute_data_age` pins to 0 and `get_historical()` returns its 20.0 default for the target sensor, so the live target is ignored whenever heating_energy is unset (the template includes it; ASHP users without a heat meter remove it). Traps: `run_simulation(save="best")` publishes HA entities by default, so a parameter sweep overwrites the published sensors unless `save` is set otherwise; predheat reads its own clock (`datetime.now(local_tz)`, not the fixture's pinned `now_utc`), so history mocks must be built against the real wall clock or `minute_data_age` comes out negative; and the registered `test_predheat` stubs `update_pred`, so no simulation physics is under any test. | none | | Load ML CPU spikes (`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*. | `ml_training_perf` | | Savings & metrics (`output.py`, `predbat.py`) | `savings_total_predbat` accumulates the **unadjusted** `saving` (`predbat.py:1183`, fed from `self.savings_today_predbat = saving` in `output.py`) while `savings_yesterday_predbat` publishes `saving_adjusted` (`output.py:3380`) — 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. | 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 got ` 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) is probably this same trap on an older version. **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=` 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 `Inverter` objects are rebuilt every cycle (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.) | `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. | `compare` | +| 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 got ` 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) is probably this same trap on an older version. **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=` 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 `Inverter` objects are rebuilt every cycle (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. | `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. | `compare` | | Car charging (`plan.py`, `fetch.py`, `execute.py`) | `car_charge_slot_kwh()` returns the raw slot kWh with no limit awareness and feeds the HTML Car kWh column, the JSON plan, the status sentence and the `car_charging_slot` attributes, while `prediction.py:799` and `prediction_kernel.cpp:827` clamp unconditionally - so "car kWh is displayed but the cost never moves" is a display-vs-model disagreement, not a planning bug. The *rate* side of beyond-cap IOG energy is already handled (`car_charge_slot_rate`/`car_rate_premium`); only the volume side is untrimmed, and `octopus_intelligent_consider_full` (default False, expert-hidden) is the only thing that trims it (GH#4888). The manual car SoC is prediction-fed and never a measurement: `predbat.py` exposes `car_charging_soc_next`, which `prediction.py` sets from the modelled first-step car SoC, so it never resets on its own (GH#4889). `update_car_charging_power()` (`execute.py`) sums *every* entity listed under `car_charging_power` - that list describes chargers, not cars, so two per-car template sensors reading one shared charger are added together; the summing is confirmed, the reporter's doubled figure was not (GH#4879). The `car_charging_slots` refresh gate that checked only `car_charging_planned` now also checks `car_charging_now` (`fetch.py:1337`, GH#4795 / PR #4796). **GH#4967 is now fixed (PR #4971, commit `4a045e07`) - the mechanism is kept for anyone reading a pre-#4971 log.** The prediction used to clamp modelled car load at the real `car_charging_limit` unconditionally, via an identical line in both `prediction.py` and `prediction_kernel.cpp`, with the discharge hold below each gated on `car_load_scale > 0` - so once the modelled car filled part-way through a slot the hold released for the rest of the window, regardless of the `octopus_intelligent_consider_full` switch, which never reached `predict()` at all (its only live uses were the read, the `False` default and the gated trim inside `load_octopus_slots()`). The fix routes the prediction through a model-facing `car_charging_limit_model` instead: with `octopus_intelligent_consider_full` off (the default) cars carrying IOG slots get `CAR_CHARGING_LIMIT_UNCAPPED`, so the fill clamp is deliberately inert and the hold releases only when the modelled car actually fills, while the real `car_charging_limit` is left untouched for `execute.py`'s "car already charged" decision, `plan_car_charging()` and `load_octopus_slots()` (it is taken **by reference** by `Prediction`, which is why the fix assigns a separate value rather than mutating it). Both engines take the model limit from the same attribute so they cannot diverge here, `update_car_manual_soc()` caps the manual car SoC write-back at the real limit because the modelled SoC can now overshoot, and `annual.py`/`compare.py` reset the attribute so an override cannot leak into their re-planning; `test_multi_car_iog.py` now asserts the switch's effect on the prediction. One interaction to re-check when the GH#4952 Ohme `watts x hours` overestimate is fixed: with consider_full off the model limit is uncapped for IOG-slot cars, so the fill clamp no longer bounds modelled car load for them either. The History view's separate reconstruction of car slots from the car energy sensor had its own faked-clock bug — GH#5004, under "The shared clock" above, fixed in PR #5025 (v9.0.2). The "Hold for car" discharge hold has **two different gate conditions** (GH#5146): live execution (`execute.py`'s carHolding block) requires an active car slot with `kwh > 0`, the car not already at limit, and skips entirely while an export window is executing (`if not isExporting:`), while the plan model (`prediction.py`, `discharge_rate_now = battery_rate_min`) holds on `(car_load_scale > 0) and (not car_charging_from_battery) and set_charge_window` — no live state, no kwh check, and `set_charge_window` is the plan's charge-window flag, not "this minute is in one". So the plan can hold in slots the live code charges through and vice versa: verify a "plan says X, status says Y" report against the right gate, and plan-table display should match `prediction.py`'s condition. The export-window exemption is deliberate and test-codified (`discharge_car_full_bat2` in `test_execute.py` asserts export + car slot + the switch off → Exporting, no pause), so it is not a #2380 regression; if the reporter has a working `car_charging_now` sensor the overlapping export window is dropped on the next replan, so *sustained* discharge means the now-slot was never created (a numeric power sensor reads as "not charging"). Read-only users cannot see the status sensor's "Hold for car" at all — `predbat.status` is forced to "Read-Only" first (`execute.py`). | `octopus_*`, `car_charging` | | Web, MCP and Chat (`web.py`, `web_mcp.py`, `chat.py`) | `html_plan_override` responds `{"success": true}` unconditionally and discards the override write's result, and the `run_in_executor()` it goes through submits to a thread pool without ever calling `.result()` on the future (`hass.py`), so an exception while persisting a slot override is swallowed with no log line anywhere. That code path is confirmed by reading; its link to the "manual override partially ignored" report it was found under is **not** (GH#3078). The MCP OAuth endpoint advertises `client_secret_basic`, but `oauth_token`/`_handle_authorization_code` only ever read the POST body and never the `Authorization` header, so a client authenticating via HTTP Basic looks like it is missing `client_id`; and `oauth_metadata_mcp` sets `issuer` to the bare host rather than `{base_url}/mcp`, which RFC 8414 requires when the metadata is served under a path suffix (GH#4799). Chat's provider table (`PROVIDERS`, `chat.py`) has no first-class Gemini entry; `type: openai` against Google's OpenAI-compatible endpoint works for plain chat but not for tool calling, because the tool-call accumulator keys fragments solely on `fragment.get("index", 0)`, so index-less SSE fragments collapse every parallel call into one garbled slot - the sibling `reasoning_details` accumulator in the same function already handles index-less fragments - and `thought_signature` appears nowhere in `chat.py` while the assistant message is replayed verbatim, so Gemini's required signature echo-back is dropped (GH#4904, both confirmed by reading, neither tested against the live API). Also `ChatRequestError.friendly()` hardcodes OpenRouter wording as its generic fallback (`OpenRouter returned HTTP N`), and so do the 402 and 429 branches — for any provider a non-401 HTTP failure is surfaced to the user as an OpenRouter error, confirmed live in GH#5074 where a direct-OpenAI 400 reached the reporter as "OpenRouter returned HTTP 400"; read the reporter's provider entry in apps.yaml, not the message. `_stream_chunks()` always appends `/chat/completions` to the user-configurable base URL and the catalogue fetch `/models`, so there is no config-only route to another path. The header status icons read **published entities, not live power**: `get_battery_status_icon()` (`web.py`) builds the SoC+icons string from `predbat.soc_kw` and the states of `binary_sensor._charging`/`_exporting`, which `set_charge_export_status()` (`output.py`) publishes from the end of `execute_plan()` — there is no path from the icon code back to the plan, so anything the header should distinguish has to be published by execute first (GH#5125/PR #5131). One string, three render sites — the page header, the dash Status table SoC row, and `html_api_get_status`'s `battery_html` JSON — all through `get_header_html`; page CSS lives in `get_header_html()` in `web_helper.py`, not a stylesheet, and the established pattern for anything coloured is a base rule plus a `body.dark-mode .foo` override, so an inline `style=` on generated markup cannot follow dark mode. | `web_*`, `web_mcp`, `web_chat` | | Self-update (`github.py`, `download.py`) | The whole update path calls the GitHub REST API **unauthenticated** - the release check (`github.py:126`) and the file listing (`download.py:79`) - so it shares the 60-requests-per-hour-per-IP unauthenticated quota with everything else behind that egress IP. Behind CGNAT, a VPN or a shared proxy that 403s even though Predbat's own volume is one check per two hours; failures are deliberately never cached, so it then retries every 5-minute cycle. The listing request's error reaches only the addon stdout via `print()`, not the HA log a user would paste, which is why the report reads as "it just won't update" (GH#4886). A phone-hotspot A/B is the cheap differential test. | none | | Cloud / divergence modelling (`fetch.py`, `plan.py`) | Until v8.55.0 `step_data_history()`'s modulation was inert for every caller but one, through operator precedence: `int(...) + 1 if flip else 0` parses as `(int(...) + 1) if flip else 0`, a constant `0` whenever `flip` is False - and `plan.py`'s PV10 call was the sole `flip=True` caller. So `metric_cloud_enable` only ever perturbed the PV10 scenario, and `metric_load_divergence` - genuinely computed from load std-dev/mean in `output.py` - did nothing at all, on defaults-on settings, for as long as the block existed (GH#4870, evaluated directly rather than inferred; fixed in `f6c925d8` and then reworked again by the envelope model in `79f28f8f`). Worth knowing when reading a pre-v8.55.0 log or replaying an older debug dump: neither knob can be the explanation there. The review of the fix also found that switching the block *on* is the risky half - an empty `pv_forecast_minute10` pins `metric_cloud_coverage` at exactly 0.5 regardless of the weather, which is every non-Solcast user. | none | | iBoost (`plan.py`) | `plan_iboost_smart()`'s inner rate-averaging loop indexed the *outer* loop's `minute` instead of the inner `slot_start`, so the "average" always collapsed to the rate at the window's start minute (GH#4817, fixed in PR #4821). Two things worth carrying forward: the `iboost_smart2`/`iboost_smart3` golden scenarios in `test_model.py` were pinned to the buggy output and had their expected values regenerated by the fix, and the `iboost_smart` unit test passed throughout because its fixture uses a flat rate that cannot expose an averaging bug. A sibling defect in the same function - a duplicate-slot guard that checks a stale loop variable and so never fires - was found and deliberately left unfixed. | `iboost_smart` | | Component auto-config gate (`components.py`) | `components.py:705-708` decides whether to print a `Warn: Skipping interface, missing required configuration` by checking whether *any* of the component's declared args appear anywhere in `args_from_apps_yaml` — not whether that component's own identifying args are set. `gecloud_data` lists `days_previous` among its constructor args, a core scheduling setting that ships uncommented in the default `config/apps.yaml`, so the gate is truthy and the warning fires on installs with no GE Cloud configuration at all (GH#4786). **That half is fixed** - `950dda36` / PR #4813, released in v8.54.0, made the gate skip args flagged `shared_config` (`components.py:781`) and flagged `days_previous` in the `gecloud_data` definition; re-driven through `Components.initialize(only="gecloud_data")` on 2026-08-31 with the reporter's exact config, no warning. The `solar` sibling **was** then reproduced and still fires: `pv_forecast_today/tomorrow/d3/d4` ship uncommented as `re:` regexes in `apps/predbat/config/apps.yaml:291-294`, so on a battery-only install their raw strings sit in `args_from_apps_yaml` while matching nothing, and the component warns it "needs at least one of" four args the user never wrote. `shared_config` alone cannot fix that one, because `pv_forecast_today` is itself in the component's `required_or`. A "component seems half-configured" report is worth checking against this gate before trusting the warning at face value. | none | -| Component health (`components.py`, `component_base.py`) | Three different signals, only one of which means "answering now". `count_errors` (`get_error_count()`) is a **lifetime counter** - incremented only when `run()` returns False or raises, never reset on success, and startup backoff books its expected retries through it - so "in error, N errors so far" can describe a long-recovered component. `api_started` means "run() returned truthy once", not "serving data": the Gateway component's `run()` deliberately returns True while `_auto_configured` is False so an offline device cannot stall startup (`gateway.py`), so a broker-less gateway flips `api_started=True` with zero data delivered. The current-health accessors are `is_alive()`/`last_updated_time()` (`components.py`), with a 60-minute staleness test. Separately, `record_status()` writes the raw message as the literal HA state of `sensor.predbat.status` (`dashboard_item()`) with nothing truncating on the path, and HA rejects states over 255 chars - long user-facing text belongs in an attribute (no limit) with a truncated state. Test trap: `update_status()` can call `record_status()` twice per cycle (charge window, then export window) and every call overwrites `self.current_status`, so a test that runs a full `update_status()` and asserts on `current_status` only ever sees the export-window message - capture between calls or assert on log output (found reviewing PR #4992). And the `run_timeout` watchdog is inert against blocking compute: `ComponentBase.start()` awaits `asyncio.wait_for(asyncio.shield(task), timeout=self.run_timeout)` on the component's **own** loop, which can only time out if that loop regains control — blocking compute never yields, so the watchdog never fires (GH#5075: `LoadMLComponent._do_training` calls the sync `train_curriculum()` with no offload). **Corrected 2026-09-22:** an earlier version of this entry said the components share the main event loop and a blocking call stalls shutdown, HA callbacks and every other component — wrong on both counts. Components are not tasks sharing one loop: `Components.start()` starts each one through `base.create_task()` (`hass.py`), which spawns a real non-daemon OS thread per component running its own `asyncio.run()` — so a sync CPU-bound call inside an async component method blocks **only that component's** loop; other components and the main loop keep running. The real cost is shutdown: `stop_all()` (`hass.py`) calls `terminate()` — which stops every component, logs each "Stopped" and "Predbat terminated" — and then `t.join(5 * 60)` per component thread; a component whose `run()` has not returned keeps its thread alive, so the join waits up to 5 minutes and after the timeout the interpreter's own non-daemon-thread shutdown waits again, without a timeout. That is why a #5075 log can show "Predbat terminated" with training still logging minutes later. The misleading "Lock released - event loop is free during training" comment is still in `load_ml_component.py` on main; PR #5112 (open draft, unmerged) fixes load_ml both ways — `asyncio.to_thread` plus a `stop_callback` on `LoadPredictor.train()`/`train_curriculum()` checked per epoch and per curriculum pass — and rewrites the comment. Note freeing the loop alone is **not** sufficient to make shutdown prompt: the thread only exits once `run()` returns, so an uninterruptible call holds the join whether it blocks the loop or a worker the loop is awaiting — a long-running component needs both off-the-loop and a check of `api_stop` inside the long call. Symptom pointer: restart/upgrade hangs for minutes, every component logged Stopped, "Predbat terminated" printed, process still alive → find the component whose `run()` cannot return, starting with any synchronous compute inside an `async def` (load_ml first). #3448 (fixed v8.33.8) was the run-blocking flavour, serialised against the prediction pools but never offloaded off the loop, which is why the shutdown flavour survives. The watchdog's timeout path is the complement of that shutdown flavour (found reviewing PR #5112, open at the time of writing): on timeout `start()` cancels the awaiting task — `asyncio.wait_for(asyncio.shield(task), timeout=self.run_timeout)` then `task.cancel()` (`component_base.py`) — which for a worker started with `await asyncio.to_thread(...)` raises `CancelledError` at the await and leaves the executor thread running to completion, a result nobody consumes; the timeout path never sets `api_stop`, the loop re-fires `run()` on the next tick with no guard against a second concurrent run, and the cancelled run never stamps its completion markers — so two workers can race on shared state and any cooperative-stop hook keyed solely on `api_stop` never trips on a watchdog timeout. The in-tree fix shape is a per-run cancellation handle fed by `stop()`, the timeout path and `fatal_error` (`component_base.py` already treats `fatal_error` as a stop condition). Only bites once a component moves CPU-bound work off the loop — unreachable for load_ml before that, because the synchronous training pinned the loop (GH#5075). Another startup-stall shape (GH#5134, code-read): a phase-0 component whose *constructor* fails (HAInterface raising on a 502 from `/api/services` during an HA restart) leaves the dependent components constructed but never `api_started` - their `run()` short-circuits while the HA component is missing - and `Components.start()` then blocks up to the full 10-minute `ComponentBase.wait_api_started()` default on each of them, so an HA core restart costs ~12.5 min of Predbat downtime instead of the ~20s restart loop. (`HAInterface` overrides the wait to 240s, but the failed instance never constructed, so that override is not the one biting; the startup backoff caps at 128 minutes and only the 10-min wait bounds a stalled phase.) On "Predbat down/slow after an HA restart", compare the log's phase-0 timestamps against the 60s→128min backoff gaps plus the 10-min expiry. | `components` | +| Component health (`components.py`, `component_base.py`) | Three different signals, only one of which means "answering now". `count_errors` (`get_error_count()`) is a **lifetime counter** - incremented only when `run()` returns False or raises, never reset on success, and startup backoff books its expected retries through it - so "in error, N errors so far" can describe a long-recovered component. `api_started` means "run() returned truthy once", not "serving data": the Gateway component's `run()` deliberately returns True while `_auto_configured` is False so an offline device cannot stall startup (`gateway.py`), so a broker-less gateway flips `api_started=True` with zero data delivered. The current-health accessors are `is_alive()`/`last_updated_time()` (`components.py`), with a 60-minute staleness test. Separately, `record_status()` writes the raw message as the literal HA state of `sensor.predbat.status` (`dashboard_item()`) with nothing truncating on the path, and HA rejects states over 255 chars - long user-facing text belongs in an attribute (no limit) with a truncated state. Test trap: `update_status()` can call `record_status()` twice per cycle (charge window, then export window) and every call overwrites `self.current_status`, so a test that runs a full `update_status()` and asserts on `current_status` only ever sees the export-window message - capture between calls or assert on log output (found reviewing PR #4992). And the `run_timeout` watchdog is inert against blocking compute: `ComponentBase.start()` awaits `asyncio.wait_for(asyncio.shield(task), timeout=self.run_timeout)` on the component's **own** loop, which can only time out if that loop regains control — blocking compute never yields, so the watchdog never fires (GH#5075: `LoadMLComponent._do_training` calls the sync `train_curriculum()` with no offload). **Corrected 2026-09-22:** an earlier version of this entry said the components share the main event loop and a blocking call stalls shutdown, HA callbacks and every other component — wrong on both counts. Components are not tasks sharing one loop: `Components.start()` starts each one through `base.create_task()` (`hass.py`), which spawns a real non-daemon OS thread per component running its own `asyncio.run()` — so a sync CPU-bound call inside an async component method blocks **only that component's** loop; other components and the main loop keep running. The real cost is shutdown: `stop_all()` (`hass.py`) calls `terminate()` — which stops every component, logs each "Stopped" and "Predbat terminated" — and then `t.join(5 * 60)` per component thread; a component whose `run()` has not returned keeps its thread alive, so the join waits up to 5 minutes and after the timeout the interpreter's own non-daemon-thread shutdown waits again, without a timeout. That is why a #5075 log can show "Predbat terminated" with training still logging minutes later. The misleading "Lock released - event loop is free during training" comment is still in `load_ml_component.py` on main; PR #5112 (open draft, unmerged) fixes load_ml both ways — `asyncio.to_thread` plus a `stop_callback` on `LoadPredictor.train()`/`train_curriculum()` checked per epoch and per curriculum pass — and rewrites the comment. Note freeing the loop alone is **not** sufficient to make shutdown prompt: the thread only exits once `run()` returns, so an uninterruptible call holds the join whether it blocks the loop or a worker the loop is awaiting — a long-running component needs both off-the-loop and a check of `api_stop` inside the long call. Symptom pointer: restart/upgrade hangs for minutes, every component logged Stopped, "Predbat terminated" printed, process still alive → find the component whose `run()` cannot return, starting with any synchronous compute inside an `async def` (load_ml first). #3448 (fixed v8.33.8) was the run-blocking flavour, serialised against the prediction pools but never offloaded off the loop, which is why the shutdown flavour survives. The watchdog's timeout path is the complement of that shutdown flavour (found reviewing PR #5112, open at the time of writing): on timeout `start()` cancels the awaiting task — `asyncio.wait_for(asyncio.shield(task), timeout=self.run_timeout)` then `task.cancel()` (`component_base.py`) — which for a worker started with `await asyncio.to_thread(...)` raises `CancelledError` at the await and leaves the executor thread running to completion, a result nobody consumes; the timeout path never sets `api_stop`, the loop re-fires `run()` on the next tick with no guard against a second concurrent run, and the cancelled run never stamps its completion markers — so two workers can race on shared state and any cooperative-stop hook keyed solely on `api_stop` never trips on a watchdog timeout. The in-tree fix shape is a per-run cancellation handle fed by `stop()`, the timeout path and `fatal_error` (`component_base.py` already treats `fatal_error` as a stop condition). Only bites once a component moves CPU-bound work off the loop — unreachable for load_ml before that, because the synchronous training pinned the loop (GH#5075). Another startup-stall shape (GH#5134, code-read): a phase-0 component whose *constructor* fails (HAInterface raising on a 502 from `/api/services` during an HA restart) leaves the dependent components constructed but never `api_started` - their `run()` short-circuits while the HA component is missing - and `Components.start()` then blocks up to the full 10-minute `ComponentBase.wait_api_started()` default on each of them, so an HA core restart costs ~12.5 min of Predbat downtime instead of the ~20s restart loop. (`HAInterface` overrides the wait to 240s, but the failed instance never constructed, so that override is not the one biting; the startup backoff caps at 128 minutes and only the 10-min wait bounds a stalled phase.) On "Predbat down/slow after an HA restart", compare the log's phase-0 timestamps against the 60s→128min backoff gaps plus the 10-min expiry. One generic first-cycle trap from GH#5177: if `run()` raises or returns False while `first` is still True, `ComponentBase.start()` keeps `first` True (it is only cleared once `api_started` is set) and re-runs the whole first-cycle block on the 60s→128min startup backoff — so a component whose `health_message()` is tied to a short cooldown shows a blank health message for most of the gap between attempts, and the run status says only `component errors: ` even though the component knows why; the pattern to copy is a diagnostic that stays set until a request succeeds (the Solis quota one did, and #5185 extended that to the datalogger cooldown). | `components` | | Run loop (`predbat.py`) | The HA-interface guard at the top of `update_time_loop()` and `run_time_loop()` (predbat.py, two near-identical copies) exists for two trigger conditions - `ha_interface is None`, and interface present with `websocket_active` False and `db_primary` False. PR #5137 (GH#5135) added the guard's two-branch wording and tested only the first, which is not the one production reaches: `ha.py` sets `websocket_active = False` on a websocket disconnect and `db_primary` defaults off, so the reachable trigger is a websocket drop on a db-primary-off install. `test_time_loop_guard.py` covers the None branch only, so a future edit to the guard's condition (dropping the `db_primary` term, swapping and/or) passes CI silently; a future "Predbat loop died / fatal_error loop" report starts there. The guard's contract lives in both copies and their log wording - a change to one copy alone looks correct and is half-wrong. | none | | Carbon intensity (`carbon.py`, `fetch.py`) | The Carbon Intensity API (`https://api.carbonintensity.org.uk`) publishes only ~48h ahead and often far less - on 2026-09-06 it had nothing at all past end-of-day, national and regional alike, so an `fw48h` request from *now* returned 15h. Two consequences worth separating. (a) The API spec requires `{from}` as an ISO8601 `YYYY-MM-DDThh:mmZ` datetime; `fetch_carbon_data()` passed a bare `YYYY-MM-DD` and made *two* calls, the second from now+48h, which lands past the horizon and returns a bare `{}` with HTTP 200 - logged at `Error:` severity every fetch. That is upstream truncation surfacing as a Predbat error, not a broken integration - **fixed in PR #4958**: `fetch_carbon_data()` now makes one spec-format call built from `datetime.now(timezone.utc)` (`carbon.py:49-53`), which covers the whole published horizon, and the comment there records why UTC rather than a local date. (b) `carbon_intensity.get(minute, 0)` in `prediction.py:1279/1298` scores an uncovered minute as **zero** gCO2/kWh, so the tail of the plan looks carbon-free and `best_carbon` swings wildly between cycles (410kg vs 1365kg on consecutive runs). The zero-scoring itself is still in the code, re-verified on main; what mitigates it is `carbon_replicate()` (`fetch.py:1767`, alongside `rate_replicate`), which extends the forecast forward on the 24h cycle so fewer minutes fall through to the default. Check `metric_carbon` before calling this a planning bug - at 0p/kg it only distorts reporting. | `carbon` | | Storage cache expiry (`storage.py`) | Expiry is judged by the **real clock, never Predbat's**: `load()` and `cleanup_expired()` compare the `.meta` sidecar's expiry against `datetime.now(timezone.utc)`, and the hourly cleanup **deletes** an expired file plus sidecar — so an entry whose expiry was built from `self.now_utc` is born expired once Predbat's clock and the real one drift apart (debug replay, paused HA, tests). Every `expiry=` call site on main derives from the real clock; `save_plan()` was the last exception and PR #5100 fixed it (GH#5079). This is the counterweight to the "build test dates from Predbat's clock" trap below — the discriminator is *which clock the code under test compares against*: slot/rate logic measures against `midnight_utc`/`now_utc`, storage expiry compares against the real one. Test tip from the same investigation: before chasing a (culprit, victim) pair from the fuzzer, run the victim alone **and at more than one time of day** — a test that builds its "expired" timestamp from the fixture clock brackets the day rather than failing outright (`plan_persistence` Test 5 was broken on its own). | `plan_persistence` | | Manual override selects (`userinterface.py`) | The option list for a manual override select has **three independent builders**, each ending in the same `if values not in time_values ... item["options"] = ...` tail: `manual_times()` (the time-slot selects, `manual: True`), `manual_rates()` (the rate/value selects, `manual_rate: True`) and `api_select_update()` (`manual_api` only, `api: True`; `api_select()` is its read side). A change to how the lists look or are ordered must be made in all three or the dropdowns diverge — `manual_api` is easy to miss, it lives ~100 lines above the other two and is worded differently. `manual_select()` cannot drive `manual_api`: it branches on `manual_rate` and otherwise falls through to `manual_times()`, which would render an API-command select as time slots (GH#5105, verified by reading and by driving both paths from the PR #5108 test). Two incidental details: with nothing selected `values` is `""` and that tail appends an **empty-string option** (rendered as `None` on the web config page), and nothing indexes `options` positionally anywhere — selection matches the literal `"off"` — so a reorder is safe despite `impact()` rating all three methods HIGH (they sit on `initialize`/`update_time_loop`/`run_time_loop`; the risk is cosmetic). | `manual_api` | | Marginal rate sensors (`marginal.py`) | The `binary_sensor._marginal_rate_now__is_cheap`/`_is_moderate` flags are **plan-relative predictions, never live inverter state** (GH#5107): `calculate_marginal_costs()` injects +1/2/4/8 kWh over the next hour (`MARGINAL_TIME_OFFSETS`) into a fixed best-plan prediction and takes the raw `run_prediction()[0]` delta. Bands: cheap ≤ 1.2 × `rate_min`, moderate ≤ max(0.5 × `rate_max`, 1.5 × `rate_min`) — on a 6.9p/28.9p tariff that is a narrow 8.3p–14.4p band. The delta **excludes `compute_metric`'s battery_value and metric_keep terms**, so battery-served extra load is priced only via displaced future import/export — while the plan is overnight charge-and-export arbitrage an extra kWh costs the *export* rate in the model, so cells flip mid-band while the battery is visibly exporting, and the overnight export slots sit near the profitability threshold and enter/leave the plan between updates. The sensors only refresh at the `calculate_marginal_costs()` call site in `plan.py`'s publish path, so they can be up to `calculate_plan_every` (default 10 min) stale. Start here on any "marginal sensor looks wrong" report; Sigenergy EMS export outside the plan is invisible to them. | none | -| Manual rates (`fetch.py` `basic_rates()`) | `day_of_week` rules are stamped across the **whole rate horizon**, each day against its own weekday (**fixed in PR #5173**, GH#5168 — previously only the explicitly modelled days were filtered and the day+2 extension write seeded whatever weekday day+2 actually was not, so the *last rule processed for a time-of-day slice won* beyond the modelled days and a weekend catch-all flattened the weekday peaks exactly 48h out; start there on any "next-but-one day's manual rates are wrong" report). Two traps that survive the fix: rules written `end: "06:59:59"` style leave the **final minute of every window unwritten** (`range(start, end)` stops one short), so 06:59/08:59/… are filled by `rate_replicate()` from the previous day — assert window interiors, not exact whole-range equality; and a minute left unmatched by the day filter falls to `rate_replicate()`, which applies `metric_future_rate_offset_import`/`_export`, so a test that only matches the tariff's own rates must pin that offset to 0. The fix's tests (13/14 in `test_basic_rates.py`, plus third-day assertions in 6/7) pin `day_of_week` on every horizon day in either rule order — before #5173 they asserted only today and tomorrow, so keep the lesson: a `day_of_week` regression can land green against tests that never look past tomorrow. | `basic_rates` | +| Manual rates (`fetch.py` `basic_rates()`/`rate_replicate()`) | `day_of_week` rules are stamped across the **whole rate horizon**, each day against its own weekday (**fixed in PR #5173**, merged 2026-09-24, GH#5168 — previously only the explicitly modelled days were filtered and the day+2 extension write seeded whatever weekday day+2 actually was not, so the *last rule processed for a time-of-day slice won* beyond the modelled days and a weekend catch-all flattened the weekday peaks exactly 48h out, every later day then taking the previous day's pattern through `rate_replicate()`, which has no `day_of_week` notion; start there on any "next-but-one day's manual rates are wrong" report). Other pre-#5173 shapes: a midnight-spanning weekday rule never reached day+2's early hours (the wrapped half seeded day+3 instead), and yesterday was checked against *today's* weekday (`int(minute_index / 1440)` truncates negative fractions to 0). Keep the mechanism for pre-#5173 logs. A consequence worth knowing before calling anything a bug on a current build: a fully-tiled manual tariff is never `rate_replicate()`d any more, so `metric_future_rate_offset_*`, `futurerate_adjust_*` and the replicated plan markers no longer touch manual-tariff minutes — "the offset stopped applying" on a manual tariff is this. **Still live — two traps that survive the fix.** A rule ending `:59:59` leaves the **final minute of every window unwritten**: `time_string_to_stamp()` floors `06:59:59` to minute 419 and the window is `range(start, 419)`, so today that minute keeps `basic_rates()`' pre-init 0 (`for minute in range(24*60): rates[minute] = 0`) and `rate_replicate()` copies it forward (probe-verified: 0p at 06:59/08:59/16:59/20:59/23:59 each day, `rate_minmax()` min 0); symptom `Import rates: min 0` / `Export rates: min 0` from a manual tariff with no genuine zero rate; workaround: end each rule at the next rule's start (`07:00:00`); in tests, assert window interiors, not exact whole-range equality. What the planner does with a lone 0p import minute is unverified (raised on PR #5173's review thread, not fixed there). And a minute left unmatched by the day filter falls to `rate_replicate()`, which applies `metric_future_rate_offset_import`/`_export`, so a test that only matches the tariff's own rates must pin that offset to 0. The fix's tests (13/14 in `test_basic_rates.py`, plus third-day assertions in 6/7) pin `day_of_week` on every horizon day in either rule order — before #5173 they asserted only today and tomorrow, so keep the lesson: a `day_of_week` regression can land green against tests that never look past tomorrow. | `basic_rates` | +| Manual load adjust (`fetch.py` `step_data_history()`) | `select.predbat_manual_load_adjust` is **additive**, never a replacement (GH#5176): `step_data_history()` adds `load_adjust.get(minute_absolute, 0) * step / plan_interval_minutes` on top of the ML/historical load (floored so total load cannot go below zero), and `output.py` mirrors it for the published `load_energy_predicted`. The docs disagreement that misled the reporter ("overwrite predicted load" in docs/manual-api.md vs "adjustments" in customisation.md) was fixed by the maintainer (`c06bff91`, 2026-09-23 — docs now say "add the adjustment amount (which can be positive or negative)"), though the plan-UI cell still says only "Adjust load (kWh)" with no sign hint. Triage method: the reporter's plan HTML marks overridden slots with the `±` symbol — that marker plus the neighbouring un-marked slots attributes "applied but additive" vs "genuinely ignored" in one glance; a +0.2 on top of a multi-kWh ML slot is invisible in the SoC drop, which is the signature of "user expected replacement semantics". `load_today_comparison` (registered) asserts the additive behaviour (+1.0 → predicted load +1.0). Recurring upstream cause: free-event load baked into history — ML training has no free-event exclusion and `load_scaling_free` only scales *forecast* event slots (GH#4942 open). The select's horizon is `MANUAL_RATE_MAX_MINUTES` (7 days, `const.py`), so same-day overrides cannot be dropped by the horizon gate. Suspected, not verified: a small (~0.1-0.2 kWh/slot) systematic offset between ML sensor values and the plan's displayed load on non-overridden slots — do not do per-slot arithmetic beyond the clean case without re-deriving it. | `load_today_comparison` | +| Status health (`output.py`, `predbat.py`) | False `Status: (unhealthy)` on the web dashboard (GH#5181, **still live on main**, re-verified 2026-09-26): `predbat.status`'s `last_updated` attribute is written with `now_utc_real.strftime(TIME_FORMAT)` (`output.py:2699`; `TIME_FORMAT` is `%z`, which never emits a colon on any Python) but `is_running()` parses it with `datetime.fromisoformat()` (`predbat.py:1960`), which is colon-strict before Python 3.11 — on ≤3.10 every parse raises `ValueError` → `return False` → `web.py` renders "(unhealthy)" and the API ping returns 500. On 3.11+ the relaxed parser accepts `+0100`, which is why CI on 3.14 stays green. `load_plan()` is **not** affected: its writer uses `.isoformat()` (always colon form), so that round-trip works pre-3.11 too. Trap for the obvious `str2time()` swap: it would lose the naive-string tolerance `is_running()` deliberately keeps (an install upgraded from before the timezone-aware write persists a naive `last_updated`; `web.py:988` already parses this attribute with `str2time()` — in-tree precedent but naive-intolerant) — fix the writer to `.isoformat()` or keep the naive branch. Suspected, not verified: which shipped environments actually run ≤3.10 (no addon/Dockerfile in-repo to read); no test can settle it (Python-version dependent). | none | ## Symptom → first place to look @@ -151,6 +155,7 @@ Grep for the named symbol rather than trusting a line number. | Battery charges or freezes when it shouldn't | Freeze paths in `prediction.py` and the inverter component's control write; check `best_soc_keep` and the freeze-related config in the replay. Rule the plan out first: the signature of a *planned* freeze is a charge window whose limit equals the reserve percent, which `prediction.py` turns into a freeze and then blocks discharge across for the whole window, with execute writing `charge_limit` = current SoC and a 0 W discharge rate. All three together mean the planner chose it and the inverter did as it was told - on a tariff where every cycle loses money and stored energy is credited at replacement cost, holding the battery full and importing everything is the arithmetic working, not a control failure (GH#4890). | | Battery pinned at 100% while the house imports at peak | Rule the plan out from the log before opening `plan.py`. `Completed run status ` says what Predbat asked for and `Inverter N count register writes` says whether it changed anything: `Demand` on every cycle, zero writes, and a flat SoC together mean Predbat asked for discharge and something outside it refused - look at the reserve/min-SoC entity binding for that integration. Three successive analyses of GH#4961 theorised about a charge window spanning the peak that the reporter's own log shows never existed. | | `Hold charging target X%-Y%` while the house still imports at a peak price, or a peak-price charge window in the plan | Three code paths, all verified by reading on main (GH#2716; the family covers #1690 and #5083, and the maintainer marked the original #2716 case fixed in Nov 2025): (1) *plan side* — the high-price gate (`plan.py`, `price_key > best_price_charge_level`) guards only windows whose limit is still 0, so a window already carrying a limit from the levelling pass is exempt and `discard_unused_charge_slots()` keeps any `limit > 0` slot with no price re-check — a peak-price charge window in the plan is not by itself gate evidence, run the config-before-code checks first; (2) *"Hold charging" can be an armed-window fallback, not a hold* — the window-disabling hold needs `set_reserve_hold` + `set_reserve_enable` + `reserve_max >= target` or `inv_has_timed_pause`, and without either the fallback re-arms the charge window while the status still reads "Hold charging"; (3) *the pre-window freeze write* — a freeze-flavoured window inside `set_soc_minutes` before charging starts logs `not yet in charge freeze, holding target SoC at 100%` and deliberately writes a 100% target, so a 100% slot visible in the inverter portal during a displayed hold is that write, not a rogue one. Forensic decoder: the status suffix in `Hold charging target X%-Y%` is the **SoC–target pair**, not window start–end; and while charging the executor writes `max(charge_limit_best[0], reserve)` as the target, so SoC rising past the target means the writes never landed or the inverter ignores mid-window limit reductions (unresolved — needs the brand). | `inverter`, `plan` | +| Predbat charged the car at peak rate, or planned a small house-battery top-up inside a peak window, with car charging enabled | Non-smart car planning is price-blind **by design**: `car_charging_plan_smart` defaults **false** and `car_charging_plan_max_price` defaults 0 (never filters), so the car fills from the earliest low-rate-scan window onward regardless of price — a plug-in mid-peak charges at peak (GH#2716, log-confirmed; smart mode is what re-sorts by price and honours the max-price skip, `plan.py`). Two more non-bug shapes from the same report: a saving-session reward is stamped onto the **import** rate too, which can push that hour above the low-rate scan threshold (`Rate thresholds ... import = rate_max - 0.5`), so the hour vanishes from `low_rates` and the car slot list shows a gap there — do not read that gap as a planning bug; and the in-progress peak-window house-battery top-up is the optimiser's keep response (`car_charging_from_battery: true` + `best_soc_keep` > 0 + the end-of-record SoC credit at replacement cost), same family as the "Hold charging target" row above — the high-price gate only skips windows whose limit is still 0, so a deliberately-selected window is exempt by design. Traps: `best_soc_keep` can be time-varying (automation or user), so the incident log and a later debug dump can disagree (4.0 vs 5.0 observed); and the `Calculate Best options` log line carries the plan-relevant config (`best_soc_keep`, `metric_battery_value_scaling`, ...) so you often don't need the yaml. | `car_charging` | | "Why does Predbat discharge to the house during cheap hours?" / low-rate hold requests | A structural model gap, not an optimiser mistake (GH#5122): any minute outside a selected charge AND export window falls into the `ECO Mode` branch of `predict()` (`prediction.py`), where the battery is modelled supplying whatever house load PV cannot cover — there is **no modelled action meaning "import from grid instead of discharging the battery"** outside a selected window. The only zero-discharge-outside-window modelled states are EV slots (`car_charging_from_battery: false`) and iBoost (`iboost_prevent_discharge`); all freeze/hold config is scoped to selected windows or manual periods. Also: a charge window covering a cheap gap saves only the round-trip loss on the cycled kWh — often ~2p, right at the default `metric_min_improvement_plan` gate of 2.0p — so a marginal window can legitimately fail to clear it (analysis, not verified against a real plan). | none | | "Predbat reports a GivEnergy error but I use Solis / Fox / GE-Cloud" | `inverter_type` was never set. It defaults to `GE` (`inverter.py`'s `get_arg("inverter_type", "GE")`, warning to the log only) and component auto-config is what actually writes it — roughly a dozen components (`solis.py`, `gecloud.py`, `fox.py`, `givtcp.py`, `sigenergy.py`, `gateway.py`, `teslemetry.py`, `deye.py`, `sunsynk.py`, `alphaess.py`, `solax.py`, `enphase.py`) — but auto-config only runs after discovery succeeds, while the component is registered as active the moment it is instantiated. So an outage or credential failure at startup leaves a non-GivEnergy inverter driven with GivEnergy capability flags, and every source read failure surfaces as `unable to read ... window - GivEnergy returned no data, check the GivEnergy credentials`, because `inverter_source_name()` prefers `INVERTER_DEF[inverter_type]["name"]` and `GE` is named "GivEnergy". Confirm from the debug yaml (no `inverter_type` key) plus the log line `Warn: ... inverter_type is not set in apps.yaml`, and treat any GE-specific behaviour in that log as assumed rather than configured. Self-heals on comms recovery; a manual `inverter_type` avoids it. The warning now names the assumed type and the component that should have set it — PR #4992 merged (`bdeb4e05`) after GH#4990, so on a current version the message reads `assuming ()` rather than a bare "assuming GE" (GH#4990). | | "Charging and exporting in the same slot" in the plan html | Two stacked artefacts, sequential in time - not simultaneous charge and export (GH#4987, replayed from the reporter's debug yaml). A **hold-charge** window whose limit equals current SoC (`HoldChrg→`, a no-op that only blocks discharge) is clipped to the minutes before an export window opens, and the 30-minute html row aggregates the two - `output.py` deliberately splits the State cell in two for exactly this case ("charging and exporting in the same slot, split the state into two"). The hold window itself is cycle-dependent: the same dump replayed later produced identical export windows and no charge window at all, so suppressing a charge window whose limit equals current SoC would be cosmetic, not a fix. When the slots back onto an Octopus saving session, note the session bonus is stamped onto the **import** rate as well as export (`load_saving_slot()` runs on both rate tables), which inflates the modelled cost of serving house load during the session - a baseline-free approximation, same class as GH#4893/#2090 (the metric impact is suspected, not quantified). | @@ -167,7 +172,7 @@ Grep for the named symbol rather than trusting a line number. | Every charge/export window executes ~N minutes late | The inverter's own clock. Compensation is manual-only via the four `inverter_clock_skew_*` settings. The warn band has moved twice: originally only ≥30 minutes was flagged (restart threshold, `INVERTER_CLOCK_SKEW_RESTART_MINUTES`), so a consistent 25-minute drift sailed under it while still logging a `difference -25.47 minutes` line every cycle (GH#4927); since PR #4991 (`87d4703a`, v9.0.2) `check_clock_skew()` also warns on a moderate 5-29 minute skew, repeated at most hourly per inverter (`INVERTER_CLOCK_SKEW_WARN_MINUTES` = 5, `INVERTER_CLOCK_SKEW_WARN_REPEAT_MINUTES` = 60), so a silent moderate skew is no longer the explanation on a current version — but "current" means v9.0.2+: a reporter on v9.0.1 or earlier is back to "only ≥30 minutes was flagged". Measured from one reporter's log: an export window written at 23:05 drew house-load import until 23:35, and a charge window enabled at 00:30 did not start until ~01:00 (GH#4927). | | SoC reads far too high, or never falls below ~90% | The sensor mapped to `soc_kw`. A cumulative "battery charge today" energy sensor makes SoC ratchet up all day and reset at midnight. Note `battery_soc:` is **not** a config key - only `soc_percent` and `soc_kw` are read, and unknown apps.yaml keys are silently ignored, so a correct-looking `battery_soc:` block mapping the right sensor does nothing at all (GH#4884). | | Charge or discharge rate pinned far below the hardware's | The rate binding in the component's auto-config, not the planner: GEC takes it from the `charge_rate` entity's `max` (GH#4908), Solis binds both directions to `max_charge_power` (GH#4940), Fox sums a duplicated `batteryList` (GH#4919). Check `battery_rate_max_raw` / `soc_max` in the debug yaml against the nameplate before reading any plan code. | -| Low power charging mode does nothing / still charges at full rate | Usually working as designed, so check the window times before reading any code. `find_charge_rate()` (`utils.py:1726-1728`) returns max rate whenever the PV forecast summed over the rest of the charge window exceeds `LOW_POWER_PV_THRESHOLD` - **0.1 kWh flat** (`const.py:83`), not scaled to system size. Log signature: `Low power mode: PV forecast in window X.XXkWh > 0.1kWh, default to max rate`. The guard is deliberate (PR #4373): the planner costs charge windows at full rate and low power is applied only to the final plan, so a throttled rate in a PV-overlapping window would defer the target into later grid import. The GH#4577 dawn split (`LOW_POWER_PV_LIGHT_FRACTION`, `const.py:93`, applied in `fetch.py:1863`) only protects the *pre-dawn* part of a window, so a window lying entirely in daylight is all PV-light and low power can never engage there - midday windows are expected to fail. `execute.py` recomputes the rate every 5-minute cycle while the window is active, which is why a manual charge-rate workaround gets overwritten and "it used to work" reports follow. Classify as enhancement, not defect; the two levers if a fix is wanted are scaling the threshold as the dawn split does, or costing a low-power variant in the planner. Related: GH#3311, GH#4557, GH#4699, GH#4975. | +| Low power charging mode does nothing / still charges at full rate | Usually working as designed, so check the window times before reading any code. `find_charge_rate()` (`utils.py:1726-1728`) returns max rate whenever the PV forecast summed over the rest of the charge window exceeds `LOW_POWER_PV_THRESHOLD` - **0.1 kWh flat** (`const.py:83`), not scaled to system size. Log signature: `Low power mode: PV forecast in window X.XXkWh > 0.1kWh, default to max rate`. The guard is deliberate (PR #4373): the planner costs charge windows at full rate and low power is applied only to the final plan, so a throttled rate in a PV-overlapping window would defer the target into later grid import. The GH#4577 dawn split (`LOW_POWER_PV_LIGHT_FRACTION`, `const.py:93`, applied in `fetch.py:1863`) only protects the *pre-dawn* part of a window, so a window lying entirely in daylight is all PV-light and low power can never engage there - midday windows are expected to fail. `execute.py` recomputes the rate every 5-minute cycle while the window is active, which is why a manual charge-rate workaround gets overwritten and "it used to work" reports follow. Classify as enhancement, not defect; the two levers if a fix is wanted are scaling the threshold as the dawn split does, or costing a low-power variant in the planner. Related: GH#3311, GH#4557, GH#4699, GH#4975. **Write-path variant (GH#5182, dup of #3311 - PR #4645 still unmerged), the answer when low power computes the right rate but the inverter still charges at full power on a service/script-driven inverter:** with `output_charge_control: "power"` the dummy `charge_rate`/`discharge_rate` entities are auto-created only when `inv_output_charge_control != "power"` (`inverter.py:630`), so there is nowhere to store the computed rate; `get_current_charge_rate()` then falls back to `battery_rate_max_raw` (`inverter.py:1952`), and `adjust_charge_immediate()` fills the service template's `{power}` placeholder from it — `{power}` is always the inverter max on a no-entity inverter, and where a #3311 helper entity exists it trails every rate change by one 5-minute cycle. Log signature: `Best rate: 600W` → service payload `{'value': '2500'}` → `count register writes 0`, with no `PV forecast in window` line. | | An export window is in the plan but moves nothing | Fractional export limits in the sentinel band. A 99% floor combined with a low-power rung emits limits like 99.3/99.5/99.7, and both `prediction.py` and `execute.py` split at the `EXPORT_LIMIT_FREEZE`/`EXPORT_LIMIT_IDLE` sentinels, so those land in "Hold exporting" - simulator and executor agree, making it consistently inert rather than a plan/execute mismatch. With the full-power rung the same 99% floor emits exactly 99.0, the freeze sentinel itself, so a plain "export to 99%" window silently becomes a freeze (GH#4914). Related, and worth knowing before concluding an overlapping charge candidate should have won: `remove_intersecting_windows()` runs **inside every scoring simulation** (`prediction.py`), not just as a final-plan post-pass, and a freeze-export window's 99.0 limit counts as enabled and does the clipping - so the optimiser scores an overlapping charge candidate as zero benefit and "never a candidate" is accurate. The clip is shared with the kernel path (`tests/test_kernel_parity.py`'s `run_intersect_window_tests`/`run_clipping_parity_tests`), so a threshold change needs both engines updated together. | | Export icon/status says "off" while the battery really is exporting | `isExporting` is set at only two sites in `execute.py` (the plan's export-window branches), and the demand-freeze block appends `[Freeze exporting]` to the status text without setting it - so a demand-period freeze export publishes `binary_sensor.predbat_exporting` as "off" (GH#5125; PR #5131, open and unmerged, builds the active-vs-freeze distinction). Read the status sentence (`[Freeze exporting]`), not the binary sensor, before concluding nothing exported. | | Predbat can't self-update, "unable to fetch" or a 403 | The unauthenticated GitHub API quota shared across the egress IP, not a broken release (GH#4886). | @@ -200,7 +205,7 @@ Grep for the named symbol rather than trusting a line number. - **Check a PR's base staleness before reviewing it.** A long-open PR's stored base commit can be well behind main, and a conflicting PR's headline fix may already be on main: PR #4756's `plan_iboost_smart()` fix duplicated the merged GH#4817 window-average fix, and resolving its `test_iboost.py` conflict in the PR's favour would have silently deleted main's `run_iboost_smart_average_test` — resurrecting the bug. Check `git merge-base --is-ancestor main` and diff any test-file changes against main's current tests, not the PR base, before analysing the "fix". - **GitNexus is indexed from main, so a PR branch is off-index.** `impact()` returns "Target not found" for PR-only symbols, and `detect_changes()` pins line-shifted hunks on unchanged neighbours - read its output as "which files", not "which symbols", and grep for callers instead (observed reviewing PR #5143). Same session: the review-creation API response reports `comments: 0` even when inline comments attach - verify with `gh api .../reviews//comments --jq 'length'` before resubmitting. - **`self.base.args` is process-lifetime, and Predbat writes its own dummy entity ids into it.** `self.args` is assigned once from apps.yaml (`hass.py`, `load_apps_yaml`) and never re-read for the life of the process, while `Inverter.__init__` assigns `create_entity()`'s output — `sensor.{prefix}_{inverter_type}_{id}_{entity_name}` — straight back into `self.base.args[...]`, and inverters are rebuilt from scratch **every 5-minute cycle**: `fetch_inverter_data()` defaults `create=True` and the normal-path caller uses the default, so `execute.py` clears and re-creates the whole list every run and anything `Inverter.__init__` samples from live entity state is re-sampled per cycle, not init-only (GH#5099; the pre-#5149 balance-path construction this sentence used to describe is gone — `balance_inverters()` is a pure function since PR #5149 and builds no `Inverter` objects). So from the second construction onwards, any "is this arg configured?" test reading `self.base.args` is reading what Predbat itself wrote last cycle — and the dummy id contains a `.`, so it passes `is_entity_id()` (PR #4745, issue #4738: a custom `GROWATTSPH` type's real `time.*` entity was overwritten by a Predbat dummy). What is lost if a guard then skips recreation on rebuild: `created_attributes` resets in `__init__` and is repopulated only by `create_entity()`, so a skipped sensor-domain write silently drops `state_class: measurement`; and because the id embeds `inverter_type`, a dummy surviving a type change names the **old** type and strands writes on a stale sensor (reproduced in a test on the PR). The obvious alternative authority, `args_from_apps_yaml`, does not generalise here: components write these same args into live `self.args` and never into the snapshot, so keying off the snapshot treats an auto-discovered inverter's select entities as unconfigured. What distinguishes them is recognising Predbat's own naming or a registry of ids created by `create_entity()` — that guard exists only on the open PR #4745, not on main. -- **Version drift.** Compare `git describe --tags` against the version in the first few lines of their log. A fair number of reports are already fixed on main, and that is a useful triage answer on its own. Two tag quirks: the `v9.0.1` and `v8.55.1` tags alias the **same commit** (`b8996659`), so "diff vs 8.55.1" is the right comparison for any v9.0.1 upgrade report from ≤v8.55.x; and PR #4991's moderate clock-skew warning postdated them both until v9.0.2 (`b8f3f956`, 2026-09-10) shipped it — a "no moderate warning" report is a pre-v9.0.2 version, not a broken feature. +- **Version drift.** Compare `git describe --tags` against the version in the first few lines of their log. A fair number of reports are already fixed on main, and that is a useful triage answer on its own. Two tag quirks: the `v9.0.1` and `v8.55.1` tags alias the **same commit** (`b8996659`), so "diff vs 8.55.1" is the right comparison for any v9.0.1 upgrade report from ≤v8.55.x; and PR #4991's moderate clock-skew warning postdated them both until v9.0.2 (`b8f3f956`, 2026-09-10) shipped it — a "no moderate warning" report is a pre-v9.0.2 version, not a broken feature. A third quirk (GH#5178): a `predbat.log` is **appended across add-on upgrades**, so a log attached to an issue can span two versions — one reporter's log opened with "version v8.55.0 currently running" while they were actually on v9.1.0. Date a log by the **last** `currently running` occurrence plus restart markers (`Predbat: Startup predbat`), and cross-check `installed_version` in the debug yaml's `CONFIG_ITEMS` (`update.predbat_version` block). "GivTCP: Inverter N REST GET ... successful" lines come from the component polling REST and do not by themselves prove which Predbat version produced the surrounding lines. - **Log noise.** `predbat.log` carries routine `Warn:` lines (config clamps, kernel status, unsupported settings). Don't quote a warning as the root cause unless it lines up with the time the reporter describes. - **Hardware questions.** A unit test settles what the code does, not what an inverter did. Several findings here were only confirmed on live hardware. If the question is hardware behaviour, say the maintainer needs to confirm it rather than running a test to look thorough. - **`gh issue list --search` without `--repo` searches all of GitHub.** Confident-looking hits can be from unrelated projects (GH#4705). Always pass `--repo springfall2008/batpred` on a duplicate search. An **empty** result deserves the same suspicion as a confident one, and usually the cause is the query rather than the machinery: a triage run reported `gh search issues` as dead on the runner because a search for GH#4961's title text returned nothing, when that issue's real title misspells "unreasonably" - the run had silently corrected the typo while retyping the title, and searching the spelling the reporter actually used finds it (GH#4973, re-probed). Fetch the issue's exact title rather than retyping it, and sanity-check an empty result with a distinctive token from a known issue before reporting no duplicates. @@ -211,7 +216,7 @@ Grep for the named symbol rather than trusting a line number. - **Two traps when adding a guard to an `Execute` entry point** (found writing the GH#4965 fix, merged as PR #4968). `test_control_ledger.py`'s `test_every_entry_point_opens_its_own_cycle` builds a bare `Execute.__new__(Execute)` stub (`test_control_ledger.py:1845`) and calls `quick_inverter_data_update()` on it directly, so any new base-class read added to that function fails the quick suite with `'Execute' object has no attribute 'get_arg'`. The stub now carries a `get_arg` lambda, but the shape is the trap: the same is true of every entry point tested this way. The second is worth more than the first - **the harness cannot reproduce the crash the issue describes**. Deleting `inverter_clock_skew_discharge_start` and calling the unguarded function does *not* raise, because that read (`inverter.py:1624`) sits behind `if "discharge_start_time" in self.base.args:` (`inverter.py:1563`), which the mock inverter never satisfies. Verified by stashing the fix and re-running `./run_all --test execute`: the regression test failed on its return-value assertion, not on the AttributeError branch it was written for. For this class of init-order bug, assert that the guard is present and returns early; an attribute-deletion reproduction passes silently whether or not the bug is fixed. - **Check that a report's premise exists before verifying its logic.** Three reports in two weeks were built on configuration or history that is not there: GH#4918 quoted `tesla_tariff_sync`, `tesla_smart_plan` and `tesla_smart_plan_max_export`, none of which appear anywhere in the codebase or `APPS_SCHEMA` (an AI assistant invented them for the reporter); GH#4917 treated `trust_future_dynamic_iog_slots` as shipped when it exists only on the closed-unmerged PR #4885 - on `main`, `rate_add_io_slots()` trusts every planned IOG slot unconditionally; and GH#5022 (an enhancement issue written by Claude under the reporter's account) claims #5015 "turned off `support_feedin_first` for FoxCloud", which `git log -S` disproves — the flag was set once by `3ed9151e` and never removed. Grep `config.py` / `APPS_SCHEMA` for every setting a report names, and check `git log` or `gh pr view` before accepting an "added for #NNNN" claim — including the background section of an issue you are about to implement. The same check runs in the other direction: an enhancement proposal can describe symbols that live only on the PR branch it was filed from — GH#5063 proposed hardening a log-redaction invalidation cache that existed only on the then-open PR #5053 and not on main (it has since merged, and PR #5171 has since moved the cache into the `LogRedaction` mixin (`log_secrets.py`) — so the cache it describes is real on current main), and overstated its own audit (production direct mutations of `self.args` are ~6 sites in 5 files — `chat_tools.py`, `create_missing_arg()` in `inverter.py`, `userinterface.py`, the one bulk `clear()+update()` in `web.py`, `web_chat.py` — not "roughly 15 files"; the deye/fox/teslemetry grep hits are `__main__` CLI blocks writing `mock_base`). - **`grid_power` is negative when importing.** That is the repo's convention: `docs/faq.md` states it, `web.py`'s power-flow code reads it that way, and `test_enphase_api.py` asserts it by name - `grid_power_invert` exists precisely for meters that do the opposite. The outlier is `predbat_metrics.py:101`'s Prometheus help string, which claims "positive for import". GH#4797 read that string as authoritative and concluded the web arrows and icons were inverted; measured against every other statement of the convention, they are not. Work out which side of that contradiction a sign report is arguing from before agreeing with it. -- **A passing test proves nothing when its fixture encodes the bug.** This has now happened often enough to be a check rather than a surprise: `web_power_flow` shares the sign assumption it would have to catch; the `iboost_smart` fixture uses a flat rate, so a rate-averaging bug cannot show; `test_balance_inverters` configures two *identical* inverters, so neither the capacity-blind SoC split nor the 3+ inverter partner ring is exercised (that was pre-#5149; the balance rework added `test_balance_pure.py`, which covers the SoC branches, cross-charge and pause semantics - and GH#4856 remains the canonical catalogue of balancer-design observations (ring partner selection, bang-bang control, equal-SoC% targets, the throttle-only surface): link to it rather than re-deriving them, and note pre-rework `balance_inverters()` line references are meaningless now that it is a pure function in `utils.py` driven from `rebalance_inverter_rates()`); PR #4935 found a cap test whose slot started after the window it was capping against, so the loop under test ran zero iterations; and GH#4826's `write_failure` test mocks a response with no error code, leaving the classification branch untested. `TestHAInterface.get_state` (test_infra.py) is its own instance of the same lesson: it returns `""` - not the caller's `default` - when a queried *attribute* is missing, while production `ha.py` returns the default, so a test written against the mock can be vacuous for default-handling when both values land in the same branch (found reviewing PR #5131). Read the fixture before quoting "the test passes" in a comment - and say what the test would have to look like to catch the reported behaviour. +- **A passing test proves nothing when its fixture encodes the bug.** This has now happened often enough to be a check rather than a surprise: `web_power_flow` shares the sign assumption it would have to catch; the `iboost_smart` fixture uses a flat rate, so a rate-averaging bug cannot show; `test_balance_inverters` configures two *identical* inverters, so neither the capacity-blind SoC split nor the 3+ inverter partner ring is exercised (that was pre-#5149; the balance rework added `test_balance_pure.py`, which covers the SoC branches, cross-charge and pause semantics - and GH#4856 remains the canonical catalogue of balancer-design observations (ring partner selection, bang-bang control, equal-SoC% targets, the throttle-only surface): link to it rather than re-deriving them, and note pre-rework `balance_inverters()` line references are meaningless now that it is a pure function in `utils.py` driven from `rebalance_inverter_rates()`); PR #4935 found a cap test whose slot started after the window it was capping against, so the loop under test ran zero iterations; and GH#4826's `write_failure` test mocks a response with no error code, leaving the classification branch untested. `TestHAInterface.get_state` (test_infra.py) is its own instance of the same lesson: it returns `""` - not the caller's `default` - when a queried *attribute* is missing, while production `ha.py` returns the default, so a test written against the mock can be vacuous for default-handling when both values land in the same branch (found reviewing PR #5131). Read the fixture before quoting "the test passes" in a comment - and say what the test would have to look like to catch the reported behaviour. A sibling shape: **an allocator probe cannot test an identity-keyed cache guard** (GH#5171 review). The intuitive regression test for the `id()`-reuse bug in `LogRedaction._log_secret_fingerprint()` — free the mapping, allocate a batch of same-shaped dicts, assert none lands on the recorded address — **passed against the unfixed code** (64 decoy dicts after `gc.collect()`, no decoy ever landed there), because whether CPython recycles a freed dict's address depends on allocator pool state. What works deterministically is asserting the property that *rules the collision out*: the stored fingerprint still refers to the mapping it describes, so the recorded address belongs to a live object and cannot be recycled at all — `any(ref is stored for ref in gc.get_referrers(superseded_args))` (dicts are not weakref-able, so `gc.get_referrers()` is the tool); `test_log_secret_fingerprint_is_not_fooled_by_address_reuse()` in `tests/test_secrets.py` is the in-repo precedent, and the fix itself (hold the mappings, compare with `is`, not `==`) is merged (`7d17ea1b`). Generalises to any guard whose correctness rests on "this address still means what it meant": test that the object is pinned, not that the allocator declined to reuse the address. - **One unexplained observation, recorded so a second sighting is recognised.** In GH#4900's log, at 00:00:27, the nightly tariff-compare scenario for `igo_fixed` - running under swapped flat 12.0p export rates - had its scenario plan accepted as the **live** plan (`using new plan` logged from inside the scenario). A recalculation 18 seconds later replaced it, so nothing was harmed that night. The code path was not identified within triage scope. If you see `using new plan` inside a compare scenario, this is the thing. - **Never run the test harness in a fresh git worktree without copying `manifest.yaml` in first.** Importing `predbat.py` runs `check_install()` (`download.py`). `apps/predbat/manifest.yaml` is gitignored, so a new worktree has none: Predbat fetches the manifest for `THIS_VERSION` from GitHub, finds the branch's file list does not match (a file the release lacks, such as `log_secrets.py`, is enough), then downloads the release and **overwrites the worktree's tracked files** with it - including whatever you just edited - and exits. Seen on 24 Sep 2026: ten files, the kernel `.so` binaries among them, reverted to v9.2.0 in one run. The only sign in the log is `Predbat files are not installed correctly, trying to download them`. Copy the main checkout's `manifest.yaml` into the worktree before the first run; if it has already happened, `git checkout -- .` and `git clean -fd apps/predbat` in the worktree, then redo your edits.