You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix: send this cycle's planned rate as {power} to service-driven inverters (#3311, #5252) - #5373
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
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.
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.
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.
fix(inverter): auto-create charge_rate entity for script-driven "power" inverters (#3311) #4645 created a dummy charge_rate entity 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 with charge_rate_percent and components, the GivEnergy maximum-rate lookup, and a new inverter-type flag. Predbat chose the rate itself, so remembering it needs none of that.
fix(execute): send this cycle's rate to service-driven inverters as {power} (#5252) #5254 passed the planned rate into the start services instead of moving the calls. That sent the rate from before balancing, needed a second deadband in the service path, and still sent the planned rate ahead of calibration. Calling the services after the rates are set avoids all three.
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.
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).
…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
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
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.
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.
…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>
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.
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>
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
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
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()). 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 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 in that direction but neither a rate entity nor a rate percentage of its own. 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. Likewise acharge_rate_percentbound 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.{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.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?
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.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}in only another inverter's entry of a per-inverter list;{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.executecalibration 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:
{power}-service gate.Every commit passes
./run_all --quickand 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 rawbattery_rate_max, so a limit close to the maximum still reaches{power}(7879ce5).🤖 Generated with Claude Code