Skip to content

Add manual_soc_max: a discharge-direction ceiling sibling to manual_soc - #4841

Open
chalfontchubby wants to merge 2 commits into
mainfrom
fix/manual-soc-max-ceiling-1578
Open

Add manual_soc_max: a discharge-direction ceiling sibling to manual_soc#4841
chalfontchubby wants to merge 2 commits into
mainfrom
fix/manual-soc-max-ceiling-1578

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Summary

select.predbat_manual_soc already lets an automation set a minimum SoC floor for a specific time - Predbat plans charging to reach it by the deadline. There was no equivalent for the opposite direction: forcing the battery down to a ceiling by a deadline, which is what #1578 actually needs - a periodic calibration discharge finishing right before a known cheap import slot (e.g. an Octopus Intelligent Go midnight slot), so the BMS can re-anchor its SoC estimate before recharging cheaply.

Design discussion (including why the naive select.predbat_manual_soc "just set a lower target" approach doesn't work - it's documented as a minimum, not an exact/maximum target) happened on the issue and in-session; this PR is the resulting implementation.

Why this should give better results than the community's existing workaround (a fixed-duration forced-export automation, see #1578's thread): because the ceiling is a real cost signal integrated into the normal optimiser search rather than a blunt forced block, Predbat is free to use the discharged energy against load or export depending on whichever is actually cheapest at each moment, and stays coordinated with everything else already in the plan (car charging, existing charge/export windows). A fixed force-export block can't do either of those.

Design

manual_soc_max / manual_soc_max_value mirror manual_soc / manual_soc_value exactly, just inverted:

  • fetch.py: merges into a new per-minute all_active_keep_max dict (mirrors all_active_keep), using min() across sources instead of max() since it's a ceiling.
  • prediction.py: the optimiser treats being above the ceiling at a "kept" minute as a cost penalty priced at the export rate (mirrors the floor's penalty, which prices being below it at the import rate - see the "pretend the excess should have been exported" comment).
  • Conflict handling: a ceiling below the floor at the same minute is a contradiction (most likely a stale override left over from a previous automation run). The floor wins as the safety-relevant constraint, and the conflicting ceiling is dropped with a Warn: log line rather than handing the optimiser two penalties pulling opposite ways.

The C++ kernel

prediction.py's hot loop has a C++ mirror (prediction_kernel.cpp) for performance, with its own documented parity rule: any behavioural change must be mirrored there and both PK_PARITY_REVISION/KERNEL_PARITY_REVISION bumped, so a stale prebuilt binary is rejected at load time (falls back to the Python engine) rather than silently disagreeing with it. Done here - both bumped from 10 to 11.

Verified locally:

  • Rebuilt the kernel (build_kernel.sh) - compiles clean with -Wall -Werror
  • Full kernel-parity suite passes, including a new randomized ceiling scenario added to test_kernel_parity.py's existing seeded sweep - constructed to derive its values from the sweep's existing floor-keep draws rather than call fresh rng.*() methods, so the existing seeded scenarios (including car-charging edge cases the suite depends on) are unaffected
  • The 20-scenario committed baseline regression (./run_all --test random) matches bit-for-bit against the committed baseline, confirming zero behavioural change when the feature is unused

Also covered

  • execute.py/output.py: plan status markers mirroring the existing floor indicator ([Manual SoC Max], a ⬇ plan icon)
  • plan.py: the export-window merge guard already applied to the floor (discard_unused_charge_slots) is now applied to the ceiling too (discard_unused_export_slots), so two export windows can't be silently combined across a ceiling deadline
  • web.py/web_helper.py: the plan page's SOC dropdown gained a second "Maximum SOC" control, reusing the existing handleSocOverride JS function (parametrised with an isMax flag rather than duplicated)
  • docs/customisation.md: new section mirroring the existing manual_soc writeup

Fixes #1578

Test plan

  • New test_manual_soc_max.py: parsing, independence from manual_soc, off clearing, and the floor/ceiling conflict resolution
  • New test in test_discard_unused_export_slots.py for the ceiling merge guard
  • Extended test_kernel_parity.py's random sweep to exercise the ceiling (RNG-draw-neutral, see above)
  • saving_session, manual_soc, discard_unused_charge_slots, discard_unused_export_slots, optimise_levels, and the 20-scenario baseline regression all pass unchanged
  • ./run_pre_commit passes (black/ruff/cspell/markdownlint)

🤖 Generated with Claude Code

…nual_soc (#1578)

manual_soc already lets an automation set a minimum SoC floor for a
specific time - Predbat plans charging to reach it. There was no
equivalent for the opposite direction: forcing the battery down to a
ceiling by a deadline, which is what #1578 actually needs (a periodic
calibration discharge finishing right before a known cheap import slot,
e.g. an Octopus Intelligent Go midnight slot).

manual_soc_max mirrors manual_soc's existing mechanism exactly, just as
a ceiling: it merges into a new all_active_keep_max per-minute dict
(fetch.py), and the optimiser treats being above the ceiling as a cost
penalty priced at the export rate (prediction.py) - the same "keep"
scoring approach already used for the floor, just inverted. Because
it's a real cost signal integrated into the normal search rather than a
fixed-duration forced export, the optimiser is free to use the energy
against load or export depending on what's actually cheapest at each
moment, which should make better use of the energy than a blunt forced
discharge automation.

A ceiling below the floor at the same minute is a contradiction (most
likely a stale override left over from a previous automation run) - the
floor wins as the safety-relevant constraint, and the conflicting
ceiling is dropped with a warning rather than handing the optimiser two
penalties pulling opposite ways.

This touches the hot loop that has both a pure-Python and a C++ kernel
implementation (prediction_kernel.cpp) for performance - mirrored the
new ceiling logic there too and bumped both parity revisions per the
kernel's own documented rule, so a stale prebuilt kernel binary falls
back to the Python engine instead of silently disagreeing with it.
Verified locally: rebuilt the kernel and the full parity suite passes,
including a new randomized ceiling scenario added to its existing
seeded sweep (constructed to consume no additional RNG draws, so the
existing seeded scenarios are unaffected).

Also covered: the web plan page's SOC dropdown gained a second "Maximum
SOC" control (web.py/web_helper.py), plan-status markers mirroring the
existing floor indicator (execute.py/output.py), and the export-window
merge guard already applied to the floor is now applied to the ceiling
too (plan.py) so two export windows can't be silently combined across a
ceiling deadline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 - GivEnergy battery maintenance mode to better assist with SOC recalculations

1 participant