Skip to content

Stop an export target landing in the reserved range (#4914) - #5020

Closed
chalfontchubby wants to merge 2 commits into
mainfrom
fix/export-limit-99-clamp-4914
Closed

chalfontchubby wants to merge 2 commits into
mainfrom
fix/export-limit-99-clamp-4914

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Fixes #4914.

The problem

An export limit packs three signals into one double: target SoC in the integer part, export power in the fraction, and mode as two reserved whole values (EXPORT_LIMIT_FREEZE = 99.0, EXPORT_LIMIT_IDLE = 100.0).

Because the fraction is live, reserving 99.0 actually consumes the whole of [99.0, 100.0). A 99% target at a low power rung packs to 99.3 / 99.5 / 99.7, which is neither == 99.0 (freeze) nor < 99.0 (forced export) — so the window silently does nothing. No error, no warning; the plan shows an export window and the battery just ignores it.

Two paths reach it, not one

optimise_export's ladder clamps the target up to the SoC floor, so a best_soc_min at 99% of soc_max produces it. This is the path #4914 describes, and it needs an unusual (though reachable) expert setting.

clip_export_slots is the more reachable one, and was not in the original report. It narrows the target to whatever the simulation says the battery actually reaches, less a ten-minute discharge margin. With a modest discharge rate that margin is small, so a nearly-full battery clips straight up to 99 — no unusual configuration at all. Measured against current main:

discharge rate clip margin resulting limit outcome
0.05 kWh/min (3 kW) 0.5 kWh 95.3 fine
0.01 kWh/min (600 W) 0.1 kWh 99.3 dead — window inert
0.002 kWh/min (120 W) 0.02 kWh 100.3 above the idle sentinel — window disabled

That last row is a third failure mode the issue does not mention: calc_percent_limit caps the integer part at 100 and the fraction is added afterwards, so the value can overshoot EXPORT_LIMIT_IDLE entirely.

The fix

Cap the target at EXPORT_TARGET_MAX_PERCENT = 98 before the power fraction is attached, in both paths. A 98% and a 99% target are the same request in practice, so nothing real is lost. The idle and freeze rungs are the reserved values themselves and pass through untouched.

Testing

New test_clip_up_never_lands_in_the_reserved_range in test_clip_export_slots.py, which reproduces the 600 W case. Verified to fail without the fix (produces 99.3) and pass with it. It also asserts the power fraction survives the clamp, so a low-power export cannot silently become full rate. Full suite passes; run_pre_commit clean.

Note

This is a stopgap, and deliberately the cheaper of the two options set out in the issue. Splitting the limit into explicit mode/target/power fields removes the reserved range altogether and makes this clamp redundant — that work exists but is much larger and touches the C++ kernel ABI, so this lands the user-visible fix now rather than waiting on it.

🤖 Generated with Claude Code

An export limit packs the target SoC in the integer part and the export power
in the fraction, with 99.0 reserved for freeze and 100.0 for idle. Reserving
99.0 therefore consumes the whole of [99.0, 100.0): a 99% target at a low power
rung packs to 99.3/99.5/99.7, which is neither == 99.0 (freeze) nor < 99.0
(forced export), so the window is silently inert.

Two paths reach it, and both are capped below the reserved range here.

optimise_export's ladder clamps the target up to the SoC floor, so a
best_soc_min at 99% of soc_max produces it. That is the path #4914 describes,
and it needs an unusual but reachable expert setting.

clip_export_slots is the more reachable one and was not in the report: it
narrows the target to whatever the simulation says the battery actually reaches,
less a ten minute discharge margin. With a modest discharge rate that margin is
small, so a nearly-full battery clips straight up to 99 - no unusual config at
all. At lower rates it overshoots further: calc_percent_limit caps the integer
part at 100 and the fraction is added afterwards, giving 100.3, which reads as
above the idle sentinel and disables the window outright.

A target of 98% and 99% are the same request in practice, so clamping loses
nothing real. The idle and freeze rungs are the reserved values themselves and
pass through untouched.

This is a stopgap. Splitting the limit into explicit mode/target/power fields
removes the reserved range altogether and makes the clamp redundant; it can be
deleted when that lands.

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

The regression permits sentinel outcomes and does not cover the optimiser path.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Prevents packed export targets from entering reserved freeze/idle ranges.

Changes:

  • Adds a 98% maximum force-export target.
  • Applies it during optimisation and export-slot clipping.
  • Adds regression coverage for clipping.
File summaries
File Description
apps/predbat/const.py Defines the maximum export target.
apps/predbat/plan.py Clamps export targets before adding power fractions.
apps/predbat/tests/test_clip_export_slots.py Tests near-full low-power clipping.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

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

Comment thread apps/predbat/plan.py
Comment thread apps/predbat/tests/test_clip_export_slots.py Outdated
…ort_slots result

Two review findings on the #4914 export-target-clamp regression tests.

test_clip_up_never_lands_in_the_reserved_range let a sentinel outcome pass:
its third assertion exempted limit in (100.0, 99.0), so a clip that
incorrectly collapsed to freeze (99.0) or idle (100.0) - losing the
requested forced low-power export exactly as silently as landing in
[99.0, 100.0) - reported PASS. Confirmed by reverting the clip_export_slots
clamp: the unfixed code produces 99.3, which the old loose checks accepted.
Pin the exact packed result (98.3) instead.

optimise_export's own floor clamp (plan.py:2358) had no coverage at all -
only the post-simulation clamp in clip_export_slots was tested. Added
test_optimise_export_floor_clamped_below_reserved_range, which monkeypatches
launch_run_prediction_export to record every candidate the optimiser hands
to simulation with best_soc_min set to round to the 99% boundary. Confirmed
by reverting just that clamp: the unfixed code simulates candidates at
99.3/99.5/99.7, exactly the reserved-range values the fix exists to avoid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

(Posted by Rik; written by Claude.)

Closing as superseded by #5047. That refactor replaced the packed-float export-limit encoding with a (mode, target, power) tuple, so the reserved-value collision this PR fixes (a 99% target packing into the same float range as the FREEZE/IDLE sentinels) is now structurally impossible — pack_export_limit() keeps mode, target, and power as separate fields. main already has regression coverage for the same scenario (test_clipped_high_target_is_storable in test_clip_export_slots.py).

Rebasing this onto main would mean reapplying dead code against an encoding that no longer exists, so closing rather than carrying it forward.

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.

Export window silently does nothing when best_soc_min lands on 99% and low power export is on

2 participants