fix(octopus): free session window must be measured from minutes_now - #4935
Conversation
…4931) load_free_slot() bounded the session against self.forecast_minutes alone. That is a DURATION from minutes_now (forecast_hours * 60), while start_minutes/end_minutes are ABSOLUTE minutes from midnight_utc, so the two are not comparable. The effective cut-off was 48 hours from midnight today rather than 48 hours from now, and it shrank as the day went on. Result: a free session Predbat had already fetched and logged was dropped before it reached the rates, with no further log line - the "Setting Octopus free session in range" message sits inside the same if. So the session is visible in the sensor and in the log, and simply absent from the plan, which is exactly what #4931 describes. Worked example from that report: Friday 18:00, session Sunday 11:00-12:00. start_minutes is 3540 (from Friday midnight) and forecast_minutes is 2880, so 3540 < 2880 is false and the session is discarded - even though the plan runs to minutes_now + forecast_minutes = 4320 and covers it comfortably. Anyone told about a free hour more than a day or so ahead loses it. load_saving_slot() and load_axle_slot(), the two sibling loaders in this file, already bound themselves with forecast_minutes + minutes_now; only the free session path was missing it. rate_replicate() fills to forecast_minutes + 48 * 60, so the extended range is populated before this runs. Tests: test_load_free_slot now pins minutes_now (18:00) instead of inheriting the wall clock, so the boundary cases mean the same thing on every run, and sizes the rate dicts the way rate_replicate does. New Test 6b covers the two-day-ahead session from #4931 and fails without this change (minute 3540 stays at 20.0p). Tests 6 and 7 encoded the old boundary and have been moved onto the corrected one - Test 6 now uses a session genuinely past the window, and Test 7 asserts the cap lands on minutes_now + forecast_minutes. Note rate_add_io_slots() clamps with a bare forecast_minutes too. It is left alone here - IO dispatch slots are near-term and changing them deserves its own change - but it looks like the same latent mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There is a correctness issue in load_free_slot() when a later slot has an invalid start/end (stale minute range can be reused with a new rate), and Test 7 currently doesn’t actually exercise the intended capping behavior with the pinned minutes_now.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes Octopus “free session” (Power Up / Happy Hour) handling so the free-session window is bounded against the plan horizon measured from minutes_now (consistent with other Octopus slot loaders), preventing sessions a couple of days ahead from being silently dropped as the day progresses.
Changes:
- Fix
load_free_slot()horizon comparisons by bounding againstself.forecast_minutes + self.minutes_nowinstead ofself.forecast_minutes. - Update
test_load_free_slotto pinminutes_nowfor deterministic boundary behavior and add coverage for the “two days ahead” scenario from #4931.
File summaries
| File | Description |
|---|---|
| apps/predbat/octopus.py | Corrects free-session bounding to use a horizon relative to minutes_now. |
| apps/predbat/tests/test_load_free_slot.py | Pins minutes_now and adjusts/adds boundary tests for free-session application within the plan window. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…enuine window-straddle Co-Authored-By: Claude Code <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped, aligns load_free_slot() with established horizon semantics used elsewhere, and is backed by targeted regression tests covering the reported failure case.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
Fixes #4931.
Root cause
load_free_slot()bounded the session againstself.forecast_minuteson its own:self.forecast_minutesis a duration fromminutes_now(forecast_hours * 60,fetch.py), whilestart_minutes/end_minutesare absolute minutes frommidnight_utc. The two are not comparable. The effective cut-off was 48 hours from midnight today rather than 48 hours from now — so the accepted window shrank as the day went on.The two sibling loaders in the same file already get this right:
load_saving_slotself.forecast_minutes + self.minutes_nowload_axle_slotself.forecast_minutes + self.minutes_nowload_free_slotself.forecast_minutesWhy it presents as "recognised but not planned"
fetch_octopus_sessions()logs the event when it fetches it, so the session shows up in the sensor and in the log. TheSetting Octopus free session in range ...line sits inside the failingif, so when the slot is dropped there is no second log line and nothing to indicate it was discarded. From the outside the rates just never change — the symptom reported in #4931.Worked example from that report — Friday 18:00, session Sunday 11:00–12:00:
start_minutes= 3540 (Sunday 11:00 measured from Friday midnight)forecast_minutes= 28803540 < 2880→ false → session discarded…even though the plan runs to
minutes_now + forecast_minutes= 1080 + 2880 = 4320 and covers minute 3540 comfortably. Anyone told about a free hour more than a day or so ahead loses it, and the further into the day they look, the worse it gets.rate_replicate()fills toforecast_minutes + 48 * 60, and runs beforeload_free_slot()infetch.py, so the extended range is already populated — no new keys are needed.Tests
test_load_free_slotpreviously never setminutes_now, so it inherited the wall clock and its boundary cases meant something different depending on when they ran. It now pinsminutes_nowto 18:00 and sizes the rate dicts the wayrate_replicatedoes.minutes_now + forecast_minutes.forecast_minutes; now asserts it lands onminutes_now + forecast_minutes, and that the minute after the window is untouched.Tests 6 and 7 encoded the old boundary, so they had to move with it — flagging that explicitly rather than burying it.
unit_test.py -k load_free_slot / octopus_free / saving_session / octopus_slots / savings_stability / fetch_octopus_ratesall pass.Noted, not changed
rate_add_io_slots()clampsend_minuteswith a bareself.forecast_minutestoo. It is left alone here — IO dispatch slots are near-term and changing that behaviour deserves its own change with its own reasoning — but it looks like the same latent mismatch and may be worth a separate look.Related
#4930 raises the manual rate-override horizon from 48 hours to 7 days. Different code path, same user-facing complaint: a free hour announced several days ahead does not make it into the plan. Regional Power Ups are announced per-customer and are not on the national free-electricity page, so the manual override is the only route for them — which is why both halves matter.