diff --git a/apps/predbat/config.py b/apps/predbat/config.py index ce32a3171..a5753114f 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -915,6 +915,13 @@ "default": False, "enable": "expert_mode", }, + { + "name": "octopus_intelligent_trust_slots", + "friendly_name": "Trust Intelligent slots before the car is seen charging", + "type": "switch", + "default": True, + "enable": "expert_mode", + }, { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", diff --git a/apps/predbat/const.py b/apps/predbat/const.py index a752afd10..8d76cac99 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -24,6 +24,13 @@ TIME_FORMAT_SOLIS = "%Y-%m-%d %H:%M:%S" PREDICT_STEP = 5 +# Dynamic load: how long a car inside one of its charging slots must show "not charging" before its +# slots, and their cheap rate, are cancelled. car_charging_now reports the car directly, so two +# minutes (one charger/integration update after the slot starts) is enough. The load test reads a +# PREDICT_STEP average that the rest of the house also moves, so it needs two full periods. +DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2 +DYNAMIC_LOAD_CAR_LOAD_MINUTES = 10 + # Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the # downside case diverges harder than the central one CLOUD_FACTOR_PV10 = 0.2 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b2990ab31..0b0800f3e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1184,6 +1184,9 @@ def fetch_sensor_data(self, save=True): # Fetch sensor data for cars, e.g. car plan, car energy, car sessions etc. self.dispatch_timeline_pending = [] self.fetch_sensor_data_cars(save=save) + # Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging - + # before the rates are built, so a cancelled dispatch never gets its cheap rate + dynamic_load_car_changed = self.dynamic_load_car_check(save=save) if "rates_export_octopus_url" in self.args: # Fixed URL for rate export @@ -1237,6 +1240,7 @@ def fetch_sensor_data(self, save=True): if import_rates: self.rate_scan(import_rates, print=False) self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates) + import_rates = self.dynamic_load_car_strip_feed_rates(import_rates) import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True) self.rate_import_no_io = import_rates.copy() for car_n in range(self.num_cars): @@ -1367,7 +1371,7 @@ def fetch_sensor_data(self, save=True): else: self.load_inday_adjustment = 1.0 - force_replan = False + force_replan = dynamic_load_car_changed # Compare on the change-detection signature, not the raw slots, so the per-cycle re-clocking # of an in-progress dispatch (start advanced to now, energy scaled to remaining time) does not # force a replan every cycle while a slot is active - only genuine slot changes do @@ -3186,8 +3190,10 @@ 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_trust_slots = self.get_arg("octopus_intelligent_trust_slots") self.car_energy_reported_load = self.get_arg("car_energy_reported_load") self.get_car_charging_planned() + self.dynamic_load_car_check_config() self.load_inday_adjustment = 1.0 self.combine_rate_threshold = self.get_arg("combine_rate_threshold") diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index fa0fd31d5..08257c8e3 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3601,6 +3601,10 @@ 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 + # Dynamic load has seen this car in its slot but not charging: none of its dispatches from now + # on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record + # what the tariff charged, and today's cost is built from them. + car_cancelled = self.dynamic_load_car_effective.get(car_n, False) if octopus_slots: # Add in IO slots @@ -3638,6 +3642,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # is behaviour-preserving there and simply avoids the staleness this PR adds.) assumed_price = rates.get(start_minutes, self.rate_min) + if car_cancelled and minute >= self.minutes_now: + continue # Car is not charging, its dispatch is not trusted as cheap + if minute in saved_slots: continue # Already applied a low rate slot to this minute, skip else: @@ -3688,6 +3695,60 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): return rates + def dynamic_load_car_strip_feed_rates(self, rates): + """ + Remove an Octopus Intelligent discount the rate feed itself delivered for a dispatch of a car that + dynamic load has cancelled (see dynamic_load_car_check()). + + The feed marks dispatch minutes as is_intelligent_adjusted (self.io_adjusted) and has already + lowered their price, so unlike rate_add_io_slots()'s own overlay there is nothing to withhold - + the price has to be overwritten. Called before the rates are replicated and snapshotted, so + nothing downstream sees the discount. + + A minute is set back to rate_max_base, and loses its io_adjusted marker, only when it is from now + onwards, outside the fixed 23:30-05:30 window (cheap by tariff, not by dispatch), covered by a + cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are + shared by every car. + """ + cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)] + if not cancelled_cars or not self.io_adjusted: + self.dynamic_load_car_stripped = 0 + return rates + + # Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and + # rate_add_io_slots() likewise treats any overlap with a period as the whole period + cancelled_minutes = set() + trusted_minutes = set() + for car_n in range(min(self.num_cars, len(self.octopus_slots))): + covered = cancelled_minutes if car_n in cancelled_cars else trusted_minutes + for slot in self.octopus_slots[car_n]: + start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) + if start_minutes == end_minutes: + continue + start_minutes = (start_minutes // 30) * 30 + end_minutes = ((end_minutes + 29) // 30) * 30 + covered.update(range(max(start_minutes, self.minutes_now), end_minutes)) + + window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] + window_start = window["start"][0] * 60 + window["start"][1] + window_end = window["end"][0] * 60 + window["end"][1] + stripped = 0 + for minute in sorted(cancelled_minutes - trusted_minutes): + if not self.io_adjusted.get(minute, False): + continue + minute_of_day = minute % (24 * 60) + if minute_of_day >= window_start or minute_of_day < window_end: + continue + rates[minute] = self.rate_max_base + del self.io_adjusted[minute] + stripped += 1 + # The feed is re-read every cycle, so the same minutes are stripped again each time - log only + # when that changes, not every 5 minutes for the length of a cancellation + if stripped and stripped != self.dynamic_load_car_stripped: + self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars)) + self.dynamic_load_car_stripped = stripped + return rates + def fetch_octopus_rates(self, entity_id, adjust_key=None): """ Fetch the Octopus rates from the sensor diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 6b07344f4..c4ddec308 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -193,6 +193,8 @@ def publish_car_plan(self): show["kwh"] = kwh show["average"] = average show["cost"] = cost + if window.get("kwh_cancelled"): + show["kwh_cancelled"] = dp2(window["kwh_cancelled"]) total_cost += cost total_kwh += kwh plan.append(show) @@ -1602,13 +1604,23 @@ def import_rate_color(rate): # Car charging? car_rate = None + car_charging_cancelled = 0.0 if self.num_cars > 0: car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end) car_total += car_charging_kwh + # A slot dynamic load cancelled (the car is not charging) is not planned for, but is still + # shown with a "?" so the car's own plan stays visible - alongside another car's live + # charging in the same step, not only when no car is charging + car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end) if car_charging_kwh > 0.0: car_charging_str = str(car_charging_kwh) + if car_charging_cancelled > 0.0: + car_charging_str += " +" + str(car_charging_cancelled) + "?" car_color = "FFFF00" car_rate = self.car_charge_slot_rate(minute_start, minute_end) + elif car_charging_cancelled > 0.0: + car_charging_str = str(car_charging_cancelled) + "?" + car_color = "#FFFFCC" else: car_charging_str = "⚊" car_color = "#FFFFFF" @@ -1810,6 +1822,8 @@ def import_rate_color(rate): json_row["extra_color"] = extra_color if self.num_cars > 0: json_row["car_charging"] = car_charging_kwh + if car_charging_cancelled > 0.0: + json_row["car_charging_cancelled"] = car_charging_cancelled json_row["car_color"] = car_color json_row["car_rate"] = car_rate json_row["car_rate_color"] = car_rate_color if rate_split else None diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 98583f88f..7f2ba3928 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -23,6 +23,8 @@ from const import ( CLOUD_FACTOR_PV10, CLOUD_WINDOW_MINUTES, + DYNAMIC_LOAD_CAR_LOAD_MINUTES, + DYNAMIC_LOAD_CAR_SENSOR_MINUTES, PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, @@ -53,6 +55,8 @@ pack_export_limit, export_limit_exports_no_battery, export_limit_is_full_discharge, + is_entity_id, + minutes_since_midnight, ) from prediction import Prediction from prediction_kernel import kernel_status_summary, set_window_start @@ -226,20 +230,12 @@ def dynamic_load(self): Return True if load status has changed and hence we need to re-plan """ prev_last_load_status = self.load_last_status - prev_last_load_car_slot = self.load_last_car_slot threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000 threshold_car = self.car_charging_threshold * MINUTE_WATT / 1000 # Last period load analysis - self.load_last_status = "baseline" - if self.load_last_period >= threshold_battery: - self.load_last_status = "high" - elif (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < (threshold_car * 0.9)): - # Check if the load is less than car charging threshold - self.load_last_status = "low" - else: - self.load_last_status = "baseline" + self.load_last_status = self.dynamic_load_classify() # Update entity for last load self.dashboard_item( @@ -249,17 +245,6 @@ def dynamic_load(self): ) self.log("Dynamic load last period {:.2f}kW, status {}, threshold_battery {}kWh, threshold_car {}kWh,".format(self.load_last_period, self.load_last_status, threshold_battery, dp1(threshold_car))) - # Is the car currently planned to charge? - load_car_slot = False - if self.car_energy_reported_load: - for car_n in range(0, self.num_cars): - for slot_n in range(0, len(self.car_charging_slots[car_n])): - slot = self.car_charging_slots[car_n][slot_n] - # Don't include the exact start minute as it may take a few for the load to filter through - if slot["start"] <= self.minutes_now < slot["end"]: - load_car_slot = True - self.log("Dynamic load adjust sees car {} charging now slot {}-{}, previous car slot {}".format(car_n, slot["start"], slot["end"], self.load_last_car_slot)) - self.load_last_car_slot = load_car_slot self.dynamic_load_baseline = {} # Dynamic load baselines are stored as kWh per PREDICT_STEP. When the car is inside the @@ -295,21 +280,10 @@ def dynamic_load(self): if self.metric_dynamic_load_adjust: minutes_now = self.minutes_now minutes_end_slot = int((self.minutes_now + self.plan_interval_minutes) / self.plan_interval_minutes) * self.plan_interval_minutes - # When dynamic load is enabled we try can do two things - # 1. Increase the load prediction in the current self.plan_interval_minutes minute period to match the actual load (if the load is higher than expected), - # extending into the following period too once the load has been high for two consecutive checks in a row (mirrors the low-load debounce below) - # 2. If the load is low and car charging is predicted then cancel off future car slots - # Note never do this just after midnight due to the load sensor reset - if self.load_last_status == "low" and self.minutes_now > 5: - if load_car_slot and prev_last_load_car_slot: - for car_n in range(0, self.num_cars): - for slot_n in range(0, len(self.car_charging_slots[car_n])): - slot = self.car_charging_slots[car_n][slot_n] - if slot["end"] > minutes_now: - # If the slot is in the future - self.log("Dynamic load adjust is cancelling car {} slot {}-{} due to low load".format(car_n, slot["start"], slot["end"])) - self.car_charging_slots[car_n][slot_n]["kwh"] = 0 - + # When dynamic load is enabled, increase the load prediction in the current self.plan_interval_minutes minute period to match the + # actual load (if the load is higher than expected), extending into the following period too once the load has been high for two + # consecutive checks in a row. Cancelling the slots of a car that is not charging is done earlier in the cycle, before the rates + # are built, by dynamic_load_car_check(). if self.load_last_status == "high": have_printed = False minutes_end_baseline = minutes_end_slot @@ -330,6 +304,266 @@ def dynamic_load(self): return False + def dynamic_load_classify(self): + """ + Classify load_last_period as "high" (above the battery's discharge rate), "low" (below both the + battery rate and the car charging threshold, so no car can be charging) or "baseline". + + Shared by dynamic_load(), which runs after the inverter fetch, and dynamic_load_car_check(), which + runs before it and so uses the previous cycle's battery_rate_max_discharge - it barely moves + between cycles, and the car grace period spans several of them. + """ + threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000 + threshold_car = self.car_charging_threshold * MINUTE_WATT / 1000 + if self.load_last_period >= threshold_battery: + return "high" + if (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < (threshold_car * 0.9)): + return "low" + return "baseline" + + def dynamic_load_car_evidence(self, car_n): + """ + Whether car_n is not charging right now, and how long that must hold before its slots are cancelled. + + Returns (not_charging, grace_minutes) where not_charging is True (not charging), False (charging) + or None (no evidence either way). + + car_charging_now is used whenever it names a real entity - a static literal in apps.yaml can never + report the car stopping. An "unknown"/"unavailable" reading is no evidence, rather than a stop. + Without a sensor the low-load test stands in, but only when the car is inside the CT clamp + (car_energy_reported_load): otherwise its charging never shows in the load at all. The load test + is skipped just after midnight, when the load_today sensor resets. + """ + entity_id = self.dynamic_load_car_sensors.get(car_n) + if entity_id: + # Read the entity cached by dynamic_load_car_refresh_sensors() rather than get_arg(index=...): + # the poll calls this every 15 seconds, and an indexed read of a single (non-list) sensor + # shared by several cars logs a set-up warning each time. No default: an entity HA does not + # have (deleted, renamed, integration reloading) must read as no evidence, not resolve to a + # "no" that looks like the car has stopped. + raw = self.resolve_arg("car_charging_now", entity_id, default=None) + if raw is None or (isinstance(raw, str) and raw.lower() in ("unknown", "unavailable")): + return None, DYNAMIC_LOAD_CAR_SENSOR_MINUTES + if isinstance(raw, str): + charging = raw.lower() in self.car_charging_now_response + else: + charging = bool(raw) + return (not charging), DYNAMIC_LOAD_CAR_SENSOR_MINUTES + if self.car_energy_reported_load: + # No load history (e.g. a load_forecast-only install, where load_last_period is a hard-coded + # 0) is not low load - it is no reading at all + if self.minutes_now <= 5 or not self.load_minutes: + return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES + return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES + return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES + + def dynamic_load_car_in_slot(self, car_n, minute): + """ + Whether minute falls inside any of car_n's charging slots, whatever their kWh - a slot the + check has already cancelled is still one the car is meant to be charging in. + """ + for slot in self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []: + if slot["start"] <= minute < slot["end"]: + return True + return False + + def dynamic_load_car_is_octopus(self, car_n): + """ + Whether car_n's slots were built by Octopus Intelligent charging (load_octopus_slots() marks them). + + Only those are ever cancelled. Predbat-led charging is a different mechanism: Predbat itself + decides when the car charges and its car_charging_slot sensor drives the charger, so cancelling + a slot would stop the very charge the check is waiting to see. + """ + slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] + return any(slot.get("octopus", False) for slot in slots) + + def dynamic_load_car_trusted_by_default(self, car_n): + """ + Whether car_n's slots are trusted while there is no evidence about them - outside a slot, or + before the car has been seen either way inside one. + + Trusted unless octopus_intelligent_trust_slots is Off, which reverses that for an Octopus + Intelligent car: its slots are assumed not to happen until the car is seen charging in one. + """ + return self.octopus_intelligent_trust_slots or not self.dynamic_load_car_is_octopus(car_n) + + def dynamic_load_car_active(self, car_n): + """ + Whether car-charging detection applies to car_n: only an Octopus Intelligent car, and only with + dynamic load on or its slots untrusted by default - which means nothing without the detection that + trusts them again, so it works whether dynamic load is on or not. + """ + if not self.dynamic_load_car_is_octopus(car_n): + return False + return self.metric_dynamic_load_adjust or not self.octopus_intelligent_trust_slots + + def dynamic_load_car_target(self, car_n, minute, now, record=True): + """ + Whether car_n's slots should be cancelled at minute (floored, as minutes_now) and now (exact). + + - Detection not active for the car: never cancelled. + - Outside any of the car's slots: the default - trusted, unless octopus_intelligent_trust_slots + is Off for an Octopus car (see dynamic_load_car_trusted_by_default()). + - Inside a slot, charging: trusted, straight away. + - Inside a slot, not charging for the grace period (see dynamic_load_car_evidence()): cancelled. + - Otherwise (still inside the grace period, or no evidence): unchanged, starting from the default. + + With record True the "not charging since" clock is started and cleared; with record False + (compare.py runs) it is only read. + """ + default_cancelled = not self.dynamic_load_car_trusted_by_default(car_n) + cancelled = self.dynamic_load_car_cancelled.get(car_n, default_cancelled) + if not self.dynamic_load_car_active(car_n) or not self.dynamic_load_car_in_slot(car_n, minute): + if record: + self.dynamic_load_car_since.pop(car_n, None) + return default_cancelled and self.dynamic_load_car_active(car_n) + + not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) + if not_charging is False: + if record: + self.dynamic_load_car_since.pop(car_n, None) + return False + if not_charging: + since = self.dynamic_load_car_since.setdefault(car_n, now) if record else self.dynamic_load_car_since.get(car_n) + if since is not None and (now - since).total_seconds() >= grace_minutes * 60: + return True + elif record: + # No evidence: the car may have been charging through it, so a "not charging" reading after + # it starts a fresh grace period rather than counting the unknown stretch towards the old one + self.dynamic_load_car_since.pop(car_n, None) + return cancelled + + def dynamic_load_car_refresh_sensors(self): + """ + Work out, once per cycle, which cars have car_charging_now set to a real entity, keeping the + entity id for dynamic_load_car_evidence(). A static literal in apps.yaml can never report the car + stopping, so it does not count. + """ + self.dynamic_load_car_sensors = {} + for car_n in range(self.num_cars): + entity_id = self.get_arg("car_charging_now", None, indirect=False, index=car_n) + if is_entity_id(entity_id): + self.dynamic_load_car_sensors[car_n] = entity_id + + def dynamic_load_car_check_config(self): + """ + With octopus_intelligent_trust_slots Off, list the cars whose Octopus Intelligent slots can + never be trusted: no car_charging_now entity and not inside the CT clamp, so nothing can ever + show the car charging. Logged as a warning whenever that list changes, not every cycle. + """ + self.dynamic_load_car_refresh_sensors() + iog_off = (not self.octopus_intelligent_trust_slots) and (not self.octopus_intelligent_charging) and ("octopus_intelligent_slot" in self.args) + if iog_off != self.dynamic_load_car_warned_iog_off: + if iog_off: + # Off only governs the slots Octopus Intelligent charging turns into the car plan; with that + # off the dispatches still feed the rate overlay, which this switch does not touch + self.log("Warn: octopus_intelligent_trust_slots is Off but octopus_intelligent_charging is Off too, so it has no effect - the Intelligent dispatch rates are still used") + self.dynamic_load_car_warned_iog_off = iog_off + + cars = [] + if not self.octopus_intelligent_trust_slots and self.octopus_intelligent_charging and not self.car_energy_reported_load: + # Only cars on Octopus Intelligent (with a slot sensor of their own, as fetch_sensor_data_cars() + # reads them) have Intelligent slots to distrust + iog_entities = self.get_arg("octopus_intelligent_slot", indirect=False) + if iog_entities and not isinstance(iog_entities, list): + iog_entities = [iog_entities] + iog_entities = iog_entities or [] + cars = [car_n for car_n in range(self.num_cars) if car_n < len(iog_entities) and iog_entities[car_n] and car_n not in self.dynamic_load_car_sensors] + if cars != self.dynamic_load_car_warned: + if cars: + self.log("Warn: octopus_intelligent_trust_slots is Off but car(s) {} have no car_charging_now sensor and are outside the CT clamp, so their Intelligent slots will never be trusted".format(cars)) + self.dynamic_load_car_warned = cars + return cars + + def dynamic_load_car_check(self, save=True): + """ + Cancel the slots of an Octopus Intelligent car that is inside one of its charging slots but not + charging - or, with octopus_intelligent_trust_slots Off, until it is seen charging in one. Slots + Predbat plans itself are never cancelled (see dynamic_load_car_is_octopus()). + + Runs in fetch_sensor_data() after the Octopus slots are built and before the rates, so that a + cancelled dispatch never gets its cheap rate (rate_add_io_slots() and + dynamic_load_car_strip_feed_rates() consult dynamic_load_car_cancelled). Every car is decided, + including one whose slots have gone, so a stale cancellation cannot outlive them. + + A cancelled car has every slot ending after now set to 0 kWh, which releases "Hold for car" and the + predicted car load; the kWh it had is kept in the slot's kwh_cancelled so the plan can still show + it. See dynamic_load_car_target() for the decision. It is re-derived every cycle from freshly + built slots, so nothing needs clearing when a cancellation ends. + + With save False (compare.py re-running the fetch for another tariff) the decision is re-derived + for the current slot and reading, but the saved state is not advanced. + + Returns True when a car's cancellation changed, so the plan is recomputed. + """ + changed = False + # This run's decision, which the rates read - in a compare.py run (save False) it can differ from + # the saved state, and the rates must follow the same decision as the slots + self.dynamic_load_car_effective = {} + for car_n in range(self.num_cars): + cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save) + if save: + was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) + if cancelled != was_cancelled: + changed = True + if cancelled and self.dynamic_load_car_in_slot(car_n, self.minutes_now): + reason = "is in a charging slot but not charging, cancelling its slots" + elif cancelled: + reason = "has not been seen charging in its Octopus Intelligent slots yet, not trusting them" + else: + reason = "slots resumed" + self.log("Dynamic load: car {} {}".format(car_n, reason)) + self.dynamic_load_car_cancelled[car_n] = cancelled + self.dynamic_load_car_effective[car_n] = cancelled + + if cancelled and car_n < len(self.car_charging_slots): + for slot in self.car_charging_slots[car_n]: + if slot["end"] > self.minutes_now: + if slot.get("kwh", 0) > 0: + slot["kwh_cancelled"] = slot["kwh"] + slot["kwh"] = 0 + # No cost for energy the plan no longer counts, so the published car plan stays + # self-consistent + if "cost" in slot: + slot["cost"] = 0 + return changed + + def dynamic_load_car_poll(self, now=None): + """ + Called from the 15 second loop between plan cycles: ask for a replan as soon as a car's + cancellation is due to change, rather than waiting up to 5 minutes for the next cycle. + + Only cars with a car_charging_now sensor are polled - the load test needs the fetch. The clock is + started here too, so a slot that begins between cycles is timed from when the car was first seen + idle in it. The decision is dynamic_load_car_target(), the same one the replan will make. + + Returns True when it set update_pending. + """ + if not self.num_cars or self.midnight_utc is None: + return False + if not self.metric_dynamic_load_adjust and self.octopus_intelligent_trust_slots: + return False + if now is None: + now = datetime.now(self.local_tz) + # Judge "in a slot" on the minute the replan's check will use - clock skew added and floored to + # PREDICT_STEP, as update_time() builds minutes_now - or for a slot ending between steps the poll + # would keep asking for a reset the replan then declines, every 15 seconds. The grace is still + # timed on the exact clock, as dynamic_load_car_check() times it on now_utc_real. + minute = minutes_since_midnight(now + timedelta(minutes=self.args.get("clock_skew", 0)), self.midnight_utc) + + due = False + for car_n in range(self.num_cars): + if car_n not in self.dynamic_load_car_sensors: + continue + if self.dynamic_load_car_target(car_n, minute, now) != self.dynamic_load_car_cancelled.get(car_n, False): + due = True + + if due: + self.log("Dynamic load: car charging state changed, will re-plan") + self.update_pending = True + return due + def find_price_levels( self, price_set, @@ -5500,6 +5734,24 @@ def car_charge_slot_kwh(self, minute_start, minute_end): car_charging_kwh = dp2(car_charging_kwh) return car_charging_kwh + def car_charge_slot_kwh_cancelled(self, minute_start, minute_end): + """ + Car charging in kWh that dynamic load has cancelled for the given self.plan_interval_minutes-minute + slot - the car was not charging, so the plan does not count on it, but it is still shown with a "?". + """ + car_charging_kwh = 0.0 + for car_n in range(self.num_cars): + for window in self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []: + start = window["start"] + end = window["end"] + kwh_cancelled = window.get("kwh_cancelled", 0) + if kwh_cancelled and start < minute_end and end > minute_start and end != start: + kwh = dp2(kwh_cancelled) / (end - start) + for minute_offset in range(minute_start, minute_end, PREDICT_STEP): + if start <= minute_offset < end: + car_charging_kwh += kwh * PREDICT_STEP + return dp2(car_charging_kwh) + def car_charge_slot_rate(self, minute_start, minute_end): """ Work out the car's own effective import rate (p/kWh) for the given diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 4ba550789..d237c8599 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -532,6 +532,7 @@ def reset(self): self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False + self.octopus_intelligent_trust_slots = True self.notify_devices = ["notify"] self.octopus_url_cache = {} self.dispatch_timeline_last = {} @@ -544,7 +545,13 @@ def reset(self): self.load_minutes_age = 0 self.load_last_period = 0 self.load_last_status = "baseline" - self.load_last_car_slot = False + self.dynamic_load_car_since = {} + self.dynamic_load_car_cancelled = {} + self.dynamic_load_car_warned = [] + self.dynamic_load_car_warned_iog_off = False + self.dynamic_load_car_sensors = {} + self.dynamic_load_car_effective = {} + self.dynamic_load_car_stripped = 0 self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} @@ -2133,6 +2140,9 @@ def update_time_loop(self, cb_args): self.check_entity_refresh() self.validate_config_check_retry() + if not self.prediction_started: + # A car's charging slots cancelled or resumed by dynamic load - replan now, not in 5 minutes + self.dynamic_load_car_poll() if self.update_pending and not self.prediction_started: # Full update required self.update_pending = False diff --git a/apps/predbat/tests/test_dynamic_load.py b/apps/predbat/tests/test_dynamic_load.py index 035d96c70..706f25a7e 100644 --- a/apps/predbat/tests/test_dynamic_load.py +++ b/apps/predbat/tests/test_dynamic_load.py @@ -29,7 +29,6 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): my_predbat.car_charging_threshold = 3.0 # 3kW my_predbat.metric_dynamic_load_adjust = True my_predbat.load_last_status = "baseline" # Initialise status - my_predbat.load_last_car_slot = False # Test 1: High load - should set load_last_status to "high" but not cancel slots print("Test 1: High load case") @@ -61,11 +60,12 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): print(f"ERROR: Car slot 1 kwh should not have changed, was {original_slot_1_kwh}, now {my_predbat.car_charging_slots[1][0]['kwh']}") failed = True - # Test 2: Low load - should cancel car slots that overlap with current time - print("Test 2: Low load case") + # Test 2: Low load - dynamic_load() classifies it but no longer cancels car slots itself; that + # moved earlier in the cycle, before the rates are built (dynamic_load_car_check(), see + # test_dynamic_load_car.py), so it can withhold an Intelligent dispatch's cheap rate too + print("Test 2: Low load case leaves the car slots to dynamic_load_car_check()") my_predbat.load_last_status = "baseline" # Reset status my_predbat.load_last_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.9) - my_predbat.load_last_car_slot = True my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 25, "kwh": 10.0}] my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 45, "kwh": 8.0}] @@ -78,73 +78,8 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): print(f"ERROR: Expected load_last_status to be 'low', got '{my_predbat.load_last_status}'") failed = True - # Verify that car slot 0 was cancelled (overlaps with current time) - if my_predbat.car_charging_slots[0][0]["kwh"] != 0: - print(f"ERROR: Car slot 0 should have been cancelled (kwh=0), but kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - # Verify that car slot 1 was also cancelled (overlaps with current 30-minute period) - if my_predbat.car_charging_slots[1][0]["kwh"] != 0: - print(f"ERROR: Car slot 1 should have been cancelled (kwh=0), but kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 3: Low load but slots don't overlap with current time - should not cancel - print("Test 3.1: Low load with non-overlapping slots, first time car starts") - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - my_predbat.load_last_car_slot = False - - # Create slots that don't overlap with current time period - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now - 5, "end": my_predbat.minutes_now + 90, "kwh": 10.0}] - my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now - 60, "end": my_predbat.minutes_now - 30, "kwh": 8.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0: - print(f"ERROR: Car slot 0 should not have been cancelled, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - if my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: - print(f"ERROR: Car slot 1 should not have been cancelled, kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 3: Low load but slots don't overlap with current time - should not cancel - print("Test 3.2: Low load with non-overlapping slots") - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - - # Create slots that don't overlap with current time period - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now - 5, "end": my_predbat.minutes_now + 90, "kwh": 10.0}] - my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now - 60, "end": my_predbat.minutes_now - 30, "kwh": 8.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - if my_predbat.car_charging_slots[0][0]["kwh"] != 0: - print(f"ERROR: Car slot 0 should have been cancelled, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - if my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: - print(f"ERROR: Car slot 1 should not have been cancelled, kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 4: Just after midnight (minutes_now <= 5) - should not cancel even with low load - print("Test 4: Low load just after midnight") - my_predbat.minutes_now = 3 # 3 minutes after midnight - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - my_predbat.load_last_car_slot = False - - # Create slots that overlap with current time - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 25, "kwh": 10.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - # Verify slots were NOT cancelled (due to midnight exclusion) - if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0: - print(f"ERROR: Car slot should not have been cancelled just after midnight, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") + if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0 or my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: + print(f"ERROR: dynamic_load() should leave car slots alone, got kwh {my_predbat.car_charging_slots[0][0]['kwh']} and {my_predbat.car_charging_slots[1][0]['kwh']}") failed = True # Test 5: Metric dynamic load adjust disabled - should not cancel slots @@ -188,7 +123,6 @@ def test_dynamic_load_high_load_baseline(my_predbat): "car_charging_energy", "load_last_period", "load_last_status", - "load_last_car_slot", "metric_dynamic_load_adjust", "dynamic_load_baseline", ) @@ -216,7 +150,6 @@ def _run_dynamic_load_high_load_baseline(my_predbat): my_predbat.car_charging_threshold = 3.0 my_predbat.metric_dynamic_load_adjust = True my_predbat.load_last_status = "baseline" - my_predbat.load_last_car_slot = False my_predbat.load_last_period = 6.0 # 6kW - above the 5kW battery threshold, so "high" minutes_end_slot = my_predbat.minutes_now + my_predbat.plan_interval_minutes @@ -302,7 +235,6 @@ def _run_dynamic_load_high_load_baseline(my_predbat): print("Test 5: Measured car energy is excluded from the next-slot baseline") my_predbat.load_last_period = 7.2 # 6kW car plus 1.2kW house load my_predbat.load_last_status = "high" # Treat this as the second consecutive high reading - my_predbat.load_last_car_slot = True my_predbat.car_energy_reported_load = True my_predbat.car_charging_hold = True my_predbat.car_charging_energy = {0: 0.5} # 0.5kWh measured in the latest 5-minute period diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py new file mode 100644 index 000000000..4d7070de4 --- /dev/null +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -0,0 +1,769 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init +""" +Dynamic load car-not-charging detection. + +With metric_dynamic_load_adjust on, a car inside one of its charging slots that is not actually +charging has every future slot cancelled (kwh 0, which releases "Hold for car" and the predicted car +load) and, for an Octopus Intelligent dispatch, loses the dispatch's cheap rate too - so the house +battery is not planned around a slot Octopus may bill at the full rate. The cancellation is +re-derived every cycle and ends as soon as the car leaves its slots or starts charging again. + +Evidence is car_charging_now when it is a real entity (2 minute grace), otherwise the low-load test +when the car is inside the CT clamp (10 minute grace), otherwise nothing. Only slots Octopus +Intelligent charging built are affected - Predbat-led charging is never cancelled. +""" +import copy +from datetime import timedelta + +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +SENSOR = "binary_sensor.car_charging_now_test" + +STATE_FIELDS = ( + "num_cars", + "minutes_now", + "now_utc_real", + "car_charging_slots", + "car_energy_reported_load", + "car_charging_threshold", + "battery_rate_max_discharge", + "load_last_period", + "metric_dynamic_load_adjust", + "octopus_slots", + "io_adjusted", + "rate_max_base", + "rate_min_base", + "forecast_minutes", + "update_pending", + "dynamic_load_car_since", + "dynamic_load_car_cancelled", + "dynamic_load_car_sensors", + "dynamic_load_car_warned", + "dynamic_load_car_warned_iog_off", + "dynamic_load_car_effective", + "dynamic_load_car_stripped", + "load_minutes", + "octopus_intelligent_trust_slots", +) + + +def _slots(): + """ + Fresh car slots as the fetch would rebuild them each cycle: one in progress from 14:00 and a + later one from 15:00. + """ + return [{"start": 840, "end": 870, "kwh": 3.5, "octopus": True}, {"start": 900, "end": 930, "kwh": 3.5, "octopus": True}] + + +def _at(my_predbat, minute, seconds=0): + """ + Move the clock to minute past midnight (plus seconds), as update_time() would: minutes_now is + floored to the 5 minute PREDICT_STEP while now_utc_real stays exact. + """ + my_predbat.now_utc_real = my_predbat.midnight_utc + timedelta(minutes=minute, seconds=seconds) + my_predbat.minutes_now = int(minute / 5) * 5 + return my_predbat.now_utc_real + + +def _cycle(my_predbat, minute, seconds=0, slots=None, save=True): + """ + One fetch cycle for car 0: rebuild its slots, then run the early (pre-rates) check. + """ + _at(my_predbat, minute, seconds) + my_predbat.car_charging_slots = [copy.deepcopy(slots if slots is not None else _slots())] + [[] for _ in range(my_predbat.num_cars - 1)] + return my_predbat.dynamic_load_car_check(save=save) + + +def _sensor(my_predbat, state): + """ + Point car_charging_now at a real entity reporting state, or remove the entity when state is None. + """ + if state is None: + my_predbat.args.pop("car_charging_now", None) + my_predbat.dynamic_load_car_refresh_sensors() + return + my_predbat.args["car_charging_now"] = SENSOR + my_predbat.ha_interface.dummy_items[SENSOR] = state + my_predbat.dynamic_load_car_refresh_sensors() + + +def _reset(my_predbat): + """ + A single car with dynamic load on and no detection state carried over. + """ + my_predbat.num_cars = 1 + my_predbat.metric_dynamic_load_adjust = True + my_predbat.car_energy_reported_load = False + my_predbat.car_charging_threshold = 3.0 / 60.0 + my_predbat.battery_rate_max_discharge = 5.0 / 60.0 + my_predbat.load_last_period = 3.0 + my_predbat.dynamic_load_car_since = {} + my_predbat.dynamic_load_car_cancelled = {} + my_predbat.dynamic_load_car_effective = {} + my_predbat.update_pending = False + my_predbat.octopus_intelligent_trust_slots = True + my_predbat.load_minutes = {0: 10.0, 5: 9.9} + + +def _kwh(my_predbat): + """ + kWh of car 0's slots after the check. + """ + return [slot["kwh"] for slot in my_predbat.car_charging_slots[0]] + + +def _check(name, condition, detail=""): + """ + Print an error for a failed condition and return whether it failed. + """ + if not condition: + print("ERROR: {} {}".format(name, detail)) + return True + return False + + +def _run(my_predbat): + """ + The individual scenarios. + """ + failed = False + saved_sensor = my_predbat.args.get("car_charging_now", None) + had_sensor = "car_charging_now" in my_predbat.args + + try: + # Sensor: two minutes of "not charging" inside a slot cancels this and every later slot + print("Test 1: car_charging_now off for 2 minutes cancels the slots") + _reset(my_predbat) + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 840, 10) + failed |= _check("t1 first sighting", not changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 841, 30) + failed |= _check("t1 inside grace", not changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 842, 10) + failed |= _check("t1 after grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 845) + failed |= _check("t1 stays cancelled without a second replan", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # Charging again resets straight away + print("Test 2: car_charging_now on again resumes the slots") + _sensor(my_predbat, "on") + changed = _cycle(my_predbat, 847) + failed |= _check("t2 resume", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # Leaving the slots resets, even though the car is still not charging + print("Test 3: leaving the car slots resets the cancellation") + _sensor(my_predbat, "off") + _cycle(my_predbat, 850) + _cycle(my_predbat, 855) + failed |= _check("t3 cancelled first", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + changed = _cycle(my_predbat, 875) + failed |= _check("t3 reset outside slot", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t3 since cleared", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + + # Unknown readings neither start the clock nor clear a cancellation + print("Test 4: unavailable car_charging_now is no evidence either way") + _reset(my_predbat) + _sensor(my_predbat, "unavailable") + _cycle(my_predbat, 840) + _cycle(my_predbat, 850) + failed |= _check("t4 no cancel on unknown", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {}".format(_kwh(my_predbat))) + _sensor(my_predbat, "off") + _cycle(my_predbat, 850) + _cycle(my_predbat, 855) + _sensor(my_predbat, "unavailable") + changed = _cycle(my_predbat, 860) + failed |= _check("t4 unknown keeps cancellation", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # An unknown stretch is no evidence either way: the car may have been charging through it, so a + # "not charging" reading after it starts a fresh grace period rather than resuming the old one + print("Test 4c: an unavailable reading restarts the grace clock") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _sensor(my_predbat, "unavailable") + _cycle(my_predbat, 841) + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 842, 30) + failed |= _check("t4c not cancelled on the old sighting", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 844, 30) + failed |= _check("t4c cancelled after a fresh grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # A static literal is not a sensor, and without the CT clamp there is no load evidence either + # A configured entity HA does not have (deleted, renamed, integration reloading) resolves to the + # read's default - that must be no evidence, not a "not charging" reading (#5229 review) + print("Test 4b: a car_charging_now entity missing from HA is no evidence") + _reset(my_predbat) + my_predbat.args["car_charging_now"] = "binary_sensor.car_charging_now_missing" + my_predbat.ha_interface.dummy_items.pop("binary_sensor.car_charging_now_missing", None) + my_predbat.dynamic_load_car_refresh_sensors() + _cycle(my_predbat, 840) + _cycle(my_predbat, 850) + failed |= _check("t4b missing entity", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {} since {}".format(_kwh(my_predbat), my_predbat.dynamic_load_car_since.get(0))) + + print("Test 5: a literal car_charging_now is not evidence") + _reset(my_predbat) + my_predbat.args["car_charging_now"] = "off" + my_predbat.dynamic_load_car_refresh_sensors() + my_predbat.load_last_period = 0.2 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t5 literal ignored", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + # Load method: only when the car is inside the CT clamp, and only after 10 minutes + print("Test 6: low load inside the CT clamp cancels after 10 minutes") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + changed = _cycle(my_predbat, 845) + failed |= _check("t6 not at 5 minutes", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 850) + failed |= _check("t6 cancels at 10 minutes", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + my_predbat.load_last_period = 4.0 + changed = _cycle(my_predbat, 855) + failed |= _check("t6 load back up resumes", changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # A load_forecast-only install has no load_today history and a hard-coded load_last_period of 0, + # which is not evidence of anything (#5229 review) + print("Test 6c: no load history is not low-load evidence") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_minutes = {} + my_predbat.load_last_period = 0 + _cycle(my_predbat, 840) + _cycle(my_predbat, 855) + failed |= _check("t6c no load data", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {}".format(_kwh(my_predbat))) + + print("Test 6b: the load test is not used just after midnight, when load_today resets") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + early_slots = [{"start": 0, "end": 30, "kwh": 3.5, "octopus": True}] + _cycle(my_predbat, 0, slots=early_slots) + _cycle(my_predbat, 5, slots=early_slots) + failed |= _check("t6b no clock before 00:05", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + + print("Test 7: low load with the car outside the CT clamp is not evidence") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = False + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t7 no load evidence outside CT", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 8: a wired sensor wins over the load test") + _reset(my_predbat) + _sensor(my_predbat, "on") + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t8 sensor says charging", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 9: dynamic load off does nothing and clears state") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + my_predbat.metric_dynamic_load_adjust = False + changed = _cycle(my_predbat, 850) + failed |= _check("t9 off", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 10: compare.py runs (save=False) apply the cancellation but do not advance it") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840, save=False) + failed |= _check("t10 no clock started", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + _cycle(my_predbat, 850, save=False) + failed |= _check("t10 applied when saved state is cancelled", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + # A car that has since resumed charging, or left its slot, is not cancelled in a comparison run + # just because the live state has not been re-derived yet (#5229 review) + _sensor(my_predbat, "on") + _cycle(my_predbat, 851, save=False) + failed |= _check("t10 not applied once charging again", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + _sensor(my_predbat, "off") + _cycle(my_predbat, 875, save=False) + failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) + # ...and the rates in that comparison run follow the same decision as its slots (#5229 review) + _sensor(my_predbat, "on") + _cycle(my_predbat, 851, save=False) + failed |= _check("t10 comparison rates follow the run's own decision", my_predbat.dynamic_load_car_effective.get(0) is False, "effective {}".format(my_predbat.dynamic_load_car_effective)) + _sensor(my_predbat, "off") + _cycle(my_predbat, 852, save=False) + failed |= _check("t10 comparison rates cancelled with its slots", my_predbat.dynamic_load_car_effective.get(0) is True and _kwh(my_predbat) == [0, 0], "effective {} kwh {}".format(my_predbat.dynamic_load_car_effective, _kwh(my_predbat))) + + # Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and + # its slot sensor drives the charger - cancelling it would stop the very charge it is waiting to + # see. Only slots Octopus Intelligent charging built are ever cancelled. + print("Test 11: a car Predbat plans itself is never cancelled, by the sensor or the load") + _reset(my_predbat) + _sensor(my_predbat, "off") + own = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 3.5}] + _cycle(my_predbat, 840, slots=own) + changed = _cycle(my_predbat, 850, slots=own) + failed |= _check("t11 sensor", (not changed) and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840, slots=own) + _cycle(my_predbat, 855, slots=own) + failed |= _check("t11 load", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + # Every car is decided in the one call, including one whose slots have gone this cycle + print("Test 11a: a cancelled car with no slots this cycle is cleared") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + changed = _cycle(my_predbat, 850, slots=[]) + failed |= _check("t11a cleared", changed and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} cancelled {}".format(changed, my_predbat.dynamic_load_car_cancelled)) + + # car_charging_slots shorter than num_cars must not raise out of the fetch (#5229 review) + print("Test 11b: fewer car slot lists than cars") + _reset(my_predbat) + _sensor(my_predbat, "off") + _at(my_predbat, 840) + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_cancelled = {1: True} + my_predbat.car_charging_slots = [_slots()] + try: + my_predbat.dynamic_load_car_check(save=False) + my_predbat.dynamic_load_car_cancelled = {1: True} + my_predbat.dynamic_load_car_check() + except IndexError as exc: + failed |= _check("t11b no IndexError", False, str(exc)) + + failed |= _run_rates(my_predbat) + failed |= _run_poll(my_predbat) + failed |= _run_untrusted(my_predbat) + failed |= _run_kwh_cancelled(my_predbat) + finally: + if had_sensor: + my_predbat.args["car_charging_now"] = saved_sensor + else: + my_predbat.args.pop("car_charging_now", None) + my_predbat.ha_interface.dummy_items.pop(SENSOR, None) + + return failed + + +def _dispatch(my_predbat, start_minute, end_minute): + """ + A raw Octopus dispatch entry covering start_minute to end_minute past midnight. + """ + start = my_predbat.midnight_utc + timedelta(minutes=start_minute) + end = my_predbat.midnight_utc + timedelta(minutes=end_minute) + return {"start": start.strftime(TIME_FORMAT), "end": end.strftime(TIME_FORMAT), "charge_in_kwh": 2.0, "source": "smart-charge", "location": "AT_HOME"} + + +def _run_rates(my_predbat): + """ + The cheap rate of a cancelled car's dispatch is never applied, and a discount the rate feed + delivered for it is stripped before the rates are replicated. + """ + failed = False + saved_low_rate = my_predbat.args.get("octopus_slot_low_rate", None) + saved_slot_max = my_predbat.args.get("octopus_slot_max", None) + try: + print("Test 12: rate_add_io_slots skips the future minutes of a cancelled car") + _reset(my_predbat) + _at(my_predbat, 840) + my_predbat.forecast_minutes = 2 * 24 * 60 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 48 + my_predbat.rate_min_base = 4.0 + my_predbat.rate_max_base = 30.0 + my_predbat.dynamic_load_car_effective = {0: True} + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) + failed |= _check("t12 past minute keeps its discount", rates[820] == 4.0, "rate {}".format(rates[820])) + failed |= _check("t12 future minute not discounted", rates[840] == 10.0 and rates[899] == 10.0, "rates {} {}".format(rates[840], rates[899])) + + my_predbat.dynamic_load_car_effective = {} + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) + failed |= _check("t12 not cancelled is discounted", rates[840] == 4.0, "rate {}".format(rates[840])) + + print("Test 13: a feed-side discount for a cancelled car's dispatch is stripped") + _reset(my_predbat) + _at(my_predbat, 840) + my_predbat.num_cars = 2 + my_predbat.rate_max_base = 30.0 + my_predbat.dynamic_load_car_effective = {0: True} + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + my_predbat.io_adjusted = {} + for minute in list(range(810, 930)) + list(range(1410, 1440)): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13 future stripped", rates[850] == 30.0 and 850 not in my_predbat.io_adjusted, "rate {} marker {}".format(rates[850], my_predbat.io_adjusted.get(850))) + failed |= _check("t13 past kept", rates[820] == 7.0 and my_predbat.io_adjusted.get(820), "rate {}".format(rates[820])) + failed |= _check("t13 other car's dispatch kept", rates[910] == 7.0 and my_predbat.io_adjusted.get(910), "rate {}".format(rates[910])) + failed |= _check("t13 fixed window kept", rates[1420] == 7.0 and my_predbat.io_adjusted.get(1420), "rate {}".format(rates[1420])) + + # The feed marks whole 30 minute rate periods, and rate_add_io_slots() rounds a dispatch out to + # them too - a dispatch off the half hour must strip the whole period (#5229 review) + print("Test 13b: a dispatch off the half hour strips its whole 30 minute periods") + my_predbat.octopus_slots = [[_dispatch(my_predbat, 967, 997)], []] + my_predbat.io_adjusted = {} + for minute in range(960, 1020): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13b leading edge stripped", rates[962] == 30.0, "rate {}".format(rates[962])) + failed |= _check("t13b trailing edge stripped", rates[1010] == 30.0, "rate {}".format(rates[1010])) + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + + # A cancelled car's dispatch that runs into the fixed 23:30-05:30 window: the part outside it goes + # back to the full rate, the part inside keeps the tariff's own off-peak rate through both the + # feed strip and rate_add_io_slots() - which only ever lowers a rate, so skipping it changes nothing + print("Test 13c: a cancelled dispatch straddling 23:30 keeps the off-peak rate inside the window") + my_predbat.num_cars = 1 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 48 + my_predbat.rate_min_base = 4.0 + dispatch = [_dispatch(my_predbat, 1380, 1470)] + my_predbat.octopus_slots = [dispatch] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + for minute in list(range(1410, 1440)) + list(range(1440, 1440 + 330)): + rates[minute] = 7.0 + # Worst case: the feed flags the whole dispatch as adjusted, including the minutes inside the window + my_predbat.io_adjusted = {} + for minute in range(1380, 1470): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + rates = my_predbat.rate_add_io_slots(0, rates, dispatch) + failed |= _check("t13c 23:00-23:30 back to the full rate", rates[1380] == 30.0 and rates[1409] == 30.0, "rates {} {}".format(rates[1380], rates[1409])) + failed |= _check("t13c 23:30-00:30 still off-peak", all(rates[minute] == 7.0 for minute in range(1410, 1470)), "rates {}".format(sorted(set(rates[minute] for minute in range(1410, 1470))))) + my_predbat.num_cars = 2 + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + + print("Test 13d: the feed strip logs when what it strips changes, not every cycle") + my_predbat.dynamic_load_car_effective = {0: True} + my_predbat.dynamic_load_car_stripped = 0 + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + for _ in range(3): + my_predbat.io_adjusted = {minute: True for minute in range(840, 870)} + my_predbat.dynamic_load_car_strip_feed_rates({minute: 7.0 for minute in range(0, 2 * 24 * 60)}) + finally: + my_predbat.log = saved_log + strip_logs = [message for message in logged if "removed the Intelligent dispatch rate" in message] + failed |= _check("t13d logged once", len(strip_logs) == 1, "logged {}".format(strip_logs)) + + my_predbat.dynamic_load_car_effective = {} + rates[850] = 7.0 + my_predbat.io_adjusted[850] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13 nothing cancelled is a no-op", rates[850] == 7.0 and my_predbat.io_adjusted.get(850), "rate {}".format(rates[850])) + finally: + for key, value in (("octopus_slot_low_rate", saved_low_rate), ("octopus_slot_max", saved_slot_max)): + if value is None: + my_predbat.args.pop(key, None) + else: + my_predbat.args[key] = value + return failed + + +def _run_poll(my_predbat): + """ + The 15 second loop asks for a replan as soon as a cancel or resume is due, rather than waiting + for the next 5 minute cycle. + """ + failed = False + print("Test 14: the poll asks for a replan once the grace has run out") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840, 5) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841)) + failed |= _check("t14 not yet due", (not due) and not my_predbat.update_pending, "due {}".format(due)) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842, seconds=10)) + failed |= _check("t14 due after grace", due and my_predbat.update_pending, "due {}".format(due)) + + print("Test 15: the poll starts the clock itself when the slot began between cycles") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 835) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=840, seconds=20)) + failed |= _check("t15 clock started", (not due) and my_predbat.dynamic_load_car_since.get(0) is not None, "due {} since {}".format(due, my_predbat.dynamic_load_car_since.get(0))) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842, seconds=30)) + failed |= _check("t15 due", due, "due {}".format(due)) + + print("Test 16: the poll asks for a replan when a cancelled car starts charging") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + my_predbat.update_pending = False + _sensor(my_predbat, "on") + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=846)) + failed |= _check("t16 resume due", due and my_predbat.update_pending, "due {}".format(due)) + + # The poll must judge "in a slot" exactly as the replan's check will - on minutes_now, floored to + # PREDICT_STEP - or it keeps asking for a change the replan then declines, every 15 seconds + print("Test 18: the poll agrees with the check about a slot ending between 5 minute steps") + _reset(my_predbat) + _sensor(my_predbat, "off") + odd_slots = [{"start": 840, "end": 872, "kwh": 3.5, "octopus": True}] + _cycle(my_predbat, 840, slots=odd_slots) + _cycle(my_predbat, 845, slots=odd_slots) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=872, seconds=10)) + failed |= _check("t18 no reset before minutes_now leaves the slot", not due, "due {}".format(due)) + changed = _cycle(my_predbat, 872, 25, slots=odd_slots) + failed |= _check("t18 check agrees", (not changed) and my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {}".format(changed)) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=875, seconds=5)) + failed |= _check("t18 reset once minutes_now leaves it", due, "due {}".format(due)) + + # Between cycles the poll reads the previous cycle's slots and its midnight_utc together, so after + # midnight a new-day slot built at 23:55 (minute 1440 on that axis) is where the poll's minute lands + print("Test 18b: the poll across midnight uses the same axis as the slots it reads") + _reset(my_predbat) + _sensor(my_predbat, "off") + midnight_slots = [{"start": 1440, "end": 1470, "kwh": 3.5, "octopus": True}] + _cycle(my_predbat, 1435, slots=midnight_slots) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=1440, seconds=30)) + failed |= _check("t18b not due at 00:00:30", not due, "due {}".format(due)) + failed |= _check("t18b clock started in the new-day slot", my_predbat.dynamic_load_car_since.get(0) is not None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=1442, seconds=40)) + failed |= _check("t18b due after the grace in the new-day slot", due, "due {}".format(due)) + + # The poll runs every 15 seconds: it must not read car_charging_now through get_arg(index=...), + # which warns every time for a second car sharing a single (non-list) sensor (#5229 review) + print("Test 18c: the poll does not re-resolve car_charging_now for every car every 15 seconds") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_refresh_sensors() + _at(my_predbat, 840) + my_predbat.car_charging_slots = [_slots(), _slots()] + my_predbat.dynamic_load_car_check() + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841)) + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841, seconds=15)) + finally: + my_predbat.log = saved_log + warnings = [message for message in logged if "incorrectly setup" in message] + failed |= _check("t18c no per-poll config warnings", not warnings, "logged {}".format(warnings)) + + print("Test 17: the poll does nothing with dynamic load off") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + my_predbat.metric_dynamic_load_adjust = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=845)) + failed |= _check("t17 off", not due, "due {}".format(due)) + return failed + + +def _cancelled(my_predbat): + """ + kwh_cancelled of car 0's slots after the check (None where a slot was not cancelled). + """ + return [slot.get("kwh_cancelled") for slot in my_predbat.car_charging_slots[0]] + + +def _run_untrusted(my_predbat): + """ + octopus_intelligent_trust_slots Off: Octopus Intelligent slots start untrusted and are only + trusted once the car is seen charging in one, until it stops or the slot ends. + """ + failed = False + try: + print("Test 19: trust Off - Octopus slots start untrusted, even with dynamic load off") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.metric_dynamic_load_adjust = False + my_predbat.octopus_intelligent_trust_slots = False + changed = _cycle(my_predbat, 830) + failed |= _check("t19 untrusted outside a slot", changed and _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, 3.5], "changed {} kwh {} cancelled {}".format(changed, _kwh(my_predbat), _cancelled(my_predbat))) + changed = _cycle(my_predbat, 840) + failed |= _check("t19 still untrusted in a slot while not charging", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 20: trust Off - the car seen charging in a slot trusts it and the later ones, straight away") + _sensor(my_predbat, "on") + changed = _cycle(my_predbat, 841) + failed |= _check("t20 trusted on charging", changed and _kwh(my_predbat) == [3.5, 3.5] and _cancelled(my_predbat) == [None, None], "changed {} kwh {} cancelled {}".format(changed, _kwh(my_predbat), _cancelled(my_predbat))) + + print("Test 21: trust Off - the car stopping for the grace period cancels them again") + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 845) + failed |= _check("t21 inside grace", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 847) + failed |= _check("t21 after grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 22: trust Off - leaving the slot goes back to untrusted, even while charging") + _sensor(my_predbat, "on") + _cycle(my_predbat, 850) + changed = _cycle(my_predbat, 875) + failed |= _check("t22 untrusted after the slot", changed and _kwh(my_predbat) == [3.5, 0] and my_predbat.dynamic_load_car_cancelled.get(0), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 23: trust Off does not touch slots Predbat planned itself") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.metric_dynamic_load_adjust = False + my_predbat.octopus_intelligent_trust_slots = False + own_slots = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 3.5}] + _cycle(my_predbat, 840, slots=own_slots) + _cycle(my_predbat, 850, slots=own_slots) + failed |= _check("t23 own slots untouched", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 24: trust Off with no evidence source never trusts the slots") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = False + my_predbat.octopus_intelligent_trust_slots = False + _cycle(my_predbat, 840) + _cycle(my_predbat, 855) + failed |= _check("t24 no evidence stays untrusted", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + + print("Test 25: trust Off - the poll asks for a replan when the car starts charging, not when the slot ends") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.octopus_intelligent_trust_slots = False + _cycle(my_predbat, 840) + my_predbat.update_pending = False + _sensor(my_predbat, "on") + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842)) + failed |= _check("t25 due on charging", due, "due {}".format(due)) + _cycle(my_predbat, 842) + _sensor(my_predbat, "off") + _cycle(my_predbat, 845) + _cycle(my_predbat, 850) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=875)) + failed |= _check("t25 not due leaving the slot already untrusted", not due, "due {}".format(due)) + + print("Test 27: trust Off warns about an Intelligent car with nothing to confirm its slots, once") + saved_iog = my_predbat.octopus_intelligent_charging + try: + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.octopus_intelligent_charging = True + my_predbat.octopus_intelligent_trust_slots = False + my_predbat.car_energy_reported_load = False + my_predbat.dynamic_load_car_warned = [] + had_slot_sensor_27 = "octopus_intelligent_slot" in my_predbat.args + saved_slot_sensor_27 = my_predbat.args.get("octopus_intelligent_slot") + my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.octopus_intelligent_slot_test" + cars = my_predbat.dynamic_load_car_check_config() + failed |= _check("t27 no evidence warned", cars == [0] and my_predbat.dynamic_load_car_warned == [0], "cars {}".format(cars)) + my_predbat.car_energy_reported_load = True + failed |= _check("t27 CT clamp is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + my_predbat.car_energy_reported_load = False + _sensor(my_predbat, "off") + failed |= _check("t27 sensor is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + # A second car with no Intelligent slot sensor of its own has no Intelligent slots to distrust + print("Test 27c: only cars on Octopus Intelligent are warned about") + _sensor(my_predbat, None) + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_refresh_sensors() + failed |= _check("t27c mixed install", my_predbat.dynamic_load_car_check_config() == [0], "cars {}".format(my_predbat.dynamic_load_car_warned)) + my_predbat.num_cars = 1 + if had_slot_sensor_27: + my_predbat.args["octopus_intelligent_slot"] = saved_slot_sensor_27 + else: + my_predbat.args.pop("octopus_intelligent_slot", None) + my_predbat.octopus_intelligent_trust_slots = True + failed |= _check("t27 trust On never warns", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + + # Off only governs slots Octopus Intelligent charging turns into the car plan - with it off, + # the dispatch rate overlay is untouched, so say so rather than silently not applying (#5229 review) + print("Test 27b: trust Off with Octopus Intelligent charging off warns that it has no effect") + _sensor(my_predbat, "off") + my_predbat.octopus_intelligent_trust_slots = False + my_predbat.octopus_intelligent_charging = False + had_slot_sensor = "octopus_intelligent_slot" in my_predbat.args + saved_slot_sensor = my_predbat.args.get("octopus_intelligent_slot") + my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.octopus_intelligent_slot_test" + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + my_predbat.dynamic_load_car_warned_iog_off = False + my_predbat.dynamic_load_car_check_config() + first = [message for message in logged if "octopus_intelligent_charging" in message] + my_predbat.dynamic_load_car_check_config() + again = [message for message in logged if "octopus_intelligent_charging" in message] + finally: + my_predbat.log = saved_log + if had_slot_sensor: + my_predbat.args["octopus_intelligent_slot"] = saved_slot_sensor + else: + my_predbat.args.pop("octopus_intelligent_slot", None) + failed |= _check("t27b warned once", len(first) == 1 and len(again) == 1, "logged {}".format(logged)) + finally: + my_predbat.octopus_intelligent_charging = saved_iog + my_predbat.dynamic_load_car_warned = [] + finally: + my_predbat.octopus_intelligent_trust_slots = True + return failed + + +def _run_kwh_cancelled(my_predbat): + """ + A cancelled slot keeps the kWh it had in kwh_cancelled, so the plan can still show it with a "?". + """ + failed = False + print("Test 26: a cancelled slot records its kWh in kwh_cancelled") + _reset(my_predbat) + _sensor(my_predbat, "off") + slots = [{"start": 840, "end": 870, "kwh": 3.5, "octopus": True}, {"start": 900, "end": 930, "kwh": 0.0, "octopus": True}] + _cycle(my_predbat, 840, slots=slots) + _cycle(my_predbat, 845, slots=slots) + failed |= _check("t26 recorded", _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, None], "kwh {} cancelled {}".format(_kwh(my_predbat), _cancelled(my_predbat))) + failed |= _check("t26 helper", my_predbat.car_charge_slot_kwh_cancelled(840, 870) == 3.5 and my_predbat.car_charge_slot_kwh(840, 870) == 0, "cancelled {}".format(my_predbat.car_charge_slot_kwh_cancelled(840, 870))) + _sensor(my_predbat, "on") + _cycle(my_predbat, 850, slots=slots) + failed |= _check("t26 gone once resumed", _cancelled(my_predbat) == [None, None], "cancelled {}".format(_cancelled(my_predbat))) + + # The published car plan stays self-consistent for a cancelled slot: no cost for energy the plan no + # longer counts, and the cancelled kWh alongside so the attributes say why kWh is 0 (#5229 review) + print("Test 26b: a cancelled slot is published with no cost and its kwh_cancelled") + _sensor(my_predbat, "off") + priced = [{"start": 840, "end": 870, "kwh": 3.5, "average": 7.0, "cost": 24.5, "soc": 10.0, "octopus": True}] + _cycle(my_predbat, 840, slots=priced) + _cycle(my_predbat, 845, slots=priced) + failed |= _check("t26b cost zeroed", my_predbat.car_charging_slots[0][0].get("cost") == 0, "slot {}".format(my_predbat.car_charging_slots[0][0])) + my_predbat.publish_car_plan() + planned = my_predbat.get_state_wrapper("binary_sensor." + my_predbat.prefix + "_car_charging_slot", attribute="planned") or [] + first = planned[0] if planned else {} + failed |= _check("t26b published", first.get("kwh") == 0 and first.get("cost") == 0 and first.get("kwh_cancelled") == 3.5, "planned {}".format(planned)) + return failed + + +def test_dynamic_load_car_not_charging(my_predbat): + """ + Dynamic load cancels the car slots, and their cheap rate, while a car in its slot is not charging. + """ + print("*** Running test: Dynamic load car not charging") + saved_state = {field: copy.deepcopy(getattr(my_predbat, field, None)) for field in STATE_FIELDS} + try: + failed = _run(my_predbat) + finally: + for field, value in saved_state.items(): + setattr(my_predbat, field, value) + print("*** Dynamic load car not charging test {}".format("FAILED" if failed else "PASSED")) + return failed diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 43504d39d..951df75cc 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -200,6 +200,45 @@ def run_test_plan_json_rate_adjust(my_predbat): print("ERROR: Expected car_charge_slot_rate to skip a window with no average key, got {}".format(rate)) failed = True + # A slot dynamic load cancelled (car in its slot but not charging) plans 0 kWh but is still shown, + # with a "?", so the user can see the car plan Predbat has decided not to rely on (#5229) + print("Test plan output shows a cancelled car slot with a question mark") + my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 0, "kwh_cancelled": 3.0, "average": 10.0, "octopus": True}] + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None) + if car_row is None: + print("ERROR: Could not find row for car minute {} in plan output".format(car_minute)) + failed = True + else: + if car_row.get("car_charging") != 0 or car_row.get("car_charging_cancelled") != 3.0: + print("ERROR: Expected car_charging 0 and car_charging_cancelled 3.0, got {} and {}".format(car_row.get("car_charging"), car_row.get("car_charging_cancelled"))) + failed = True + if "