Repository navigation
fix: send this cycle's planned rate as {power} to service-driven inverters (#3311, #5252) - #5373
Draft
chalfontchubby wants to merge 2 commits into
Draft
chalfontchubby wants to merge 2 commits into
chalfontchubby wants to merge 2 commits into
Conversation
This was referenced Oct 3, 2026
Closed
chalfontchubby
marked this pull request as draft
October 3, 2026 20:27
…wer} is this cycle's (#5252) Since 72b5817, execute_plan() writes charge/discharge rates in apply_rate_intent() after its per-inverter loop, so the balancer can see the whole fleet first. But adjust_charge_immediate() and adjust_export_immediate() were still called inside the loop, and built {power} from the stored rate - the previous cycle's. A service-driven inverter (charge_start_service / discharge_start_service with {power}, e.g. Solax) started every low-power window at the maximum for a cycle and lagged a cycle behind every rate change, undoing part of #4619. Where a start or freeze service sends {power} (services_send_power(): a service given by name alone, or a template referencing {power}), the loop now queues those calls (queue_immediate()) and execute_plan() makes them, in the same order, straight after apply_rate_intent(). {power} is then the rate actually set this cycle, after balancing. Otherwise - most service templates do not use {power} - the calls are made where they were, keeping their order against the inverter's other writes. Calls queued before an inverter is found calibrating still run, as they did before - dropping them would also drop a stop - now carrying calibration's full rates. This replaces #5254, which passed the planned rate into the calls instead: that sent the rate from before balancing, and needed its own deadband in the service path. The execute tests configure start services, the test double records the {power} a real call would send, and every scenario asserts it equals the rate written that cycle; without the change the first charging scenario fails. 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>
chalfontchubby
force-pushed
the
fix/power-inverter-remember-rate-3311
branch
from
October 4, 2026 10:19
9ee2d62 to
64acf8e
Compare
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Power-service detection must distinguish charge from discharge to avoid remembering rates that are never applied.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
Fixes service-driven inverter power values so {power} reflects the current cycle’s balanced rate.
Changes:
- Defers power-dependent service calls until rates are applied.
- Remembers rates for inverters without rate entities.
- Adds regression tests and documentation.
| File | Description |
|---|---|
docs/inverter-setup.md |
Documents {power} rate behavior. |
apps/predbat/const.py |
Defines power-aware service names. |
apps/predbat/utils.py |
Detects services using {power}. |
apps/predbat/inverter.py |
Adds remembered-rate handling. |
apps/predbat/execute.py |
Defers immediate service calls. |
apps/predbat/unit_test.py |
Registers new tests. |
apps/predbat/tests/test_execute.py |
Verifies current-cycle service power. |
apps/predbat/tests/test_inverter_rate_no_entity.py |
Tests entity-free rate handling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+511
to
+512
| # Whether a start or freeze service sends the rate as {power} - see rate_without_entity() | ||
| self.inv_services_send_power = services_send_power(self.base.args) |
This branch has not been deployed
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.

Posted by Claude on Rik's behalf.
Fixes #3311
Fixes #5252
Replaces #4645 and #5254, both now closed.
Problem
Some setups drive the inverter through Home Assistant services, such as the Solax SX4 template, which runs a script. Predbat passes those services the charge or discharge power as
{power}, and that value was wrong in two ways:execute_plan()calls the start services inside its per-inverter loop. The rates are only written after that loop, so that the balancer can see the whole fleet first. So{power}was always the previous cycle's rate: the first call of a low power window carried the maximum, and every rate change lagged a cycle.charge_rate/discharge_rateentity. A script-driven inverter has no such entity, so the read-back fell back tobattery_rate_max, and a low power charge ran at full power.Change
execute.py). This only applies where a start or freeze service actually sends{power}. A service counts if it's given by name alone, which passes every default option, or if it's a template with a value referencing{power}, including forms like{power:.0f}(services_send_power()). In those setups, the loop queues itsadjust_charge_immediate()/adjust_export_immediate()calls and makes them in the same order once balancing andapply_rate_intent()are done.{power}is then the rate actually set this cycle, after balancing. Every other setup is unaffected, including the many service templates that never use{power}: their calls run where they did, in the same order against the inverter's other writes.inverter.py). This applies to a "power" inverter that has a{power}service but neither a rate entity nor a rate percentage. Predbat keeps the last rate set on theInverterobject, which persists between plan cycles (fix(inverter): Persist inverters across planning cycles #5126), and reads that back instead ofbattery_rate_max. A plain number incharge_ratealso counts as "no entity" and is never written to. In a mixed fleet, that number is another inverter's default.{power}, which is part of the start service's dedup, doesn't make the script run again every cycle. The one exception is a move to or from 0W, which always counts as a change. On a 10kW battery the deadband is 500W, so a 400W low power charge straight after an export would otherwise stay at the export's 0W and never start. A rate register is left out of that exception: one that stores a small rate as 0 (GivEnergy's whole-percent step, or a*_rate_percentunder 1%) would otherwise be rewritten every cycle.Also fixed along the way:
self_test()passed kW-per-minute values as rates, and the "is not a number" errors had the inverter id and the value swapped.Why not #4645 or #5254?
charge_rateentity so there was something to read back. Most of its five weeks of review went into problems the dummy entity caused: keeping each inverter's entry in the per-inverter lists, the interaction withcharge_rate_percentand components, the GivEnergy maximum-rate lookup, and a new inverter-type flag. Predbat chose the rate itself, so remembering it needs none of that.Known limits
execute_plan()raises partway through, the queued calls for that cycle aren't made. They used to have gone out already for the inverters handled before the error.{power}service.charge_rate_percentwhile another is script-driven is Mixed inverter setups: the charge/discharge rate is read and written by "is the key set", not by each inverter's own entry #5359.Tests
test_execute. The scenarios now configure start services. The test double records the{power}a real call would send, and every scenario asserts it equals the rate written that cycle. Without commit 1, the first charging scenario fails with "power 1000 should match … 2000".inverter_rate_no_entitycovers:{power};services_send_power()andqueue_immediate().It uses the SX4 template's own
inverter:block.inverter_rate_no_entity_execute(6kW) andinverter_rate_no_entity_execute_10kwdriveexecute_plan()through idle→charge and idle→export→charge. Each asserts that the firstcharge_startcarries the planned rate and that the next cycle sends nothing new.Mutation checks. Each change was reverted in turn, and its test fails:
{power}-service gate.Every commit passes
./run_all --quickand pre-commit on its own.🤖 Generated with Claude Code