Repository navigation
fix(inverter): Persist inverters across planning cycles - #5126
Conversation
fetch_inverter_data() rebuilt every Inverter on every call, and it is called one or more times per cycle - the plan run re-reads the inverter again before execute_plan(). Any state an Inverter accumulated was therefore wiped before it could be used, which is why the commit-once guard added in #4711 held in the unit tests (one long-lived Inverter) but never suppressed anything in production. A reporter's log on v9.0.3 shows 377 update-button presses over one night, one or two per cycle, with every register logging "No write needed". The rebuild was never a deliberate design: before #1803 fetch_inverter_data() had no choice, it always did self.inverters = []. That PR added a create parameter to allow reuse but kept create=True as the default, and only one caller ever opted out, so the main loop kept rebuilding. Keep the objects instead, and split __init__ into the identity/state half that runs once and a refresh_config() that runs every cycle. Everything that reads runtime-changeable config (battery_min_soc, battery_scaling, the rate limits, battery_calibration, battery_temperature) or has a per-cycle side effect (battery size tracking, clock skew detection, reserve bounds) moves into refresh_config, so persisting the object does not freeze a setting a user can change while Predbat is running. The static half - inverter type, capability flags, dummy entity creation - cannot change under a live object, since apps.yaml changes restart Predbat. The create parameter is now meaningless and is removed rather than left as a flag that silently does nothing. The two Inverter test doubles gain a no-op refresh_config; they carry no config to re-read. Measured with the reporter's config (GS, hour/minute entities only, a charge_discharge_update_button), driven through fetch_inverter_data() as production does: main presses/cycle = [2, 1, 1, 1, 1, 1] fixed presses/cycle = [2, 0, 0, 0, 0, 0] State is deliberately not persisted across restarts: losing it costs one re-commit on startup, which is defensible anyway, and is not worth an HA storage dependency. Addresses #4712. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uck-state bugs Persisting the Inverter objects across cycles (previous commit) exposed a mess in __init__: real one-shot identity state, real None sentinels, and placeholder values indistinguishable from genuine config (reserve_percent = 4.0, battery_rate_max_raw = 2600.0) were all set the same way, in the same block. Nothing depended on the specific placeholder values before - the unconditional reassignment in refresh_config()/update_status() ran inside the same __init__ call, so the gap was never observable - but it made the actual lifetime of each attribute illegible, and it is exactly the kind of ambiguity that led battery_size_tracking() to informally treat 0/<=0 as its own "not set" sentinel on nominal_capacity. Split into three groups: - genuinely persistent state (count_register_writes, the committed-schedule guards) stays as plain __init__ assignment, set once - everything refresh_config()/update_status() unconditionally overwrites moves to a new reset_cycle_state(), seeded to None rather than a real-looking value - None cannot be mistaken for real data - the few values that are genuine standing defaults rather than placeholders (inverter_limit 7500W, export_limit 99999W - only conditionally overridden; battery_power/pv_power/load_power 0 - arithmetically safe before update_status() has ever run) keep their real defaults reset_cycle_state() runs once, from __init__, not on every refresh - these attributes persist and get reassigned in place after that, the same as any other state on a persisted object now. Fixes two real bugs the persistence work introduced along the way: - in_calibration was write-only (refresh_config() only ever set it True, never back False, because most inverter types never configure battery_calibration at all). Harmless while the object was rebuilt every cycle - __init__ reseeded it False each time - but on a persisted object a battery that left calibration would leave Predbat stuck thinking it never did. refresh_config() now does a real two-way read. - battery_voltage was seeded 52.0, a guessed value masquerading as a real reading - unlike battery_power/pv_power/load_power it is a divisor, in set_current_from_power(), reachable before update_status() has ever fetched a real value (test_force_export_off_does_not_press_every_cycle constructs an Inverter and calls adjust_force_export() directly, hitting this in production shape). Seeded None instead; set_current_from_power() now checks for that explicitly and skips with a warning rather than computing a current from a made-up voltage. update_status() supplies its own 52.0 fallback via get_arg() once it runs, so nothing is lost - the instance-level 52.0 was never a second source of truth, just a duplicate of that same fallback with none of its logging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
refresh_config() should re-seed standing defaults like inverter_limit/export_limit each cycle to avoid stale overrides persisting after config keys are removed, and one new test comment misstates the current suite-level fixture isolation model.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes repeated schedule “commit” button presses on time_button_press inverters by persisting Inverter instances across cycles (so commit-once guards can latch), while still re-reading runtime-changeable configuration each cycle via a new refresh_config() split out of Inverter.__init__.
Changes:
- Persist
Inverterobjects across planning/execution cycles infetch_inverter_data()and refresh their runtime config each cycle instead of rebuilding. - Refactor
Inverterinitialization into persistent vs per-cycle state (reset_cycle_state()+refresh_config()), and add a charge-side commit-once guard mirroring the export-side behavior. - Add regression tests and update test doubles to account for per-cycle
refresh_config().
File summaries
| File | Description |
|---|---|
| apps/predbat/execute.py | Reuses existing Inverter objects across cycles; rebuilds only on count/type change and calls refresh_config() on reuse. |
| apps/predbat/inverter.py | Splits init vs runtime refresh, introduces cycle-state seeding, adds charge commit-once guard, and avoids dividing by unknown battery_voltage. |
| apps/predbat/tests/test_inverter.py | Adds regression tests for persistence, charge commit-once behavior, calibration clearing, and current calc before voltage is known. |
| apps/predbat/tests/test_execute.py | Updates stubs and calls to match new fetch_inverter_data() signature and per-cycle refresh_config(). |
| apps/predbat/tests/test_inverter_config_sensor.py | Updates calls to fetch_inverter_data() after signature change. |
| apps/predbat/tests/test_infra.py | Updates inverter test double to include inverter_type and refresh_config() required by the new reuse path. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…resh refresh_config() only overwrote these when the config key was present, so removing the key at runtime (or a test popping it) left the previous cycle's override in place indefinitely instead of reverting to the standing default (Copilot review, #5126). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Posted by Rik; working with Claude.) Both Copilot findings on this PR are fixed in 58c634b: refresh_config() left a stale inverter_limit/export_limit override in place. Both were only overwritten A test comment described the wrong scope. Said "the suite shares one PredBat/HA fixture across every registry test" when that's actually true of Both covered by the existing |
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core inverter lifecycle/state semantics (object persistence + per-cycle refresh) with broad runtime impact, so it warrants final human review despite strong regression coverage.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 0 new
- Review effort level: Lite
springfall2008
left a comment
There was a problem hiding this comment.
(Review by Claude, posted by @springfall2008.)
Reviewed the whole diff and verified the findings against the branch rather than just reading it. Two reproduce on the PR head; details are inline.
Two things that check out first: I ran an attribute-lifetime audit comparing main's __init__ against the PR's refresh_config() + update_status() — apart from inverter_time (noted inline), nothing that used to be reseeded per cycle can now go stale, and both count_register_writes (zeroed by execute.py:788) and the window lists (rebuilt from []) are safe to persist. I also test-merged into current main: clean, and #2444's multi-format clock parse survives the move into refresh_config().
Blocking, reproduced:
- A partially-mapped
apps.yamldefeats the entire fix — the charge side folds write results intoschedule_write_okwithout theelif start_hour_id:guards the export side has, so one unmapped hour/minute entity latchescharge_schedule_commit_pendingforever and presses every cycle. Measured on this branch with a GS config missingcharge_start_minute/charge_end_minute:
unmapped minute entities: cycles 1-5, presses=1 each, pending=True, committed=None -> 5 presses
all four mapped: cycle 1 presses=1, cycles 2-5 presses=0 -> 1 press
- A dropped
inverter_timereading now leaves a stale timestamp on the persisted object, so clock-skew detection runs against it and eventually callsauto_restart()for a clock that is correct:
PROBE first refresh (good reading): inverter_time = 2026-09-19 12:00:00+01:00
PROBE after 'unavailable': inverter_time = 2026-09-19 12:00:00+01:00 <- stale
- Separately,
quiet=not createsilently removes every per-cycle inverter diagnostic line frompredbat.logfor the life of the process — the evidence this PR itself was diagnosed from.
The core idea (persist the objects, split the per-cycle config re-read out of __init__) looks right to me, and the lifetime regrouping in commit 2 is a genuine improvement. The issues below are about the guard's edges, not the approach.
Three fixes from @springfall2008's review on #5126: - An unmapped charge_start_minute/charge_end_minute entity (falsy) made write_and_poll_option() return False unconditionally, permanently poisoning schedule_write_ok and re-pressing the commit button every cycle forever - the exact bug the PR exists to fix. adjust_force_export() already guarded this with `elif start_hour_id:` on the export side; adjust_charge_window() used a plain `else:`. Mirrored the export side's guard exactly. - Persisting the Inverter object meant self.inverter_time = None (in reset_cycle_state()) only ran once at construction, not every cycle. A dropped/unavailable reading then left the previous cycle's stale timestamp in place, so clock skew accumulated against a dead reading instead of being treated as "no reading" - eventually triggering an unwarranted auto_restart. Reset every cycle instead, right before the read. - update_status()/refresh_config() logging verbosity was tied to `create` (object rebuild), which is now only true on the very first cycle - silencing every per-cycle inverter diagnostic (clock skew, soc_max, charge windows/settings) for the life of the process. That log is the primary triage evidence for "the plan is wrong" and inverter-write reports, including the one this PR itself was diagnosed from. Always log. New regression test reproduces the unmapped-minute-entity scenario directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-ups from @springfall2008's review on #5126: - fetch_inverter_data() takes an explicit quiet parameter rather than deriving verbosity from whether the Inverter objects were just created. Tying the two together silenced the per-cycle diagnostics for the life of the process once the objects persisted; forcing them always-on instead would have made balance_inverters() verbose too, which runs every 120s rather than once per plan cycle. The parameter restores the pre-PR split (verbose on the plan-cycle reads, quiet on the balance read) without reintroducing the coupling. - reset_cycle_state() no longer seeds inverter_limit/export_limit: refresh_config() assigns both unconditionally before anything can read them, so the seeds were unreachable and duplicated the same two literals. Those literals are now INVERTER_LIMIT_DEFAULT_W / EXPORT_LIMIT_DEFAULT_W in const.py. - test_control_ledger's fetch_inverter_data stub still carried the create= keyword this branch removed, and only passed because nothing called it by keyword until now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the two bespoke commit guards with a single mechanism owned by press_and_poll_button(), addressing @springfall2008's review point that this was the third such pair and that a check inside press_and_poll_button() would "cover all four at once and stop the next report needing a fourth pair". Before: adjust_force_export() and adjust_charge_window() each carried their own last_*_committed / *_pending attributes, with different key shapes and different retry ordering, while adjust_battery_target() and disable_charge_window() had no guard at all and pressed on an observed-state comparison that is pinned true on hardware that never reads a written value back (#2328). After: commit_needed()/record_commit() keyed by scope, with last_committed and commit_pending dicts replacing the four attributes. All four call sites use it. Behavioural fixes that fall out of the consolidation, each raised in review: - Commit keys now describe only what is written, never what was observed. The charge key included old_start/old_end, which guaranteed a mismatch on the cycle after a successful commit - two presses per window change on stuck readback hardware, and on non-reading hardware a key that never settles. - Pending is checked before the suppression comparison, so a retry can no longer be suppressed by a key that happens to match, leaving the window programmed but uncommitted until it next changes on its own. - Pending is cleared on every recorded outcome rather than only inside the "something changed" branch, where a suppressed cycle left it set indefinitely. - adjust_battery_target() and disable_charge_window() stop pressing every cycle. Scope is deliberately not the button: three scopes share the charge-side button, so keying the memory by button would let a target SoC commit mask a pending window commit. test_commit_scopes_are_independent pins that. The two adjust_charge_window scenarios that changed expectation were asserting suppression that came from observed state (new == old, enable read back "on") rather than from anything having been committed - the first commit of a window in a run now presses once, matching the export side's post-restart behaviour since #4000. Also moves the commit-memory reset into the two scenario helpers, rather than the single ad-hoc call site where the symptom first surfaced (review point on test_inverter.py:202). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r for what it is Two further review points from @springfall2008 on #5126. The same-count inverter_type rebuild is removed rather than its comment fixed. The comment justified it as covering "the half refresh_config() does not re-derive", which is not true: refresh_config() re-reads inverter_type and re-derives every inv_* capability flag from INVERTER_DEF, and re-creates the type-named dummy entities, all every cycle. So the branch redid work the refresh had already done while discarding last_committed/commit_pending for every inverter - forcing exactly the redundant commit and button press this PR exists to prevent - and cost a get_arg("inverter_type") per inverter per cycle to detect it. test_inverters_persist_across_cycles asserted the rebuild, so a wrong justification had become load-bearing. It now asserts the stronger property: a type change is absorbed without a rebuild, the capability flags follow the new type (GE's "HH:MM:SS" -> GS's "H M"), and the commit state survives. Verified the new assertions fail if the type is not re-read. reset_cycle_state() becomes _init_attribute_defaults(). The old name invited the one call its docstring forbade - wiring it into the per-cycle refresh, which the name suggests it is for, would null charge_enable_time, soc_kw, battery_voltage and the window lists mid-cycle. Construction-time only, and now named to say so. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t-parameter-lifetime-4712 Conflicts in const.py and tests/test_inverter.py, both from work merged to main today touching the same regions. - const.py: took main's INVERTER_QUICK_UPDATE_SECONDS = 60 (the balance loop now re-applies twice as often, which makes the quiet=True on that path in this branch matter more, not less) and kept this branch's INVERTER_LIMIT_DEFAULT_W / EXPORT_LIMIT_DEFAULT_W alongside it. - tests/test_inverter.py: main's #5123 added button-press write-counting tests in the same region this branch adds its commit-guard tests. Both sets kept, and this branch's edits to the shared scenario helpers re-applied on top of main's versions - the commit-memory resets, the widened press_and_poll_button stubs, the last_committed/commit_pending migration of main's export tests, and the two adjust_charge_window expectations that now assert a first commit. Full quick suite and pre-commit green.
Final review point from @springfall2008 on #5126. refresh_config() has ways to raise on a live system that have nothing to do with configuration being wrong - an entity reading back None reaching int(), say. Clearing self.inverters there discarded every inverter's commit-once state, so the next successful cycle re-committed and re-pressed the button on all of them, which on Solis is a non-volatile write per press - the thing this PR exists to stop. The objects are now left intact on that path. The construction path still clears, because a half-built list is not safe to reuse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Posted by Rik; written by Claude.) Thanks for the review - the reproductions on the two blocking items made them quick to confirm and fix. All 13 points have a reply on their own thread; leaving them for you to resolve. Summary, and the two things worth your eye. Blocking
Should fix
Consolidated (0d60ef6) - your Also fixed
Deferred - Two things I would rather you saw than found: Two The commit memory is keyed by scope, not by Merged main in (b8545c7) - it had moved 130 commits. |
|
(Review by Claude, posted by @tdlj.) Reviewed at Three things I'd want resolved before merge, then a set of smaller ones. 1. The commit guards can now suppress a press that is genuinely neededThis is the one I'd most like your view on, because
Confirmed by driving
The inverter keeps 00:00–00:00 until the plan happens to change the window on its own.
Same shape, and this one is self-concealing. The press is inside
On the docstring's counter-argument. L3450-L3455 says this is deliberately the whole decision rather than a term a caller ANDs with its own "did anything move" flag, because those flags are pinned true on H M formats and on non-reading hardware. That's correct, and I'm not proposing the AND — an AND can only suppress, which is the failure above. The gap is on a different axis: the key describes intent, so divergence introduced outside Predbat is structurally invisible to the guard, and there is no path back to a commit except a plan change. The useful distinction is that "did the register actually move" is not the same signal as "did the plan's intent move", and it isn't pinned true on the #2328 hardware — that report's whole signature is every register logging Two shapes that would both preserve your invariant, since each can only add presses:
I'd lean to the second. If you'd rather accept the drift and keep the guard as-is, that's a defensible call — but it's worth saying so explicitly in the docstring, since right now it reads as though intent-keying has no cost. 2.
|
|
(Written by Claude, posted by @chalfontchubby.) Status, and it is not a good one: we have been round this eight times and have not landed on a stable refactoring. I would rather say that plainly than keep pushing commits at it. Where it actually standsYour review was right on all 13 points and they are all addressed. But working through them produced two further rounds of self-inflicted churn:
Both are fixed with fail-first tests, but the pattern is the point: each round found something, and each round was found by review or by a test I only wrote because I was told to check my working rather than because I doubted myself. Several of the wrong turns were accompanied by confident comments asserting properties I had not tested. That is worse than no comment, because it discourages the next reader from checking. I do not think "one more pass will be clean" is a claim I have earned today. What has held upThe persistence premise itself. You were content with the direction and I still think it is right — none of the churn above is in persistence, it is all in the commit-guard work built on top. The two are entangled in the branch's first commit, which is a structural mistake on my part rather than a necessary one. What it turned up along the wayThis is the part I think is worth your time regardless of what happens to the branch: Verified by execution. Latent bugs the lifetime audit found, all real on a persisted object and all previously masked by the per-cycle rebuild: Not verified, so stating it as a reading rather than a finding: tracing the code suggests #4711's export-side commit guard has never actually suppressed anything in production — it lives on the What I would suggestI do not want to push more code at this until there is agreement on scope. Options as I see them:
I lean to 1 or 2. Happy to do the work either way, but the call on scope should be yours rather than mine. |
|
(Written by Claude, posted by @chalfontchubby.) Following the status note above — one design point that I think is more important than anything still sitting in the branch, and which came out of Rik pushing back on the shape of this rather than on any particular bug. The guard has been trying to make a misbehaving inverter look fine. Silently re-asserting a schedule every N minutes, forever, is Predbat covering for hardware that is not doing what it was told. The user sees a plan that says "charging 02:00–05:00" and an inverter that is not charging. No error, no status, nothing in the log they would think to look for. The cost lands on them and the diagnosis is hidden. That is the worst combination available, and it is what the current design does by construction. I had framed the open question as "what should Specifically, and this is a defect in what I have already written: a single failed button press does already Two things I think belong in whatever version of this lands:
Neither is in the branch. I am not adding them unilaterally given where this PR has got to — flagging them because I think the "report loudly rather than paper over it" principle is more valuable to Predbat than this particular guard is, and it applies well beyond More generally, and with the self-criticism from the previous comment still standing: the right move some rounds ago was to stop patching and put up a flare, rather than keep trying to make it right after the point where the design question had outgrown the patch. That is on me. |
|
(Written by Claude, posted by @chalfontchubby.) One more on the guard, and this one is a question rather than a finding — it is the gap I am least able to answer from outside your design intent. A cloud outage and a misbehaving inverter are currently indistinguishable to the guard. Writes fail, the button does not confirm,
Treat them the same and you get both wrong — cry wolf on every provider hiccup until people stop reading the warnings, or stay silent on a real fault while it quietly costs them a cheap-rate window. This makes the escalation I proposed in the previous comment not safe as stated. It would fire on every cloud outage. It needs a way to tell the two apart or it should not go in. Two defects in the branch that follow from the same gap: The retry backoff delays recovery after an outage. A two-hour GE Cloud or FoxESS outage puts every scope into the backed-off state, so the first cycle after connectivity returns may not re-commit for up to And The question.
What I have not verified, and would want to before building anything on it: how a cloud inverter's write path actually relates to its component's health state. Whether If the answer is "yes, key it on component health", I think the guard gets simpler rather than more complex — the health signal replaces some of the guessing the four-term OR is currently doing, and the arbitrary constants stop having to cover the outage case at all. If the answer is "those are independent and cannot be relied on to agree", then I think loud reporting has to be more conservative than I suggested, and the backoff needs a different recovery trigger. Either way it seems worth settling before more code goes on this branch. |
A register put back from outside Predbat is committed again. The guard keys on Predbat's intent, so when the vendor app or a firmware reset moved a charge window or target SoC while the plan stayed the same, the registers were rewritten but the button was never pressed. The write helpers now count registers_moved: a write to a real register that read something else beforehand and reads the new value back. The charge window and target SoC pass whether their own writes moved anything as commit_needed(moved=), which is ORed in. The #2328 hardware logs "No write needed" for every register and a write that never reads back is not counted, so neither presses. The enable switches are left out because they can land and then read back "off" on the next cycle. The H M hour/minute writes on both sides go through write_hm_time_part(). An unmapped entity is skipped as before; a literal where an entity id is expected is now reported by check_write_entity() but no longer fails the commit, which kept it pending and pressed the button on every cycle. fetch_inverter_data(quick=True) replaces quiet=True on the INVERTER_QUICK_UPDATE_SECONDS path and skips refresh_config(), so check_clock_skew()'s auto-restart, the battery size tracking reads and the inverter summary log stay once per plan cycle. The first build on that path is quiet too. Smaller: - set_current_from_power() also skips a battery_voltage of 0 - drop the duplicate inverter_time reset and the test doubles' inverter_type, which nothing reads since the type-change rebuild went - tests that switch the shared fixture to GS restore all of args, the settle-sleep test restores commit_pending, and the two tests that assert a charge press start from no recorded commit New tests cover the external reset for the charge window and target SoC, a limit that never reads back, a literal minute entity, the quick path, and the export guard latching through fetch_inverter_data(). The first three fail on 3a5da63. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…#4738) For inverter types whose charge_time_format is not HH:MM:SS, refresh_config() replaced the charge/discharge start/end time args with a self-created dummy sensor unconditionally. A custom inverter definition that points those at real time entities (#4738) had its configuration silently discarded, so every window write went to the dummy. The dummy is now only created when apps.yaml did not give this inverter a real entity for the slot, as suggested on review. That is judged per inverter from args_from_apps_yaml, the snapshot taken before Predbat's own defaulting: apps.yaml must name an entity or a re: pattern for this inverter (a single value counts for inverter 0). A placeholder such as the Sofar templates' '00:00:00' does not, so the dummy Predbat writes into the live slot never reads back as configuration - since #5126 inverters persist and this runs every plan cycle, where a lost dummy is re-created and a type change followed. The entity itself is taken from the live args, where any re: pattern is already resolved, and is never Predbat's own dummy for the slot, so a pattern that matched nothing (which leaves the slot empty for the dummy to fill) does not bring it back either. A single value is put back after create_missing_arg() turns it into a list of defaults. Only non-HH:MM:SS types reach this, and every component that sets these time entities also sets an HH:MM:SS type, so component-discovered entities are unaffected. No shipped template names an entity here for a built-in type. The test covers a list and a single value from apps.yaml, nothing set, a bare placeholder (including on refresh after HA loses the dummy), refresh on a persisted object, a type change arriving through refresh_config(), an entity not from apps.yaml, two-inverter fleets where apps.yaml sets only inverter 0 (list and single value), a malformed value, and re: patterns that matched nothing (single and list) or matched an entity. It restores args in place and the HA dummy state afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th no rate entity (#3311) A script-driven "power" inverter (the Solax SX4 template) has no charge_rate or discharge_rate register, so get_current_charge_rate()/get_current_discharge_rate() fell back to battery_rate_max, and that maximum was sent to charge_start_service/discharge_start_service as {power} however low the planned rate was: a low-power charge ran at full power. Predbat chooses the rate itself, so hold the last one set on the Inverter object, which outlives the plan cycle (#5126), and read that back when a "power" inverter that is sent {power} by a start or freeze service (services_send_power()) has neither a rate entity nor a rate percentage (rate_without_entity()). Without those services nothing applies a held rate, so those inverters read back the maximum as before. A plain number in its rate entry - in a mixed fleet, another inverter's default from create_missing_arg() - is not an entity either, and is never written to. With the previous commit calling the start services after the rates are set, {power} is the planned rate from the first cycle of a window. A restart reads battery_rate_max until the next rate is set. "current" inverters already get a rate entity, and "none" inverters are unchanged. A rate held this way moves as a register would (rate_changed()): a change inside the 5% deadband is held, so {power} - part of the start service's dedup - does not re-send the script every cycle. Any move to or from 0 is a change, though: on a 10kW battery (a 500W deadband) a 400W low power charge straight after an export would otherwise stay at the export's 0. A rate register keeps the plain deadband, so one that stores a small rate as 0 is not rewritten every cycle. Also fixes self_test() passing kW per minute as rates, and the swapped inverter id and value in the "is not a number" errors. This replaces #4645, which created a dummy HA entity for the same purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#4738) For inverter types whose charge_time_format is not HH:MM:SS, refresh_config() replaced the charge/discharge start/end time args with a self-created dummy sensor unconditionally. A custom inverter definition that points those at real time entities (#4738) had its configuration silently discarded, so every window write went to the dummy. The dummy is now only created when apps.yaml did not give this inverter a real entity for the slot, as suggested on review. That is judged per inverter from args_from_apps_yaml, the snapshot taken before Predbat's own defaulting: apps.yaml must name an entity or a re: pattern for this inverter (a single value counts for inverter 0). A placeholder such as the Sofar templates' '00:00:00' does not, so the dummy Predbat writes into the live slot never reads back as configuration - since #5126 inverters persist and this runs every plan cycle, where a lost dummy is re-created and a type change followed. The entity itself is taken from the live args, where any re: pattern is already resolved, and is never Predbat's own dummy for the slot, so a pattern that matched nothing (which leaves the slot empty for the dummy to fill) does not bring it back either. A single value is put back after create_missing_arg() turns it into a list of defaults. Only non-HH:MM:SS types reach this, and every component that sets these time entities also sets an HH:MM:SS type, so component-discovered entities are unaffected. No shipped template names an entity here for a built-in type. The test covers a list and a single value from apps.yaml, nothing set, a bare placeholder (including on refresh after HA loses the dummy), refresh on a persisted object, a type change arriving through refresh_config(), an entity not from apps.yaml, two-inverter fleets where apps.yaml sets only inverter 0 (list and single value), a malformed value, and re: patterns that matched nothing (single and list) or matched an entity. It restores args in place and the HA dummy state afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(Written by Claude, posted by @chalfontchubby.)
Problem
The update-button-commit mechanism on
time_button_pressinverters (Solis and others) was pressing the button every 5-minute cycle instead of once per genuine schedule change. A reporter on v9.0.3 supplied full logs (predbat.log/apps.yaml/debug.yaml) showing 377 presses in one night — every register logging "No write needed", the button pressed anyway, andcount register writes 0on the same cycle, hiding the repeats from the write counter entirely.That's the same signature as the original #2328 report:
On Solis this is a non-volatile write each time (EEPROM wear), and each press also zeroes the timed current registers (#4415/#4709).
Root cause
fetch_inverter_data()defaults tocreate=Trueand runs one or more times per cycle — the plan run re-reads the inverter again beforeexecute_plan()— so theInverterobjects were rebuilt before any commit-once guard could ever latch. #4711 added exactly such a guard on the export side, and it worked in the unit tests (which reuse one long-livedInverter) but did nothing in production, because the object it lived on didn't survive to the next cycle.The rebuild itself was never a deliberate design — before #1803 (Dec 2024)
fetch_inverter_data()had nocreateparameter at all, it always rebuilt. That PR added the parameter to allow reuse but keptcreate=Trueas the default, and only one caller ever opted out, so the main loop kept rebuilding regardless.Fix
Commit 1 — persist the
Inverterobjects across cycles instead of rebuilding them.__init__is split into a one-time identity half and arefresh_config()that runs every cycle, re-reading everything that's genuinely runtime-changeable (battery_min_soc,battery_scaling, the rate limits,battery_calibration) so persisting the object doesn't freeze a setting a user can change while Predbat runs. Also adds the charge-side commit-once guard that never existed (adjust_charge_window()had no equivalent to the export side's protection), with the same write-result accumulation (schedule_write_ok) that review required on the export side in #4711.Measured with a real reporter config (GS, hour/minute entities only, a
charge_discharge_update_button), driven throughfetch_inverter_data()as production does:Commit 2 — persisting the objects exposed a mess in
__init__: real one-shot state, realNonesentinels, and placeholder values indistinguishable from genuine config (reserve_percent = 4.0,battery_rate_max_raw = 2600.0) were all set the same way in the same block. Harmless while the object was rebuilt every cycle; not harmless once it persists, since a stale placeholder can now live long enough to be read for real. Regrouped by actual lifetime into a newreset_cycle_state(), seeded toNonerather than a real-looking value wherever nothing depends on a specific default.That pass caught two real bugs the persistence work introduced along the way:
in_calibrationwas write-only — only ever setTrue, never backFalse— previously masked by__init__reseeding it every cycle. On a persisted object a battery leaving calibration would stay stuck. Now a real two-way read.battery_voltagewas seeded52.0, a guessed value used as a divisor inset_current_from_power(), reachable beforeupdate_status()has ever fetched a real reading. SeededNoneinstead; the divide site now checks explicitly and skips with a warning rather than computing from a made-up voltage.Scope
Deliberately not persisted across Home Assistant restarts — that would need HA storage, which is overkill for a cost of one re-commit on reboot.
The register-write counter fix (a button press wasn't counted, hiding the repeats from
count_register_writes) is independent and already up as #5123.Testing
scheduled_charge_enablecommits once not every cycle, a failed write/press retries on the next cycle rather than being recorded as done, a same-countinverter_typechange at runtime rebuilds correctly,in_calibrationclears whenbattery_calibrationgoes off,set_current_from_power()doesn't crash beforeupdate_status()has runrun_shuffle --hunt --quickrun against a static tree: 50/50 clean, no order-dependency pair found (Test infrastructure keeps producing fixture state-leak bugs #5079)Fixes #4712. Fixes #2328 - matches that report's log signature exactly (every register "No write needed", button pressed anyway). Follow-ups were split off #2328 and #4712's later comments so they survive this close as their own tracked items, unrelated to this PR's fix: #5127 (unconfirmed GS_fb00 +
charge_limitmicromanagement report, needs fresh logs), #5128 (Feed-in priority modes 64/96/98 never reachable), #5129 (charge_limitshould default on for GS_fb00), #5130 (a register read-back briefly bouncing through a wrong value after a write - a write-settling question, not this PR's object-recreation fix).