Repository navigation
Add manual_soc_max: a discharge-direction ceiling sibling to manual_soc - #4841
Merged
Merged
Conversation
…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>
chalfontchubby
force-pushed
the
fix/manual-soc-max-ceiling-1578
branch
from
September 9, 2026 08:29
bf80500 to
b524109
Compare
Contributor
There was a problem hiding this comment.
🟡 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 rawmanual_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_ratesexpands one selector into every minute of its 30-minute slot, so a single conflicting floor/ceiling selection reaches this loop up to 30 times. Sincefetch_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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…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>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
select.predbat_manual_socalready 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_valuemirrormanual_soc/manual_soc_valueexactly, just inverted:fetch.py: merges into a new per-minuteall_active_keep_maxdict (mirrorsall_active_keep), usingmin()across sources instead ofmax()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).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 bothPK_PARITY_REVISION/KERNEL_PARITY_REVISIONbumped, 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:
build_kernel.sh) - compiles clean with-Wall -Werrortest_kernel_parity.py's existing seeded sweep - constructed to derive its values from the sweep's existing floor-keep draws rather than call freshrng.*()methods, so the existing seeded scenarios (including car-charging edge cases the suite depends on) are unaffected./run_all --test random) matches bit-for-bit against the committed baseline, confirming zero behavioural change when the feature is unusedAlso 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 deadlineweb.py/web_helper.py: the plan page's SOC dropdown gained a second "Maximum SOC" control, reusing the existinghandleSocOverrideJS function (parametrised with anisMaxflag rather than duplicated)docs/customisation.md: new section mirroring the existingmanual_socwriteupFixes #1578
Test plan
test_manual_soc_max.py: parsing, independence frommanual_soc,offclearing, and the floor/ceiling conflict resolutiontest_discard_unused_export_slots.pyfor the ceiling merge guardtest_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_commitpasses (black/ruff/cspell/markdownlint)🤖 Generated with Claude Code