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 "3.0?" not in html_plan: + print("ERROR: Expected the cancelled car slot cell '3.0?' on #FFFFCC in the HTML plan") + failed = True + if raw_plan.get("totals", {}).get("car_charging", 0) != 0: + print("ERROR: Cancelled kWh must not count towards the planned car total, got {}".format(raw_plan.get("totals", {}).get("car_charging"))) + failed = True + + # Two cars in the same step, one charging and one cancelled: the cancelled car's "?" must still show + # alongside the live kWh, not be dropped because the row has some live car charging (#5229 review) + print("Test plan output shows a cancelled car slot next to another car's live charging") + saved_num_cars = my_predbat.num_cars + my_predbat.num_cars = 2 + while len(my_predbat.car_charging_slots) < 2: + my_predbat.car_charging_slots.append([]) + my_predbat.car_charging_slots[1] = [{"start": car_minute, "end": car_minute + 30, "kwh": 2.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 or car_row.get("car_charging") != 2.0 or car_row.get("car_charging_cancelled") != 3.0: + print("ERROR: Expected car_charging 2.0 and car_charging_cancelled 3.0, got {}".format(car_row and (car_row.get("car_charging"), car_row.get("car_charging_cancelled")))) + failed = True + if ">2.0 +3.0?" not in html_plan: + print("ERROR: Expected the car cell to show the live 2.0 and the cancelled +3.0?") + failed = True + my_predbat.car_charging_slots[1] = [] + my_predbat.num_cars = saved_num_cars + # Clean up my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index d57918509..04b31bb26 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -201,6 +201,7 @@ from tests.test_add_now_to_octopus_slot import test_add_now_to_octopus_slot from tests.test_octopus_slots_change import test_octopus_slots_change from tests.test_dynamic_load import test_dynamic_load_car_slot_cancellation, test_dynamic_load_high_load_baseline +from tests.test_dynamic_load_car import test_dynamic_load_car_not_charging from tests.test_fox_api import run_fox_api_tests from tests.test_deye_const import run_deye_const_tests from tests.test_deye_config import run_deye_config_tests @@ -514,6 +515,7 @@ def main(): ("record_status_state_clamped", test_record_status_state_clamped, "Status sensor state is clamped at the 255 characters Home Assistant accepts", False), ("dynamic_load_car", test_dynamic_load_car_slot_cancellation, "Dynamic load car slot cancellation tests", False), ("dynamic_load_high", test_dynamic_load_high_load_baseline, "Dynamic load high-load baseline tests", False), + ("dynamic_load_car_not_charging", test_dynamic_load_car_not_charging, "Dynamic load car not charging tests", False), ("units", run_test_units, "Unit tests", False), ("manual_api", run_test_manual_api, "Manual API tests", False), ("manual_soc", run_test_manual_soc, "Manual SOC target tests", False), diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index c96267c34..f9a5681d9 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -7129,7 +7129,9 @@ def get_plan_renderer_js(): // Car charging (conditional) if (jsonData.num_cars > 0) { - const carVal = row.car_charging > 0 ? row.car_charging : '⚊'; + // A slot dynamic load cancelled (car not charging) still shows its kWh, with a "?" + const carCancelled = row.car_charging_cancelled > 0 ? `${row.car_charging_cancelled}?` : ''; + const carVal = row.car_charging > 0 ? (carCancelled ? `${row.car_charging} +${carCancelled}` : row.car_charging) : (carCancelled || '⚊'); html += `${carVal}`; } diff --git a/docs/car-charging.md b/docs/car-charging.md index 8a45f1ad4..7be7217ab 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -478,6 +478,18 @@ Predbat will still assume all Octopus charging slots are low rates even if some - The switch **switch.predbat_octopus_intelligent_ignore_unplugged** (*expert mode*) (default value is Off) can be used to prevent Predbat from assuming the car will be charging or that future extra low-rate slots apply when the car is unplugged. This will only work correctly if **car_charging_planned** is set correctly in `apps.yaml` to detect your car being plugged in +- The switch **switch.predbat_octopus_intelligent_trust_slots** (*expert mode*) (default value is On) controls whether Predbat believes an Intelligent charging slot will happen before your car has actually been seen charging in it. +When On, every slot is trusted until the car is seen not charging in one (see **switch.predbat_metric_dynamic_load_adjust** below). +When Off, Intelligent slots are assumed not to happen: the car's charging is not predicted and a daytime slot's low rate is not used for the house battery, until the car is seen charging inside a slot. +From then on that slot and the later ones are trusted, until the car stops charging or the slot ends, after which the next slot has to be confirmed again. +The overnight 23:30-05:30 rate stays low either way, as that is part of the tariff. +Whether the car is charging comes from **car_charging_now** when it is set to a real sensor, otherwise from the house load if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load**). +With neither, the car's slots can never be confirmed, and Predbat logs a warning. +This switch works whether or not dynamic load adjustment is turned on. +It only applies when **switch.predbat_octopus_intelligent_charging** is On, as it acts on the car plan that Octopus Intelligent charging builds. With that Off, the Intelligent dispatch rates are used as before and Predbat logs a warning. + +Slots that Predbat has decided will not happen are still shown in the car column of the plan, with a question mark after the kWh (e.g. **3.5?**), so you can see the car's own schedule even though the plan is not counting on it. + - Let the Octopus app control when your car charges. #### Reading the dispatch timeline in the logs @@ -615,7 +627,10 @@ e.g. you are using Intelligent Octopus or you use the car slots in Predbat to co - **input_number.predbat_car_charging_loss** gives the percentage amount of energy lost when charging the car (load in the home vs energy added to the battery). A good setting is 0.08 which is 8%. -- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging. +- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's Octopus Intelligent charging slots, including the dispatch's cheap rate, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends. +Slots Predbat plans itself (Predbat-led charging) are never cancelled. +This releases the battery hold set by **switch.predbat_car_charging_from_battery**, and stops the house battery being planned around a dispatch Octopus may bill at the full rate. +It uses **car_charging_now** when that is a real sensor (reacting within about 2 minutes), otherwise the house load if the car is inside the CT clamp (about 10 minutes). If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging. - See [Car charging filtering](#filtering-car-charging-energy-from-house-load) and [Planned car charging](#planned-car-charging) for further car charging setup details. diff --git a/docs/customisation.md b/docs/customisation.md index 941396405..3c67aaf95 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -189,7 +189,12 @@ This setting will not impact the real calculated costs and is only used for plan **switch.predbat_metric_dynamic_load_adjust** (default False) is a toggle that when enabled allows Predbat to take into account your energy consumption within the last 5 minutes. If the load is above what your battery can deliver the plan is updated to predict this load will continue during the current slot, thus preventing forced export in the plan. If the load remains high for two checks in a row, this prediction is extended into the following slot too, so the plan stays up to date across the slot boundary. -If car charging is planned but the load indicates that the car is not charging then Predbat will assume the car will no longer charge during this slot thus allowing the plan to include potential export. +If your car is charged by Octopus Intelligent and the current time is inside one of its charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again. +The dispatch's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate. +Slots Predbat plans itself (Predbat-led charging) are never cancelled, as Predbat is the one deciding when the car charges. +Whether the car is charging is taken from **car_charging_now** when it is set to a real Home Assistant entity, after 2 minutes of it reporting not charging. +Without that sensor, if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load** On), the house load is used instead, after 10 minutes of load too low for a car to be charging. +The slots come back as soon as the car starts charging again or the current slot ends. **input_number.predbat_battery_rate_max_scaling** is a percentage factor to adjust your maximum charge rate from that reported by the inverter. For example, a value of 0.95 would be 95% and indicate charging at 5% slower than reported. @@ -302,6 +307,7 @@ These are described in detail in [Car Charging](car-charging.md) and are listed - **input_number.predbat_car_charging_loss** - percentage energy lost when charging the car - **switch.predbat_octopus_intelligent_charging** - controls whether Octopus Intelligent (via the Octopus Energy integration) controls the car charging or Predbat plans the car charging - **switch.predbat_octopus_intelligent_ignore_unplugged** (_expert mode_) - used with Octopus Intelligent to prevent Predbat from assuming the car will be charging when the car is unplugged +- **switch.predbat_octopus_intelligent_trust_slots** (_expert mode_) - when Off, Octopus Intelligent slots are assumed not to happen until the car is seen charging in one, see [Car charging](car-charging.md) - **binary_sensor.predbat_car_charging_slot** - set to On by Predbat when the car should be charged (Predbat-led charging) - **select.predbat_car_charging_plan_time** - the time you want the car to be charged by - **switch.predbat_car_charging_plan_smart** - allows Predbat to allocate car charging slots to the cheapest times rather than all low-rate slots