fix(fetch): exclude saving-session/Axle boosted minutes from automatic rate thresholds (#5050) - #5052
chalfontchubby wants to merge 9 commits into
Conversation
…c rate thresholds (#5050) A saving session / Axle VPP event boosts both the import and export rate tables by the event reward, tagging those minutes "saving" in rate_import_replicated/rate_export_replicated (load_saving_slot() in octopus.py, load_axle_slot() in axle.py). In automatic threshold mode (rate_low_threshold=0), set_rate_thresholds() computed rate_import_cost_threshold from self.rate_max, which includes those event-boosted minutes. On a two-rate tariff (25.95p day / 3.49p night) plus a +100p saving-session event, that pushed the threshold to 125.45p, and rate_scan_window() classified the whole ordinary-price day as "low rate" - binary_sensor.predbat_low_rate_slot stuck ON for ~24h, through the genuine 3.49p night window and the whole expensive day rate, as reported. Added rate_minmax_excluding_saving() and used it for the threshold-stat inputs in set_rate_thresholds() only. self.rate_min/rate_max/rate_average (and the export equivalents) are deliberately left untouched - they feed dashboard sensors, graph scaling, and plan.py pricing, where the real boosted price is exactly what should be shown; this is a narrower, separate scan rather than a change to those. Verified against the exact triage reproduction (48h two-rate tariff, +100p event 17:00-19:00, rate_low_threshold=0): unfixed, rate_import_cost_threshold=125.45 and the 25.95p day blocks come back as low-rate windows; fixed, threshold=25.45 (true day rate - 0.5) and only the genuine 3.49p night rate qualifies. Confirmed the new tests fail against the unfixed wiring and pass with the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical and moderate review findings remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes automatic rate-threshold misclassification caused by saving-session/Axle price boosts.
Changes:
- Excludes saving-event minutes from threshold statistics.
- Preserves boosted rates for dashboards and planning.
- Adds and registers regression tests.
File summaries
| File | Summary | Findings |
|---|---|---|
apps/predbat/unit_test.py |
Registers the new tests. | — |
apps/predbat/tests/test_set_rate_thresholds.py |
Adds threshold regression coverage. | Moderate (3 votes): shared fixture state is not restored, making tests order-dependent. |
apps/predbat/fetch.py |
Adds saving-excluded rate statistics and applies them to thresholds. | Critical (1 vote): genuine 0p free sessions tagged saving are filtered out, potentially misclassifying rates (lines 2294 and 2396). Moderate (1 vote): export saving-event and manual-threshold coverage is missing. |
Review details
Suppressed comments (2)
apps/predbat/fetch.py:2397
- The new tests exercise only an import boost;
rate_export_replicatedis empty and no case coversrate_high_threshold > 0. This branch changes both automatic and manual export threshold statistics, so add an export saving-event case that asserts the computed export threshold while leavingrate_export_maxunchanged.
rate_export_min, rate_export_max, rate_export_average = self.rate_minmax_excluding_saving(self.rate_export, self.rate_export_replicated)
apps/predbat/fetch.py:2397
set_rate_thresholds()is also called when only one rate table exists (the fetch path guards withif self.rate_import or self.rate_export). For an empty side,rate_minmax_excluding_saving({})falls back to(99999, 0, 0), so an import-only configuration now publishes an export threshold of99998.9, and an export-only configuration compares against an import minimum of99999instead of the existingself.rate_*values. Filter only non-empty tables or retain the existing side's statistics when that table is absent.
rate_min, rate_max, rate_average = self.rate_minmax_excluding_saving(self.rate_import, self.rate_import_replicated)
rate_export_min, rate_export_max, rate_export_average = self.rate_minmax_excluding_saving(self.rate_export, self.rate_export_replicated)
- Files reviewed: 3/3 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.
…old stats (Copilot review on #5052) The "saving" tag on rate_replicate marks two economically opposite things with the same string: a saving-session/Axle event REWARD (load_saving_slot/load_axle_slot's export/reward branches, the #5050 case - a synthetic high that should not raise the automatic threshold), and a free/discounted import session (load_free_slot, load_axle_slot's import branch - a genuinely cheap slot the automatic threshold should be free to pick as "low rate"). rate_minmax_excluding_saving() excluded every "saving"-tagged minute regardless of which. With a flat tariff plus one free slot, that left only the flat rate - rate_max == rate_min - and set_rate_thresholds() took its "everything but the most expensive" branch, setting the threshold above every rate and making the whole ordinary-price day read as low-rate. Reproduced exactly with that scenario before fixing. Now two passes: the first, over untagged minutes only, sets a baseline max. The second excludes a "saving"-tagged minute only if its rate exceeds that baseline - the one shape a reward produces - and admits every other minute, tagged or not, including a "saving"-tagged discount at or below the baseline. Verified both scenarios hold simultaneously: the free-slot case now correctly gives rate_min=0.0/rate_max=20.0 (not 20.0/20.0), and the original #5050 two-rate-plus event-boost scenario still gives rate_max=25.95, not the inflated 125.95. Also fixed test_set_rate_thresholds.py's fixture leak (same review): _setup_two_rate_tariff() overwrote minutes_now, the rate tables/statistics, thresholds and num_cars directly on the shared PredBat fixture with no restore - unit_test.py passes one instance through the whole registry, so a later test would inherit this group's synthetic 48h tariff and faked midnight. Snapshot/restore around the whole group in a try/finally. Both mutation-tested: reverting the direction check reproduces the free-slot regression exactly; reverting the snapshot/restore reproduces the leak (minutes_now 720 -> 0 after the group runs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h_history (Copilot review on #5061) unit_test.py runs every registered test against one shared PredBat instance, but this test's cleanup reset soc_kwh_history to {} unconditionally rather than restoring whatever an earlier test may have left there - the same state-leak pattern already fixed in the #5052 review. Snapshot before mutating, restore what was actually there on cleanup. Verified directly: seeded soc_kwh_history with unrelated data before running the test, confirmed it comes back unchanged afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…minmax_excluding_saving tests (Copilot review on #5052) Every existing test in this file used rate_import_replicated only and left rate_high_threshold at 0 (automatic mode) - the export side (rate_export_replicated) and manual mode (rate_high_threshold > 0) were both genuinely untested. set_rate_thresholds()'s manual branch (rate_export_cost_threshold = rate_export_average * rate_high_threshold) multiplies by the average directly, so a boosted minute contaminating it would not be caught by the automatic-mode min/max comparisons the other tests already cover. Added a scenario with a flat 15p export tariff and a +50p Axle-style export event over 2h, rate_high_threshold=1.0 so the threshold equals whichever average was used. Verified directly before writing the assertion: the contaminated average is ~19.17p, the clean one exactly 15.0p, and set_rate_thresholds() correctly produces 15.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Posted by Rik; written by Claude.) Copilot's follow-up review flagged two more findings, both as suppressed comments rather than inline threads. Missing export-side / manual-mode test coverage - real gap, fixed in c435781. Every test here used Empty-table fallback producing |
There was a problem hiding this comment.
🟡 Changes recommended
Moderate issues remain in reward filtering, empty-table handling, and test-state restoration.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
apps/predbat/fetch.py:2425
- Calling the helper for an empty export table changes the existing no-export behavior.
rate_minmax({})returns(99999, 0, 0), whereas the initialized export stats are all zero;fetch.pystill callsset_rate_thresholds()when only import rates exist, so this makes the export branch produce a threshold of99998.9instead of the previous-0.1. Preserve the existing stored statistics when a rate table is empty (and likewise for import if that side is absent).
rate_min, rate_max, rate_average = self.rate_minmax_excluding_saving(self.rate_import, self.rate_import_replicated)
rate_export_min, rate_export_max, rate_export_average = self.rate_minmax_excluding_saving(self.rate_export, self.rate_export_replicated)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: chalfontchubby <48563392+chalfontchubby@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
An unresolved production-path filtering issue and a critical test expectation issue remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
apps/predbat/fetch.py:2334
- This relies on the replicate reason remaining
"saving", but the production fetch path appliesbasic_rates(..., prev=import_rates, rate_replicate=...)after loading saving/Axle sessions (fetch.py:1191-1195), and itsrate_incrementbranch rewrites affected minutes to"increment"(fetch.py:2213-2215). The documentation explicitly supports using an exportrate_incrementduring a saving session, so that supported combination bypasses this filter and leaves the boosted event price in the threshold statistics. Preserve the saving-event provenance separately from override tags, or otherwise distinguish the two sources.
if rate_replicate.get(minute) == "saving":
apps/predbat/tests/test_set_rate_thresholds.py:289
- The missing comma concatenates these adjacent string literals into
rate_high_thresholdalert_active_keep. The snapshot comprehension at line 306 then raisesAttributeErrorbefore any of the registered tests run, so this suite cannot pass as written. Add the comma afterrate_high_threshold.
"rate_high_threshold"
"alert_active_keep",
apps/predbat/tests/test_set_rate_thresholds.py:278
- The test helper also overwrites
rate_import_baseandrate_export_baseat lines 50 and 53, but neither field is included in this snapshot. Thefinallyblock therefore leaves the synthetic 48-hour base curves on the shared PredBat instance, allowing later tests to observe leaked base-rate state. Include both fields in the snapshot.
"rate_import",
"rate_import_replicated",
"rate_export",
"rate_export_replicated",
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…ate, fix a test-crashing typo, and a snapshot gap (Copilot review on #5052) Three findings from the latest Copilot review of the #5050 saving-session threshold-exclusion fix, all confirmed and fixed. rate_minmax_excluding_saving() could be defeated by a later, supported override ------------------------------------------------------------------------------------ It read rate_replicate.get(minute) == "saving" live, but the production fetch path calls load_saving_slot()/load_free_slot()/load_axle_slot() (which tag rate_replicate "saving") and then basic_rates()/apply_manual_rates() (whose rate_increment branch overwrites that same minute's tag to "increment"/"user") in that order - a documented, supported combination (an override active during a saving session). The boosted price then silently re-entered the threshold stats, since the "saving" provenance was already gone from rate_replicate by the time set_rate_thresholds() ran. Fixed by capturing a frozen rate_import_saving_minutes/rate_export_saving_minutes set immediately after the three loaders run and before any override can touch rate_replicate, and having rate_minmax_excluding_saving() read that set instead of the live, later-overwritable dict. rate_replicate itself is untouched - output.py still needs its final tag for display. A missing comma silently concatenated two snapshot field names ------------------------------------------------------------------ _SNAPSHOT_FIELDS in test_set_rate_thresholds.py had "rate_high_threshold" and "alert_active_keep" on adjacent lines with no comma between them, so Python's implicit string-literal concatenation merged them into one nonexistent attribute name - the snapshot comprehension raised AttributeError before any test in the module could run at all. Added the comma. Two fields the test helper mutates were missing from the snapshot ------------------------------------------------------------------ _setup_two_rate_tariff() sets rate_import_base/rate_export_base on the shared PredBat test instance but neither was in _SNAPSHOT_FIELDS, so the group's own restore left those synthetic 48h curves on the instance for every later test in the registry. Confirmed the leak directly (both fields differed from their original value after the group ran) and added them to the snapshot. All three verified with mutation testing: reverting each fix in turn reproduces exactly the failure its regression test describes (including a new test that reproduces the override-overwrite scenario directly), and only that test fails. ./run_all --quick and ./run_pre_commit both green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Posted by Rik; written by Claude.) Addressed all 3 findings from the latest review:
Fixed by capturing a frozen
All three verified with mutation testing. Full |
There was a problem hiding this comment.
🟡 Changes recommended
fetch.py has unresolved stale simulation state and an implementation-comment mismatch.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
apps/predbat/fetch.py:1201
- This explanation says
rate_minmax_excluding_saving()readsrate_replicatelive, but the helper explicitly consumes the frozenrate_import_saving_minutes/rate_export_saving_minutesset and never reads that dictionary. The contradiction makes the reason for taking the snapshot unclear and could lead a future change to remove the state protection; please align the comment with the implementation.
# rate_minmax_excluding_saving()'s exclusion, since it reads rate_replicate live and
# the "saving" provenance would already be gone by the time it runs (#5052 review).
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
…was explaining (Copilot review on #5052) The comment above the rate_import_saving_minutes snapshot still described rate_minmax_excluding_saving() as reading rate_replicate live and being fooled by a later overwrite - the exact problem the snapshot fixes, written before the fix existed and never updated once it landed. Reworded to describe what the code actually does now: the function reads the frozen snapshot precisely so a later overwrite can't erase the provenance it needs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
(Posted by Rik; written by Claude.) Fixed: the comment above the Pushed as |
…escribe rate_import_saving_minutes/rate_export_saving_minutes are populated only in fetch_sensor_data() and hold absolute minute offsets into the live tariff's rate tables. compare.fetch_rates() and annual._apply_rates() both replace rate_import/rate_export with a simulated tariff and then call set_rate_thresholds(), which reads those sets - so after a live cycle containing a saving session the stale offsets excluded whatever unrelated minutes happened to sit at the same positions in the simulated tariff from the min/max/average scan, skewing comparison and annual replay results. Live planning was unaffected. Reset both alongside the existing rate_low_threshold/rate_high_threshold resets in each, so the exclusion is a no-op unless the simulation itself tagged saving minutes. The test asserts on the source of each function rather than calling it: both drive the whole scan pipeline and rewrite ~24 fields on the shared my_predbat fixture (dashboard_values and the window lists among them), which leaked into optimise_levels when driven directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
This seems over complex, why not just calc the min/max from the pre-adjusted rates (which are already stored)? |
Posted by Claude on behalf of @chalfontchubby.
Fixes #5050.
Problem
A saving session / Axle VPP event boosts both the import and export rate tables by the event reward, tagging those minutes
"saving"inrate_import_replicated/rate_export_replicated(load_saving_slot()inoctopus.py,load_axle_slot()inaxle.py). In automatic threshold mode (rate_low_threshold=0),set_rate_thresholds()computedrate_import_cost_thresholdfromself.rate_max, which includes those event-boosted minutes.On a two-rate tariff (25.95p day / 3.49p night) plus a +100p saving-session event, that pushed the threshold to 125.45p, and
rate_scan_window()classified the whole ordinary-price day as "low rate" -binary_sensor.predbat_low_rate_slotstuck ON for ~24h, through the genuine 3.49p night window and the whole expensive day rate, matching the report exactly.Fix
Added
rate_minmax_excluding_saving()and used it for the threshold-stat inputs inset_rate_thresholds()only.self.rate_min/self.rate_max/self.rate_average(and the export equivalents) are deliberately left untouched - they feed dashboard sensors, graph scaling, andplan.pypricing, where the real boosted price is exactly what should be shown; this is a narrower, separate scan rather than a change to those.This also resolves the triage's point 2 (the in-cycle auto-correction only fires after
low_ratesis already computed with the wrong threshold) as a side effect:rate_scan_window()now gets the correct threshold on the first pass, soself.low_rates- what downstream low-rate sensors, car-charging slots, andplan.py's candidate set consume - is correct from the start rather than needing the post-hoc correction to paper over it.Verification
Reproduced against the exact triage numbers (48h two-rate tariff, +100p event 17:00-19:00,
rate_low_threshold=0):rate_import_cost_threshold=125.45, the 25.95p day blocks come back as low-rate windows.threshold=25.45(true day rate - 0.5), only the genuine 3.49p night rate qualifies.Three new tests in
test_set_rate_thresholds.py, confirmed to fail against the unfixed wiring and pass with the fix:rate_minmax_excluding_savingreturns the tariff's own min/max/average, skipping the boosted minutesfalls back to the plain scan when an event covers the entire forecast window (no genuine minute to scan)
end-to-end:
set_rate_thresholds()+rate_scan_window()no longer misclassify the day rate, whilerate_maxitself stays untouched for other consumers./run_all --quickgreen (310 tests)./run_pre_commitgreen🤖 Generated with Claude Code