Repository navigation
docs(debug-journal): fold the 2026-10-15 queue slice (2 candidates); correct drifted Solcast ceiling cites - #5551
Merged
Conversation
…correct drifted Solcast ceiling cites Folds GH#5545 (PV source precedence, Solcast-direct no-fallback and the staleness-key requirement) into the Solcast integration row, and adds a symptom row for GH#5549 (reserve at/above soc_max empties the charge- candidate scan, the kernel freeze interaction and the discard trap). Corrects the two drifted line cites on the GH#4730 max_kwh entry (9999 init now solcast.py:1718, ceiling warning now solcast.py:1538). Co-Authored-By: Claude Code <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Incorrect cache-lifetime and reserve-recovery guidance could mislead future investigations and repairs.
2 open findings
What changed in this PR
Updates Predbat’s debugging journal with two queued investigations; no runtime code changes.
Changes:
- Expands Solcast source-precedence and fallback notes.
- Adds a symptom entry for reserve settings that eliminate charge candidates.
- Refreshes Solcast array-ceiling citations.
| File | Description |
|---|---|
tools/debug-journal.md |
Incorporates investigation guidance and updates source citations. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| | 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. **Two ownership/executor findings on the same install shape are both fixed on main (PRs #5401 and #5405, merged 2026-10-05, unreleased at the time of writing - keep the pre-fix mechanisms for logs on v9.3.5-):** with `ohme_automatic` on and `ohme_control` off on a non-Intelligent tariff, Predbat's own car plan had **no executor** - `control_charge()` is the only executor and `enable_control()` returns early without `ohme_control`, every other consumer of `car_charging_slots` is model/display-side, and no load-only mode existed, so the charger followed Ohme's schedule while Predbat's plan was a fiction (GH#5399). Now Ohme's session slots become the car plan through the same path Intelligent dispatches take, labelled a charger schedule so they earn no cheap rate (import rates untouched; `reprice_charger_schedule_slots()` re-prices them on this cycle's rates because `load_octopus_slots()` runs before rates are built), and the dispatch/schedule/Predbat-led choice is re-made on every poll instead of once at start-up. And slot-ownership auto-detection was tariff-only - `octopus_intelligent_wanted()` never looked at which Intelligent device Octopus actually drives, so on an Intelligent tariff with a car-linked device (BMW/Mini/VW, `is_charger: false`) Ohme claimed slots Octopus schedules through the car itself (GH#5402). Detection now looks at the Octopus component's live non-suspended Intelligent devices (`is_ohme_charger()` = `is_charger` + provider "ohme"): an Ohme among them takes the slots; Octopus driving another device leaves them to Octopus and `ohme_control` stands down; every device suspended or an Intelligent tariff with no device keeps Ohme's schedule as the plan; an explicit `ohme_automatic_octopus_intelligent` still wins. `test_ohme.py`'s older tariff-only cases encode the pre-#5405 detection, so a green suite there proves nothing about a device-check change. **Two 404 classes on `/v1/chargeSessions/...` — keep them apart (GH#5444, verified by reading the vendored `ohme.py` on v9.3.6-4):** #4719 is the route-withdrawal class with Spring's `No static resource ...` body (route dead); the no-active-session class (2026-10-08) has body `Entity of type: 'ActiveChargeSessionDB' and id: '<serial>' not found` — the stop route is live but keyed on the *device serial* in the session-id position, and Ohme's server keeps no active-session row once a charge has finished or the car is unplugged, so any pause attempt in that state 404s forever. Predbat-side amplifier: `control_charge()` awaits `async_max_charge()`/`async_pause_charge()` with **no exception guard**, so the `ApiException` escapes before `self.control_charging = should_charge` records the transition — the state machine believes it still holds a window and retries the identical pause every `CONTROL_INTERVAL_SECONDS` (60 s), and each failure also skips `update_success_timestamp()`, which the component health monitor reads, so a long storm degrades the component toward unhealthy. Upstream `dan-r/ohmepy` main has the same unguarded stop/resume, so the guard belongs in Predbat's control layer. **Path trap:** the log's `File "/config/ohme.py"` traceback is the vendored Predbat client (message strings `Warn: Ohme API response error` / `Outside the charge plan, pausing the charger`), not the HA Ohme integration. **Drift check:** upstream ohmepy's `VERSION` now lives in `ohme/const.py` while Predbat pins it inline in `ohme.py` — diff both files when checking vendored drift. **An attached-but-idle car reads unplanned (same ticket):** with `ohme_control: true` Ohme's schedule is deliberately not the car plan (`charger_slots_wanted()` returns False while `control_active`) and `plan_car_charging()` runs only while `binary_sensor.predbat_ohme_connected` is on — published ON only for `CONNECTED_STATUSES` = PENDING_APPROVAL/CHARGING/PLUGGED_IN/PAUSED, which **excludes FINISHED** — so a just-completed session (attached, idle) reads "not planned": no slots and Predbat-led control keeps the charger paused; a manual app charge creates a live session and flips the gate. Workaround: `ohme_control: false` (v9.3.6+ then takes Ohme's own schedule as the plan, #5401). The plugged-in-idle session mode is the chain's unverified link — confirm it from a reporter capture taken during a no-slots hour before treating it as root cause. | `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.<prefix>_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.<prefix>_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. `pv_calibration()`'s past-forecast history additionally flows through the **module-global** `history_attribute_to_minute_data` (solcast.py imports it by name), so an instance-level override cannot intercept it — patch `solcast.history_attribute_to_minute_data` and restore it in a `finally`, and re-patch before a second call in the same test. **Open-Meteo shading is client-side (GH#5224):** the Open-Meteo `v1/forecast` API has no `horizon` parameter (closest solar-geometry options are `tilt`/`azimuth` for GTI), so any horizon feature — Predbat's or a user asking for one — must be applied client-side to the downloaded irradiance, which is exactly what the HA Open-Meteo Solar Forecast integration (rany2) does; Predbat's existing knob is the static per-array monthly `shading_factors` applied in `gti_hourly_to_period_kwh()` (`solar_model.py`), which cannot express geometric blocking. *Suspected, not verified:* that integration's published attribute names could not be confirmed compatible with Predbat's HA-sensor PV path (`detailedForecast`/`forecast` with `pv_estimate` entries), so do not recommend pointing Predbat at its sensors as a workaround without checking the entity shape. **Band built from the wrong series with calibration off (GH#5345, fixed in PR #5347 \[v9.3.4\]: `pv_calibration()` now takes `calibrate_band` and builds the band on the P50 it is returned with — the switch-off path swaps both p50 and the band back to raw, and Open-Meteo keeps its own ensemble band instead of a synthesised `create_pv10` one; keep the pre-#5347 mechanism for older logs):** on a `create_pv10` source (Open-Meteo, Forecast.Solar) with `metric_pv_calibration_enable` off, `pv_calibration()` built `pv_forecast_minute10`/`90` and the published `pv_estimate10`/`90` from the **calibrated** series and only swapped P50 back to raw at its final `return`, so P10 could exceed P50 and P90 fall below it, and `get_cloud_factor`'s (ΣP50 − ΣP10) gap was arbitrary - reported as "the saw tooth has gone". Signature in `sensor.<prefix>_pv_today` `detailedForecast`: `pv_estimate10 / pv_estimateCL` and `pv_estimate90 / pv_estimateCL` constant in every slot while `pv_estimateCL / pv_estimate` varies. Dead end: it is not Open-Meteo's ensemble P10 being "confident" - at the time that download was fetched and then overwritten by `pv_calibration()`, because every Open-Meteo branch set `create_pv10`; the same change makes `fetch_pv_forecast()` keep an ensemble band (`create_pv10` False, `calibrate_band` True) and fall back to the history-based band only when the ensemble download fails. The band is the ensemble's P10 and P90 **as ratios of the ensemble's own median**, applied to the deterministic P50, not the ensemble's absolute values: the P50 comes from the best-match deterministic call and the spread from `icon_seamless`, two different model runs that can disagree on a whole day's level - live on 2 Oct 2026 the absolute ensemble P10 sat above the deterministic P50 for a day three ahead (clamped, leaving a 2% downside) and at 0.28 of it elsewhere. Known weakness of the ratio: the deterministic forecast is re-issued between ensemble runs and moved one site's next-day total from 1635 to 3887 Wh/m2 in ten minutes, above the ensemble's own P90, and P50 x (P90/median) then overshoots anything the sky can deliver - only the `1.2 x kWp` power ceiling bounds it, there is no clear-sky cap. So an implausibly high `pv_estimate90` on an Open-Meteo site maps here. Test trap from that work: the solar test mock returns the first response whose key is a substring of the URL, and `api.open-meteo.com` is a substring of `ensemble-api.open-meteo.com`, so a test registering both in that order silently serves the forecast body to the ensemble call and exercises the no-ensemble path. The in-function `enabled_calibration` flag means "enough history", not the user switch, so the fixed 0.7/1.3 band does not apply with the switch off. Routine SolarAPI Info lines are not in shipped logs; read the `pv_today`/`pv_forecast_h0` attributes instead. A source that publishes a 0-PV day is not a safe fallback either — the counter-argument to any "just publish 0" suggestion: a 0-yield day is a `pv_calibration()` down-day (actual < 10% of forecast) and ≤ 2 valid days switches calibration off entirely, leaving the raw forecast (GH#5388 context, code-read on main; `solcast.py`). | `solcast` | | ||
| | Solcast (`solcast.py`) | `max_kwh` — the array-capacity ceiling used to sanity-check the forecast — is initialised to `9999` just before the `fetch_pv_forecast()` branch chain (`solcast.py:1718`) 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:1538`) 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.<prefix>_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.<prefix>_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. `pv_calibration()`'s past-forecast history additionally flows through the **module-global** `history_attribute_to_minute_data` (solcast.py imports it by name), so an instance-level override cannot intercept it — patch `solcast.history_attribute_to_minute_data` and restore it in a `finally`, and re-patch before a second call in the same test. **Open-Meteo shading is client-side (GH#5224):** the Open-Meteo `v1/forecast` API has no `horizon` parameter (closest solar-geometry options are `tilt`/`azimuth` for GTI), so any horizon feature — Predbat's or a user asking for one — must be applied client-side to the downloaded irradiance, which is exactly what the HA Open-Meteo Solar Forecast integration (rany2) does; Predbat's existing knob is the static per-array monthly `shading_factors` applied in `gti_hourly_to_period_kwh()` (`solar_model.py`), which cannot express geometric blocking. *Suspected, not verified:* that integration's published attribute names could not be confirmed compatible with Predbat's HA-sensor PV path (`detailedForecast`/`forecast` with `pv_estimate` entries), so do not recommend pointing Predbat at its sensors as a workaround without checking the entity shape. **Band built from the wrong series with calibration off (GH#5345, fixed in PR #5347 \[v9.3.4\]: `pv_calibration()` now takes `calibrate_band` and builds the band on the P50 it is returned with — the switch-off path swaps both p50 and the band back to raw, and Open-Meteo keeps its own ensemble band instead of a synthesised `create_pv10` one; keep the pre-#5347 mechanism for older logs):** on a `create_pv10` source (Open-Meteo, Forecast.Solar) with `metric_pv_calibration_enable` off, `pv_calibration()` built `pv_forecast_minute10`/`90` and the published `pv_estimate10`/`90` from the **calibrated** series and only swapped P50 back to raw at its final `return`, so P10 could exceed P50 and P90 fall below it, and `get_cloud_factor`'s (ΣP50 − ΣP10) gap was arbitrary - reported as "the saw tooth has gone". Signature in `sensor.<prefix>_pv_today` `detailedForecast`: `pv_estimate10 / pv_estimateCL` and `pv_estimate90 / pv_estimateCL` constant in every slot while `pv_estimateCL / pv_estimate` varies. Dead end: it is not Open-Meteo's ensemble P10 being "confident" - at the time that download was fetched and then overwritten by `pv_calibration()`, because every Open-Meteo branch set `create_pv10`; the same change makes `fetch_pv_forecast()` keep an ensemble band (`create_pv10` False, `calibrate_band` True) and fall back to the history-based band only when the ensemble download fails. The band is the ensemble's P10 and P90 **as ratios of the ensemble's own median**, applied to the deterministic P50, not the ensemble's absolute values: the P50 comes from the best-match deterministic call and the spread from `icon_seamless`, two different model runs that can disagree on a whole day's level - live on 2 Oct 2026 the absolute ensemble P10 sat above the deterministic P50 for a day three ahead (clamped, leaving a 2% downside) and at 0.28 of it elsewhere. Known weakness of the ratio: the deterministic forecast is re-issued between ensemble runs and moved one site's next-day total from 1635 to 3887 Wh/m2 in ten minutes, above the ensemble's own P90, and P50 x (P90/median) then overshoots anything the sky can deliver - only the `1.2 x kWp` power ceiling bounds it, there is no clear-sky cap. So an implausibly high `pv_estimate90` on an Open-Meteo site maps here. Test trap from that work: the solar test mock returns the first response whose key is a substring of the URL, and `api.open-meteo.com` is a substring of `ensemble-api.open-meteo.com`, so a test registering both in that order silently serves the forecast body to the ensemble call and exercises the no-ensemble path. The in-function `enabled_calibration` flag means "enough history", not the user switch, so the fixed 0.7/1.3 band does not apply with the switch off. Routine SolarAPI Info lines are not in shipped logs; read the `pv_today`/`pv_forecast_h0` attributes instead. A source that publishes a 0-PV day is not a safe fallback either — the counter-argument to any "just publish 0" suggestion: a 0-yield day is a `pv_calibration()` down-day (actual < 10% of forecast) and ≤ 2 valid days switches calibration off entirely, leaving the raw forecast (GH#5388 context, code-read on main; `solcast.py`). **PV source precedence and the missing Solcast fallback (GH#5545, re-verified on main 2026-10-15):** `fetch_pv_forecast()` picks the source by one elif chain, in this order: Forecast.Solar with `forecast_solar_open_meteo_first` set (Open-Meteo primary, Forecast.Solar fallback) → Forecast.Solar (the only source with an in-tree fallback, `forecast_solar_open_meteo_backup`, and it triggers on *empty data* only, `solcast.py:1742`) → `open_meteo_forecast` (`solcast.py:1749`) → Solcast direct (`solcast.py:1756`) → HA sensors. So configuring `open_meteo_forecast` makes Open-Meteo the *only* PV plan source even with `solcast_host`/`solcast_api_key` also set - Solcast is never consulted; route any "I configured Solcast but Open-Meteo feeds my plan" report here before suspecting anything else. Solcast direct has **no fallback at all**: on any failed HTTP call (429 included) `cache_get_url()` returns the stale cached payload rather than None (`solcast.py:221`), so after any prior success `pv_forecast_data` is non-empty and an empty-data trigger like the Forecast.Solar backup can never fire for Solcast - the plan then ages indefinitely on stale cache (`solcast_poll_hours` gates the refetch cadence, not staleness), which means any Solcast backup has to key off staleness (`solcast_last_success_timestamp` is already tracked at `solcast.py:226`), not off empty data. Two source-label facts that go with it: `self.active_forecast_source` keeps the source that actually served the fetch (`solcast.py:1810`), while the 7-day calibration-settling warning in `log_source_change()` is issued for the *configured* source (`solcast.py:1811`) - a backup flapping would not retrigger that warning unless deliberately included. | `solcast` | |
| | `Note: Inverter does not support charge/discharge freeze - disabled` although the user set that flag — or "the `has_service_api`/`has_rest_api` flag is on/off and Predbat behaves the same" | Two INVERTER_DEF flag families that do not do what their names suggest. The capability flags are per-inverter-type table values, not top-level config: `INVERTER_DEF[type]["support_charge_freeze"]` is copied to `inv_support_charge_freeze` in `Inverter.__init__` and `execute.py` force-disables the feature from those, so a top-level `support_charge_freeze:` in apps.yaml is read by nothing — it must go inside the `inverter:` block, which is merged into `INVERTER_DEF[type]` before the flags are read (the pattern `templates/luxpower.yaml`/`fronius.yaml`/`huawei.yaml` already use; docs/inverter-setup.md documents it — GH#1406, Sofar `SF`/`SFMB` ship both flags False; a custom `inverter_type` is seeded from a copy of the `GE` definition before the user's `inverter:` block overrides it key-by-key, so anything the block leaves out inherits GivEnergy's flags — GH#5066). Residual on #1406: a `templates/sofar.yaml` shipping the `inverter:` override itself (plus commented `charge_freeze_service`/`discharge_freeze_service` examples) is the open fix idea; whether Sofar's `battery_save` mode meets Predbat's charge-freeze contract is an unverified hardware question. Opposite direction: `has_service_api` is not an `INVERTER_DEF` key at all and `has_rest_api` is set on every definition but never read anywhere — REST is enabled by the `givtcp_rest` args and the service path by the presence of the `*_service` keys in apps.yaml (`call_service_template()` returns False when the key is absent); only `has_mqtt_api` is actually consumed (GH#5066). Two service-path facts a "can Predbat call integration X's service?" question needs (GH#5203, where the bot first wrongly answered "no mechanism" and had to post a correction): the six `*_service` config items (`charge_start_service` … `discharge_freeze_service`, `config.py`) are free-form service routes on the immediate-control path (`adjust_charge_immediate`/`adjust_export_immediate` through `call_service_template()`), where a dict-form entry names any HA service plus arbitrary data keys and `{placeholder}` values are `.format()`-resolved from the call's data (`device_id`, `target_soc`, `power`, the start/end times); identical calls are deduplicated by hash unless `repeat: True`/`always: True` (the GH#4876 interplay), and shipped templates now carry these keys across two dozen inverter templates. Window programming stays entity-based — no service hook — and there is no `set_charge_window`/`set_discharge_window` config item to stop window programming entirely. Upstream note from the same issue: Predbat's supported Solis Modbus path (wills106 solax-modbus + `templates/ginlong_solis*.yaml`) exposes TOU slots + commit button only, hultenvp/solis-sensor exposes no dispatch either, while the dedicated HACS integration **Pho3niX90/solis_modbus** does expose remote dispatch (`solis_modbus.solis_dispatch`/`_stop`/`_schedule`, capability-gated on register 34502 = 0xAA55) — reachable from Predbat today through the `*_service` keys, so Solis "remote dispatch" is a template/config question, not a Predbat code gap. | | ||
| | `Warn: Inverter N write_and_poll_switch: No entity_id for scheduled_charge_enable/scheduled_discharge_enable to write`, or `write_and_poll_option: No entity_id for inverter_mode to write`, while the plan works fine and the only casualty is `Completed run status <mode> with Errors reported` red | Presence flags, not a write failure. `check_write_entity()` (`inverter.py`) logs the warning and records `had_errors` - that red banner is its only consequence - for any control whose bound arg is not an entity id, while plan/execute continue unaffected, which is why reporters describe working control under a red banner (GH#5539). A custom inverter type is seeded from a copy of `GE` in `refresh_config()` before the `inverter:` block overrides key-by-key, so its *presence* flags are GE's unless overridden: `has_charge_enable_time: True` (GE's own `has_discharge_enable_time` is False) and `has_ge_inverter_mode: true`. Flag on with no bound entity means every pass warns - enable writes from `disable_charge_window()` and `adjust_force_export()` (and, since PR #5543, the between-windows enable-off write from execute.py); `inverter_mode` under a GE-mode type warns via `write_and_poll_option()`. Flag off instead creates Predbat-owned dummy entities in `refresh_config()` for the unbound controls - silent no-op writes, and enable state is simulated from window start/end equality; the window-only semantics are tested by `test_disable_charge_window` (window reset to midnight via `adjust_charge_window()`). First check in any future one-brand report of this warning: the reporter's `inverter:` block flags, not a code change. Distinct from the #1406 entry above (capability flags, `support_charge_freeze` family). Unverified, kept as suspicion: that the flags-false route fully preserves a known-working window-only setup - the disable paths change what gets written (window zeroing, window-derived enable state). | | ||
| | Reserve keeps ratcheting up to a high value and never comes back | `battery_min_soc` floors `set_reserve_min` **one-way**: `Inverter.__init__` bumps the reserve min up to it and nothing ever lowers it back — the log line is `Increasing set_reserve_min from X% to battery_min_soc of Y%` (`inverter.py`). If Y tracks the battery's live SoC or flaps within minutes, the user's `battery_min_soc` is bound to a dynamic entity (the stock Solar Assistant Growatt SPH template ships one) and the ratchet pins the reserve at the highest value ever read — 97% observed, leaving no export headroom on an 11.5 kWh battery (GH#4013). If `battery_min_soc` is unset it defaults to `set_reserve_min` and nothing ratchets, which is why "removed it and it works" reports check out. `battery_scaling_auto` only rescales `soc_max`, never reserve — a red herring in the same log. Broader reading for "reserve stuck at N%": **the first knob to check is `set_reserve_min` (default 4), because `battery_min_soc` is a raise-only floor *on* it, not an independent floor** — with `battery_min_soc` scalar `0` set and `set_reserve_enable` on (`has_reserve_soc` types), `reserve_percent` is the `set_reserve_min` default of 4 while `reserve_percent_current` correctly reads 0, so the observed "ignored `battery_min_soc`, stuck at 4%" is the other knob, not the scalar being dropped (GH#5232, probe-verified: `get_arg(..., index=0)` returns scalars fine — `resolve_arg()` extracts-by-index only for *lists*, so scalars are silently accepted). The resolution for "I want a 0% floor" is `set_reserve_min: 0`. **A display-only sibling (GH#5400, code-verified 2026-10-05):** the device's own register floor (`reserve_device_bounds()`, read from the reserve entity's min/max attributes - PR #4956's modelling fix reaches only `reserve_percent`) never raises the *shown* `set_reserve_min`, which is raised one-way from `battery_min_soc` alone in `Inverter.__init__` - so a component publishing an entity `min` without binding `battery_min_soc` (GE Cloud publishes entity min/max from the register's static validation rule and binds no arg; by contrast GivTCP publishes GE's assumed 4% floor lowered-only by the user's `battery_min_soc`) leaves the log's `Reserve min: 4%` line below the floor actually in force. Cosmetic: the clamp corrects `reserve_percent` itself, `self.reserve_min` feeds just the log - and feeding `set_reserve_min` from this static register metadata is the lower-risk direction for that failure than the dynamic-entity ratchet above, so it is a maintainer call, not a defect report answer. | | ||
| | Raised the reserve to keep the battery full (e.g. `set_reserve_min` to 100%) and the battery then never charges - `Filtered charge windows []` in the plan log, battery drains onto grid | The charge-candidate scan needs SoC levels strictly *above* the reserve to enumerate: `while loop_soc > self.reserve` (`plan.py:2696`) starts from `soc_max` (`plan.py:2513`, capped by `best_soc_max` at `plan.py:2556`), so a reserve at or above soc max means the scan never runs, and with freeze off the reserve-equal candidate is removed as well (`plan.py:2725`), leaving off-only. Downstream then makes it look worse than it is: the sim floors SoC at the reserve whenever actual SoC is below it (`prediction.py:1323`, kernel `prediction_kernel.cpp:1296`), so freeze-at-reserve candidates score model-dead and `prune_dead_plan_slots()` (call site `plan.py:2183`) drops them; the plan log line is `plan.py:2263`. Two traps: any charge limit **at** the reserve percent is treated as a freeze by the kernel (`prediction_kernel.cpp:823`, `charge_limit_n = max(soc, reserve)`), so a fix that caps only `self.reserve` does not unfreeze a 100% limit - `reserve_percent` has to be capped with it; and `discard_unused_charge_slots()` keeps any `limit > 0` (`plan.py:3467`) without comparing it to the reserve, so the emptying happens upstream - don't chase that filter. Reachability: the UI caps `set_reserve_min` at 100 (`config.py`) and Fox has no `reserve_device_bounds()` override, so nothing clamps the reserve below soc max; the execute-side tell is `Current reserve is X%, already at target` (`inverter.py:2121`). Same collapse from the other bound: `best_soc_max` below the reserve (GH#4766); a high reserve on the export side is just no export headroom (GH#4013, row above). `best_soc_min` is the one floor that does raise a charge target above the current SoC (`plan.py:2693` raises it above the reserve, appended as a candidate at `plan.py:2721`) - both the user workaround and the in-tree precedent for planning toward a floor. | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Automated journal fold (run of 2026-10-15); a human merge is the review gate on this file. The queue held 2 candidates (slice capped at 15), and both were folded in after verification against current main (
9bbe3f75, v9.4.0); 0 remain queued. One pre-existing entry was corrected for line drift, and the post-fold merge sweep corrected nothing else — details per item below.Folds (2/2)
solcast.pyon main: thefetch_pv_forecast()elif order (Forecast.Solar+forecast_solar_open_meteo_first→ Forecast.Solar →open_meteo_forecast→ Solcast direct → HA sensors; the row now names all five), thatforecast_solar_open_meteo_backuptriggers on empty data only (solcast.py:1742), thatopen_meteo_forecastconfigured alongside Solcast creds means Solcast is never consulted, thatcache_get_url()returns the stale payload rather than None on failure (solcast.py:221), thatsolcast_poll_hoursgates refetch cadence not staleness, thesolcast_last_success_timestampstaleness key (solcast.py:226), and theconfigured_sourcevsactive_forecast_sourcesplit in the 7-day warning (solcast.py:1810/:1811). The candidate verified against61422275, so all line numbers were re-anchored before landing; no claim had been invalidated by anything merged since.plan.py:2696scan condition starting fromsoc_maxatplan.py:2513,plan.py:2556best_soc_max cap, theplan.py:2725reserve-candidate removal, kernel freeze testprediction_kernel.cpp:823, sim floorprediction.py:1323/prediction_kernel.cpp:1296,prune_dead_plan_slots()call siteplan.py:2183(def atplan.py:3523— the candidate's cite was the call site),discard_unused_charge_slots()keepinglimit > 0atplan.py:3467,set_reserve_minUI cap 100, no Foxreserve_device_bounds()override,inverter.py:2121execute-side tell, and the GH#4766/GH#4013 sibling framing). Kept as a symptom-shaped row ("raised the reserve to keep the battery full →Filtered charge windows []") since that is how such a report will arrive.