Skip to content

Commit dc5e972

Browse files
springfall2008claude
authored andcommitted
fix(export): make the encoding's producers and its readers agree
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>
1 parent d6fade5 commit dc5e972

9 files changed

Lines changed: 261 additions & 14 deletions

‎apps/predbat/execute.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,23 @@ def build_status_extra(status_extra_parts):
9393
return status_extra
9494

9595

96+
def export_target_percent_or_zero(export_limit):
97+
"""The SoC percentage an export instruction targets, or 0 where it carries no target.
98+
99+
Only EXPORT_MODE_TARGET has a target; export_target_of returns None for the other two modes so a
100+
caller cannot use their sentinels as if they were one. Every value this feeds here - the
101+
discharge floor, the displayed target, the inverter target register - is a percentage, so the
102+
two modes resolve to the bottom of the range rather than to None.
103+
104+
Spelled out rather than written `or 0` at each site: that reads as a guard against a falsy
105+
target, which is not what is being guarded. The distinction matters because the callers all sit
106+
inside a target-mode branch already, so the fallback is unreachable for them and a reader needs
107+
to see that it is a type normalisation and not a live default.
108+
"""
109+
target = export_target_of(export_limit)
110+
return 0 if target is None else target
111+
112+
96113
class Execute:
97114
"""Execution mixin for applying optimised plans to physical inverters.
98115
@@ -478,7 +495,7 @@ def execute_plan(self):
478495
# Turn minutes into time
479496
discharge_start_time = self.midnight_utc + timedelta(minutes=minutes_start)
480497
discharge_end_time = self.midnight_utc + timedelta(minutes=(minutes_end + export_adjust)) # Add in 1 minute margin to allow Predbat to restore demand mode
481-
discharge_soc = max(((export_target_of(self.export_limits_best[0]) or 0) * self.soc_max) / 100.0, self.reserve, self.best_soc_min)
498+
discharge_soc = max((export_target_percent_or_zero(self.export_limits_best[0]) * self.soc_max) / 100.0, self.reserve, self.best_soc_min)
482499
self.log("Next export window will be: {} - {} at reserve {}".format(discharge_start_time, discharge_end_time, self.export_limits_best[0]))
483500
if (self.minutes_now >= minutes_start) and (self.minutes_now < minutes_end) and (export_mode_of(self.export_limits_best[0]) != EXPORT_MODE_IDLE):
484501
if not self.set_export_freeze_only and export_mode_of(self.export_limits_best[0]) == EXPORT_MODE_TARGET and (self.soc_kw > discharge_soc):
@@ -500,7 +517,7 @@ def execute_plan(self):
500517
# instruction's own target rather than to the instruction itself
501518
target = self.export_window_best[0].get("target")
502519
if target is None:
503-
target = export_target_of(self.export_limits_best[0]) or 0
520+
target = export_target_percent_or_zero(self.export_limits_best[0])
504521
self.isExporting_Target = int(target)
505522

506523
status = "Exporting"
@@ -530,14 +547,14 @@ def execute_plan(self):
530547
isExporting = True
531548
target = self.export_window_best[0].get("target")
532549
if target is None:
533-
target = export_target_of(self.export_limits_best[0]) or 0
550+
target = export_target_percent_or_zero(self.export_limits_best[0])
534551
self.isExporting_Target = int(target)
535552
else:
536553
status = "Hold exporting"
537554
status_per_inverter[inverter.id] = status
538555
target = self.export_window_best[0].get("target")
539556
if target is None:
540-
target = export_target_of(self.export_limits_best[0]) or 0
557+
target = export_target_percent_or_zero(self.export_limits_best[0])
541558
status_extra_parts.append((inverter.id, "target", status, "{}%-{}%".format(inverter.soc_percent, inverter.soc_percent))) # append multi-inverter target SoC's together
542559
self.isExporting_Target = inverter.soc_percent
543560
self.log("Export Hold (Demand mode) as export is now at/below target or freeze only is set - current SoC {}kWh and target {}kWh".format(self.soc_kw, discharge_soc))
@@ -843,7 +860,7 @@ def export_target_soc_percent(self):
843860
Returns:
844861
- int: export target as a percentage of the battery
845862
"""
846-
target = export_target_of(self.export_limits_best[0]) or 0
863+
target = export_target_percent_or_zero(self.export_limits_best[0])
847864
if not self.set_reserve_enable:
848865
target = max(target, calc_percent_limit(max(self.reserve, self.best_soc_min), self.soc_max))
849866
return target

‎apps/predbat/inverter.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
INVERTER_MAX_RETRY,
3333
EXPORT_MODE_TARGET,
3434
EXPORT_MODE_IDLE,
35+
FULL_EXPORT_POWER,
3536
INVERTER_WRITE_POLL_INTERVAL,
3637
INVERTER_WRITE_POLL_MAX_INTERVAL,
3738
INVERTER_CLOCK_SKEW_RESTART_MINUTES,
@@ -1751,7 +1752,7 @@ def update_status(self, minutes_now, quiet=False):
17511752

17521753
# Pre-fill best discharge enables
17531754
if self.discharge_enable_time:
1754-
self.export_limits = [pack_export_limit(EXPORT_MODE_TARGET, 0, 1.0) for i in range(len(self.export_window))]
1755+
self.export_limits = [pack_export_limit(EXPORT_MODE_TARGET, 0, FULL_EXPORT_POWER) for i in range(len(self.export_window))]
17551756
else:
17561757
self.export_limits = [pack_export_limit(EXPORT_MODE_IDLE) for i in range(len(self.export_window))]
17571758

‎apps/predbat/output.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3483,7 +3483,12 @@ def calculate_yesterday(self):
34833483
else:
34843484
soc_was = battery_soc_yesterday_array.get(export_end_minute, 0.0)
34853485
soc_percent = calc_percent_limit(soc_was, self.soc_max)
3486-
self.export_limits_best.append(soc_percent)
3486+
# A target instruction, not the bare percentage this used to append. A bare
3487+
# number still decodes through the legacy path, but only by accident, and
3488+
# not at the top of the range: a slot that ended at 99% read back as a
3489+
# freeze and one at 100% as an idle window, dropping it from the History
3490+
# view entirely.
3491+
self.export_limits_best.append(pack_export_limit(EXPORT_MODE_TARGET, soc_percent))
34873492

34883493
if "charging" in charge_during_slot:
34893494
# Assume charging at this time

‎apps/predbat/prediction_kernel.py‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,22 @@ def double_array(values):
405405
# because it is one C-level pack per window instead of three Python attribute stores.
406406
_EXPORT_LIMIT_STRUCT = struct.Struct("@iid")
407407

408+
# That reasoning is checked rather than trusted. from_buffer_copy already rejects a total-size
409+
# mismatch, but two layouts of the same size with the double at a different offset would pack wrong
410+
# and run on silently - and the platforms most likely to differ (armv7l, i686) are cross-built, so
411+
# nobody exercises them here. Both the size and the field offsets are compared, the offsets derived
412+
# from the same format string rather than written out, so this cannot drift from the Struct above.
413+
#
414+
# A mismatch disables the fast path instead of raising: the struct packing is an optimisation, and
415+
# the per-field fallback below is correct on any layout. That matches how the rest of this module
416+
# treats a kernel it cannot trust - fall back to something slower that works, rather than take the
417+
# process down.
418+
_EXPORT_LIMIT_STRUCT_USABLE = _EXPORT_LIMIT_STRUCT.size == ctypes.sizeof(PkExportLimit) and tuple(getattr(PkExportLimit, name).offset for name in ("mode", "target", "power")) == (
419+
0,
420+
struct.calcsize("@i"),
421+
struct.calcsize("@ii0d"),
422+
)
423+
408424
# Contents-keyed cache of marshalled export-limit buffers for the single-run path
409425
# (run_prediction_kernel). The batch path caches by list identity, which never hits here because
410426
# every prediction is handed a fresh list; a search fans out thousands of simulations over the same
@@ -423,9 +439,20 @@ def _build_export_limit_array(export_limits):
423439
below, not here, because an unnormalised legacy element (a list or a dict) is unhashable and
424440
would break tuple(export_limits) itself, not just this packing loop.
425441
"""
442+
buffer = PkExportLimit * len(export_limits)
443+
if not _EXPORT_LIMIT_STRUCT_USABLE:
444+
# Struct packing is not layout-identical to the ctypes struct on this platform, so fill the
445+
# fields one at a time instead. Slower, and never taken on any platform Predbat ships.
446+
packed = buffer()
447+
for index, (mode, target, power) in enumerate(export_limits):
448+
entry = packed[index]
449+
entry.mode = mode
450+
entry.target = target if mode == EXPORT_MODE_TARGET else 0
451+
entry.power = power if mode == EXPORT_MODE_TARGET else FULL_EXPORT_POWER
452+
return packed
426453
pack = _EXPORT_LIMIT_STRUCT.pack
427454
raw = b"".join(pack(mode, target, power) if mode == EXPORT_MODE_TARGET else pack(mode, 0, FULL_EXPORT_POWER) for mode, target, power in export_limits)
428-
return (PkExportLimit * len(export_limits)).from_buffer_copy(raw)
455+
return buffer.from_buffer_copy(raw)
429456

430457

431458
def export_limit_array(export_limits):

‎apps/predbat/tests/test_calculate_yesterday.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,6 +1627,15 @@ def _capture_publish_html_plan(*args, **kwargs):
16271627
if unmarked:
16281628
print("ERROR: {} of {} rebuilt export slots held two states but were not marked as mixed".format(len(unmarked), len(captured["export_window_best"])))
16291629
failed = True
1630+
# A rebuilt limit has to be a real export instruction, not the bare SoC percentage this used
1631+
# to append. A bare number still decodes through the legacy compatibility path, so every
1632+
# mode assertion above passes either way - but only for targets below 99, where the packed
1633+
# encoding's reserved values begin: a slot that ended at 99% would read back as a freeze and
1634+
# one at 100% as an idle window, dropping it from the History view.
1635+
bare = [limit for limit in captured["export_limits_best"] if not isinstance(limit, tuple)]
1636+
if bare:
1637+
print("ERROR: {} of {} rebuilt export limits are bare numbers rather than (mode, target, power) instructions: {!r}".format(len(bare), len(captured["export_limits_best"]), bare))
1638+
failed = True
16301639

16311640
_restore_methods(my_predbat, original_run_pred)
16321641
my_predbat.savings_last_updated = None

‎apps/predbat/tests/test_clip_export_slots.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# pylint: disable=attribute-defined-outside-init
1010
from tests.test_infra import reset_inverter
1111
from const import EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE, EXPORT_MODE_TARGET
12-
from utils import export_mode_of, export_target_of, export_power_of, pack_export_limit
12+
from utils import export_mode_of, export_target_of, export_power_of, pack_export_limit, export_limits_to_stored, export_limits_from_stored
1313

1414

1515
def run_clip_export_slots_tests(my_predbat):
@@ -24,6 +24,7 @@ def run_clip_export_slots_tests(my_predbat):
2424
failed |= test_normal_export_clipped_up_when_soc_above_reserve_with_zero_limit(my_predbat)
2525
failed |= test_normal_export_clipped_up_when_soc_flat_above_limit(my_predbat)
2626
failed |= test_export_power_does_not_shift_the_clip_target(my_predbat)
27+
failed |= test_clipped_high_target_is_storable(my_predbat)
2728
failed |= test_disabled_window_ignored(my_predbat)
2829
failed |= test_passed_window_clipped(my_predbat)
2930
failed |= test_multiple_windows_mixed(my_predbat)
@@ -250,6 +251,40 @@ def test_export_power_does_not_shift_the_clip_target(my_predbat):
250251
return failed
251252

252253

254+
def test_clipped_high_target_is_storable(my_predbat):
255+
"""A target clipped to the top of the range survives being saved and reloaded.
256+
257+
The clip pass narrows a target towards the SoC the simulation says is reachable, so a battery
258+
predicted to sit near full with a derated discharge rate clips to 99% or 100%. The stored-form
259+
validator used to reject anything at or above the old freeze sentinel, so the write side emitted
260+
those faithfully and the read side turned them into an idle window - the export vanished when a
261+
plan was restored after a restart, or when a debug dump was replayed. Paired with the encoding
262+
test of the same name so the clip pass and the validator cannot disagree again unnoticed.
263+
"""
264+
print("**** test_clipped_high_target_is_storable ****")
265+
failed = False
266+
setup(my_predbat)
267+
268+
for rate_kw, soc_kwh in ((0.06, 9.95), (0.06, 9.99), (0.3, 9.99)):
269+
my_predbat.battery_rate_max_discharge = rate_kw / 60.0
270+
window = [make_window(0, 60)]
271+
limits = [pack_export_limit(EXPORT_MODE_TARGET, 50)]
272+
_, clipped = my_predbat.clip_export_slots(0, make_predict_soc(0, soc_kwh), window, limits, 1, 5)
273+
limit = clipped[0]
274+
if export_mode_of(limit) != EXPORT_MODE_TARGET:
275+
print("ERROR: clipping a {}kWh SoC at {}kW gave mode {}, expected a target".format(soc_kwh, rate_kw, export_mode_of(limit)))
276+
failed = True
277+
continue
278+
if export_target_of(limit) < 99:
279+
print("ERROR: clipping a {}kWh SoC of 10.0kWh at {}kW gave target {}, expected the top of the range - the fixture no longer exercises the case".format(soc_kwh, rate_kw, export_target_of(limit)))
280+
failed = True
281+
restored = export_limits_from_stored(export_limits_to_stored([limit]))[0]
282+
if restored != limit:
283+
print("ERROR: the clipped limit {} did not survive a store/load round trip, became {}".format(limit, restored))
284+
failed = True
285+
return failed
286+
287+
253288
def test_disabled_window_ignored(my_predbat):
254289
"""Windows with limit=100 should be ignored and left unchanged"""
255290
print("**** test_disabled_window_ignored ****")

0 commit comments

Comments
 (0)