refactor(export): replace the packed export-limit float with a (mode, target, power) tuple - #5047
Conversation
There was a problem hiding this comment.
🟡 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()includes1 - powerfor 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 semantictargetmakes 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. Useexport_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
PkExportLimitis 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 onePkExportLimitbuffer. 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.
Automated reply from Copilot agent: addressed in a79a69cc. I applied the remaining review-thread fixes, including target normalization in |
There was a problem hiding this comment.
🟡 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 andexport_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 populatedwindow["target"]with the tuple instruction,.get("target")returns that tuple andint(target)raisesTypeError; the fallback only handles a missing key. Decode whichever value is present withexport_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
Nonefallback and is passed toint(). Normalize the stored target throughexport_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 as50.7. Comparing that value withsoc_percent_maxcan label a target that has actually been reached asHldExp(for example, SoC max 50.5%). Compareexport_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.pyor added toTEST_REGISTRY, so./run_allwill 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 forrun_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 byexport_limit_to_stored()withtarget: 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-infconverts tofloat('-inf'), reachesint(packed)inunpack_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 to99.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'sn_best_soc >= currentguard 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.cppfile 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 by99. 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
a79a69c to
c342cf1
Compare
|
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). |
There was a problem hiding this comment.
🟡 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
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>
1eef2a0 to
775b4ec
Compare
775b4ec to
4e9b077
Compare
There was a problem hiding this comment.
🟡 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
NaNpower because both comparisons with NaN are false. A hand-edited/corrupt stored value such aspower: .nantherefore 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
| # 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 |
| try: | ||
| return unpack_export_limit(float(stored)) | ||
| except (TypeError, ValueError): | ||
| return pack_export_limit(EXPORT_MODE_IDLE) |
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>
4e9b077 to
e3b4722
Compare
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 as1 - fractionin 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
refactor(export): route all export-limit reads through named accessors- addsexport_mode_of/export_target_of/export_power_of/export_limit_exports_no_battery/export_limit_is_full_discharge/export_limit_sort_key/pack_export_limitinutils.py, and converts every read site acrossplan.py,prediction.py,execute.py,output.pyandgateway.pyto 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 bare99/100, so the old idiom can't quietly come back.fix(plan): keep the export power out of the SoC a target exports to- a real bug this sweep surfaced:clip_export_slotswas 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.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 anisinstance(tuple)fast path, write sites build tuples viapack_export_limit, and the kernel marshalling wraps values throughexport_limit_sort_keyfor ordering.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.refactor(kernel): pass export limits as three field arrays, not one packed double (ABI 6)- the C++ kernel takesexport_modes/export_targets/export_powersas 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.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 onestruct.Struct("@iid")-packed buffer,from_buffer_copy'd into a C array-of-structs, cutting marshalling overhead.perf(kernel): cache the marshalled export-limit buffer by contents- the sameexport_limits_bestlist 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
./run_all, not just--quick) and./run_pre_commitpass 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 tomainat every commit except commit 2, which is a deliberate behaviour fix (see above).model_kerneltests green throughout;KERNEL_PARITY_REVISIONandKERNEL_ABI_VERSIONbumped appropriately per commit, all 6 cross-compiled.sotargets rebuilt viazigat each ABI change.test_window.py(commit 3), and round-trip/old-float/plain-YAML persistence tests (commit 4).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 behinddebug_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.--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.Speed
This needed more digging than expected, and the honest answer is: no measured regression, but also no confidently measured improvement in production.
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.Plan calculation tooklog 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.plan_stats.shcomparison for that version is still accumulating samples as of this PR.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
run_randomcorpus.self.export_limits_bestis 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 viaload_saved_plan, so it's a narrow window, but worth hardening if it recurs.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com