From 4b9b870f8c202c1d633c1237c0f7321049515887 Mon Sep 17 00:00:00 2001 From: CI Date: Tue, 8 Sep 2026 00:08:17 +0100 Subject: [PATCH] docs(debug-journal): fold in eight triage findings and correct entries the fortnight's merges invalidated Co-Authored-By: Claude Code --- .cspell/custom-dictionary-workspace.txt | 1 + tools/debug-journal.md | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index cda02e6aa..4a9862e1f 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -237,6 +237,7 @@ geserial geseriale getaffinity getall +getattr getrusage Ginlong gitnexus diff --git a/tools/debug-journal.md b/tools/debug-journal.md index abc8ce26b..93fdde2b8 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -42,7 +42,7 @@ A `predbat_debug.yaml` or debug-history snapshot attached to a bug report can ca "The plan is wrong" has repeatedly turned out to be settings rather than a defect: - **GH#4222** (odd freeze-export windows) — traced to the reporter's own `combine_export_slots: True` plus a high `metric_min_improvement_export`, not a code regression. A stale local test file also produced a false kernel regression partway through that investigation. -- **GH#4478** (exports scheduled late) — there *was* a real ordering bug (`optimise_swap_export` ran before the later plan passes, fixed in PR #4513), but the user-visible difference was dominated by `best_soc_keep: 4.0` in their config. +- **GH#4478** (exports scheduled late) — there *was* a real ordering bug (`optimise_swap_export` ran before the later plan passes, fixed in PR #4513), but the user-visible difference was dominated by `best_soc_keep: 4.0` in their config. The commented-out swap call left at the old position in `optimise_all_windows()` can make a reader think the pass is dead - it is not: the live call runs deliberately **last**, after the plan and solar passes that add the near-term exports the swap exists to push back. Read `optimise_all_windows()` end to end before claiming any pass "runs during planning"; symbol presence in `plan.py` is not evidence either way. - **GH#4840** ("random" exports at peak rate on a flat 12p export tariff) — `metric_battery_value_scaling: 0.9` against the 1.0 default. `compute_metric()` credits leftover SoC at `battery_value_rate()`, which is the *replacement* cost of a stored kWh (cheapest future import grossed up by charge losses). Exporting is profitable to the planner exactly when `export × inverter_loss × battery_loss_discharge` beats that credit, so the protective margin is only `(export realised) / (replacement cost)` — on 10.54p import against 12p export that is `11.174 / 11.319`, i.e. **1.28%**. Any scaling below ~0.987 flips every export decision on a near-parity tariff, and the trade genuinely loses ~0.2p/kWh. Two things made this hard to see: the reporter had also turned off `metric_cloud_enable` (default **True**), so the planner assumed perfectly smooth PV and predicted *zero* grid import for 48h — restoring that default alone also removed the export; and the modelled 48h cost looks ~52p *better* with the export, because the plan simply ends with 4.7kWh less in the battery. Note the slider's step was 0.1 until the PR for #4840 reduced it to 0.01, so the smallest possible nudge below the default used to overshoot the flip point by 7.8x - and 0.02 was not enough either, since 0.98 is still below 0.9872. - **Three different "keep" mechanisms** get conflated in reports, and only two of them can hold a battery up. `best_soc_keep` is *soft*: purely a cost-model penalty added when predicted `soc <= best_soc_keep` (the `metric_keep +=` line in `prediction.py`), scaled by `best_soc_keep_weight` and ramped in over the first four hours of the horizon. It never creates a charge window and never raises the reserve, so an automation setting it to 3 kWh cannot pin a full 8.64 kWh battery at 100% (GH#4961). `manual_soc_keep` (the web-UI `manual_soc` override) and weather-alert keeps are *hard-ish*: both merge into `all_active_keep`, which forces `keep_minute_scaling` to at least 10.0 - a 10x-import-rate penalty that effectively forbids discharging below the keep percent. `set_reserve_enable` with a high `reserve` is the only true hard floor. Related, because it comes up in the same reports: `set_discharge_during_charge` **defaults True** (`config.py`), and even when it is on, `prediction.py` still pins the discharge rate to `battery_rate_min` while SoC sits within 1% of the window's charge limit. That at-target hold is what `HoldChrg` means, and it is not `FrzChrg` - `is_freeze_charge()` requires the limit's percent to equal `reserve_percent`. @@ -77,7 +77,7 @@ Grep for the named symbol rather than trusting a line number. | 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. | `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). `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). 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 ~50s verify window reading HA's cache - the write had already landed by the next cycle. | `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. 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. | `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. 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). | `teslemetry` | +| 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` trial setting 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). | `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). | `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` | @@ -90,14 +90,15 @@ 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. | 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. | `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). | `inverter` | +| 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. | `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). | `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). **Anyone fixing that clamp needs three things the issue does not mention** (GH#4967, all re-verified on main). First, the clamp is duplicated: `prediction.py:799` and `prediction_kernel.cpp:827` carry the identical unconditional line, and the discharge hold below each is gated on `car_load_scale > 0` (`prediction_kernel.cpp:843`), so once the modelled car fills part-way through a slot the hold releases for the rest of the window. Fix one engine only and the Python and kernel plans diverge; `tests/test_kernel_parity.py` is the net, but the kernel needs the same edit up front. Second, `octopus_intelligent_consider_full` never reaches `predict()` at all - its only live uses are the read (`fetch.py:3048`), the `False` default (`predbat.py:499`) and the gated trim inside `load_octopus_slots()` (`octopus.py:3235-3240`). Third, `car_charging_limit` is taken **by reference** by `Prediction`, and the same list is read by `execute.py` for the real "car already charged" control decision and by `plan_car_charging()`, so a fix must assign a separate model-facing value rather than mutate it. Fix-order note: the unconditional clamp is currently the only thing bounding GH#4952's Ohme `watts x hours` overestimate, so removing it before that lands lets an inflated slot energy inflate modelled car load. Nothing asserts the switch's effect on `predict()` today - it appears in tests only as a pass-through or pinned `False`. | `octopus_*`, `car_charging` | +| 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. | `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). | `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). | `components` | | 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` | ## Symptom → first place to look @@ -109,6 +110,8 @@ Grep for the named symbol rather than trusting a line number. | "Random" exports that lose money on a flat export tariff | `metric_battery_value_scaling` below 1.0, then `metric_cloud_enable` being off. See "Check configuration before code" above (GH#4840) — the protective margin is ~1%, so a small reduction is enough. | | 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. | +| "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; PR #4992 (open at the time of writing) renames the messages only (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). | | Rates wrong, missing or not updating | `fetch.py` rate scanning, then the provider component (`octopus.py`, `kraken.py`, `energydataservice.py`). | | Inverter setting not applied | `execute.py` into the component's reconcile loop; check whether `read_only` is set. | | Energy totals jump or reset | Provider-side cumulative counters and any monotonic clamping. | @@ -117,7 +120,7 @@ Grep for the named symbol rather than trusting a line number. | 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. | -| 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). | +| 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. | | 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). | | A downloaded debug yaml has empty step arrays | Expected, not corruption. The arrays are built during `calculate_plan()` and freed at the end of the same `update_pred()` cycle whenever `debug_enable` is off (`predbat.py:1329-1334`), so a UI download taken afterwards never sees them. Deterministic on every download, not a race in the 2-hour auto-disable window (GH#4853). | | Memory usage high / addon RSS creeps up | Measured on Linux against a live config (no Load ML, ~40 threads): the idle floor is ~75 MB of imports before any data (stdlib 23, aiohttp 12, numpy 13 even with ML off, requests 6, own modules 13), live app data is only ~25 MB (HA state cache 8, minute arrays 5, rates 3, config 3), and the rest was allocator residue. Two things made most of the difference and are now in the tree: `yaml.dump()` builds a node tree ~30x the text (~150 MB for a full debug dict) so `create_debug_yaml()` streams one top-level key at a time through `DebugYamlDumper`, whose shared anchor counter is what stops the pieces failing to load with "found duplicate anchor"; and glibc keeps freed pages plus one malloc arena per thread, so `update_pred()` calls `malloc_trim()` after `gc.collect()` and `initialize()` caps arenas with `limit_malloc_arenas()` (both no-ops on macOS/musl). Baseline 266 MB peak / 162 MB idle became 138 / 126. If someone is still well above that, ask whether Load ML is on (adds ~30 MB idle plus 16 OpenBLAS threads) and check `Threads` in `/proc//status`; the next lever is lazy imports (numpy when ML is off, components on enable), not the state cache. | @@ -137,9 +140,10 @@ Grep for the named symbol rather than trusting a line number. `debug_cases` stops at the first failing case, so a second case can start failing only after you fix the first. Before regenerating, confirm the shift is the intended one by temporarily restoring the old behaviour and checking the golden files pass again. -- **Build test dates from Predbat's clock, not the machine's.** Predbat runs on the timezone set in apps.yaml (Europe/London in the harness), not the host's, so a fixture built with `datetime.now()` is on a different date from the code under test for part of every day. `test_saving_session` and `test_alert_feed` failed exactly that way under `TZ=America/Los_Angeles` at 21:30 local - and the clearest case is instructive: `load_free_slot()` measures a slot against `midnight_utc`, so a window built for the machine's "today" starts before Predbat's day began and is dropped entirely. Anchor fixture dates to `my_predbat.midnight_utc` (`96e1073c`, PR #4877). Hardcoded future dates in test data have the same shelf-life problem and were pushed out to 2099 (`d850365b`). +- **Build test dates from Predbat's clock, not the machine's.** Predbat runs on the timezone set in apps.yaml (Europe/London in the harness), not the host's, so a fixture built with `datetime.now()` is on a different date from the code under test for part of every day. `test_saving_session` and `test_alert_feed` failed exactly that way under `TZ=America/Los_Angeles` at 21:30 local - and the clearest case is instructive: `load_free_slot()` measures a slot against `midnight_utc`, so a window built for the machine's "today" starts before Predbat's day began and is dropped entirely. Anchor fixture dates to `my_predbat.midnight_utc` (`96e1073c`, PR #4877). Hardcoded future dates in test data have the same shelf-life problem and were pushed out to 2099 (`d850365b`). Newest instance: PR #4971's `multi_car_iog` tests force `now_utc` to 12:00 on the *machine's* UTC date while `midnight_utc` stays on the real clock, so between 23:00 UTC and midnight the forced slot falls behind local midnight and is dropped - observed as `multi_car_iog_load_slots_regression` and `multi_car_iog_model_limit_fetch_4967` both failing with "car 0 should have IOG slots, got none" at 00:06 local, on a clean docs-only tree (mechanism reasoned from the code, not reproduced at a passing hour). - **`midnight_utc` is local midnight, and `now_utc` is local time.** Both names are wrong in the same direction and it has misled at least one review into hunting for a UTC-vs-local frame mismatch that does not exist. `update_time()` sets `now_utc_real = datetime.now(self.local_tz)` (`predbat.py:654`) - already local, not UTC - and `midnight_utc` is that value with the time zeroed (`predbat.py:655`), i.e. the local midnight of Predbat's configured timezone as a timezone-aware datetime. Anything comparing a slot against `midnight_utc` is therefore working in local wall-clock, which is usually what you want and never what the name says. Before concluding a rate or slot bug is a timezone frame error, check which of the two frames the code is actually in. - **Test ordering.** Some tests share a `PredBat` instance, which is exactly why `run_debug_cases` builds a fresh one per case. A test that fails in a full run but passes alone is usually pollution, not the reporter's bug — another reason to run only the targeted test. +- **A textually clean auto-merge can still be a semantic conflict.** Merging `origin/main` into an old PR branch reported no conflicts while main's GivTCP refactor (#4864) had deleted `Inverter.rest_data`, which the PR both tested on and passed to the constructor - the merged tree crashed the first `Inverter()` construction in two test modules, only one of them the PR's own (PR #4645). For PR-cleanup work the full `run_pre_commit` suite is the detector, not the PR's own module. Corollary: `rest_data` is gone since #4864 and `inverter_source_active()` (`inverter.py`) is the replacement question for "is this inverter component/REST-backed" - anything written against `self.rest_data` predates the refactor. And when a test plants a components stub, construction-time calls run against whatever stub the *previous* test module left behind, so a new components-consulting call in `Inverter.__init__` needs a `getattr` guard on the registry surface, not a fake patched into one module. - **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. - **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.