|
32 | 32 | LOAD_FORECAST_HISTORY_MAX_DAYS, |
33 | 33 | PREDBAT_MAX_CARS, |
34 | 34 | CAR_CHARGING_LIMIT_UNCAPPED, |
| 35 | + CAR_CHARGING_NOW_CONFIRM_GUARD_MINUTES, |
| 36 | + CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS, |
35 | 37 | CLOUD_WINDOW_MINUTES, |
36 | 38 | CLOUD_ARRAY_MARGIN, |
37 | 39 | PV_ARRAY_KWP_UNKNOWN, |
@@ -978,6 +980,13 @@ def fetch_sensor_data(self, save=True): |
978 | 980 | self.rate_export_replicated = {} |
979 | 981 | self.rate_slots = [] |
980 | 982 | self.io_adjusted = {} |
| 983 | + # Built up by rate_add_io_slots() (per car) as it decides which dynamic (out-of-window) IOG |
| 984 | + # dispatch minutes trust_future_dynamic_iog_slots trusts; consumed by |
| 985 | + # exclude_dynamic_io_slots() afterwards so both agree on the same trust decisions (#4516). |
| 986 | + # Cleared here, beside self.io_adjusted which it is compared against, so the two can never |
| 987 | + # desync: a minute trusted on a previous cycle must not keep protecting its io_adjusted |
| 988 | + # discount on this one, after the dispatch behind it may have been withdrawn. |
| 989 | + self.trusted_dynamic_minutes = set() |
981 | 990 | self.low_rates = [] |
982 | 991 | self.high_export_rates = [] |
983 | 992 | self.octopus_slots = [[] for _ in range(self.num_cars)] |
@@ -1241,6 +1250,10 @@ def fetch_sensor_data(self, save=True): |
1241 | 1250 | self.rate_import_no_io = import_rates.copy() |
1242 | 1251 | for car_n in range(self.num_cars): |
1243 | 1252 | import_rates = self.rate_add_io_slots(car_n, import_rates, self.octopus_slots[car_n]) |
| 1253 | + # #4516: undo any dynamic (out-of-window) IOG dispatch discount that arrived via the |
| 1254 | + # rate feed itself (self.io_adjusted) rather than rate_add_io_slots()'s own overlay - |
| 1255 | + # see exclude_dynamic_io_slots()'s docstring for why both sources need handling. |
| 1256 | + import_rates = self.exclude_dynamic_io_slots(import_rates) |
1244 | 1257 | self.load_saving_slot(self.octopus_saving_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated) |
1245 | 1258 | self.load_free_slot(self.octopus_free_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated) |
1246 | 1259 | load_axle_slot(self, self.axle_sessions, import_rates, export=False, rate_replicate=self.rate_import_replicated) |
@@ -1500,12 +1513,14 @@ def fetch_sensor_data_cars(self, save=True): |
1500 | 1513 | self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_slot may not be set correctly in apps.yaml".format(entity_id, car_n)) |
1501 | 1514 | self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True) |
1502 | 1515 |
|
1503 | | - # Completed and planned slots - merge from all cars |
| 1516 | + # Completed and planned slots - merge from all cars, tagging which record each came |
| 1517 | + # from for trust_future_dynamic_iog_slots. Copies rather than in-place mutation, |
| 1518 | + # since get_state_wrapper() may return a cached or shared list. |
1504 | 1519 | if completed: |
1505 | | - self.octopus_slots[car_n] += completed |
| 1520 | + self.octopus_slots[car_n] += [dict(slot, _confirmed=True) for slot in completed] |
1506 | 1521 | if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n]): |
1507 | 1522 | # We only count planned slots if the car is plugged in or we are ignoring unplugged cars |
1508 | | - self.octopus_slots[car_n] += planned |
| 1523 | + self.octopus_slots[car_n] += [dict(slot, _confirmed=False) for slot in planned] |
1509 | 1524 |
|
1510 | 1525 | # Extract vehicle data if we can get it |
1511 | 1526 | size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh") |
@@ -2552,6 +2567,31 @@ def get_car_charging_now_tristate(self, car_n): |
2552 | 2567 | return None |
2553 | 2568 | return self.car_charging_now[car_n] |
2554 | 2569 |
|
| 2570 | + def has_car_charging_now_sensor(self, car_n): |
| 2571 | + """ |
| 2572 | + True if car_n has a real car_charging_now sensor configured in apps.yaml. |
| 2573 | +
|
| 2574 | + Deliberately reads raw config presence (self.args) rather than the resolved |
| 2575 | + self.car_charging_now list: the resolved list defaults every car to False, so it can't |
| 2576 | + distinguish "configured, and the car isn't charging" from "never configured at all", and |
| 2577 | + fetch_sensor_data_cars() needs this answer before get_car_charging_planned() has built it. |
| 2578 | +
|
| 2579 | + Single source of truth for the trust_future_dynamic_iog_slots "started" sensor gate, called |
| 2580 | + from both the startup warning (fetch_sensor_data_cars()) and the actual per-car gate |
| 2581 | + (rate_add_io_slots(), octopus.py) so the two can never drift apart (#4516, #4885 review). |
| 2582 | + """ |
| 2583 | + configured = self.args.get("car_charging_now", None) |
| 2584 | + if configured is None: |
| 2585 | + return False |
| 2586 | + if not isinstance(configured, list): |
| 2587 | + # A scalar (un-indexed) value applies to every car, not just car 0: resolve_arg() only |
| 2588 | + # indexes when the configured value is itself a list (userinterface.py - "isinstance( |
| 2589 | + # value, list) and index is not None"), so a single sensor resolves identically for |
| 2590 | + # every car_n. Returning car_n == 0 here would wrongly degrade car 1+ to "none" on |
| 2591 | + # a single-sensor multi-car install whose sensor does in fact cover them. |
| 2592 | + return True |
| 2593 | + return car_n < len(configured) and configured[car_n] not in (None, "") |
| 2594 | + |
2555 | 2595 | def get_car_charging_planned(self): |
2556 | 2596 | """ |
2557 | 2597 | Get the car attributes |
@@ -2595,6 +2635,72 @@ def get_car_charging_planned(self): |
2595 | 2635 | charging_now = False |
2596 | 2636 | self.car_charging_now[car_n] = charging_now |
2597 | 2637 |
|
| 2638 | + # Record which 30-min IOG settlement slots car_charging_now corroborates, as a "trusted |
| 2639 | + # streak". rate_add_io_slots()'s "started" trust level reads the resulting |
| 2640 | + # car_charging_now_confirmed_slots rather than car_charging_now directly, because |
| 2641 | + # get_arg() re-samples live state on each replan instead of consuming the edge that woke |
| 2642 | + # it: a charging pulse shorter than the ~15-40s wakeup-to-read latency can revert before |
| 2643 | + # any replan reads it, and so be missed entirely rather than merely seen late. |
| 2644 | + # |
| 2645 | + # Per read, given a streak's anchor (the slot of its last guard-passing True read): |
| 2646 | + # |
| 2647 | + # True, >guard left in its slot -> confirm this slot, re-anchor here |
| 2648 | + # True, <=guard left -> no change; an active streak rolls on below |
| 2649 | + # False -> end the streak immediately |
| 2650 | + # |
| 2651 | + # then backfill anchor..current (capped) so slots no replan landed in are still covered. |
| 2652 | + # |
| 2653 | + # Rolling forward rather than demanding a fresh read per slot tolerates a charger toggling |
| 2654 | + # at a settlement boundary (seen live: a readiness sensor flipping Charging->Ready->Charging |
| 2655 | + # within two minutes, negotiation noise rather than a real stop), which would otherwise |
| 2656 | + # leave a mid-dispatch slot unconfirmed. |
| 2657 | + # |
| 2658 | + # The guard is deliberately asymmetric - it gates streak starts but not ends. A False |
| 2659 | + # reading ends a streak wherever in the slot it lands, because a momentary False-then-True |
| 2660 | + # flip costs only the minutes between the two reads (the True half starts a fresh streak), |
| 2661 | + # whereas guarding the end would delay noticing a real stop. The rollover cap, not a |
| 2662 | + # closing-minutes guard, is what bounds a missed negative edge. |
| 2663 | + # State is pre-sized for PREDBAT_MAX_CARS in PredBat.__init__, and num_cars is clamped to |
| 2664 | + # that (#4533), so it never needs growing here. |
| 2665 | + current_slot_start = (self.minutes_now // 30) * 30 |
| 2666 | + current_slot_end = current_slot_start + 30 |
| 2667 | + slot_reading_confirms = charging_now and (current_slot_end - self.minutes_now) > CAR_CHARGING_NOW_CONFIRM_GUARD_MINUTES |
| 2668 | + |
| 2669 | + # Read the anchor before this cycle's reading moves it, so the backfill spans from the |
| 2670 | + # streak's original evidence rather than collapsing onto the slot just confirmed. |
| 2671 | + streak_anchor = self.car_charging_now_streak_last_read[car_n] |
| 2672 | + |
| 2673 | + if slot_reading_confirms: |
| 2674 | + self.car_charging_now_confirmed_slots[car_n].add(current_slot_start) |
| 2675 | + self.car_charging_now_streak_last_read[car_n] = current_slot_start |
| 2676 | + elif not charging_now: |
| 2677 | + # Clearing the anchor too skips the backfill, so nothing is confirmed on the strength |
| 2678 | + # of a streak this read just ended. |
| 2679 | + streak_anchor = None |
| 2680 | + self.car_charging_now_streak_last_read[car_n] = None |
| 2681 | + # The third case (True inside the guard window) intentionally falls through untouched. |
| 2682 | + # Corollary worth knowing: if a streak has lapsed and every subsequent positive read lands |
| 2683 | + # inside the guard window, no new streak starts and "started" silently behaves as |
| 2684 | + # "none" for that car. It needs replans to have all but stopped for a whole slot, so |
| 2685 | + # it is unlikely at the ~15s poll cadence, and it fails safe (under-trusting, not over-). |
| 2686 | + |
| 2687 | + # Confirm every slot from the anchor to now, so slots no replan happened to land in are |
| 2688 | + # still covered. Idempotent. |
| 2689 | + if streak_anchor is not None: |
| 2690 | + slots_since_anchor = (current_slot_start - streak_anchor) // 30 |
| 2691 | + if 0 <= slots_since_anchor <= CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS: |
| 2692 | + # + 30 rather than + 1: range()'s stop is exclusive and a step is a whole slot. |
| 2693 | + for backfill_slot in range(streak_anchor, current_slot_start + 30, 30): |
| 2694 | + self.car_charging_now_confirmed_slots[car_n].add(backfill_slot) |
| 2695 | + else: |
| 2696 | + # Cap exceeded with no fresh evidence - lapse the streak, but leave slots already |
| 2697 | + # confirmed while it was valid alone. |
| 2698 | + self.car_charging_now_streak_last_read[car_n] = None |
| 2699 | + |
| 2700 | + # Prune old slots so this can never grow unbounded over a long-running install - anything |
| 2701 | + # more than a day behind the current slot is of no further use to rate_add_io_slots(). |
| 2702 | + self.car_charging_now_confirmed_slots[car_n] = {s for s in self.car_charging_now_confirmed_slots[car_n] if s >= current_slot_start - 24 * 60} |
| 2703 | + |
2598 | 2704 | # Other car related configuration |
2599 | 2705 | self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False) |
2600 | 2706 | self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0) |
@@ -3177,6 +3283,48 @@ def fetch_config_options(self): |
3177 | 3283 | self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging") |
3178 | 3284 | self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged") |
3179 | 3285 | self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full") |
| 3286 | + self.octopus_intelligent_limit_future_slots = self.get_arg("octopus_intelligent_limit_future_slots") |
| 3287 | + if self.octopus_intelligent_limit_future_slots and self.octopus_intelligent_charging and not self.octopus_intelligent_consider_full: |
| 3288 | + self.log( |
| 3289 | + "Warn: switch.predbat_octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - " |
| 3290 | + "load_octopus_slots() never zeroes out the slots beyond what the car's real SoC/limit still needs, so this switch " |
| 3291 | + "has nothing to act on and future daytime IOG slots will be treated as low rate exactly as before. Turn " |
| 3292 | + "octopus_intelligent_consider_full On too for this to have any effect." |
| 3293 | + ) |
| 3294 | + self.record_status( |
| 3295 | + "Warn: octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - has no effect", |
| 3296 | + had_errors=True, |
| 3297 | + ) |
| 3298 | + self.trust_future_dynamic_iog_slots = self.get_arg("trust_future_dynamic_iog_slots") |
| 3299 | + if self.trust_future_dynamic_iog_slots == "started": |
| 3300 | + # "started" needs a genuine car-reported sensor behind it (see rate_add_io_slots()). |
| 3301 | + # Without one it falls back to "none" for that car (checked again, from scratch, inside |
| 3302 | + # rate_add_io_slots() itself - this block is only the one-time startup warning, not the |
| 3303 | + # actual gate) rather than guessing "is the car charging" from some other live signal |
| 3304 | + # (e.g. inferring it from a load-power jump), which is exactly the false-positive risk |
| 3305 | + # (a kettle mistaken for the car) that blocked this feature in review (#4885) - |
| 3306 | + # car_charging_now is deliberately a distinct, charger/car-reported sensor |
| 3307 | + # (docs/car-charging.md), not a load-signature guess. |
| 3308 | + # Checked here, ahead of get_car_charging_planned() below, against raw config presence |
| 3309 | + # rather than the resolved self.car_charging_now list, which get_car_charging_planned() |
| 3310 | + # hasn't built yet this cycle. Per-car: a multi-car install may have the sensor for some |
| 3311 | + # cars and not others, so each car's warning (and its actual fallback, in |
| 3312 | + # rate_add_io_slots()) is independent rather than the whole install falling back together. |
| 3313 | + for car_n in range(max(self.num_cars, 1)): |
| 3314 | + if not self.has_car_charging_now_sensor(car_n): |
| 3315 | + if car_n not in self.trust_iog_no_sensor_warned: |
| 3316 | + self.log( |
| 3317 | + "Warn: select.predbat_trust_future_dynamic_iog_slots is 'started' for car {} but car_charging_now is not configured for it in apps.yaml - " |
| 3318 | + "'started' needs a real charger/car-reported sensor to trust the current dispatch slot early, so this car will behave as 'none' " |
| 3319 | + "(no daytime dispatch slot trusted) until car_charging_now is set for it.".format(car_n) |
| 3320 | + ) |
| 3321 | + self.record_status( |
| 3322 | + "Warn: trust_future_dynamic_iog_slots is 'started' for car {} but car_charging_now is not configured - behaves as 'none'".format(car_n), |
| 3323 | + had_errors=True, |
| 3324 | + ) |
| 3325 | + self.trust_iog_no_sensor_warned.add(car_n) |
| 3326 | + else: |
| 3327 | + self.trust_iog_no_sensor_warned.discard(car_n) |
3180 | 3328 | self.car_energy_reported_load = self.get_arg("car_energy_reported_load") |
3181 | 3329 | self.get_car_charging_planned() |
3182 | 3330 | self.load_inday_adjustment = 1.0 |
|
0 commit comments