Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ solisx
sourcery
Southwell
sparkline
Speshman
spki
springfall
starthour
Expand Down
14 changes: 14 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,20 @@
"default": False,
"enable": "expert_mode",
},
{
"name": "octopus_intelligent_limit_future_slots",
"friendly_name": "Only treat future Intelligent dispatch slots as low rate while the car still needs them",
"type": "switch",
"default": False,
"enable": "expert_mode",
},
{
"name": "octopus_slot_count_zero_kwh",
"friendly_name": "Count zero-kWh Intelligent dispatch slots towards the daily octopus_slot_max cap",
"type": "switch",
"default": False,
"enable": "expert_mode",
},
{
"name": "car_charging_plan_smart",
"friendly_name": "Car Charging Plan Smart",
Expand Down
13 changes: 13 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2694,6 +2694,19 @@ def fetch_config_options(self):
self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging")
self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged")
self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full")
self.octopus_intelligent_limit_future_slots = self.get_arg("octopus_intelligent_limit_future_slots")
if self.octopus_intelligent_limit_future_slots and self.octopus_intelligent_charging and not self.octopus_intelligent_consider_full:
self.log(
"Warn: switch.predbat_octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - "
"load_octopus_slots() never zeroes out the slots beyond what the car's real SoC/limit still needs, so this switch "
"has nothing to act on and future daytime IOG slots will be treated as low rate exactly as before. Turn "
"octopus_intelligent_consider_full On too for this to have any effect."
)
self.record_status(
"Warn: octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - has no effect",
had_errors=True,
)
self.octopus_slot_count_zero_kwh = self.get_arg("octopus_slot_count_zero_kwh")
self.car_energy_reported_load = self.get_arg("car_energy_reported_load")
self.get_car_charging_planned()
self.load_inday_adjustment = 1.0
Expand Down
89 changes: 85 additions & 4 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2823,6 +2823,22 @@ def load_saving_slot(self, octopus_saving_slots, rate_dict, export=False, rate_r
if not export:
self.load_scaling_dynamic[minute] = self.load_scaling_saving

def minute_in_iog_fixed_window(self, minute_abs):
"""
True if minute_abs (minutes-since-midnight-of-today, may be negative or beyond
forecast_minutes) falls within the fixed IOG off-peak window (23:30-05:30), which is
guaranteed cheap by the tariff itself, not by the dispatch mechanism - so a slot inside it
is never at risk of being reclaimed the way an out-of-window dispatch slot is (#4482).
"""
window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"]
start_minute = window["start"][0] * 60 + window["start"][1]
end_minute = window["end"][0] * 60 + window["end"][1]
minute_of_day = minute_abs % 1440
if window["cross_midnight"]:
return minute_of_day >= start_minute or minute_of_day < end_minute
else:
return start_minute <= minute_of_day < end_minute

def decode_octopus_slot(self, car_n, slot, raw=False):
"""
Decode IOG slot
Expand Down Expand Up @@ -3041,6 +3057,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
"""
octopus_slot_low_rate = self.get_arg("octopus_slot_low_rate", True)
octopus_slot_max = self.get_arg("octopus_slot_max", OCTOPUS_SLOT_MAX_DEFAULT)
limit_future_slots = self.octopus_intelligent_limit_future_slots

# Track slots per 24-hour period (keyed by day offset from midday)
# Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc.
Expand All @@ -3050,6 +3067,24 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
slots_added_set = set()
plan_interval_minutes = self.plan_interval_minutes
saved_slots = set() # For logging purposes, track which slots we actually applied as low rate
current_block = (self.minutes_now // 30) * 30

# #4482: Octopus often grants more daytime dispatch slots than the car actually needs - it
# can't see the car's real SoC, only Predbat can (car_charging_soc/car_charging_limit).
# load_octopus_slots() already caps car_charging_slots[car_n] at the car's real remaining
# requirement (when octopus_intelligent_consider_full is on), zeroing the kwh of any slot
# beyond that - so the 30-min blocks it still lists a positive kwh for are exactly the ones
# the car is still expected to draw on. A future block outside that set is heading for the
# same fate as a rescinded slot: Octopus will reclaim it once it notices the car has stopped
# drawing, so don't commit the house battery to it either.
expected_blocks = set()
if limit_future_slots:
for car_slot in self.car_charging_slots[car_n]:
if car_slot.get("kwh", 0) <= 0:
continue
block_start = (car_slot["start"] // 30) * 30
block_end = ((car_slot["end"] + 29) // 30) * 30
expected_blocks.update(range(block_start, block_end, 30))

if octopus_slots:
# Add in IO slots
Expand Down Expand Up @@ -3093,23 +3128,69 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
# Calculate the 30-min slot start for this minute
slot_start = (minute // 30) * 30

# A future out-of-window slot the car's own real SoC/limit shows it no
# longer needs (#4482) - only applies to slots that haven't started yet, a
# slot already underway or completed is trusted regardless of what
# car_charging_slots now says about future need, and the fixed window is
# never affected since it's guaranteed cheap by the tariff itself.
needed = (not limit_future_slots) or (slot_start <= current_block) or (slot_start in expected_blocks) or self.minute_in_iog_fixed_window(slot_start)

# Whether this dispatch entry actually delivers charge to the car. A
# zero-kWh entry (e.g. a plug-independent SMART grid-flex event - #4483
# review follow-up) is a real tariff discount Octopus is offering, but it
# isn't a car-charging dispatch: by default it doesn't compete for the
# octopus_slot_max budget (which models Octopus's own ~6-hour
# car-dispatch-per-day limit), and the #4482 "does the car still need
# this" question above doesn't apply either - there's no car draw to need.
# octopus_slot_count_zero_kwh restores the old behaviour of counting every
# dispatch entry, zero-kWh or not, toward the cap like any other.
zero_kwh_exempt = (kwh <= 0) and not self.octopus_slot_count_zero_kwh

# At the start of each 30-min slot, decide if we can add it
if minute % 30 == 0:
if slots_per_day[day_offset] < octopus_slot_max:
if zero_kwh_exempt:
slots_added_set.add(slot_start)
rates[minute] = assumed_price
elif needed and slots_per_day[day_offset] < octopus_slot_max:
slots_per_day[day_offset] += 1
slots_added_set.add(slot_start)
rates[minute] = assumed_price
else:
assumed_price = self.rate_max_base
# A slot rejected because the car doesn't need it (#4482,
# needed=False) must actively restore the ordinary out-of-window
# rate, not just skip adding a new low one. For a genuine Octopus
# Intelligent tariff, fetch_octopus_rates() can already receive the
# dispatch-discounted rate directly (rate_replicate() only
# gap-fills minutes with no real fetched value, so it never
# touches this one) - leaving rates[minute] alone here would keep
# that low rate live even though this slot was just rejected.
#
# A slot rejected purely because octopus_slot_max was already
# reached (needed is still True here) is left untouched, exactly as
# before this PR (#4483 review follow-up, Speshman): it may still
# be a genuine live dispatch/tariff event Predbat is simply
# choosing not to count against its own budget, not one Octopus is
# known to have rescinded, so overwriting it would be wrong.
if not needed:
rates[minute] = self.rate_max_base
self.io_adjusted.pop(minute, None)
else:
# For minutes within a 30-min slot, only apply if the slot was added
# For minutes within a 30-min slot, only apply if the slot was added,
# otherwise restore - matching the slot-start decision above.
# minute_data() (utils.py) sets self.io_adjusted for every minute in
# an adjusted block, not just its first, so the whole block must be
# cleared here too, not just slot_start.
if slot_start in slots_added_set:
rates[minute] = assumed_price
elif not needed:
rates[minute] = self.rate_max_base
self.io_adjusted.pop(minute, None)

if minute % 30 == 0 and start_minutes > -24 * 60:
self.log(
"Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}".format(
self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate
"Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, needed {}, zero_kwh_exempt {}".format(
self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, needed, zero_kwh_exempt
)
)

Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ def reset(self):
self.octopus_intelligent_charging = False
self.octopus_intelligent_ignore_unplugged = False
self.octopus_intelligent_consider_full = False
self.octopus_intelligent_limit_future_slots = False
self.octopus_slot_count_zero_kwh = False
self.notify_devices = ["notify"]
self.octopus_url_cache = {}
self.ge_url_cache = {}
Expand Down
56 changes: 54 additions & 2 deletions apps/predbat/tests/test_fetch_config_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,58 @@ def mock_expose_config(key, value):
# Restore num_cars for any tests appended after this one
mock_config.config["num_cars"] = 2

# Test 15: octopus_intelligent_limit_future_slots warns when octopus_intelligent_consider_full
# is off (#4482) - the switch would otherwise have nothing to act on, since
# load_octopus_slots() never zeroes out unneeded future slots without consider_full also on.
# Runs here, before the mocks are restored below, since it needs my_predbat.get_arg still
# pointed at mock_config to control octopus_intelligent_limit_future_slots/consider_full.
print("\n*** Test 15: octopus_intelligent_limit_future_slots warns without consider_full ***")

original_log = my_predbat.log
log_messages = []
my_predbat.log = lambda message: log_messages.append(message)

mock_config.config["octopus_intelligent_limit_future_slots"] = True
mock_config.config["octopus_intelligent_charging"] = True
mock_config.config["octopus_intelligent_consider_full"] = False

my_predbat.had_errors = False
my_predbat.fetch_config_options()

detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg]
assert len(detailed_warnings) == 1, "Should log the detailed warning exactly once, got {}".format(len(detailed_warnings))
assert my_predbat.had_errors is True, "Missing consider_full with limit_future_slots on should flag had_errors via record_status"

# Turning on octopus_intelligent_consider_full should silence the warning
log_messages.clear()
mock_config.config["octopus_intelligent_consider_full"] = True
my_predbat.had_errors = False

my_predbat.fetch_config_options()

detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg]
assert len(detailed_warnings) == 0, "Should not warn once octopus_intelligent_consider_full is on, got {}".format(detailed_warnings)
assert my_predbat.had_errors is False, "Should not flag had_errors once octopus_intelligent_consider_full is on"

# Switch off should also silence the warning even without consider_full configured
mock_config.config["octopus_intelligent_consider_full"] = False
mock_config.config["octopus_intelligent_limit_future_slots"] = False
log_messages.clear()
my_predbat.had_errors = False

my_predbat.fetch_config_options()

detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg]
assert len(detailed_warnings) == 0, "Should not warn when octopus_intelligent_limit_future_slots is off, got {}".format(detailed_warnings)
assert my_predbat.had_errors is False, "Should not flag had_errors when octopus_intelligent_limit_future_slots is off"

my_predbat.log = original_log
my_predbat.had_errors = False
mock_config.config["octopus_intelligent_limit_future_slots"] = False
mock_config.config["octopus_intelligent_consider_full"] = False

print("✓ octopus_intelligent_limit_future_slots warning test passed")

# Restore original methods
my_predbat.get_arg = original_get_arg
my_predbat.manual_times = original_manual_times
Expand All @@ -384,11 +436,11 @@ def mock_expose_config(key, value):
my_predbat.expose_config = original_expose_config
my_predbat.args = original_args

# Test 15: get_car_charging_planned resolves all 8 supported cars against the real
# Test 16: get_car_charging_planned resolves all 8 supported cars against the real
# CONFIG_ITEMS/config_index (not the MockConfigProvider above) - this is the exact code path
# that crashed with TypeError: float() argument must be a string or a real number, not
# 'NoneType' when num_cars exceeded the number of car_charging_rate_N entities defined.
print("\n*** Test 15: get_car_charging_planned resolves all 8 cars via real config_index ***")
print("\n*** Test 16: get_car_charging_planned resolves all 8 cars via real config_index ***")

original_num_cars = my_predbat.num_cars
my_predbat.num_cars = 8
Expand Down
Loading
Loading