Skip to content

Fix a crash rebuilding yesterday's plan from a slot-edge-only status - #4943

Merged
springfall2008 merged 1 commit into
mainfrom
fix/yesterday-edge-only-window-none-bounds
Sep 5, 2026
Merged

springfall2008 merged 1 commit into
mainfrom
fix/yesterday-edge-only-window-none-bounds

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

The crash

File "/config/output.py", line 3428, in calculate_yesterday
  plan_html_yesterday, plan_json_yesterday = self.publish_html_plan(...)
File "/config/output.py", line 1181, in publish_html_plan
  charge_window_n = self.in_charge_window(self.charge_window_best, try_minute)
File "/config/plan.py", line 1138, in in_charge_window
  if minute_abs >= window["start"] and minute_abs < window["end"]:
TypeError: '>=' not supported between instances of 'int' and 'NoneType'

This propagates out of calculate_plan() and kills the whole update_pred() cycle — a detail of the History view takes the plan down with it. Reported from a user log at 13:30:35, two minutes after #4872 merged.

Cause

calculate_yesterday() rebuilds each History slot with two walks over the same per-minute status list:

  • one decides whether the slot produces a charge/export window (the dominant-state tally)
  • one decides where that window starts and ends

#4872 widened only the first to trust a slot's first/last 5 minutes when they hold one state throughout. The second stayed on range(edge_minutes, plan_interval_minutes - edge_minutes).

So a state living solely in a trusted edge was counted by the tally but never seen by the boundary scan, and the window was appended as {"start": None, "end": None}.

The two walks now cover exactly the same trusted minutes, so a counted state always has a start minute by construction. This also makes a short plan_interval_minutes safe: at 10 or below the old interior range was empty, so any activity would have hit this.

A second defect in the same loop

Fixing the first surfaced this one. The charge/export handoff — whichever side starts second ends the one that started first — also fired when both sides started at the same minute, which is exactly what cross-charging is. It closed the export window at its own start minute.

Every cross-charging slot therefore rebuilt a zero-width window: present in the list, invisible to in_charge_window() (start <= m < end can never hold), and worth nothing in the simulated cost. Probed on main:

export windows: [{'start': 0, 'end': 0}, {'start': 30, 'end': 30}, ...]
charge windows: [{'start': 0, 'end': 30}, {'start': 30, 'end': 60}, ...]

The export half of cross-charging was thus still missing from History long after #4466 restored it — because _test_cross_charging_reconstructed_as_both_windows only asserted the list was non-empty, and a list of 60 zero-width windows passes that happily.

An equal start is an overlap, not a handoff, so both sides now run the full slot.

Important

This changes reported savings for anyone whose inverters cross-charge — those export limits now actually apply in the yesterday simulation. In the direction of being correct, but the numbers will move.

Tests

  • _test_edge_only_state_still_gets_real_window_bounds — both slot edges, and checks the bounds survive the real in_charge_window() lookup, since that is where the crash landed.
  • _test_cross_charging_export_window_covers_the_slot — asserts full-slot coverage, that the export window matches the charge window, and that in_charge_window() can see it.
  • _test_cross_charging_reconstructed_as_both_windows strengthened to require start < end. This is the assertion whose absence let the zero-width bug hide.

Both new tests were confirmed to fail against the unfixed code before the fix went in.

./run_all --quick passes in full, including the debug_cases golden plans (22.66s, no shift). ./run_pre_commit clean.

🤖 Generated with Claude Code

calculate_yesterday() rebuilds each History slot with two walks over the
same per-minute status list: one decides *whether* the slot produces a
charge or export window, the other decides *where* that window starts and
ends. #4872 widened only the first to trust a slot's first/last 5 minutes
when they hold one state throughout, leaving the second on the interior
minutes alone.

A state living solely in a trusted edge was therefore counted by the tally
but never seen by the boundary scan, so the window was appended with its
bounds still None. in_charge_window() then compared an int against None:

    TypeError: '>=' not supported between instances of 'int' and 'NoneType'

That propagates out of calculate_plan() and kills the whole update_pred()
cycle, so a detail of the History view took the plan down with it. The two
walks now cover exactly the same trusted minutes, so a counted state always
has a start minute by construction. This also makes a short
plan_interval_minutes safe: at 10 or below the old interior range was
empty, so any activity at all hit this.

Fixing that surfaced a second defect in the same loop. The charge/export
handoff - whichever side starts second ends the one that started first -
also fired when both sides started at the *same* minute, which is exactly
what cross-charging is. It closed the export window at its own start
minute, so every cross-charging slot rebuilt a zero-width window: present
in the list, invisible to in_charge_window(), and worth nothing in the
simulated cost. The export half of cross-charging was thus still missing
from History long after #4466 restored it, because the test only asserted
the window list was non-empty - a list of 60 zero-width windows passed it.
An equal start is an overlap, not a handoff, so both sides now run the
full slot.

Note this changes reported savings for anyone whose inverters cross-charge:
those export limits now actually apply in the yesterday simulation.

Tests: both edges covered, cross-charging asserted to cover its whole slot
and to be visible to the real in_charge_window() lookup, and the existing
cross-charging test strengthened to require start < end - the missing
assertion that let this hide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 5, 2026 12:48

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.

🟢 Approval recommended

The production fix addresses the reported crash and cross-charging reconstruction defect with targeted regression tests added to prevent recurrence.

Pull request overview

Fixes a calculate_yesterday() crash that could terminate the whole update_pred() cycle when reconstructing yesterday’s plan from History slot statuses, and corrects cross-charging slot reconstruction so export windows have real duration and are visible to in_charge_window().

Changes:

  • Align the slot “boundary scan” with the same trusted-minute range used by the dominant-state tally, preventing {"start": None, "end": None} windows that crash in_charge_window().
  • Fix charge/export “handoff” logic so equal start minutes are treated as overlap (cross-charging) rather than ending one side at its own start.
  • Add/strengthen regression tests around edge-only states and cross-charging export windows; document the debugging lesson in the triage journal.
File summaries
File Description
apps/predbat/output.py Aligns History slot scans with tally trust range and fixes cross-charging handoff to prevent None/zero-width windows.
apps/predbat/tests/test_calculate_yesterday.py Adds new regression tests and strengthens assertions to catch zero-width export windows.
.claude/skills/issue-triage/references/debug-journal.md Adds a debugging note capturing the “two scans must agree” lesson and the cross-charging zero-width pitfall.
Review details

Suppressed comments (1)

apps/predbat/tests/test_calculate_yesterday.py:1662

  • This test docstring says the rebuilt window bounds “lie inside its own slot”, and the loop also carries slot_start, but the assertions never validate slot alignment/range. Adding explicit bounds checks (and using slot_start) would better lock in the intended behaviour and avoid the slot_start variable being effectively unused.
    for label, windows, slot_start in (
        ("charge", captured.get("charge_window_best") or [], charge_slot_start),
        ("export", captured.get("export_window_best") or [], export_slot_start),
    ):
        if len(windows) != 1:
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • 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 on lines +1188 to +1193
# A window is only rebuilt if it covers real time. Asserting the list is merely non-empty
# let a list of zero-width windows count as a fix for years - see the dedicated test below.
empty = [window for window in captured["export_window_best"] if window["start"] >= window["end"]]
if empty:
print("ERROR: {} rebuilt export windows cover no time at all, e.g. {!r}".format(len(empty), empty[0]))
failed = True
@springfall2008
springfall2008 merged commit 11f5168 into main Sep 5, 2026
3 checks passed
@springfall2008
springfall2008 deleted the fix/yesterday-edge-only-window-none-bounds branch September 5, 2026 12:55
springfall2008 pushed a commit that referenced this pull request Sep 7, 2026
…ries

The first flush since the journal became writable again. Four candidates that the
2026-09-06 and 2026-09-07 runs verified but could not land, plus the corrections those
runs found while re-reading the file against what has merged since.

Folded in:

- GH#4967 - the `octopus_intelligent_consider_full` clamp is duplicated in
  prediction_kernel.cpp, `car_charging_limit` is held by reference, and the clamp is
  currently the only bound on GH#4952's overestimate. Extends the car-charging row.
- GH#4975 - new symptom row for "low power mode does nothing": the PV-overlap guard is
  a fixed 0.1 kWh threshold and midday windows are expected to fail it, so these are
  enhancement reports rather than defects.
- GH#4965 - new trap. The interesting half is that the harness cannot reproduce the
  crash the issue describes, so a guard-presence assertion is the only honest
  regression test.

Rewritten rather than folded:

- GH#4973 claimed `gh search issues` was dead on the runner. Re-probing dissolved the
  premise - the run had silently corrected a typo in the issue title it searched for.
  The salvageable lesson went into the existing duplicate-search bullet.

Corrected:

- Octopus row: GH#4946 fixed by PR #4957 (`dispatch_billed_off_peak()`)
- Fox row: the half of GH#4939 the reporter actually hit was event routing, fixed by
  PR #4962; the zero-caller `set_battery_charging_time()` half is still live
- Carbon row: half (a) fixed by PR #4958; half (b) narrowed to what is still true
- GivTCP row: PR #4864 moved REST handling out, so the `inverter.py:1435` citation had
  drifted onto unrelated code
- Two-scans trap: both halves fixed by PR #4943, was written in present tense

Every claim above was re-verified against main at f0cb3f0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
springfall2008 added a commit that referenced this pull request Sep 7, 2026
…ries (#4980)

* docs(debug-journal): recover the stalled backlog and correct five entries

The first flush since the journal became writable again. Four candidates that the
2026-09-06 and 2026-09-07 runs verified but could not land, plus the corrections those
runs found while re-reading the file against what has merged since.

Folded in:

- GH#4967 - the `octopus_intelligent_consider_full` clamp is duplicated in
  prediction_kernel.cpp, `car_charging_limit` is held by reference, and the clamp is
  currently the only bound on GH#4952's overestimate. Extends the car-charging row.
- GH#4975 - new symptom row for "low power mode does nothing": the PV-overlap guard is
  a fixed 0.1 kWh threshold and midday windows are expected to fail it, so these are
  enhancement reports rather than defects.
- GH#4965 - new trap. The interesting half is that the harness cannot reproduce the
  crash the issue describes, so a guard-presence assertion is the only honest
  regression test.

Rewritten rather than folded:

- GH#4973 claimed `gh search issues` was dead on the runner. Re-probing dissolved the
  premise - the run had silently corrected a typo in the issue title it searched for.
  The salvageable lesson went into the existing duplicate-search bullet.

Corrected:

- Octopus row: GH#4946 fixed by PR #4957 (`dispatch_billed_off_peak()`)
- Fox row: the half of GH#4939 the reporter actually hit was event routing, fixed by
  PR #4962; the zero-caller `set_battery_charging_time()` half is still live
- Carbon row: half (a) fixed by PR #4958; half (b) narrowed to what is still true
- GivTCP row: PR #4864 moved REST handling out, so the `inverter.py:1435` citation had
  drifted onto unrelated code
- Two-scans trap: both halves fixed by PR #4943, was written in present tense

Every claim above was re-verified against main at f0cb3f0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(debug-journal): fold in three findings recovered from the run logs

A sweep of the triage bot's logs for findings that never reached the queue.

- Ohme row, GH#4952 (still live): `slot_list()` derives energy as `watts * hours`
  while the observed amperage taper shows `watts` is a per-slot cap - 2.55x over on
  the reporter's data - and the `estimatedSoc` field that would give the true delta is
  never read. The same slots publish as AT_HOME dispatches with no source, so the whole
  block is stamped at the low rate. Cross-referenced to the car-charging row's
  fix-order note, since the predict() clamp is currently the only bound on it.
- Octopus row, GH#4950 (fixed, PR #4951): the low-rate slot budget was keyed on the
  loop minute rather than the slot start, so a noon-straddling window drew a fresh
  budget at midday. Had no entry at all.
- New trap: `midnight_utc` is local midnight and `now_utc` is local time. Both names
  are wrong in the same direction, and it already sent one review hunting for a
  timezone frame mismatch that does not exist.

Deliberately not folded in: PR #4957's "three unshared copies of the dispatch
predicate" finding, which the merge it reviewed has since addressed -
`dispatch_billed_off_peak()` is now a shared helper and the one remaining plain
location test carries an explicit #4946 cross-reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CI <ci@example.com>
Co-authored-by: Claude Opus 5 (1M context) <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.

2 participants