Skip to content

Feat Automatically set battery pause mode for charge/discharge rate of 0 #2155 - #5856

Open
gcoan wants to merge 3 commits into
springfall2008:mainfrom
gcoan:set-pause-mode-2155
Open

gcoan wants to merge 3 commits into
springfall2008:mainfrom
gcoan:set-pause-mode-2155

Conversation

@gcoan

@gcoan gcoan commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Set battery pause mode when a charge/discharge limit or balancing holds a rate at 0

Fixes #2155

Problem

Setting inverter_limit_charge or inverter_limit_discharge to 0 (for example via select.predbat_manual_api) stops Predbat asking for charge or discharge, but many inverters still trickle at 150-200W when the rate is 0.
Inverter balancing has the same problem: it takes a rate to 0 but the battery keeps trickling.
On inverters that have a timed pause mode (has_timed_pause, e.g. GivEnergy) only the pause mode stops this.

What this does

If the inverter has a pause mode, Predbat now sets it to match:

charge limit discharge limit pause mode
0 0 Pause both
0 above 0 Pause charge
above 0 0 Pause discharge
above 0 above 0 Disabled (unless the plan itself needs a pause)

The same happens when balancing holds an inverter's charge or discharge rate at 0, and the pause is released when the limit is raised or the hold ends. Inverters without a pause mode are unchanged, because Inverter.adjust_pause_mode() returns immediately for them.

It only applies when Predbat is controlling the charge/export windows (Control charge, Control charge & discharge). Monitor and Control SoC only never write a pause for a limit of 0, consistent with them not writing rates.

Changes to execute.py

The pause mode used to be written from inside the execute_plan() loop, from several separate blocks (the charge hold, Freeze Export, the car hold, the iBoost hold, and a final reset), while rates were written afterwards by apply_rate_intent()/apply_inverter_rates(). A limit of 0 and the balancer both act after the loop, so a second pause write in the loop could not see them, and a second write elsewhere would just undo the first. So the pause write now happens in one place:

  • execute_plan(): the 7 direct adjust_pause_mode(pause_charge/discharge=True) calls and the final reset call are removed. The blocks still set pause_charge_requested / pause_discharge_requested and resetPause exactly as before, and those flags are already carried in the per-inverter intent (pause_charge / pause_discharge). Nothing about when a block asks for a pause has changed. I also removed an unused max_rate variable and added a comment at the top of the function and at each removed call explaining where the write went.
  • apply_inverter_rates(): after the existing rate writes, it makes one adjust_pause_mode() call whose arguments are the OR of (a) the plan's own request from the intent, (b) a limit of 0, i.e. battery_rate_max_charge/_discharge <= 0 (these are the hardware maximum reduced by inverter_limit_charge/_discharge, so 0 only means the user set a limit of 0), and (c) a balancer hold. The call is made when reset_rates is true (the same condition as the old final reset, i.e. Predbat controls the windows) or when any pause is requested. Two new keyword arguments, balance_pause_charge / balance_pause_discharge, default to False, so existing callers are unaffected.
  • apply_rate_intent(): works out the two balancer flags, which are true when the balancer has overridden a rate (it differs from the plan's own rate) and taken it to 0 or below, and passes them on. It runs on the once-a-minute balancing poll as well as at plan time, so the pause tracks the balancer hold as it starts and ends.

Why this is safe for the rest of execute.py

  • Same decisions, one write. Which blocks ask for a pause, and their resetPause gating, are untouched. The old code called adjust_pause_mode() in exactly the cases where a block claimed a pause or the final reset ran; the new write condition (reset_rates or a pause requested) covers the same cases, with the flags carried in the intent.
  • Monitor and Control SoC only are unchanged. The new limit-of-0 rule is gated on reset_rates, so with both windows off nothing new is written. The plan's own Freeze Export during Demand pause, which is not gated on the windows, still works as before.
  • No rate logic changed. The rate calculation and the existing rate writes are untouched.
  • Non-pause inverters unchanged. adjust_pause_mode() returns early without inv_has_timed_pause, so nothing happens there.
  • Fewer writes, not more. A plan could previously cause more than one pause write in a cycle (a block's write followed by a later one). Now there is one per inverter per cycle, and adjust_pause_mode() only writes when the value differs.
  • Balancing tests unaffected. They run on real inverters with the pause entity removed, so the pause is never written there.

Three small behaviour differences to be aware of:

  1. If two blocks both requested a pause in one cycle (e.g. a charge-window hold pausing discharge and Freeze Export pausing charge), the old code made two writes and the last won; the requests now merge, so Pause both is possible. I believe this is more correct, and the overlap is unusual.
  2. The pause write now happens after the rate writes for that inverter instead of earlier in the cycle. Both are within the same execute_plan() call.
  3. While any inverter is calibrating, Predbat stands down for the whole fleet (full rates, target 100%, reserve 0, intent cleared), and no pause is written either. On main, inverters processed before the calibrating one had already written their pause in their blocks that cycle. An inverter that was paused when calibration started stays in that state until calibration ends.

Known edge: if an inverter's configured maximum rate were ever 0 through a sensor error, it would now also be paused. The default maximum is 2600W, so this needs a misread sensor to reach.

New tests (test_execute.py: test_rate_limit_zero_pauses_battery)

All run on the existing mock inverters in the execute test group:

  1. Limit combinations through apply_inverter_rates(): neither limit, charge limit 0, discharge limit 0, both 0 give no pause, Pause charge, Pause discharge and Pause both. Also a plan-requested pause merged with a limit of 0, and a plan-only pause.
  2. Release: both limits at 0 pause both, raising the charge limit releases only the charge pause, raising the discharge limit releases the rest.
  3. Monitor mode (reset_rates false) with limits of 0 writes no pause, with an error message explaining that monitor mode must not change the inverter.
  4. Balancer hold through apply_rate_intent(): a charge rate taken to 0 on one inverter pauses only that inverter's charge, and re-applying the plan without the override releases it.
  5. Real balancer, end to end: a cross-charging fleet through execute_plan() holds the cross-charging inverter at 0 and pauses its charge only.
  6. Freeze Export during Demand plus a discharge limit of 0 gives Pause both.
  7. Control SoC only (both windows off) with a charge limit of 0 writes no pause.
  8. execute_plan() with both limits at 0 pauses both inverters.

Docs

  • manual-api.md: new section on inverter_limit_charge/_discharge of 0 with the table above. Also added - inverter_limit_charge_dc to the list of supported overrides, missing from the docs and highlighted by the bot on feat(api): let the manual API override inverter_limit_export #5839
  • what-does-predbat-do.md: added explainer of what Predbat writes in each mode (Monitor, Control SoC only, Control charge, Control charge & discharge).
  • customisation.md: balance inverters now sets the pause mode alongside the rates where the inverter supports it.

Testing

  • Unit tests: ./run_all passes; pre-commit passes.
  • Manual, run on my own GivEnergy three-inverter installation: confirmed correct pause modes continue to be set for Predbat plan activity (freeze export, freeze charge), charging. Override inverter_limit_charge and inverter_limit_discharge set correct pause mode when set to zero via predbat_manual_api, and pauses released when rates were increased or overrides removed. Combination checking such as Pause Discharge being set in Charging mode and inverter_limit_discharge set to 0 resulted in Pause Both being set.
  • Manual, second tester validated on his setup using inverter balancing and observed that pause mode was now being correctly set when Predbat inverter balancing set charge/discharge rate to zero and trickle battery flow that had previously occurred no longer happened

@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 29, 2026

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Reviewed at 1740275. The rewrite is sound overall: I traced the old write conditions (the 7 block claims + the final if resetPause: reset on main) against the new single merged write in apply_inverter_rates() and they cover the same cases, with the limit-of-0 and balancer-hold sources added as intended; balance_inverters() mutates only rate keys so plan pause flags survive on the balancer path; and the release paths (limit raised, hold lifted, applied on plan runs and on the 60s poll) all land on the same write. 2 findings, both low: 1 inline (docs), 1 in the body (a pause-write gap during calibration cycles).

apps/predbat/execute.py:542-543 (body) — pause writes stop for already-processed inverters on a cycle where a later inverter is calibrating. The calibration branch does intent.clear() + break, so apply_rate_intent({}) runs and nothing — including pause mode — is written for inverters processed before the calibrating one. On main the pause writes happened inside those blocks, so they were made even on such a cycle. Scenario: a two-inverter fleet where inverter 1 (later in self.inverters) reports battery_calibration active for a few hours; set_freeze_export_during_demand is on, or a limit of 0 is set. Every execute cycle during those hours, inverter 0's pause is neither set nor released — on main it was written every cycle — so cross-charge prevention silently stops applying to it (and if it was already paused, it stays latched). Not covered by the PR's tests and not in the PR body's disclosed differences; the exception is also missing from the new comment at apps/predbat/execute.py:456. Note the calibrating fleet is a deliberate stand-down and the calibration branch itself resets rates to max for the whole fleet (execute.py:528-532), so this may be acceptable — flagging because the comment and the PR body claim the single write covers everything. (Verified by reading execute_plan()'s calibration branch at 528-543 and apply_rate_intent()'s skip-of-missing-ids at 264-266; no test at that head exercises a calibration cycle together with a pause-claiming block.)

Comment thread docs/what-does-predbat-do.md Resolved
@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 29, 2026
@gcoan gcoan added the BOT_REVIEW Trigger an autotriage label Oct 30, 2026

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Re-reviewed at 938e473 ("respond to bot review comments") — both findings from the review at 17402756 are addressed. I verified each fix against the code at this head. 0 new findings (0 inline, 0 in body).

  1. docs/what-does-predbat-do.md — mode bullets. The Monitor and Control SoC only bullets now each name both exceptions, and both exceptions are real in the code: the Freeze Export during Demand block is not gated on the charge/export windows or the mode select (execute.py:950-964), so it claims a charge pause with both windows off; and the balancer writes through apply_rate_intent() → apply_inverter_rates(), whose pause write fires whenever a pause is requested regardless of mode (execute.py:344-345). For a direction the executor never claimed (None), balance_inverters() treats the inverter's own maximum as the rate in force, so it can take an unclaimed direction to 0 in Control SoC only and Monitor too (utils.py:2905-2915); the poll gates only on the balance switch and read-only (execute.py:1611).

  2. Calibration stand-down (apps/predbat/execute.py:463 comment, plus the new test_calibration_writes_no_pause). The exception is now disclosed at the sentence that claims the single write covers everything, and the new test pins the behaviour. Its premises check out against the code at this head: a calibration cycle breaks out with a cleared intent, so apply_rate_intent() writes nothing — pause mode included — for the inverters processed before the calibrating one (execute.py:527-545, skip at 264-266, call at 1205); the pause flags the test reads are the test double's adjust_pause_mode outputs (test_execute.py:153-155), so the assertions are real rather than vacuous; the 60s poll declines a calibrating fleet and drops the stored intent (execute.py:1618-1621) and the plan-run balancer stops too (utils.py:2886-2890), so no stale plan's pause is applied; and the next normal cycle writes the correct pause again (execute.py:340-345).

No new issues in the +95/−4 delta.

@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 30, 2026

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FR: Set battery_pause_mode intelligently based on inverter_limit_charge/discharge

2 participants