Skip to content

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

Merged
springfall2008 merged 6 commits into
mainfrom
fix/manual-soc-max-ceiling-1578
Sep 12, 2026
Merged

springfall2008 merged 6 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

chalfontchubby and others added 2 commits September 9, 2026 09:24
…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>

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.

🟡 Changes recommended

Moderate findings remain for 0% ceiling handling, repeated conflict warnings, and clearing max overrides.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds manual_soc_max, a discharge-direction SoC ceiling integrated with planning, the prediction engines, UI, documentation, and tests.

Changes:

  • Adds configuration, parsing, conflict handling, and optimizer penalties.
  • Mirrors ceiling behavior in the C++ kernel with parity coverage.
  • Adds web controls, plan indicators, documentation, and regression tests.
File summaries
File Summary
docs/customisation.md Documents the maximum SoC selector.
apps/predbat/web.py Exposes and processes max SoC overrides. Moderate (2 votes): avoid updating selector state while clearing an override.
apps/predbat/web_helper.py Adds maximum-SoC UI controls.
apps/predbat/userinterface.py Parses maximum-SoC selections.
apps/predbat/unit_test.py Registers the new tests.
apps/predbat/tests/test_manual_soc_max.py Tests selector behavior. Nit (3 votes): exercise the production fetch path for conflict coverage.
apps/predbat/tests/test_kernel_parity.py Extends kernel parity scenarios.
apps/predbat/tests/test_discard_unused_export_slots.py Tests ceiling export-window boundaries.
apps/predbat/prediction.py Applies ceiling penalties. Moderate (2 votes): preserve and support an explicit 0% ceiling. Nit (1 vote): add a focused Python-engine behavior test.
apps/predbat/prediction_kernel.py Marshals ceiling data to the kernel.
apps/predbat/prediction_kernel.cpp Mirrors ceiling behavior. Moderate (1 vote): support an explicit 0% ceiling.
apps/predbat/predbat.py Initializes new runtime state.
apps/predbat/plan.py Prevents export-window merging across ceilings.
apps/predbat/output.py Adds the maximum-SoC plan indicator. Nit (1 vote): base the icon on the effective constraint map.
apps/predbat/fetch.py Builds and resolves ceiling constraints. Moderate (1 vote): deduplicate repeated conflict warnings.
apps/predbat/execute.py Adds the maximum-SoC status marker. Nit (1 vote): base the marker on the effective constraint map.
apps/predbat/config.py Defines new ceiling controls.
.cspell/custom-dictionary-workspace.txt Adds new terminology.
Review details

Suppressed comments (5)

apps/predbat/execute.py:140

  • Conflict filtering removes an ineffective ceiling from all_active_keep_max, but this status flag reads the raw manual_soc_max_keep, so a ceiling below a floor is still reported as [Manual SoC Max]. Base the marker on the post-resolution map so the status reflects the constraint the optimiser actually received.
        in_manual_soc_max = self.manual_soc_max_keep.get(self.minutes_now, 0) > 0

apps/predbat/fetch.py:980

  • manual_rates expands one selector into every minute of its 30-minute slot, so a single conflicting floor/ceiling selection reaches this loop up to 30 times. Since fetch_sensor_data() runs repeatedly, a stale contradiction can emit dozens of identical warnings every cycle; deduplicate by selector slot (or log once per fetch) while still removing all conflicting minutes.
        for minute in list(self.all_active_keep_max.keys()):
            floor_value = self.all_active_keep.get(minute, 0)
            if floor_value > self.all_active_keep_max[minute]:
                self.log("Warn: manual_soc_max target {}% at minute {} is below the manual_soc/alert floor {}% for the same minute - ignoring the ceiling there".format(self.all_active_keep_max[minute], minute, floor_value))

apps/predbat/output.py:1235

  • The conflict path deletes the ceiling from all_active_keep_max, while this icon check uses the raw override map. As a result, the plan can show the down-arrow for a ceiling that was explicitly dropped because it contradicted the floor. Check the effective map instead.
            in_manual_soc_max = self.manual_soc_max_keep.get(minute, 0) > 0

apps/predbat/prediction.py:1289

  • The new parity sweep only proves that Python and C++ agree; it does not assert that a ceiling changes the score or selected plan as intended. Both mirrored branches could omit or misprice this penalty and still pass parity, so add a focused Python-engine case with SoC above and below the ceiling and an expected metric/planning result.
            # Metric keep max - pretend the excess above the ceiling should have been exported instead of held
            if best_soc_max > 0 and soc >= best_soc_max:
                metric_keep += (soc - best_soc_max) * export_rate * keep_minute_scaling * step / 60.0

apps/predbat/prediction_kernel.cpp:763

  • The kernel repeats the same 0-as-absent check. Even if the Python path is corrected, a 0% ceiling would still be ignored whenever the kernel is used; use a presence check or distinct sentinel that matches the Python implementation and allows target 0.
        if (alert_keep_max > 0) {
            keep_minute_scaling = std::max(keep_minute_scaling, 10.0);
            best_soc_max = std::min(alert_keep_max / 100.0 * soc_max, soc_max);
  • Files reviewed: 18/24 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/prediction.py Outdated
Comment thread apps/predbat/web.py Outdated
Comment thread apps/predbat/tests/test_manual_soc_max.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@chalfontchubby chalfontchubby added the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Sep 12, 2026
CI and others added 3 commits September 12, 2026 09:29
…ndent, test the real merge path

- web.py: restore the indentation the Copilot Autofix commit stripped, which
  made the file unparseable and failed pre-commit CI
- prediction.py/prediction_kernel.{py,cpp}: absence of a ceiling was encoded as
  0, so the default manual_soc_max_value of 0 - the full calibration discharge
  #1578 asks for - silently did nothing. Absence is now a negative sentinel;
  both parity revisions bumped 11 -> 12
- fetch.py: extract the keep floor/ceiling merge into combine_active_keep() so
  the conflict-resolution test exercises the production code, not a copy of it
- tests: T4 now calls combine_active_keep(); new T5 runs a real prediction and
  asserts a 0% ceiling penalises held charge (fails against the old > 0 check)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 removed the BOT_CLEANUP Trigger: bot should address PR review feedback and CI failures, then commit and push label Sep 12, 2026
@springfall2008
springfall2008 merged commit 669620f into main Sep 12, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/manual-soc-max-ceiling-1578 branch September 12, 2026 19:26
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

3 participants