Skip to content

refactor(export): replace the packed export-limit float with a (mode, target, power) tuple - #5047

Merged
springfall2008 merged 11 commits into
mainfrom
split/export-limit
Sep 16, 2026
Merged

springfall2008 merged 11 commits into
mainfrom
split/export-limit

Conversation

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Claude wrote this PR (code and description) working with @chalfontchubby.

Why

export_limits_best[n] was a single packed float carrying three orthogonal signals: target SoC in the integer part, export power as 1 - fraction in the decimal part, and mode via two reserved sentinel values (99.0 = freeze, 100.0 = idle). Every consumer re-derived intent by comparing against those sentinels, inconsistently - some == 99, some < 99, some >= 99. That encoding caused real bugs, notably GH#4914: a target of 99% at reduced power lands in the reserved range and reads as "freeze" instead of "export to 99%", leaving the window doing nothing.

This replaces the packed float with a (mode, target, power) tuple, decoded everywhere through named accessors, with the C++ kernel updated to match.

What changed, commit by commit

  1. refactor(export): route all export-limit reads through named accessors - adds export_mode_of / export_target_of / export_power_of / export_limit_exports_no_battery / export_limit_is_full_discharge / export_limit_sort_key / pack_export_limit in utils.py, and converts every read site across plan.py, prediction.py, execute.py, output.py and gateway.py to use them. The packed float is still the live representation underneath - this commit only changes how it's read. A pre-commit hook (check_export_limit_encoding.py) rejects new code that compares an export limit against the reserved sentinels or bare 99/100, so the old idiom can't quietly come back.
  2. fix(plan): keep the export power out of the SoC a target exports to - a real bug this sweep surfaced: clip_export_slots was reading the raw packed float as the target SoC, so a reduced-power export (e.g. 47.3 = target 47% at 70% power) clipped against 47.3% instead of 47%. Fixed in both the Python and C++ paths, with a regression test.
  3. refactor(export): make an export limit a (mode, target, power) tuple - swaps the internal representation. Because every read site already went through an accessor in commit 1, this is a small, mechanical change: the accessors grow an isinstance(tuple) fast path, write sites build tuples via pack_export_limit, and the kernel marshalling wraps values through export_limit_sort_key for ordering.
  4. feat(export): persist export limits as a self-describing mapping - the persisted plan (cache and debug dumps) now stores {mode: "target", target: 47, power: 0.7} instead of a bare float. A permanent compatibility path (export_limit_from_stored) reads the old float form, the new mapping, or a bare 3-element list (what a tuple becomes after a YAML/JSON round trip) - so an old cached plan or an old bug-report debug dump keeps loading.
  5. refactor(kernel): pass export limits as three field arrays, not one packed double (ABI 6) - the C++ kernel takes export_modes/export_targets/export_powers as three parallel arrays instead of one packed double array, so the reserved-value ambiguity GH#4914 depended on can't exist at the kernel boundary either.
  6. perf(kernel): marshal export limits as one array-of-structs, packed with struct (ABI 7) - three parallel arrays meant three separate marshalling passes and three pointers per call; this packs {mode, target, power} into one struct.Struct("@iid")-packed buffer, from_buffer_copy'd into a C array-of-structs, cutting marshalling overhead.
  7. perf(kernel): cache the marshalled export-limit buffer by contents - the same export_limits_best list is often marshalled many times across a plan search; this caches the packed buffer keyed by its contents (bounded at 512 entries) so repeated scenarios skip re-marshalling.

Testing

  • Full unit suite (./run_all, not just --quick) and ./run_pre_commit pass standalone at every one of the 7 commits, verified with a script that checks out each commit in turn and runs both - not just at the tip.
  • run_random (20 fixed scenarios) is bit-identical to main at every commit except commit 2, which is a deliberate behaviour fix (see above).
  • Kernel parity and model_kernel tests green throughout; KERNEL_PARITY_REVISION and KERNEL_ABI_VERSION bumped appropriately per commit, all 6 cross-compiled .so targets rebuilt via zig at each ABI change.
  • New regression tests added in the commit that fixes each bug: the SoC-clip bug (commit 2), three tuple-shaped export scenarios in test_window.py (commit 3), and round-trip/old-float/plain-YAML persistence tests (commit 4).
  • A real bug found deploying to my own running addon: scenario_summary_state() (the verbose debug "STATE:" log line) compared an export-limit tuple directly against a float with >=, a leftover bare-number comparison the accessor sweep in commit 1 missed. It never surfaced in the test suite because that call site only runs behind debug_enable, and only when an active freeze/target export window is below the current SoC - which is exactly what a live plan produced within a few hours of deploying. Fixed and folded back into commit 1 (where the sweep belongs), with a new direct regression test (test_plan_scenario_summary.py) that reproduces it and fails without the fix.
  • Replayed my own live debug dumps (--debug_file) against the branch: a fresh dump (already saved in the new mapping format, proving the upgrade path works) replays cleanly; an old dump from before this branch existed (packed-float format) also replays cleanly through the legacy compatibility path, with no exceptions either way.
  • Cross-checked the same old dump against v9.0.1 (the version actually running in production) to separate "replay drift" from "branch regression": both versions compute the identical fresh metric (-141.7968) from the same input, confirming the refactor is behaviour-neutral on real data as well as synthetic scenarios.

Speed

This needed more digging than expected, and the honest answer is: no measured regression, but also no confidently measured improvement in production.

  • A Mac-local interleaved A/B (run_random-based) measured struct-packing (commit 6) alone at ~+2.9% slower than main, recovered to ~+1.2% net once the buffer cache (commit 7) was added.
  • Deployed to my own running Predbat addon (a Pi 4) and compared Plan calculation took log timings, p10 (the uncontended single-pass figure) over an equivalent overnight window: branch measured ~+11% slower than the prior night's v9.0.1 baseline (p10 2.91s vs 2.63s), consistent across every percentile - too large and too consistent to be noise.
  • To isolate the cause, redeployed at commit 5 (ABI 6 - three parallel arrays, no struct packing, no buffer cache) rather than the tip. Overnight plan_stats.sh comparison for that version is still accumulating samples as of this PR.
  • Separately, replaying an old debug dump through both the branch and v9.0.1 interleaved (6 rounds each, alternating) showed no measurable difference - branch and v9.0.1 medians were within noise of each other (~0.27-0.28s).

So: real production plans on the full 7-commit branch showed a slowdown the synthetic Mac benchmark didn't predict; a single-scenario interleaved replay shows nothing. That's not fully reconciled yet. Flagging it rather than claiming a number I can't stand behind - happy to keep investigating post-merge, or to hold commits 6/7 back if reviewers would rather land the correctness fix (commits 1-5) first and chase the last bit of kernel perf separately.

Known follow-ups

  • Reconcile the Mac-vs-Pi kernel performance discrepancy (see Speed above) - which of ABI 6, ABI 7's struct packing, or the buffer cache is actually responsible, under real traffic rather than the synthetic run_random corpus.
  • self.export_limits_best is not defensively coerced on load if a hot-reload or partial restart leaves stale data in memory (as opposed to the on-disk formats, which are all handled) - a clean process restart always fixes this via load_saved_plan, so it's a narrow window, but worth hardening if it recurs.

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

Unresolved critical persistence and deserialization findings remain, along with additional correctness fixes.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR replaces packed export-limit floats with explicit (mode, target, power) tuples across planning, persistence, execution, and Python/C++ kernel paths.

Changes:

  • Adds tuple accessors, persistence compatibility, and static encoding checks.
  • Updates kernel ABI 7 marshalling and buffer caching.
  • Adds regression coverage for planning, persistence, execution, and replay.
File summaries
File Reviewed changes / final review note
tools/test_check_export_limit_encoding.py Tests export-limit encoding validation.
tools/check_export_limit_encoding.py Adds static validation; moderate (3 votes): cover reversed bare-number comparisons.
apps/predbat/utils.py Provides tuple accessors and persistence helpers; moderate (2 votes): validate malformed mappings and fall back to idle.
apps/predbat/userinterface.py Handles debug YAML serialization; critical (1 vote): normalize nested preclip export limits on load.
apps/predbat/unit_test.py Updates test registration and infrastructure.
apps/predbat/tests/test_window.py Adds tuple-shaped window scenarios.
apps/predbat/tests/test_trim_export.py Updates export trimming tests.
apps/predbat/tests/test_single_debug.py Updates debug replay coverage.
apps/predbat/tests/test_prune_dead_slots.py Updates dead-slot pruning tests.
apps/predbat/tests/test_prediction_batch.py Covers batched tuple marshalling.
apps/predbat/tests/test_plan_scenario_summary.py Covers tuple scenario summaries.
apps/predbat/tests/test_plan_persistence.py Tests saved-plan restoration and compatibility.
apps/predbat/tests/test_optimise_swap_export.py Tests export-window swapping.
apps/predbat/tests/test_optimise_solar.py Updates solar optimization tests.
apps/predbat/tests/test_optimise_levels.py Updates optimization-level tests.
apps/predbat/tests/test_manual_overrides.py Updates tuple-based override tests.
apps/predbat/tests/test_kernel_static_cache.py Tests kernel buffer caching.
apps/predbat/tests/test_kernel_parity.py Tests Python/kernel parity.
apps/predbat/tests/test_infra.py Updates test fixtures and helpers.
apps/predbat/tests/test_hit_charge_cache.py Updates cache scenarios.
apps/predbat/tests/test_export_encoding.py Tests tuple and legacy-format compatibility.
apps/predbat/tests/test_export_commitment.py Tests export commitments.
apps/predbat/tests/test_execute.py Updates execution behavior tests.
apps/predbat/tests/test_clip_export_slots.py Tests target clipping.
apps/predbat/tests/test_calculate_yesterday.py Covers historical-plan reconstruction.
apps/predbat/prediction.py Uses tuple accessors during simulation.
apps/predbat/prediction_kernel.py Marshals and caches structured limits; nit (1 vote): update the stale three-array comment.
apps/predbat/prediction_kernel.cpp Implements ABI 7 structs; nit (1 vote): correct the stale ABI-history note.
apps/predbat/predbat.py Persists and restores plans; critical (2 votes): normalize nested preclip lists during restoration.
apps/predbat/plan.py Migrates planning and clipping; moderate (1 vote): use the semantic target accessor instead of the packed sort key.
apps/predbat/output.py Publishes and persists tuple limits; critical (2 votes): normalize nested saved targets; moderate (1 vote): use the target field for chart series.
apps/predbat/inverter.py Uses tuple-form export defaults.
apps/predbat/gateway.py Maps tuple limits to gateway plans.
apps/predbat/execute.py Consumes tuple accessors for execution decisions.
apps/predbat/enphase.py Documents deliberate legacy comparisons.
apps/predbat/const.py Defines export modes and legacy sentinels.
.pre-commit-config.yaml Registers encoding checks.
.cspell/custom-dictionary-workspace.txt Adds terminology for the new tooling and tests.
Review details

Suppressed comments (4)

apps/predbat/output.py:2320

  • The chart series is supposed to contain the requested SoC percentage, but export_limit_sort_key() includes 1 - power for target limits. A reduced-power 50% export is therefore published as 50.3/50.7% and its kW series is scaled to that inflated target. Read the target field for target mode; retain the legacy sort key only for freeze's sentinel display.
                soc_perc = float(export_limit_sort_key(export_limits[window_n]))

apps/predbat/plan.py:3094

  • export_limit_sort_key() deliberately recreates the legacy packed value, including the reduced-power fraction. Storing it as the window's semantic target makes a 50% target at 30% power appear as 50.7% to the plan/status consumers (and to the execute fallback), even though the target field is documented here as a plain percentage. Use export_target_of(limit) for target mode and reserve the sort key for mode-only display sentinels.
            window["target"] = float(export_limit_sort_key(limit))

apps/predbat/prediction_kernel.cpp:50

  • This ABI note says the export limits use three parallel arrays, but the current ABI is 7 and PkExportLimit is an array-of-structs below. The stale description makes the ABI history and Python/C++ marshalling contract contradictory; document ABI 6 as the parallel-array transition and ABI 7 as the struct packing change.
// ABI 6: export limits travel as three parallel arrays (mode, target, power) rather than one packed
// double per window. The packed form put the target in the integer part, the export power in the
// fraction and the mode in two reserved whole values, so the kernel had to unpack it by arithmetic -
// and reconstructing the power as 1 - frac round-tripped through binary floating point on the hot
// path. Fields carry the same three signals with no arithmetic and no reserved range, which also
// removes the (99.0, 100.0) interval that read as neither a freeze nor a target (GH#4914).

apps/predbat/prediction_kernel.py:899

  • This comment still describes the export limits as three memoised arrays, but limit_arrays() now caches one PkExportLimit buffer. Update it to describe the single marshalled buffer so future maintenance does not assume three arrays or three marshalling passes.
    # The export limits are shared across a fan-out for exactly the same reason the window lists are,
    # so their three arrays are memoised the same way and on the same terms - keyed on identity, with
    # the list retained so an id() cannot be recycled mid-batch.
  • Files reviewed: 38/44 changed files
  • Comments generated: 5
  • 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/output.py Outdated
Comment thread apps/predbat/predbat.py
Comment thread apps/predbat/userinterface.py
Comment thread apps/predbat/utils.py Outdated
Comment thread tools/check_export_limit_encoding.py Outdated

Copilot AI commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

🟡 Changes recommended

Unresolved critical persistence and deserialization findings remain, along with additional correctness fixes....

Automated reply from Copilot agent: addressed in a79a69cc. I applied the remaining review-thread fixes, including target normalization in output.py, semantic target use in plan/chart paths, checker coverage for reversed numeric comparisons, and the stale ABI/marshalling comment updates.

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

Unresolved critical and moderate findings remain in tuple validation, kernel compatibility, execution, sorting, rendering, and encoding checks.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (11)

Previously missed (1) — in code that hasn't changed since the last review.

apps/predbat/utils.py:1633

  • export_mode_of(99.5) deliberately returns TARGET and export_power_of(99.5) returns 0.5, but this legacy compatibility branch still classifies the same value as no-battery because it uses >= 99. The trim pass can therefore treat a legacy 99%-at-reduced-power export as freeze/neutral; use the decoded mode membership test here for both tuples and legacy numbers.

apps/predbat/execute.py:501

  • If update_target_values() or a debug replay has populated window["target"] with the tuple instruction, .get("target") returns that tuple and int(target) raises TypeError; the fallback only handles a missing key. Decode whichever value is present with export_target_of(...), as the HTML path does, before converting it.
                        target = self.export_window_best[0].get("target")
                        if target is None:
                            target = export_target_of(self.export_limits_best[0]) or 0
                        self.isExporting_Target = int(target)

apps/predbat/execute.py:531

  • The freeze branch has the same tuple-valued target failure as the force-export branch: a present tuple bypasses the None fallback and is passed to int(). Normalize the stored target through export_target_of(...) regardless of whether the window field is present.
                            target = self.export_window_best[0].get("target")
                            if target is None:
                                target = export_target_of(self.export_limits_best[0]) or 0
                            self.isExporting_Target = int(target)

apps/predbat/plan.py:982

  • export_limit_sort_key() deliberately preserves the legacy power fraction, so (target=50, power=0.3) sorts as 50.7. Comparing that value with soc_percent_max can label a target that has actually been reached as HldExp (for example, SoC max 50.5%). Compare export_target_of(export_target) for target mode; keep the freeze-mode branch separate.
                if export_limit_sort_key(export_target) >= soc_percent_max:

apps/predbat/tests/test_plan_scenario_summary.py:17

  • This new regression function is never imported by unit_test.py or added to TEST_REGISTRY, so ./run_all will not execute the tuple-vs-float debug-path test despite the PR relying on it for coverage. Register the function in the shared runner (as was done for run_export_encoding_tests) so the regression actually runs.
    apps/predbat/utils.py:1748
  • A new tuple such as (EXPORT_MODE_TARGET, 99, 0.7) is serialized by export_limit_to_stored() with target: 99, but this validation treats it as malformed and reloads it as idle. That makes the advertised 99% reduced-power export disappear after a plan/debug round trip; allow target 99 and update the corresponding malformed-input test while still rejecting values above the valid target range.
    apps/predbat/utils.py:1744
  • A truncated mapping such as {"mode": "target"} is treated as a target-0% export because the missing target defaults to zero. That is a destructive command, not a safe malformed-input fallback, and contradicts the docstring's promise that malformed limits become idle; require the target field for target mode or return idle when it is absent.
    apps/predbat/utils.py:1753
  • The legacy-float fallback does not catch OverflowError. A YAML/JSON value such as -inf converts to float('-inf'), reaches int(packed) in unpack_export_limit(), and raises instead of becoming idle, contradicting the stated malformed-input behavior and aborting a debug replay/load.
    apps/predbat/utils.py:1661
  • The sort key still places freeze at 99.0, but a valid tuple target of 99% at reduced power sorts to 99.3 (or higher). Thus freeze is considered a deeper limit than that target, contradicting the docstring's requirement that no-battery modes sort above every target and causing the trim pass's n_best_soc >= current guard to reject a reduction to freeze. The ordering needs an explicit mode rank or a key above the entire target range.
    tools/check_export_limit_encoding.py:31
  • The checker skips the entire prediction_kernel.cpp file even though ABI 7 no longer contains a legitimate packed-float decoder there. Any future C++ consumer that reintroduces a comparison against the old 99/100 encoding will therefore bypass the hook's stated protection; remove this broad exemption or make the exemption line-scoped to only intentional compatibility code.
    tools/check_export_limit_encoding.py:84
  • The checker searches only one physical line at a time (stripped), so a parenthesized comparison split across lines can evade both regexes, e.g. export_limits[n] >= followed by 99. That leaves the promised guard bypassable; scan logical/tokenized comparisons and add a multiline regression case.
  • Files reviewed: 38/44 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread apps/predbat/prediction_kernel.py
Comment thread apps/predbat/utils.py Outdated
Comment thread apps/predbat/output.py Outdated
@chalfontchubby

Copy link
Copy Markdown
Collaborator Author

Claude here. Rewrote history to fold the review fixes into the commits that introduced each bug, rather than stacking them on top - so the branch stays bisectable and every commit still passes the full suite standalone. The earlier automated replies (55b3e2d6, c05b0e79, 1be5cde6, a79a69cc) point to commits that no longer exist after the rewrite, but the fixes themselves are all still present.

One of a79a69cc's own fixes (output.py's stored_target handling) turned out to be wrong when folded back in - it broke the ordinary case while fixing the legacy one, caught by run_random before push. Fixed properly and documented in the amended commit message.

Also fixed two more gaps the same review flagged that hadn't been addressed yet: the kernel marshaller trusting every element was already a tuple (prediction_kernel.py), and the 3-element sequence branch of export_limit_from_stored being unvalidated (utils.py).

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

Valid 99% targets are lost during persistence, and remaining packed-key comparisons still misclassify reduced-power targets.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

apps/predbat/utils.py:1745

  • A 99% target is the regression case this PR is meant to make representable, but this validation converts every persisted {mode: "target", target: 99, ...} back to idle. Thus a valid 99%-at-reduced-power plan works only until it is saved/reloaded (and the round-trip test excludes 99). Allow target 99 and update the malformed-input/round-trip cases accordingly.
  • Files reviewed: 38/44 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread apps/predbat/utils.py
Comment thread apps/predbat/utils.py
Comment thread apps/predbat/output.py
Comment thread apps/predbat/plan.py
Comment thread apps/predbat/tests/test_plan_scenario_summary.py
springfall2008 pushed a commit that referenced this pull request Sep 13, 2026
run_prediction_kernel (the single-run path) rebuilt the PkExportLimit buffer
on every call. The batch path caches it by list identity, which never hits
here because every prediction is handed a fresh list - but a search fans out
thousands of simulations over the same handful of distinct limit
configurations, so keying on the tuple contents instead turns almost every
call after the first into a dict lookup. The kernel only ever reads the
buffer, so sharing one between callers of the same plan is safe. Bounded at
512 distinct plans - the working set in one fan-out is tens.

Measured on debug_cases calculate_plan, interleaved A/B against the previous
commit over five rounds: ~1.532s -> ~1.506s, about -1.7%, taking the stack
to roughly +1.2% over main. Not faster than main, but a real, contained
improvement.

run_random 20/20 bit-identical, quick suite and kernel parity green.

GitHub Copilot review on PR #5047 flagged run_prediction_kernel_batch's memoisation
comment as stale after this commit - it still described three cached arrays where
limit_arrays() now marshals and caches one PkExportLimit buffer. Reworded to match.

GitHub Copilot review on PR #5047 also found export_limit_array's cache-key line -
key = tuple(export_limits) - and the packer it feeds both trusted every element was
already a tuple, unconditionally. A caller still holding an unnormalised legacy
element (a bare packed float, a malformed short sequence, or a stored mapping) would
raise here: a list or dict element makes tuple(export_limits) itself unhashable
before the packer is even reached, so the fix has to happen before the cache key is
built, not inside _build_export_limit_array. Added the same isinstance-gated
normalise-via-export_limit_from_stored guard used at the other two kernel entry
points introduced earlier in this PR (export_limit_arrays in the ABI 6 commit,
_build_export_limit_array's predecessor in the ABI 7 commit) - one check per call,
so a plan already in tuple form pays nothing beyond it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@springfall2008
springfall2008 force-pushed the split/export-limit branch 2 times, most recently from 1eef2a0 to 775b4ec Compare September 14, 2026 12:49
@springfall2008
springfall2008 requested a balanced review from Copilot September 15, 2026 19:16

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

Malformed persisted values can produce unsafe export instructions, and Enphase still conflates a valid 99% target with freeze mode.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

apps/predbat/utils.py:1874

  • A target mapping with either field missing is treated as a valid 0%/full-power export. For example, a truncated {"mode": "target"} cache entry reloads as a command to discharge fully, contradicting the documented idle fallback for malformed data. Require both target-mode fields before decoding so incomplete persisted data fails safe.
    apps/predbat/utils.py:1850
  • This range check accepts NaN power because both comparisons with NaN are false. A hand-edited/corrupt stored value such as power: .nan therefore reaches prediction and execution as a NaN discharge multiplier instead of taking the promised idle fallback. Express the range as a positive bounded predicate so non-finite NaN is rejected.
  • Files reviewed: 38/44 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread apps/predbat/enphase.py
Comment on lines +1060 to +1064
# encoding-ok: export_soc is a plain SoC percentage the gateway has already decoded out
# of the export instruction, not a packed limit - 99 is this API's own "hold" marker and
# the constant is reused for it rather than a second literal meaning the same thing
"dtg": {"enabled": export_enabled and export_soc < EXPORT_LIMIT_FREEZE, "start": export_start, "end": export_end, "limit": dtg_limit}, # encoding-ok
"rbd": {"enabled": export_enabled and export_soc == EXPORT_LIMIT_FREEZE, "start": export_start, "end": export_end, "limit": None}, # encoding-ok
Comment thread apps/predbat/utils.py
Comment on lines +1875 to +1878
try:
return unpack_export_limit(float(stored))
except (TypeError, ValueError):
return pack_export_limit(EXPORT_MODE_IDLE)
chalfontchubby and others added 11 commits September 15, 2026 23:42
An export window's instruction is a single double in export_limits_best
carrying three orthogonal signals: target SoC in the integer part, export
power in the fraction (stored as 1 - power), and mode as two reserved whole
values (EXPORT_LIMIT_FREEZE 99.0, EXPORT_LIMIT_IDLE 100.0). Every consumer
re-derived intent by comparing against the sentinels, inconsistently - some
== 99, some < 99, some >= 99.

Add the vocabulary (EXPORT_MODE_TARGET/FREEZE/IDLE, FULL_EXPORT_POWER) and
the accessors in utils.py - export_mode_of / export_target_of /
export_power_of / export_limit_exports_no_battery / export_limit_is_full_discharge
/ export_limit_sort_key - plus pack_export_limit() as the one place the
encoding is written down. They decode the packed double for now; a later
commit swaps the representation underneath without touching a call site.

Convert every read across plan.py, prediction.py, execute.py, output.py and
gateway.py to the accessors, and the export ladder in optimise_export to a
list of (mode, power) rungs built through pack_export_limit rather than raw
floats mixing the two. export_mode_of matches the freeze sentinel exactly,
not by range, preserving the majority reading of the [99.0, 100.0) interval
the packed encoding cannot itself produce.

A pre-commit hook (check_export_limit_encoding.py) rejects new code that
compares an export limit against EXPORT_LIMIT_FREEZE/IDLE or a bare 99/100,
pointing at the accessors; the reserved constants have to stay for the
legacy-plan compatibility paths, so nothing in the language stops the idiom
returning. enphase.py carries the one deliberate exemption - its export_soc
is an already-decoded SoC percentage, not a packed limit.

No behaviour change: run_random 20/20 bit-identical, full quick suite green,
kernel parity unaffected. New accessor coverage in test_export_encoding.py
pins them to the hand-written decode in prediction.py and the C++ kernel.

GitHub Copilot review on PR #5047 found the bare-number sentinel check only matched
when the export-limit name was the left operand: a reversed comparison like
`100 <= export_limit` or `99 == limits[n]` slipped past the hook despite the docstring
claiming either operand order was covered. Made the regex symmetric and added a
reversed-operand regression case in test_check_export_limit_encoding.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two passes computed the SoC an export window aims at from the packed limit
directly: clip_export_slots as soc_max * limit / 100, and the prediction hot
loop's discharge_min as soc_max * export_limit_now / 100 (mirrored in the C++
kernel). The packed value carries the target in its integer part and 1 - power
in the fraction, so a 50% target at 30% power reads as 50.7 and the SoC is
inflated by 0.7% of the battery.

The effect is not a slightly different target - it is whether the clip-up runs
at all. The pass narrows a target towards what the simulation says is reachable,
gated on soc_min > limit_soc; inflating limit_soc can make that false, so a slow
export keeps aiming at a target the model says it will not reach, purely because
it was slow.

Both engines now take the target field (export_target_of), and the two modes,
which carry no target, keep the SoC their sentinels produced - 99% for a freeze,
100% for idle, where it never binds. Kernel parity revision bumped 10 -> 11 and
all six platform binaries rebuilt.

run_random 20/20 unchanged (the corpus has no low-power export at the clip
boundary); new coverage in test_clip_export_slots pins that same target with
different power clips to the same place. Full quick suite and kernel parity green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swap the representation the accessors sit on from the packed double to a
plain 3-tuple - mode, target SoC percentage, export power. A tuple builds at
C speed, indexes as fast as an attribute reads and hashes without a
Python-level call, which matters because the prediction cache keys on a
whole plan of these on every simulation. It is also exact: the packed power
was recovered by subtracting the integer part, so 0.7 came back as
0.69999999999999929 or 0.70000000000000284 depending on the target it was
packed against, and two windows both at 70% did not compare equal.

pack_export_limit now returns the tuple; unpack_export_limit decodes a
legacy packed float to one. Every accessor keeps a bare-number branch for
plans and debug dumps written before the split, which arrive indefinitely -
a permanent compatibility path. export_limit_sort_key returns the packed
value a limit collapses to, and the three sites that order limits by depth
(min() in the combine pass, the trim pass's shallower-discharge test) and
the display paths that format one as a number go through it, so lexical
tuple ordering never leaks in.

Call sites are unchanged - they have been on the accessors since the first
commit. The container writes are converted: the planner's clip-up rebuild,
inverter.py's pre-fill, prediction.py's per-window idle constant, execute's
two int(limit) target reads. The kernel marshal boundary converts each
tuple back to its packed float; the kernel's own field-array rework is a
later commit. A JSON plan round trip returns lists, so the plan loader
restores 3-lists to tuples.

run_random 20/20 bit-identical, full quick suite green including debug_cases
(goldens still hold packed numbers; the comparison converts to that form
deliberately) and kernel parity.

GitHub Copilot review on PR #5047 found two display paths still misused
export_limit_sort_key() for a target export's on-screen percentage: the packed
value carries 1 - power in its fraction, so a 50% target at 70% power displayed
as 50.3% (output.py's plan chart series, and plan.py's clip_export_slots window
display, which had the correct target already computed two lines above for the
SoC-clip fix in the previous commit and just wasn't using it for display too).
Both now read the semantic target field via export_target_of, reserving the
sort key for the freeze/idle sentinel case it was designed for.

output.py's replayed-debug-dump branch (stored_target, from a window's saved
target) also needed a fix, and the first attempt at it was wrong: routing
every stored_target through export_limit_from_stored() unconditionally, on
the Copilot review's suggestion, fixed the raise on a genuine legacy tuple but
broke the ordinary case - export_limit_from_stored() treats its input as a
packed *limit* (mode/target/power), not a target *percentage*, so the normal
plain-number stored_target (47.0) was misread as a packed instruction and
export_target_of() came back None. That surfaced immediately as a run_random
regression (scenarios 8 and 19 crashing on dp2(None) two lines further down in
publish_html_plan), not as a silent behaviour change - caught before push.
Fixed by only decoding when stored_target is actually tuple/list/dict shaped;
a plain number is used as-is, matching what export_window_best[]['target'] is
documented to hold.

A second occurrence of the same shape landed in get_charge_export_text's status text
(the sensor.predbat_status detail line): its target_export used .get()'s default
argument to fall back to export_target_of() when 'target' is absent, but a *present*
legacy tuple/list/mapping value was used as-is otherwise and printed as
"force exporting to (0, 47, 0.7)%" (GitHub Copilot review, PR #5047). Fixed with the
same only-normalise-when-legacy-shaped rule as publish_html_plan above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The packed float was an internal encoding and a poor persisted format: 99.0
does not say "freeze" to anything that has not read const.py, and a tuple
would emit as an ordinary sequence a reader could not tell from a window
count. A saved plan and a debug dump now store each limit as a mapping:

    - mode: target
      target: 47
      power: 0.7
    - mode: freeze

Only the fields that apply to the mode are written. Reading accepts the
mapping, a 3-element sequence (what a YAML or JSON round trip makes of the
tuple), and a bare packed float - the last decoded through
unpack_export_limit, which fixes the legacy path that returned a bare float
into export_limits_best. A malformed entry becomes an idle window rather
than raising, so a bad limit in a diagnostic artefact does not stop a
replay.

read_debug_yaml restored export_limits_best straight into __dict__ without
decoding it; it now runs the restored value through export_limits_from_stored.
create_debug_yaml converts it to the mapping form and the DebugYamlDumper
carries a tuple representer, so a dump loads with plain yaml.safe_load.

DEBUG_SCHEMA_VERSION marks the shape of these fields; a dump from a newer
Predbat warns rather than silently misreading one, and an absent version
means "before versioning" and still replays.

run_random 20/20 bit-identical, quick suite green including debug_cases
(themselves old-format dumps, which replay unchanged) and kernel parity.

GitHub Copilot review on PR #5047 found three gaps in this compatibility path.
plan_preclip (the pre-clip snapshot plan selection scores against) carries an
export-limit list as its fourth element, but neither load_saved_plan nor
read_debug_yaml decoded it - only the top-level export_limits/export_limits_best
fields were. A restored or replayed preclip snapshot therefore kept nested
3-element lists where later code expects tuples, and calculate_plan crashed
comparing one against a float the next time that snapshot was scored - the same
failure mode as the scenario_summary_state bug earlier in this PR, in a
different consumer of the same nested data. Both load paths now run
plan_preclip[3] through export_limits_from_stored. Separately, a malformed
target mapping (a non-numeric target or power) reached int()/kernel packing
unchecked and raised instead of taking the documented idle fallback; target and
power are now coerced and range-checked, falling back to idle on any conversion
or range error, with regression cases for each.

The malformed-mapping validation above had a sibling gap in the neighbouring branch:
export_limit_from_stored's 3-element sequence form (what a tuple becomes after a YAML
or JSON round trip) returned tuple(stored) unvalidated - a malformed sequence such as
[EXPORT_MODE_TARGET, None, 0.7] passed straight through and only failed later, in the
kernel marshaller's struct.pack, rather than falling back to idle as the docstring
promises (GitHub Copilot review, PR #5047). Both branches now share one
_export_limit_from_fields() validator, so a malformed value is rejected the same way
regardless of which shape it arrived in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…acked double (ABI 6)

The kernel took one double per export window with the target in the integer
part, the export power in the fraction and the mode as two reserved whole
values, so it unpacked the encoding by arithmetic - and reconstructed the
power as 1 - frac every step, which is inexact in binary floating point
(1 - 0.7 is 0.30000000000000004). It now takes three parallel arrays - mode,
target, power - matching how the window times are already passed.
PK_EXPORT_LIMIT_FREEZE/IDLE are gone; pk_export_is_idle / pk_export_is_freeze
read the mode field and pk_export_power is deleted since the power arrives as
itself.

One behaviour change, deliberate: the force-export test asked "< freeze"
while the freeze test asked "== freeze", so a value in (99.0, 100.0)
satisfied neither and the window did nothing at all (GH#4914). That state
cannot be represented once mode is a field, so it is gone rather than
preserved. The Python engine already asked the mode here, so this also ends
a real divergence the parity comment claimed did not exist.

export_limit_arrays() fills the three array.arrays in one pass, unpacking the
tuple in the for statement. read_debug_yaml / create_debug_yaml decode and
encode self.export_limits (the current inverter state) as well as
export_limits_best, since the kernel now needs every limit list as tuples.
execute.py's three isExporting_Target fallbacks take export_target_of rather
than int() on the instruction.

ABI 5 -> 6 and parity 11 -> 12 so an older binary is rejected and the Python
engine used. All six platform binaries rebuilt (zig cross toolchain).

run_random 20/20 bit-identical, quick suite green, kernel parity and
model_kernel confirm the kernel loads and agrees with the Python engine.

GitHub Copilot review on PR #5047 found export_limit_arrays' unpacking loop trusted
export_limits was always tuple-shaped, unconditionally. A caller still holding an
unnormalised legacy element - a bare packed float, a malformed short sequence, or a
stored mapping, the same shapes the persistence layer above this commit now guards
against on load - would raise here instead of falling back to idle as every other
entry point into this encoding does. Added a cheap isinstance check (one per call,
not per element) that routes through export_limit_from_stored only when something in
the list is not already a tuple.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ith struct (ABI 7)

The three parallel arrays become one array of a 16-byte record - int32 mode,
int32 target, double power, the double landing 8-aligned as the C compiler
pads it. The simulation reads all three fields of the same window on the
same step, so they belong on one cache line, and it is one buffer to fill
instead of three.

The array-of-structs on its own measured no faster - filling it meant three
Python attribute stores per window on ctypes members, dearer per element
than array.array's bulk path, so the allocation win was given straight back.
struct.Struct("@iid").pack packs one window per C-level call and
from_buffer_copy casts the bytes; native alignment gives the same 16-byte
record, verified against ctypes.sizeof.

Measured on debug_cases calculate_plan, interleaved A/B against main over
several rounds: ~1.495s -> ~1.539s, about +2.9%, down from the +14% this
work started at and in line with the target.

ABI 6 -> 7 (the struct layout changed); parity stays 12 - the hot loop's
logic is byte-identical, only the memory layout differs. All six platform
binaries rebuilt.

run_random 20/20 bit-identical, quick suite and kernel parity green.

GitHub Copilot review on PR #5047 flagged the ABI-history comment above
PK_ABI_VERSION as stale after this commit - it still described ABI 6 as the
current three-array form with no mention of the struct-packed array-of-structs
this commit introduces. Reworded to describe both transitions (ABI 6 dropping
the packed double for three arrays, ABI 7 packing those into PkExportLimit
structs) so the comment matches what PK_ABI_VERSION actually is by the time a
reader gets here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
run_prediction_kernel (the single-run path) rebuilt the PkExportLimit buffer
on every call. The batch path caches it by list identity, which never hits
here because every prediction is handed a fresh list - but a search fans out
thousands of simulations over the same handful of distinct limit
configurations, so keying on the tuple contents instead turns almost every
call after the first into a dict lookup. The kernel only ever reads the
buffer, so sharing one between callers of the same plan is safe. Bounded at
512 distinct plans - the working set in one fan-out is tens.

Measured on debug_cases calculate_plan, interleaved A/B against the previous
commit over five rounds: ~1.532s -> ~1.506s, about -1.7%, taking the stack
to roughly +1.2% over main. Not faster than main, but a real, contained
improvement.

run_random 20/20 bit-identical, quick suite and kernel parity green.

GitHub Copilot review on PR #5047 flagged run_prediction_kernel_batch's memoisation
comment as stale after this commit - it still described three cached arrays where
limit_arrays() now marshals and caches one PkExportLimit buffer. Reworded to match.

GitHub Copilot review on PR #5047 also found export_limit_array's cache-key line -
key = tuple(export_limits) - and the packer it feeds both trusted every element was
already a tuple, unconditionally. A caller still holding an unnormalised legacy
element (a bare packed float, a malformed short sequence, or a stored mapping) would
raise here: a list or dict element makes tuple(export_limits) itself unhashable
before the packer is even reached, so the fix has to happen before the cache key is
built, not inside _build_export_limit_array. Added the same isinstance-gated
normalise-via-export_limit_from_stored guard used at the other two kernel entry
points introduced earlier in this PR (export_limit_arrays in the ABI 6 commit,
_build_export_limit_array's predecessor in the ABI 7 commit) - one check per call,
so a plan already in tuple form pays nothing beyond it.

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

The test mutates the full planning fixture to reach scenario_summary_state, so running it
against the shared suite instance leaked that state into every test after it. It builds its
own instance instead, imported inside the function because unit_test imports this module.

Agent-Logs-Url: https://github.com/springfall2008/batpred/sessions/72fd5bcb-c70f-4cee-85d2-8a415cc2c1f4

Co-authored-by: chalfontchubby <48563392+chalfontchubby@users.noreply.github.com>
Review of the split found two places where a limit was produced in a shape the rest of the
encoding does not accept, and both only bite at the top of the target range - exactly where
the packed encoding used to run out of room, which is why neither side's tests saw them.

calculate_yesterday's history reconstruction still appended a bare SoC percentage for a real
export, while its freeze sibling had been converted. A bare number decodes through the legacy
compatibility path, so it works by accident below 99 and every mode assertion passes either
way - but a slot that ended at 99% reads back as a freeze and one at 100% as an idle window,
dropping it from the History view.

The stored-form validator rejected any target at or above the freeze sentinel, a bound the
tuple form has no reason to keep. clip_export_slots genuinely produces the top of the range:
it narrows a target towards the SoC the simulation says is reachable, so a near-full battery
on a derated discharge rate clips to 99 or 100. export_limit_to_stored wrote those out
faithfully and export_limit_from_stored read them back as idle, so a restored plan or a
replayed debug dump silently lost the export. The bound is now the representable range.

Also from review, none of it behavioural:

- create_debug_yaml converted only the two Predbat-level limit lists. Each inverter keeps its
  own copy, which rode into the dump as a bare sequence and came back from a replay as a list,
  where export_mode_of compares it against a float and raises. Written and read the same way
  as the other two now.
- execute.py asked for a target as `export_target_of(...) or 0` in five places. That reads as
  a guard against a falsy target, which is not what is being guarded - the callers are all
  inside a target-mode branch, so it is a type normalisation with an unreachable fallback.
  Spelled out as export_target_percent_or_zero().
- prediction_kernel checks at import that struct's "@iid" layout really does match
  PkExportLimit, rather than reasoning about it in a comment. from_buffer_copy already catches
  a size mismatch, but not two layouts of the same size with the double at a different offset,
  and armv7l/i686 are cross-built so nobody runs them here. A mismatch drops to per-field
  assignment rather than raising, matching how this module treats a kernel it cannot trust.
- export_limit_sort_key documents that it is deliberately lossy at the top of the range: a 99%
  target and a freeze both key as 99.0 because the display paths print this number. Ordering
  and display only - every mode test goes through export_mode_of, which reads the field.
- inverter.py uses FULL_EXPORT_POWER rather than a bare 1.0.

Tests: the encoding round trips now sweep the full 0-100 target range; test_clip_export_slots
pairs the clip pass with the validator so the two cannot disagree again; calculate_yesterday
pins that a rebuilt limit is an instruction rather than a bare number; and the marshaller's
fast and fallback paths are compared field for field. Each was confirmed to fail with its fix
reverted. Full quick suite green with PREDBAT_KERNEL_REQUIRED=1, pre-commit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unblocks pre-commit on this branch - both are genuine words (derate's
past tense, already listed; struct.calcsize, stdlib) that just hadn't
been added yet.

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.

4 participants