Repository navigation
fix(inverter): auto-create charge_rate entity for script-driven "power" inverters (#3311) - #4645
chalfontchubby wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The new REST-less "power" auto-creation logic only checks for key presence, which can miss per-inverter missing/None entries and still fall back to max rate in multi-inverter/partial-list configurations.
Pull request overview
This PR fixes #3311 by ensuring script/service-driven inverters that use output_charge_control: "power" but don’t have REST data can still persist and read back the computed low-power charge/discharge rate via auto-created synthetic charge_rate / discharge_rate entities.
Changes:
- Add a per-inverter-type flag (
has_charge_rate_entity) and use it to conditionally auto-create syntheticcharge_rate/discharge_rateentities for REST-less"power"inverters. - Add inverter tests covering creation, non-clobbering when already configured, and no creation for REST-driven
"power"inverters. - Add the new flag to the Solax Gen4 (SX4) inverter definition.
File summaries
| File | Description |
|---|---|
| apps/predbat/inverter.py | Adds conditional synthetic entity auto-creation for REST-less "power" inverters and introduces a per-type flag to control it. |
| apps/predbat/config.py | Adds has_charge_rate_entity to the SX4 inverter definition to document/declare the intended behavior. |
| apps/predbat/tests/test_inverter.py | Adds regression tests for entity creation, non-clobbering, and REST-driven exclusion. |
Review details
Suppressed comments (1)
apps/predbat/inverter.py:588
- The REST-less "power" branch only auto-creates charge_rate/discharge_rate when the key is entirely absent. If the key exists but this inverter’s slot is missing/None (e.g., shorter list in multi-inverter configs), get_current_charge_rate() will still resolve to None and fall back to battery_rate_max_raw, recreating the #3311 behaviour for that inverter. Consider treating the arg as "not configured" when the per-inverter index is out of range or None, while still not clobbering scalar (single-value) configs.
self.create_missing_arg("idle_start_time", "00:00:00")
self.create_missing_arg("idle_end_time", "00:00:00")
self.base.args["idle_start_time"][id] = self.create_entity("idle_start_time", "00:00:00")
self.base.args["idle_end_time"][id] = self.create_entity("idle_end_time", "00:00:00")
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Posted by Claude on behalf of @chalfontchubby. Addressed the Copilot review's suppressed finding on The auto-creation branch gated on Now gated per index: create the synthetic entity when this inverter's slot is absent or |
Copilot review on #4645: gating auto-creation on `"charge_rate" not in self.base.args` misses a mixed multi-inverter config where charge_rate is a list shorter than the inverter count, or carries a None in one inverter's slot. The key is present, so the old guard skipped creation, and get_current_charge_rate() for that inverter still resolved to battery_rate_max_raw - the #3311 fault - for a source-less "power" inverter in a later slot. Gate per index instead: create the synthetic entity when this inverter's slot is absent or None, extending the list if it is short, while still never overwriting a real per-inverter value or a single (non-list) user value that applies to every inverter. Folds the charge_rate/discharge_rate pair into one loop. Test: a two-inverter "power" config with charge_rate configured for inverter 0 only now auto-fills inverter 1's slot and leaves inverter 0's entity untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
c68e38a to
1206e55
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate findings remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
apps/predbat/config.py:1952
- This new SX4/Solax definition entry is not exercised by the added tests: every fixture sets
inverter_typetoGE. BecauseInverter.__init__currently defaultshas_charge_rate_entitytoTrue, the suite would still pass if this line were absent or attached to the wrong inverter definition. Add a case usingSX4(or the custom Solax definition) and verify the resulting service payload, not only the generic GE branch.
"has_charge_rate_entity": True,
apps/predbat/inverter.py:431
- This newly assigned attribute is never read anywhere in the repository, and no
INVERTER_DEFentry still declareshas_fox_inverter_mode. The Fox mode handling was removed, so retaining this dead capability field adds misleading state and makes the inverter definition harder to reason about; remove the stale assignment.
self.inv_has_fox_inverter_mode = INVERTER_DEF[self.inverter_type].get("has_fox_inverter_mode", False)
apps/predbat/tests/test_inverter.py:793
- This regression test only verifies that the
charge_rate/discharge_ratekeys exist; it never writes a planned low-power value or callsadjust_charge_immediate(). It would still pass ifget_current_charge_rate()or thecharge_start_servicepayload continued to usebattery_rate_max_raw, so add a Solax/SX4-shaped assertion that the service receives a value such as 2500 W.
if "charge_rate" not in my_predbat.args:
print("ERROR: {} charge_rate entity was not auto-created for a source-less 'power' inverter".format(test_name))
failed = True
if "discharge_rate" not in my_predbat.args:
print("ERROR: {} discharge_rate entity was not auto-created for a source-less 'power' inverter".format(test_name))
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
…st args Review feedback on #4645: constructing an Inverter writes a dummy entity into args for every register the type lacks AND creates the matching state in the shared HA interface. The tests snapshotted args alone, so the entity state leaked into every test running after them in this module - measured at 12 entities, including the sensor.predbat_GE_1_* set the review named. Snapshot and restore both through one helper rather than per test, and apply it to test_short_per_inverter_list_gets_its_dummy_entity too: the review cited it as the model to follow, but it had the same gap. Verified by diffing the fixture's args keys and dummy_items around the whole group - 12 leaked entities before, none after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n adoption, split gap/tail log - automatic_config() records what each key held before its first claim and hands a capability-gated key back to it when a re-run's gate fails (a late endpoint lacking v3 or a register), instead of leaving the gap-slot claim pointing at an entity that is never published. - run() publishes again straight after rediscover() adopts an endpoint, so the re-run's discovery-key gates see the adopted inverter's sensors rather than failing for all of them. - Gap slots below the highest answered endpoint get their own Warn line naming the URL and the cost (planned without live data until it answers); only tail slots are "left as configured". - Rewrite the automatic_config() header comment to the per-endpoint invariant. - Update the debug-journal GH#5029 row and PR #4645 proxy bullet for _per_endpoint_values(). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r" inverters (#3311) Solax's low-power-mode charge rate is driven via charge_start_service (a plain HA script call), not a REST/cloud API, but output_charge_control: "power" meant the dummy charge_rate/discharge_rate entities were only ever auto-created for "current" mode. Without the entity, the computed rate had nowhere to be stored and read back as battery_rate_max_raw, sending full power to the script regardless of what was planned. Whether an inverter type needs the entity is declared by a new has_charge_rate_entity INVERTER_DEF flag (default True). Creation is gated per inverter index rather than on key presence, never overwrites a configured value, and is skipped when an inverter-source component is active but has not written the key at all (its automatic config is off, or it has not run yet), so a REST/cloud-controlled inverter's rate writes never land in a dummy. Squashed from the review history of #4645 onto current main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two cold reviews of #4645 found these ways the auto-created charge_rate entity still failed: - With the rate keys absent, inverter 0 seeded [its dummy, default, default, default]. Inverter 1 then read the bare default in its own slot as configured and skipped creation, so its rate writes went nowhere and it read back battery_rate_max - the #3311 fault for every inverter after the first. An absent key now starts empty and is padded with None, as create_missing_arg() already does for a short list; a bare number left in a slot by a "current" mode inverter's seeding is treated as unset too. - Once the dummy was in args it was never created again, so after an HA restart the sensor stayed missing, and a fresh Inverter object never learnt its attributes (writes dropped the unit). Predbat's own dummy is now re-asserted on every refresh; create_entity() leaves existing state. - GE/GEC/GEE size battery_rate_max_raw from a configured charge_rate's "max" attribute. The dummy has none, so every refresh after it existed (each plan cycle now inverters persist) dropped to 2600W. Predbat's own dummy is now treated as unset there. - A slot whose *_rate_percent is configured (GECloud 3-phase units, #4908) was given a dummy it does not need, which the previous point then turned into a wrong battery maximum. Such a slot is now left alone. The #3311 setup is a user-defined SOLAX type, which copies GE's definition and never read the SX4 entry: the template now states has_charge_rate_entity itself, and the SX4 comment no longer claims to be the originating case. New tests check the planned rate reaches charge_start_service as {power} on that setup (and that opting out falls back to battery_rate_max). The flag and its mixed-fleet limitation are documented alongside the other definition keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7cb4bcc to
a75b70c
Compare
Two cold reviews of #4645 found these ways the auto-created charge_rate entity still failed: - With the rate keys absent, inverter 0 seeded [its dummy, default, default, default]. Inverter 1 then read the bare default in its own slot as configured and skipped creation, so its rate writes went nowhere and it read back battery_rate_max - the #3311 fault for every inverter after the first. An absent key now starts empty and is padded with None, as create_missing_arg() already does for a short list; a bare number left in a slot by a "current" mode inverter's seeding is treated as unset too. - Once the dummy was in args it was never created again, so after an HA restart the sensor stayed missing, and a fresh Inverter object never learnt its attributes (writes dropped the unit). Predbat's own dummy is now re-asserted on every refresh; create_entity() leaves existing state. - GE/GEC/GEE size battery_rate_max_raw from a configured charge_rate's "max" attribute. The dummy has none, so every refresh after it existed (each plan cycle now inverters persist) dropped to 2600W. Predbat's own dummy is now treated as unset there. - A slot whose *_rate_percent is configured (GECloud 3-phase units, #4908) was given a dummy it does not need, which the previous point then turned into a wrong battery maximum. Such a slot is now left alone. The #3311 setup is a user-defined SOLAX type, which copies GE's definition and never read the SX4 entry: the template now states has_charge_rate_entity itself, and the SX4 comment no longer claims to be the originating case. New tests check the planned rate reaches charge_start_service as {power} on that setup (and that opting out falls back to battery_rate_max). The flag and its mixed-fleet limitation are documented alongside the other definition keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a75b70c to
8d2714b
Compare
| # object has anything but a registry that carries the method. A registry that cannot | ||
| # answer - the harness stubs one with, at most, the few lookups its own test needs - is | ||
| # treated as "no source", which is also what a None registry already meant above. | ||
| source_active = getattr(components, "inverter_source_active", None) |
There was a problem hiding this comment.
Why getattr() I usually don't allow them?
There was a problem hiding this comment.
Posted by Claude on behalf of @chalfontchubby.
Fair point. It was only there so Inverter construction would tolerate test stubs whose registry lacked the method, and production code shouldn't bend to test doubles. Removed in 36b4ade, back to main's direct components.inverter_source_active() call. Nothing needs it any more: the full quick suite passes. I also rebased onto main to clear the merge conflict.
…r" inverters (#3311) Solax's low-power-mode charge rate is driven via charge_start_service (a plain HA script call), not a REST/cloud API, but output_charge_control: "power" meant the dummy charge_rate/discharge_rate entities were only ever auto-created for "current" mode. Without the entity, the computed rate had nowhere to be stored and read back as battery_rate_max_raw, sending full power to the script regardless of what was planned. Whether an inverter type needs the entity is declared by a new has_charge_rate_entity INVERTER_DEF flag (default True). Creation is gated per inverter index rather than on key presence, never overwrites a configured value, and is skipped when an inverter-source component is active but has not written the key at all (its automatic config is off, or it has not run yet), so a REST/cloud-controlled inverter's rate writes never land in a dummy. Squashed from the review history of #4645 onto current main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two cold reviews of #4645 found these ways the auto-created charge_rate entity still failed: - With the rate keys absent, inverter 0 seeded [its dummy, default, default, default]. Inverter 1 then read the bare default in its own slot as configured and skipped creation, so its rate writes went nowhere and it read back battery_rate_max - the #3311 fault for every inverter after the first. An absent key now starts empty and is padded with None, as create_missing_arg() already does for a short list; a bare number left in a slot by a "current" mode inverter's seeding is treated as unset too. - Once the dummy was in args it was never created again, so after an HA restart the sensor stayed missing, and a fresh Inverter object never learnt its attributes (writes dropped the unit). Predbat's own dummy is now re-asserted on every refresh; create_entity() leaves existing state. - GE/GEC/GEE size battery_rate_max_raw from a configured charge_rate's "max" attribute. The dummy has none, so every refresh after it existed (each plan cycle now inverters persist) dropped to 2600W. Predbat's own dummy is now treated as unset there. - A slot whose *_rate_percent is configured (GECloud 3-phase units, #4908) was given a dummy it does not need, which the previous point then turned into a wrong battery maximum. Such a slot is now left alone. The #3311 setup is a user-defined SOLAX type, which copies GE's definition and never read the SX4 entry: the template now states has_charge_rate_entity itself, and the SX4 comment no longer claims to be the originating case. New tests check the planned rate reaches charge_start_service as {power} on that setup (and that opting out falls back to battery_rate_max). The flag and its mixed-fleet limitation are documented alongside the other definition keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s active Drop the getattr() around components.inverter_source_active() and go back to main's direct call. It was added so Inverter construction would tolerate test stubs whose registry lacked the method; no test needs that any more, and production code shouldn't bend to an incomplete test double. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8d2714b to
36b4ade
Compare
TestHAInterface always has dummy_items, and the rest of test_inverter.py uses it directly, so the getattr fallbacks in the snapshot/restore helpers only hid a broken fixture (same point as the #4645 review on inverter.py). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # Script-driven "power" mode with no REST/native register to hold the computed | ||
| # charge/discharge rate. Stated explicitly although it is the default; the Solax Gen4+ | ||
| # template (templates/solax_sx4.yaml, the #3311 setup) sets it in its own inverter: block. | ||
| "has_charge_rate_entity": True, |
| if self.base.get_arg(rate_arg + "_percent", indirect=False, index=self.id, default=None): | ||
| # The percentage register is this inverter's real rate control (GECloud's 3-phase | ||
| # units, #4908) and get_current_charge_rate() reads it in preference, so the empty | ||
| # power slot beside it is not a gap to fill. | ||
| continue |
| # inv_needs_charge_rate_entity branch below) has no "max" attribute either, so it is | ||
| # treated as unset too - otherwise every refresh after it was created fell back to 2600W. | ||
| charge_rate_entity = self.base.get_arg("charge_rate", indirect=False, index=self.id) | ||
| if charge_rate_entity and charge_rate_entity != self.dummy_entity_id("charge_rate"): |
|
Posted by Claude on behalf of @chalfontchubby. Closing in favour of #5373, which fixes #3311 a different way. Instead of creating a dummy Most of this PR's review went into problems the dummy entity itself caused: entries in the per-inverter lists, the interaction with |
…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>


Summary
Fixes #3311 - Solax low power mode sends
battery_rate_maxinstead of the planned rate tocharge_start_service.Root cause.
output_charge_control: "power"inverters normally write their rate straight to the inverter through an integration (GivTCP REST, a cloud component), so the dummycharge_rate/discharge_rateentities were only ever auto-created for"current"mode. Solax is"power"mode but script-driven:charge_start_servicecalls a plain HA script, andget_current_charge_rate()/adjust_charge_rate()go throughargs["charge_rate"]exactly as"current"mode does. With no entity, the low-power calculation runs (the reporter's log shows "Best rate: 2500W") but has nowhere to store the result, so it reads back asbattery_rate_maxand the script gets full power. Community member f948lan found the same root cause and worked around it withinput_numberhelpers - this makes that automatic.Change
has_charge_rate_entityinverter-definition flag (default True) declares whether a"power"type needs the entity. The Solax template states it in its owninverter:block, and it is documented with the other definition keys.charge_ratelist - is not an entity, so it is filled; on main such a number was read as a fixed rate and every write to it failed);charge_rate_percent/discharge_rate_percentis configured is left alone - the percentage register is the real control (GECloud 3-phase units, GEC (GivEnergy Cloud) with ge_cloud_automatic: true uses hardcoded 2600W default instead of correctly-fetched 20000W inverter capability (3-phase HV inverter) #4908);charge_rate'smaxattribute. Predbat's own dummy has none, so it is treated as unset there rather than dropping the maximum to 2600W.Known limitation. A script-driven inverter alongside an integration-backed one whose automatic config is off still gets no entity (the integration has written nothing, so the third rule above applies to every inverter). Configure
charge_ratefor it inapps.yamlin that case. (Conversely, if a"current"mode inverter earlier in the fleet has already seeded the list, an integration-backed inverter with automatic config off does get a dummy - its rate writes were rejected as a fixed value before, so this only changes how that misconfiguration fails.)Separate, pre-existing issue. Since the balance fold into
execute.py,execute_plan()writes rates inapply_rate_intent()after its per-inverter loop, whileadjust_charge_immediate()/adjust_export_immediate()build{power}inside the loop. So a service-driven inverter receives the previous cycle's rate: the first start call of a low-power window still carries the maximum, corrected one cycle later. That affects every service-driven inverter (including the #4619 discharge path), not just this change - tracked in #5252.Rebased onto current main as a single commit plus the review fixes; the block now runs in
refresh_config(), i.e. every plan cycle since #5126 made inverters persist, and is idempotent there.Test plan
New tests in
test_inverter.py:"power"inverter; not created for an integration-backed onecharge_rateis not replacedbattery_rate_maxacross a refresh and a fresh build once the dummy existscharge_start_servicereceives the planned 2500W as{power}; with the flag off it receivesbattery_rate_max./run_all --quickand./run_pre_commitcleanFollow-up
Mixed setups, where one inverter uses
charge_rate_percentand another a script-driven{power}, are tracked in #5359.The rate readers and writers still choose their path by whether the key exists, not by each inverter's own entry.
That was already wrong on main for the Solax side. This PR adds one new warning in one of those setups, described in the issue.
🤖 Generated with Claude Code