Repository navigation
fix(fetch): exclude saving-session/Axle boosted minutes from automatic rate thresholds (#5050) - #5163
chalfontchubby wants to merge 19 commits into
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unresolved issues remain around empty-rate handling, final automatic rescanning, and saving-event rate-base mapping; the reset test is also source-only.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
This PR updates automatic rate-threshold calculations to exclude saving-session and Axle reward boosts while preserving genuine discounts.
Changes:
- Adds saving-minute tracking and base-rate normalization.
- Resets stale saving metadata in simulated tariffs.
- Adds threshold regression tests and registers the suite.
| File | Description |
|---|---|
apps/predbat/unit_test.py |
Registers the new test suite. |
apps/predbat/tests/test_set_rate_thresholds.py |
Adds threshold and state-reset regression tests. |
apps/predbat/predbat.py |
Initializes saving-minute tracking state. |
apps/predbat/fetch.py |
Implements saving-aware threshold calculations. |
apps/predbat/compare.py |
Clears stale saving metadata. |
apps/predbat/annual.py |
Clears stale saving metadata. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
5a44a67 to
f0bbae0
Compare
…test isolation - compare.py: a tariff that reuses the live (already boosted) rates for a side now keeps that side's live saving-minute sets and pre-saving snapshots, captured by run_all(); only a side whose rates the tariff replaces is cleared. Clearing them unconditionally left #5050 in place inside compare (whole 30p day low rate). - set_rate_thresholds(): an empty rate table keeps that side's stored stats, as before, instead of scanning to the (99999, 0, 0) placeholder (export threshold 99998.9 vs -0.1 on main). - Tests: the manual export test sets its own pre-saving snapshots (it passed only on the previous test's leftovers); new automatic-mode export test; empty-table test; compare tests for both-replaced, one-side-replaced and reused paths; the ordering test now pins load_free_slot() and the export-side snapshots; nested helpers have docstrings; the rate_scan *_minute fields join the fixture snapshot. - Docs: note that both thresholds count event rewards above the tariff at the tariff's own rate, while free/discounted sessions stay cheap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f0bbae0 to
f377cf5
Compare
…test isolation - compare.py: a tariff that reuses the live (already boosted) rates for a side now keeps that side's live saving-minute sets and pre-saving snapshots, captured by run_all(); only a side whose rates the tariff replaces is cleared. Clearing them unconditionally left #5050 in place inside compare (whole 30p day low rate). - set_rate_thresholds(): an empty rate table keeps that side's stored stats, as before, instead of scanning to the (99999, 0, 0) placeholder (export threshold 99998.9 vs -0.1 on main). - Tests: the manual export test sets its own pre-saving snapshots (it passed only on the previous test's leftovers); new automatic-mode export test; empty-table test; compare tests for both-replaced, one-side-replaced and reused paths; the ordering test now pins load_free_slot() and the export-side snapshots; nested helpers have docstrings; the rate_scan *_minute fields join the fixture snapshot. - Docs: note that both thresholds count event rewards above the tariff at the tariff's own rate, while free/discounted sessions stay cheap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5163 review) reset_sample_state() promises to clear every field a previous sample could leave behind, and already clears the rates and thresholds these four sit beside. _apply_rates() keeps its own clearing because _run_scenarios() installs a second tariff without coming back through the reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly (#5163 review) fetch_rates() decided whether a tariff had installed its own rates by comparing the rate table against the deepcopy it started from. That held only because every rate source assigns a fresh dict and nothing copies the table in between. Each branch that installs a tariff's own rates now sets import_replaced/export_replaced, and both the #5286 dispatch-marker restore and the saving-minute clearing read those flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tats (#5163 review) The cap mapped every saving-tagged minute back to its pre-session rate, including a minute the user had since overridden. A fixed 50p put over a +50p session to discourage charging read as the 25.95p day rate, so the manual threshold dropped from 20.47 to 18.46. A new apply_rate_overrides() helper applies the overrides and manual rates to the live table and, when there are saving minutes, the same to the pre-saving snapshot, so the cap takes a session minute back to "these rates without the session". That covers fixed, increment and manual overrides alike. With no saving minutes nothing reads the snapshot, so the overrides are not applied (or logged) twice. compare.py applies a tariff's own override to a kept snapshot in the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f377cf5 to
1d6272e
Compare
…c rate thresholds (#5050) A saving session or Axle VPP event boosts import/export rates by the event reward and tags those minutes "saving". In automatic threshold mode set_rate_thresholds() computed its stats from rate_max/rate_min/rate_average, which include those boosted minutes, so a two-rate tariff plus a large reward could push the low-rate threshold above every genuine tariff rate and classify a whole ordinary-price day as "low rate" (#5050). Threshold stats now come from rate_minmax_excluding_saving(), which maps each saving-tagged minute back to its own pre-event rate. Capping is downward only, so a genuine free/discounted session stays visible as a real low rate. The snapshot it caps against is taken after the IOG/SmartFlex dispatch overlay but before any session or override runs: rate_import_base predates rate_add_io_slots(), so capping against that would discard a legitimate IOG discount wherever a dispatch slot and a session overlap. rate_max/rate_min/rate_average themselves are left untouched - dashboard sensors, graph scaling and plan.py pricing all want the real boosted price. compare.py and annual.py replace the rates with a simulated tariff and re-run set_rate_thresholds(), so they clear the saving-minute sets and their snapshots alongside the rates those describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…test isolation - compare.py: a tariff that reuses the live (already boosted) rates for a side now keeps that side's live saving-minute sets and pre-saving snapshots, captured by run_all(); only a side whose rates the tariff replaces is cleared. Clearing them unconditionally left #5050 in place inside compare (whole 30p day low rate). - set_rate_thresholds(): an empty rate table keeps that side's stored stats, as before, instead of scanning to the (99999, 0, 0) placeholder (export threshold 99998.9 vs -0.1 on main). - Tests: the manual export test sets its own pre-saving snapshots (it passed only on the previous test's leftovers); new automatic-mode export test; empty-table test; compare tests for both-replaced, one-side-replaced and reused paths; the ordering test now pins load_free_slot() and the export-side snapshots; nested helpers have docstrings; the rate_scan *_minute fields join the fixture snapshot. - Docs: note that both thresholds count event rewards above the tariff at the tariff's own rate, while free/discounted sessions stay cheap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5163 review) reset_sample_state() promises to clear every field a previous sample could leave behind, and already clears the rates and thresholds these four sit beside. _apply_rates() keeps its own clearing because _run_scenarios() installs a second tariff without coming back through the reset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly (#5163 review) fetch_rates() decided whether a tariff had installed its own rates by comparing the rate table against the deepcopy it started from. That held only because every rate source assigns a fresh dict and nothing copies the table in between. Each branch that installs a tariff's own rates now sets import_replaced/export_replaced, and both the #5286 dispatch-marker restore and the saving-minute clearing read those flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tats (#5163 review) The cap mapped every saving-tagged minute back to its pre-session rate, including a minute the user had since overridden. A fixed 50p put over a +50p session to discourage charging read as the 25.95p day rate, so the manual threshold dropped from 20.47 to 18.46. A new apply_rate_overrides() helper applies the overrides and manual rates to the live table and, when there are saving minutes, the same to the pre-saving snapshot, so the cap takes a session minute back to "these rates without the session". That covers fixed, increment and manual overrides alike. With no saving minutes nothing reads the snapshot, so the overrides are not applied (or logged) twice. compare.py applies a tariff's own override to a kept snapshot in the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ession minutes with no tariff rate (#5163 review) test_axle_event_on_flat_export_tariff_admits_ordinary_windows builds a +100p Axle export event through load_axle_slot() on a flat 20p tariff and runs the fetch_sensor_data() sequence (set_rate_thresholds, rate_scan_window, ratchet). With the saving state the threshold is 19.9 and the windows before the event are admitted; a control run without it reproduces the unfixed 20.5p / event-windows-only / ratchet-to-99 behaviour (#4036, #5221). rate_minmax_excluding_saving() now ignores a saving minute that has no rate in the pre-saving tariff: load_axle_slot() builds such a minute from rate_dict.get(minute, 0), and counting the synthetic reward as a tariff rate brought the #5050 symptom back for that minute. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1d6272e to
46f2828
Compare
|
Posted by Claude on behalf of @chalfontchubby. Summary: this PR stops saving-session / Axle event boosts from distorting the automatic rate thresholds. Those thresholds decide which slots count as low-rate, which export windows the planner is offered, and when non-smart car charging runs, so one root cause shows up as several different symptoms. It fixes #5050 and #5237, and for the Axle clipping case reported on #4036 it restores the export windows that were being hidden. How it helps, issue by issue
Evidence for the Axle caseReplay of the 4 Sep debug file (v8.54.3 dump,
The metric is a cost, so lower is better: about 56p better for that day. Caveats: it is one file, the rebuild assumes New test A correction to our earlier comment on #5221On 24 Sep we said this PR gave a "correct" 20.5p threshold and that the ratchet then undid it. That was wrong: 20.5p is main's value. The check had called Behaviour change to be aware ofOn an event day the automatic import threshold now drops, for example from 329.5p to 29.5p in the 7p/30p tariff with a +300p session. Ordinary day-rate slots are no longer charge candidates on those days, so the planner will not charge at the day rate just to export into the event. The cheap night slots stay candidates. This is described in the PR body under "Trade-off worth a maintainer's view". Not covered
The latest commit on the branch adds the test above and makes a session minute that has no rate in the underlying tariff ( |
|
Posted by Claude on behalf of @chalfontchubby. Another real-world case to add to the table in the summary comment above: #5344.
This looks like a contributing factor to the "battery doesn't charge enough overnight while the car's on an IOG dispatch" shape reported there and on #5330 - not the whole story (there's a separate day-boundary and sort-tie-break issue we're tracking for those), but a clean, independently-confirmed hit on exactly the bug this PR describes. Given #5050, #5237, #4036/#5221, and now #5344 all trace back to the same unfixed threshold-vs-session-reward interaction, this seems worth prioritising for review/merge. |
|
Posted by Claude on behalf of @chalfontchubby. Another real-world case to add to the table in the summary comment above: #5392.
The main #5392 symptom (the house battery idle during the car's overnight slots) is a separate rounding bug, fixed in #5396. But the event-inflated This PR deliberately leaves |
|
Posted by Claude on behalf of @springfall2008. Thanks for the rework. I reviewed head 46f2828. The mechanism is sound and I have one concern that needs settling before merge, plus a few small points. Blocking: no pre-charge before a same-day sessionThe PR changes which slots reach the optimiser's candidate lists, and on a session day that removes every day-rate charge window. Measured on this branch with a 7p/30p import tariff, 15p export, a +300p session at 17:30-18:30 and the time set to 10:00:
So when a session is announced after the night rate has passed, the planner has no charge window before it, and charging at 30p to export at 315p is no longer possible. A session known the day before is unaffected, because the night slot is still a candidate. You raised this under "Trade-off worth a maintainer's view" and described it as a side effect of the inflated threshold. I read it as documented behaviour: A single threshold cannot express "cheap slots, plus anything before the event", so I see two ways forward:
My preference is option 2, at the cost of a second mechanism beside the threshold. If you think it brings #5050 back in some form, or is not worth the complexity, say so and we can go with option 1. What I checked and found sound
Minor
|
…ort event (#5163 review) The tariff-only threshold removed every day-rate charge window on a saving session day. A session announced after the night rate had passed then got no pre-charge, which docs/energy-rates.md promises. On the review's case (7p/30p import, 15p export, a +300p session at 17:30, time 10:00) main offered 15 windows before the session and this branch offered none. set_rate_thresholds() now looks for the last export event whose price beats the tariff's own highest import price. find_low_rate_windows() gives the plan every import window up to that event's export price that ends before it starts, and the tariff-only windows after it. That case is back to 15 windows before the session. Day-rate windows after the session are no longer offered, and an event paying less than the import price (#5237's 9.375p session) widens nothing. The low rate sensors keep the tariff-only windows (low_rates_tariff), so the pre-event day rate does not turn binary_sensor.predbat_low_rate_slot on (#5050). compare runs the same method. rate_scan_window() takes an optional start minute for the scan after the event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d scan windows as fetch does (#5163 review) Debug files written before #5163 carry no saving minutes or pre-saving snapshots, so a replay on the shared fixture could pick up a previous test's values. Reset them as rate_max_base already is. --redo now calls find_low_rate_windows(), so the replay offers the same pre-event windows as fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t does, and note the ordering test matches source text (#5163 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…threshold on tariff windows, and leave a manual threshold alone (#5163 review) From the review of the pre-event windows: - A window whose import price carries on into the event (an export-only event, a high combine_rate_threshold, or an override flattening the event) was dropped whole, taking the pre-charge with it. It is now cut at the event start. - The automatic tightening took the highest pre-event average, so on an event day the published import threshold, the plan colouring and the dispatch timeline read the day rate as cheap. It now follows the tariff's own windows. - Car charging was planned on the plan's windows, so a car without smart planning could charge at the day rate ahead of an export event it cannot export into. Live and compare now plan the car on low_rates_tariff. - A manual rate_low_threshold is the user's cap, so the pre-event windows only apply in automatic mode. The docs say that a manual threshold can mean no charge ahead of an otherwise profitable event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s PR added (#5163 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
review) A saving session on a 0p export tariff only raises the import rate, because fetch adds it to export only when export pays. The pre-event windows only looked at export session minutes, so these users lost the pre-charge that main gives them: charging at the day rate beforehand to avoid importing at the session price. find_event_pre_charge() replaces find_export_event_pre_charge(). A session minute qualifies when its import or its export price beats the tariff's own highest import rate, and the pre-event windows go up to the best such price. Free sessions and discounted Axle import events lower the price, so they never qualify. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… progress, and keep the pre-session rates lean (#5163 review) From the second round of reviews: - Pre-event windows were scanned at the event's own price, so with two sessions in the horizon the earlier session's 330p minutes were offered as charge windows. They are now scanned at the tariff's highest import rate + 0.1p - main's "export beats import" rule, restricted to before the event. Every tariff slot qualifies and no event minute does. - A session already running was treated as starting now, which logged a pre-charge on every cycle of the session and scanned for nothing. Only events still to start count, and the log line is written only when windows are actually offered. - apply_rate_overrides() re-ran basic_rates() on the pre-session rates every cycle with a session, logging each time, even with no overrides configured. It now does so only when there is something to apply. - The pre-session snapshots were full per-minute rate tables, kept every cycle and written into every debug dump. Only their session minutes are kept now, the only ones read. rate_minmax_excluding_saving() goes straight to rate_minmax() when there are no session minutes. - energy-rates.md says a pre-charge needs the automatic threshold. The octopus.py dispatch timeline comment no longer claims the plan only charges at that threshold. The review history is gone from the test docstrings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the pre-event scan, and override the session rates on a whole table (#5163 review) From the review of 923dba9: - Events were found by price. An event whose import was not boosted (an export-only Axle event) could be offered as a charge window ahead of a later one, and a dip in an event's price split it in two, putting the "start" mid-event. Each run of consecutive session minutes is now one event, and price only decides whether it qualifies. The qualifying events' minutes are left out of the pre-event scan, so windows stop where any of them begins and the hand-written cut goes. The scan after the event starts at the event. - compare applied a tariff's override to the session-minutes-only snapshot. basic_rates() bounds and wraps override ranges by a table's last minute, so an all-day increment landed several times and an override starting after the session was skipped. override_session_rates() lays the snapshot over the rates to give basic_rates() a whole table, then cuts back to the session minutes. Fetch's apply_rate_overrides() and compare both use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows, and start the event at its first qualifying minute (#5163 review) From the review of 11c208d. Leaving whole events out of the pre-event scan was the wrong cut: - A free session running straight into a saving session merged with it into one event, so its 0p minutes were left out of the plan. - An event starting between 5-minute steps shifted every later window off the grid, and could leave a window a minute or two long. - An export-only event's slots were dropped if it was an earlier event, but offered if it was the last one. The pre-event scan now covers only the minutes before the grid step the event starts in, at the tariff's highest import rate + 0.1p. Windows end there by construction, and the scan after the event starts at the same grid step. An earlier event with raised import is above the tariff maximum, so price keeps it out. Any slot at a tariff import price is a candidate, as on main. An event starts at its first minute priced above the tariff, so a free session before it stays a window, and a price dip inside it still does not split it. override_session_rates() only returns session minutes that had a pre-session rate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iff's top import rate (#5163 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Posted by Claude on behalf of @chalfontchubby. Thanks for the review. You were right about the pre-charge. The Blocking point: I've gone with your option 2. In automatic mode the plan gets every tariff slot before the last qualifying session or event still to come, and the tariff's own cheap windows after it. An event qualifies when its export or import price beats the tariff's highest import rate, so import-only sessions on 0p export count too. The event minutes themselves are never offered as charge windows. The low-rate sensors and car plans keep the tariff-only windows, so #5050 stays fixed. On your case (7p/30p, 15p export, +300p at 17:30, time 10:00) the plan now has 15 windows before the session, the same as
One choice I'd like you to check: a manual Minor points:
The PV10 "dispatch gone" price was inflated by the Axle reward in #5392. That fix has moved to its own PR, #5411, stacked on this one. The PR description is rewritten to lead with the design. |
…ever below the slot's rate (#5392) In the PV10 worst case an Intelligent Octopus dispatch slot that may go away is priced at rate_max. rate_max includes any saving session or Axle reward, so in #5392 a +100p Axle event put every such minute at 132.25p - an event elsewhere in the day inflating a worst case it has nothing to do with. set_rate_thresholds() keeps the event-excluded import maximum from #5163 as rate_import_tariff_max, and Prediction uses it as its rate_max. A gone dispatch now pays the greater of that and the slot's own rate, so an event on the slot itself still counts and the worst case can never be cheaper than the nominal case. rate_scan() resets the tariff maximum to the raw one, so a rescan without fresh thresholds falls back to the old, higher price. A debug replay of a file without the field falls back to rate_max the same way. The same change is made in the C++ kernel, whose parity revision goes to 17; the checked-in kernel binaries are rebuilt by CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-max fix(prediction): price a gone dispatch at the tariff's own maximum, never below the slot's rate


🤖 This PR was written by Claude.
Fixes #5050
Replaces #5052 (closed). This is a clean reimplementation against current
main, not a rebase.Problem
A saving session or Axle VPP event raises the import and/or export rate by the event reward, and tags those minutes
"saving".set_rate_thresholds()worked its thresholds out fromrate_max/rate_min/rate_average, which include those boosted minutes. A two-rate tariff plus a large reward could then push the low-rate threshold above every real tariff rate, so the whole day read as "low rate". In #5050 that leftbinary_sensor.predbat_low_rate_sloton for about 24 hours.Design
1. Thresholds use the tariff's own prices.
rate_minmax_excluding_saving()caps each session minute down to the rate it would have without the session. User overrides on session minutes are kept. Capping only goes down: an event reward is removed, but a free or discounted session still counts as cheap. Both thresholds use these stats, in automatic and manual modes.rate_max/rate_min/rate_averagethemselves are unchanged, because dashboards, graph scaling and plan pricing want the real event price.2. The plan can still charge ahead of an event (your option 2). In automatic mode,
find_event_pre_charge()treats each run of consecutive session minutes (import or export) as one event. The event starts at its first minute whose export or import price is above the tariff's highest import rate.find_low_rate_windows()then gives the plan:rate_export_max > rate_maxrule from Add automatic Octopus saving session support (beta) #249 limited to before the event;Windows end at the event by construction. An earlier event whose import is raised is above the tariff maximum, so it is never offered as a charge window. Import-only sessions qualify, so a 0p export tariff still gets a pre-charge to cover the house through the session. A free session that runs straight into a saving session stays a cheap window before it. An event already running has nothing before it to charge in, so it doesn't count.
3. The sensors and car plans keep the tariff's own cheap windows. These go in a separate list,
low_rates_tariff. It feedsbinary_sensor.predbat_low_rate_slot, thepredbat.low_rate_*sensors and car charging plans, because a car cannot export. With no qualifying event it is the same list object aslow_rates, so ordinary days scan once, exactly as onmain. The automatic threshold tightening also follows the tariff's own windows, so on event days the published threshold and the dispatch timeline do not read the day rate as cheap.4. A manual
rate_low_thresholdis a cap. It is not widened for events, so with a manual threshold Predbat will not charge above it ahead of an event, even when that would pay.customisation.mdandenergy-rates.mdsay so.compare runs the same window logic. Per side, it keeps the live session minutes when it reuses the live rates and clears them when the tariff replaces them. annual always installs a different tariff, so it clears them.
On your review case (7p/30p import, 15p export, +300p session at 17:30-18:30 on both sides, time 10:00):
mainThe 19 windows
mainhas and this head does not are day-rate slots after the session. The same case with 0p export, so the session is on import only, gives the same numbers.Behaviour changes worth checking
main, the event inflated the average and so admitted day-rate slots. This is deliberate, see design point 4.mainthe inflated threshold admitted the whole day.main. The optimiser decides whether they pay.Changes since your review of 46f2828
run_single_debug()resets the saving-session fields before reading a dump, and--redousesfind_low_rate_windows()as fetch does.rate_minmax_excluding_saving()docstring is down to what it does. The review-history references are gone from the code comments and test docstrings.override_session_rates().The PV10 "dispatch gone" pricing change that was on this branch locally (the Axle reward inflating it, from #5392) has moved to its own PR, #5411, stacked on this one.
Testing
test_set_rate_thresholds.py: 32 tests. The new ones cover:./run_all --quickand--test debug_casesgreen../run_pre_commitgreen./code-review high. Their correctness findings are fixed. Not done:annual.pykeeps its own window scan. It never has session minutes, so it behaves identically, and_apply_rateshas a large blast radius.compare.run_all()does not restore the new fields afterwards, the same as the existinglow_rates.Scope and future work
This covers tagged events only. The general case, where a price threshold hides a whole untagged period (e.g. a 20p morning / 90p afternoon export tariff), is #5221. The pre-event step here could extend to "export before a forecast clipping period" in the same way.
🤖 Generated with Claude Code