Skip to content

fix: send this cycle's planned rate as {power} to service-driven inverters (#3311, #5252) - #5373

Draft
chalfontchubby wants to merge 2 commits into
mainfrom
fix/power-inverter-remember-rate-3311
Draft

chalfontchubby wants to merge 2 commits into
mainfrom
fix/power-inverter-remember-rate-3311

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • It came a cycle late (Service-driven inverters receive the previous cycle's rate as {power} #5252). 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.
  • It was always the maximum (Low power mode does not work on Solax #3311). The power is read back from the charge_rate/discharge_rate entity. A script-driven inverter has no such entity, so the read-back fell back to battery_rate_max, and a low power charge ran at full power.

Change

  1. Start services are called after the rates are set (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 its adjust_charge_immediate()/adjust_export_immediate() calls and makes them in the same order once balancing and apply_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.
  2. Predbat remembers the rate it set when there is nowhere else to store it (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 the Inverter object, which persists between plan cycles (fix(inverter): Persist inverters across planning cycles #5126), and reads that back instead of battery_rate_max. A plain number in charge_rate also counts as "no entity" and is never written to. In a mixed fleet, that number is another inverter's default.
  3. The remembered rate moves as a register would. A change inside the usual 5% deadband is held, so {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_percent under 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?

Known limits

  • Balancing. The balancer's 60-second poll can change a remembered rate without calling the service. The script picks up the new rate on the next plan cycle, up to 5 minutes later.
  • Errors partway through a cycle. If 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.
  • Solis FB00. The FB00 mode switch is written inside the immediate calls, so it moves after the reserve reset, but only on a setup that also has a {power} service.
  • Inverters with a rate register above about 8kW still keep a low power charge after an export at 0W, because the 400W is inside their deadband. This is unchanged from main and is tracked separately in A rate register held at 0W ignores a low power rate inside its deadband (batteries above 8kW) #5381.
  • Mixed fleets. One inverter using charge_rate_percent while 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.
  • Restarts. After a Predbat restart, the remembered rate reads as the maximum until the next rate is set.

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_entity covers:

    • the read-back;
    • the register-like deadband and the 0W rule;
    • surviving a config refresh;
    • an entity still being read, including a decimal string with no unit;
    • a register reading 0W not being rewritten, in both watts and percent;
    • a plain number in the rate entry being treated as no entity;
    • no remembered rate when no service sends {power};
    • services_send_power() and queue_immediate().

    It uses the SX4 template's own inverter: block.

  • inverter_rate_no_entity_execute (6kW) and inverter_rate_no_entity_execute_10kw drive execute_plan() through idle→charge and idle→export→charge. Each asserts that the first charge_start carries the planned rate and that the next cycle sends nothing new.

  • Mutation checks. Each change was reverted in turn, and its test fails:

    • deferring the calls;
    • remembering the rate;
    • the 0W rule;
    • keeping the 0W rule off registers;
    • treating a plain number as no entity;
    • the {power}-service gate.
  • Every commit passes ./run_all --quick and pre-commit on its own.

🤖 Generated with Claude Code

chalfontchubby and others added 2 commits October 4, 2026 10:47
…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
chalfontchubby force-pushed the fix/power-inverter-remember-rate-3311 branch from 9ee2d62 to 64acf8e Compare October 4, 2026 10:19
@chalfontchubby chalfontchubby changed the title fix(inverter): send the planned rate as {power} to service-driven inverters, with or without a rate entity (#3311, #5252) fix: send this cycle's planned rate as {power} to service-driven inverters (#3311, #5252) Oct 4, 2026
@chalfontchubby
chalfontchubby requested a balanced review from Copilot October 7, 2026 13:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

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 thread apps/predbat/inverter.py
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working solax

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Service-driven inverters receive the previous cycle's rate as {power} Low power mode does not work on Solax

2 participants