Skip to content

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

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

chalfontchubby wants to merge 17 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()). Only a string value counts, or the inverter's own entry of a per-inverter list, as those are what get filled in; a {power} nested inside a mapping is never filled in. 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 in that direction but neither a rate entity nor a rate percentage of its own. 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. Likewise a charge_rate_percent bound only for another inverter (Mixed inverter setups: the charge/discharge rate is read and written by "is the key set", not by each inverter's own entry #5359): this inverter holds its rate rather than reading its empty entry as 100%, and neither control is written for it.
  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.

If another inverter enters calibration partway through the loop, the calls already queued are made after calibration's full-rate writes and before control ownership is dropped, as they were inline.

With main's control-failure monitor (#5563), each inverter's control expectation is recorded after the queued start calls, so the monitor's grace period starts once the service has actually been called. A test pins that order.

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.
  • 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;
    • a percentage bound only for another inverter, with this inverter's rate entry a number, missing or empty, for charge and discharge;
    • a {power} in only another inverter's entry of a per-inverter list;
    • 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.

  • execute calibration ownership. A calibration break leaves no control owned by the calls queued before it.

  • 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.

Before Predbat sets a rate after a restart, a held rate reads as the maximum after any inverter_limit_charge/inverter_limit_discharge, not the raw battery_rate_max, so a limit close to the maximum still reaches {power} (7879ce5).

🤖 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 Outdated
… {power} (#5373 review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Unresolved moderate findings affect call ordering, per-inverter service handling, and regression-test coverage.

0 open findings

1 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Defer only calls with power-bearing payloads

apps/​predbat/​execute.py:364

This global gate queues every immediate call whenever any configured service anywhere in self.args mentions {power}. In a mixed setup where only one direction or one inverter uses a power-bearing service, the other inverter/direction's non-power start/stop calls are now all delayed until after apply_rate_intent(), changing the established ordering even though their payload cannot be stale. Determine queueing per call's inverter/direction (or keep separate affected/unaffected queues) so only calls that actually send {power} are deferred.

Low severity Document percentage-controlled discharge rate sources

docs/​inverter-setup.md:3689

This documents the discharge payload as coming from discharge_rate, but get_current_discharge_rate() first uses discharge_rate_percent when that control is configured, and the new no-entity path can use Predbat's remembered rate. Please mirror the charge description here so percentage-controlled and service-driven discharge setups are documented accurately.

🧠 Review effort: Lite

…one does

discharge_start_service's power is read from discharge_rate_percent when
that is configured, and on a "power" inverter with no rate entity it is
the rate Predbat last set. The docs only named discharge_rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Posted by Claude on behalf of @chalfontchubby.

Replying to the two "previously missed" points in the latest Copilot review:

Document percentage-controlled discharge rate sources: agreed, fixed in 82a98cb. The discharge power entry in docs/inverter-setup.md now matches the charge one. It names discharge_rate_percent, a discharge_rate entity, and the remembered rate for a power inverter that has neither.

Defer only calls with power-bearing payloads: no change here. The split it suggests wouldn't isolate anything:

  • The start, stop and freeze services are global apps.yaml settings. call_service_template() reads them from self.base.args, not per inverter, so every inverter's calls use the same templates.
  • Each immediate call uses services from both directions. adjust_export_immediate() calls charge_stop_service and then discharge_start_service, and adjust_charge_immediate() can fall back to discharge_stop_service. So a queue split by direction can't tell which calls carry {power}.
  • The only effect on a call that doesn't send {power} is that it runs after apply_rate_intent() in the same cycle, instead of between rate writes. The immediate calls still run in the order the loop chose.

@chalfontchubby
chalfontchubby requested a lite review from Copilot October 9, 2026 19:04
@chalfontchubby
chalfontchubby marked this pull request as ready for review October 9, 2026 19:04

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.

🔵 Needs a closer look

Two moderate execution issues remain unresolved, along with a self-test regression-coverage nit.

0 open findings

🧠 Review effort: Lite

With no rate set yet (startup or restart), a "power" inverter whose rate
Predbat holds read back the raw battery_rate_max. A configured
inverter_limit_charge/discharge within the 5% deadband of that maximum
was then never taken as a change, so {power} stayed above the limit until
the plan asked for a rate further away. Fall back to the direction's
maximum after the inverter limits instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Two moderate issues remain involving stale remembered rates and per-inverter {power} detection.

0 open findings

🧠 Review effort: Lite

chalfontchubby and others added 12 commits October 15, 2026 21:46
Conflicts:
- apps/predbat/inverter.py adjust_charge_rate()/adjust_discharge_rate():
  main (#5542, #5359) chose the watts/percent controls per inverter via
  rate_controls(); this branch skipped the watts write when Predbat holds
  the rate itself (held_by_predbat). Combined: take the per-inverter
  write_watts/write_percent from rate_controls() and write watts only when
  write_watts and not held_by_predbat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…centage (#5359)

rate_without_entity() decided by whether the charge/discharge_rate_percent
setting existed for the fleet, while get_current_*_rate() now asks this
inverter's own entry through rate_controls() (#5542). In a mixed fleet
whose percentage is bound only for another inverter, and whose rate entry
holds a plain number here, the read-back took the number - the maximum -
and sent it as {power}, the #3311 fault. The hold now asks rate_controls()
too, so it and the read-back always agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
services_send_power() matched {power} anywhere in str(value), so a template
with {power} inside a nested mapping (data: {power: "{power}"}) counted as
sending the rate. call_service_template() only formats a string value, or
each string of a per-inverter list, so that {power} is never filled in -
yet it turned on the deferred start calls and the held rate. Only string
values and per-inverter lists of strings now count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ownership

On the calibration break, the immediate calls earlier inverters had queued
ran after the loop, so after clear_control_ledger(). A write they make -
Solis FB00's energy control switch, through write_and_poll_option() - then
recorded ownership again while the firmware drives the settings, and the
firmware's next change would read as third-party interference. Inline, as
on main, those calls came before the clear. They now run in the
calibration branch after its full-rate writes and before ownership is
dropped, so they still carry calibration's rates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per-direction hold

test_rate_held_per_direction said setting a discharge rate records nothing, but wrote rate_last_set directly, so adjust_discharge_rate() recording a rate it does not hold went unnoticed. It now calls adjust_discharge_rate() and checks nothing is recorded, then keeps the planted-value read-back check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The charge and discharge {power} notes said a rate is held provided a start or freeze service sends {power}. services_send_power() is asked per direction, so it is the charge (or discharge) start or freeze service that must; the notes now name them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…age, bound to neither control (#5359)

rate_controls() answers by the settings' presence for an inverter bound to neither control. So a script-driven inverter in a fleet whose charge_rate_percent is bound for another inverter only, with no (or an empty) rate entry of its own, was not held: it read its empty percentage as 100%, sent the maximum as {power}, and warned each cycle of a missing entity when the rate changed. rate_without_entity() now asks this inverter's own percentage entry, a rate it holds is read back from what it set ahead of the percentage, and neither control is written for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y sends {power}

A per-inverter list in a service template counted as sending the rate when any inverter's entry referenced {power}, but call_service_template() sends each inverter only its own entry. An inverter whose entry is a literal then held a rate it was never sent. services_send_power() takes an optional inverter index, and rate_without_entity() passes it; execute_plan()'s fleet-wide gate still counts any inverter's entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the queued calls now made just above it, "Those writes" read as only theirs; it covers calibration's writes as well. Also documents the nested test helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd say why it skips rate_controls()

The other_inverters_percentage test's docstring still said the hold asks the same way as get_current_charge_rate(); since 7966699 it asks this inverter's own percentage entry because rate_controls() falls back to the setting's presence. It also only drove the charge side; a discharge case now checks the mirrored read-back, {power} and that no write is attempted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflict in apps/predbat/execute.py, against d1ba4f8 (control-failure
monitor, #5563):

- utils import line: this branch added services_send_power, main added
  get_charge_rate_curve / get_discharge_rate_curve. Kept all of them.
- After apply_inverter_rates()'s rate writes: main added the
  requested_charge_w / requested_discharge_w bookkeeping and the new
  record_control_expectations(), expected_battery_rates() and
  observe_control_failures() methods; this branch added queue_immediate().
  Kept both, main's first.

End of execute_plan(): the auto-merge put main's
record_control_expectations() between apply_rate_intent() and this
branch's deferred immediate calls. Moved it after the queued calls, so a
slot's grace period (entered_at) starts once its start service has
actually been called, and the expectations are still recorded once every
inverter's status is final, as main intends.

Auto-merged inverter.py, test_execute.py, unit_test.py and
docs/inverter-setup.md checked for semantic overlap: #5541's
freeze-on-overshoot in adjust_charge_immediate() uses charge_freeze_service,
which services_send_power() already counts, so the deferral and remembered
rate cover it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5373)

The merge with main put record_control_expectations() after the deferred
immediate calls, so a slot's grace period starts once its start service
has been called. This checks that order for a {power} start service: each
inverter's adjust_charge_immediate() is made before its Charging
expectation is recorded. With the auto-merge's order (expectations before
the queued calls) both inverters fail it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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