Skip to content

Commit f8c71d8

Browse files
fix(octopus): make IOG slot trust independent of entry and car order
The trust gating added by this PR decided a 30-minute block from whichever dispatch entry the Octopus feed happened to list first, and whichever car fetch.py happened to loop over first. Both are arbitrary, and both could price a genuine dispatch at the day rate. Found by review on #5110 and by successive cold reviews of the fix itself. Entry order. Several entries can cover one block - Octopus lists a planned dispatch and, later, a completed record for it - and `_confirmed` is the only per-ENTRY input to `trusted`, since car_charging_now_confirmed_slot_starts is keyed on slot_start and therefore shared. slots_decided made the first entry visited decide the block permanently, and saved_slots then skipped the later entry's minutes before it could be consulted. So an unconfirmed planned entry rejected a block its own completed record proved had happened. Confirmation is now resolved across all entries before any is applied. Car order. rate_add_io_slots() runs once per car into one shared rates dict, and its reject path rewrites the rate AND drops the io_adjusted marker. The guard against undoing another car's dispatch read trusted_dynamic_minutes, which that same loop builds as it goes - so a rejecting car running first saw it empty, popped the marker, and nothing anywhere restores one. The rate was later restored but the marker was not, dropping the block out of plan.py's IOG skew and prediction.py's PV10 hedge. resolve_protected_dispatch_minutes() now resolves the shield across every car before the loop begins. One decision, one implementation. car_trusted_dispatch_blocks() is the single per-car answer to "which blocks does this car have a genuine dispatch for", used by both the shield and the loop's own gates. An earlier version of the shield re-derived a looser variant alongside the loop - it dropped the `needed` gate at "planned" and skipped the per-car degrade to "none" for a car with no real car_charging_now sensor - shielding blocks the loop itself rejects, which would have kept a rescindable slot cheap. That is the #4516 failure this PR exists to prevent. assumed_price reads rate_import_no_io, the snapshot fetch takes before the per-car loop, rather than the working rates dict this function mutates. On main nothing ever wrote rate_max_base into rates so the read-back was safe; this PR's restore path made it reachable, letting one car's rejection be read back by the next car as though it were the tariff price. trusted_dynamic_minutes records the whole block when `needed and trusted` held, regardless of the octopus_slot_max cap. The cap budgets what Predbat PLANS - it is documented per-car while the rates dict is install-wide - not what it believes the price to be. Gating the record on the cap meant exclude_dynamic_io_slots() stripped the very minutes the #4483 cap-only exemption had just protected, on any install whose feed delivers the discount. Test 39 asserted that bookkeeping directly and is rewritten to assert the user-visible outcome, with io_adjusted markers it previously lacked. Also corrects an end_minutes clamp that used forecast_minutes where its three siblings use forecast_minutes + minutes_now. forecast_minutes is a duration, so the clamp truncated roughly minutes_now of the horizon; pre-existing, but this PR added the consumer that makes it strip a real dispatch's discount. Tests 45-51 cover entry order, cap accounting under duplication, the tariff read-back, car-order equivalence including the io_adjusted marker, shield breadth in both directions, and the reject path against a discount distinguishable from rate_max_base - most existing tests run on a baseline equal to rate_max_base, so the restore was invisible to them. Each was confirmed to fail against the behaviour it pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8575ffa commit f8c71d8

4 files changed

Lines changed: 522 additions & 39 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,11 @@ def fetch_sensor_data(self, save=True):
987987
# desync: a minute trusted on a previous cycle must not keep protecting its io_adjusted
988988
# discount on this one, after the dispatch behind it may have been withdrawn.
989989
self.trusted_dynamic_minutes = set()
990+
# Minutes some car has a genuine dispatch for, resolved across every car before the per-car
991+
# rate_add_io_slots() loop begins - see resolve_protected_dispatch_minutes(). Distinct from
992+
# trusted_dynamic_minutes, which that loop builds as it goes and exclude_dynamic_io_slots()
993+
# consumes afterwards; this one exists so no car's rejection can depend on running order.
994+
self.protected_dispatch_minutes = set()
990995
self.low_rates = []
991996
self.high_export_rates = []
992997
self.octopus_slots = [[] for _ in range(self.num_cars)]
@@ -1248,6 +1253,14 @@ def fetch_sensor_data(self, save=True):
12481253
self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates)
12491254
import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True)
12501255
self.rate_import_no_io = import_rates.copy()
1256+
# Resolve which minutes any car has a genuine dispatch for BEFORE the per-car loop
1257+
# mutates anything. rate_add_io_slots() runs once per car into this one shared dict, and
1258+
# its reject path both rewrites the rate and drops the io_adjusted marker - so a car
1259+
# that rejects a block must not undo a different car's real dispatch at the same minute.
1260+
# Computing the shield up front makes that independent of the order the cars run in;
1261+
# consulting the set the loop itself builds meant a rejecting car running first saw it
1262+
# empty, and nothing ever restores a dropped marker (#5110 review).
1263+
self.protected_dispatch_minutes = self.resolve_protected_dispatch_minutes()
12511264
for car_n in range(self.num_cars):
12521265
import_rates = self.rate_add_io_slots(car_n, import_rates, self.octopus_slots[car_n])
12531266
# #4516: undo any dynamic (out-of-window) IOG dispatch discount that arrived via the

0 commit comments

Comments
 (0)