Skip to content

Commit 04ed2d8

Browse files
feat(octopus): gate IOG dispatch-slot trust on a real car_charging_now sensor (#4482, #4516)
Octopus Intelligent daytime dispatch slots are provisional: Octopus can move or withdraw one before it happens, and allocates them from its own assumption of what the car needs, since it cannot see the car's real SoC. Predbat treats every granted slot as guaranteed cheap for the house battery, so it can make an irreversible decision - an early force export, or deferring a charge - in anticipation of a window that then disappears, or import during a slot the car never uses, which Octopus bills at the full peak rate. Two independent gates, which a future out-of-window slot must both clear: - switch.predbat_octopus_intelligent_limit_future_slots (default Off) - does the car's own remaining SoC requirement still want this slot? Requires octopus_intelligent_consider_full, and warns at startup if enabled without it. - select.predbat_trust_future_dynamic_iog_slots (default "planned") - will Octopus honour it? "none" trusts no dynamic slot; "completed" requires Octopus's own metered record; "started" also accepts a slot corroborated by car_charging_now; "planned" is the long-standing trust-everything behaviour. The fixed 23:30-05:30 window is never gated - it is cheap by tariff structure, not by dispatch. Both defaults preserve today's behaviour, so an upgrade changes no plan until a user opts in. This reworks the design of the closed PR #4885 onto current main. That PR was rejected on the grounds that distrusting a slot costs a house-battery discharge into the car while waiting on a sensor, and that "it takes 5-10 minutes based on a sensor to figure out if the car is really charging". Measured on a live system since: a watched car_charging_now change reaches a register write in 17-40s (update_time_loop polls every 15s, so it does not wait for the 5-minute scheduled cycle), and the inverter acts on it 36-65s later - so under two minutes end to end from a locally reported charging state, not 5-10. The second objection was false positives: inferring "the car is charging" from a load signature could take a kettle for the car. Hence the sensor gate here - "started" requires a real charger/car-reported car_charging_now for that car, and degrades to "completed" per car without one, rather than guessing. #4948 has since landed the dispatch-timeline diagnostic that PR also carried, so only the trust logic is new. Two details worth knowing: car_charging_now confirms a 30-minute settlement slot as a "trusted streak" rather than per reading. get_arg() re-samples live state on each replan instead of consuming the edge that woke it, so a charging pulse shorter than the wakeup-to-read latency can be missed entirely rather than merely seen late. A guard-passing positive read therefore starts a streak that rolls forward through following slots until an explicit False reading, which also tolerates a charger toggling at a settlement boundary - observed live as a readiness sensor flipping Charging->Ready->Charging within two minutes, negotiation noise rather than a real stop. The guard applies only to streak starts, so a reading in a slot's last few minutes cannot confirm the slot after it; a cap bounds how far a streak rolls without fresh evidence, so a missed negative edge degrades to untrusted rather than silently behaving as "planned". Both constants are judgement calls, not measured. Rejecting a slot actively restores rate_max_base rather than merely declining to discount it, because on a real Intelligent tariff fetch_octopus_rates() can deliver the discounted rate directly from the feed. exclude_dynamic_io_slots() does the same for a feed-side discount with no matching dispatch entry, consuming the trust decisions rate_add_io_slots() just made so the two cannot disagree. Both touch only minutes from now onwards: an elapsed minute records what the tariff actually charged, and rewriting it would inflate today_cost() without changing a plan that never looks behind minutes_now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8b9fe02 commit 04ed2d8

11 files changed

Lines changed: 1166 additions & 24 deletions

‎apps/predbat/config.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,21 @@
903903
"default": False,
904904
"enable": "expert_mode",
905905
},
906+
{
907+
"name": "octopus_intelligent_limit_future_slots",
908+
"friendly_name": "Only treat future Intelligent dispatch slots as low rate while the car still needs them",
909+
"type": "switch",
910+
"default": False,
911+
"enable": "expert_mode",
912+
},
913+
{
914+
"name": "trust_future_dynamic_iog_slots",
915+
"friendly_name": "When to trust a dynamic (daytime) Intelligent dispatch slot as cheap for the house battery",
916+
"type": "select",
917+
"options": ["none", "started", "planned"],
918+
"default": "planned",
919+
"enable": "expert_mode",
920+
},
906921
{
907922
"name": "car_charging_plan_smart",
908923
"friendly_name": "Car Charging Plan Smart",

‎apps/predbat/const.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@
7272
INVERTER_QUICK_UPDATE_SECONDS = 60 # Minimum seconds between quick inverter data updates, also the balance re-apply interval
7373
PREDBAT_MAX_CARS = 8 # Matches PK_MAX_CARS in prediction_kernel.cpp and the car_charging_rate/_1../_7 config items - the hard ceiling on num_cars
7474
CAR_CHARGING_LIMIT_UNCAPPED = 9999.0 # Model-facing car charge limit (kWh) that makes predict()'s fill clamp inert - larger than any real car battery (#4967)
75+
# The car_charging_now "trusted streak" that backs trust_future_dynamic_iog_slots's "started" level
76+
# (#4516) - see get_car_charging_planned() in fetch.py for the mechanism these two tune.
77+
# Minutes of a 30-min settlement slot that must remain for a True reading to start a streak, so a
78+
# reading in a slot's closing minutes can't confirm the slot that follows it. Comfortably above the
79+
# measured sensor-edge-to-replan latency (~15s poll, 17-40s to act).
80+
CAR_CHARGING_NOW_CONFIRM_GUARD_MINUTES = 3
81+
# How far a streak rolls forward without a fresh True read of its own before lapsing. Bounds the
82+
# damage if a negative edge is ever missed, which would otherwise leave "started" rolling forward
83+
# indefinitely and behaving as "planned". 4 slots covers a 2-hour BOOST. Untuned - a judgement call.
84+
CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS = 4
7585
DEBUG_ENABLE_MAX_HOURS = 2 # Auto-disable switch.predbat_debug_enable after this long left on, to bound the raw per-cycle debug.yaml disk writes it triggers (and the C++ kernel bypass it forces) if left on by accident - the rotating debug-history buffer covers longer-term history at a coarser interval instead
7686
# How far ahead a manual override may be placed. The two horizons differ on purpose: a manual
7787
# charge/export/freeze/demand slot is bounded by the plan, since Predbat can only act on a slot the

‎apps/predbat/fetch.py‎

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
LOAD_FORECAST_HISTORY_MAX_DAYS,
3333
PREDBAT_MAX_CARS,
3434
CAR_CHARGING_LIMIT_UNCAPPED,
35+
CAR_CHARGING_NOW_CONFIRM_GUARD_MINUTES,
36+
CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS,
3537
CLOUD_WINDOW_MINUTES,
3638
CLOUD_ARRAY_MARGIN,
3739
PV_ARRAY_KWP_UNKNOWN,
@@ -978,6 +980,13 @@ def fetch_sensor_data(self, save=True):
978980
self.rate_export_replicated = {}
979981
self.rate_slots = []
980982
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()
981990
self.low_rates = []
982991
self.high_export_rates = []
983992
self.octopus_slots = [[] for _ in range(self.num_cars)]
@@ -1241,6 +1250,10 @@ def fetch_sensor_data(self, save=True):
12411250
self.rate_import_no_io = import_rates.copy()
12421251
for car_n in range(self.num_cars):
12431252
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)
12441257
self.load_saving_slot(self.octopus_saving_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated)
12451258
self.load_free_slot(self.octopus_free_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated)
12461259
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):
15001513
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))
15011514
self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True)
15021515

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.
15041519
if completed:
1505-
self.octopus_slots[car_n] += completed
1520+
self.octopus_slots[car_n] += [dict(slot, _confirmed=True) for slot in completed]
15061521
if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n]):
15071522
# 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]
15091524

15101525
# Extract vehicle data if we can get it
15111526
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):
25522567
return None
25532568
return self.car_charging_now[car_n]
25542569

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+
25552595
def get_car_charging_planned(self):
25562596
"""
25572597
Get the car attributes
@@ -2595,6 +2635,72 @@ def get_car_charging_planned(self):
25952635
charging_now = False
25962636
self.car_charging_now[car_n] = charging_now
25972637

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+
25982704
# Other car related configuration
25992705
self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False)
26002706
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):
31773283
self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging")
31783284
self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged")
31793285
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)
31803328
self.car_energy_reported_load = self.get_arg("car_energy_reported_load")
31813329
self.get_car_charging_planned()
31823330
self.load_inday_adjustment = 1.0

0 commit comments

Comments
 (0)