From 73f342e6ff1749865d08c40fb848a47d324eaaca Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 9 Oct 2026 19:34:17 +0100 Subject: [PATCH 1/2] fix(car): low-load test needs live load power to be low too before cancelling an Intelligent dispatch (#5461) The Octopus Intelligent slot confirmation cancelled a genuine dispatch while a 7.4kW car was charging in view of the CT: the 5 minute load figure read 4.80kW against a 5.4kW cut-off while the inverter reported 7410W. The 5 minute figure lags a car that is still ramping up, and a cloud inverter's late readings. - A low 5 minute figure now only counts as "not charging" when the live load_power total is also under the cut-off. Live load that is not low, or has no reading, makes the window no evidence either way. - Installs without load_power keep the 5 minute figure alone. - The cut-off drops from 90% to 85% of car_charging_threshold, for a load sensor that under-reads while the battery is charging. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/const.py | 3 + apps/predbat/plan.py | 55 +++++++- apps/predbat/tests/test_dynamic_load.py | 2 +- apps/predbat/tests/test_dynamic_load_car.py | 136 +++++++++++++++++++- docs/car-charging.md | 5 + 5 files changed, 195 insertions(+), 6 deletions(-) diff --git a/apps/predbat/const.py b/apps/predbat/const.py index cffb378f6..d1a631fcd 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -33,6 +33,9 @@ DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2 DYNAMIC_LOAD_CAR_START_MINUTES = 3 DYNAMIC_LOAD_CAR_LOAD_MINUTES = 5 +# The load test reads "low" - no car can be charging - under this fraction of car_charging_threshold. The +# margin below the threshold allows for a load sensor that under-reads while the battery is charging (GH#5461) +DYNAMIC_LOAD_CAR_LOW_FACTOR = 0.85 # A car_charging_now reading only confirms the half hour it is in (keeping that half hour cheap once the car # stops, GH#5316) from this many minutes into it: the sensor can lag a car that stopped just before the boundary DYNAMIC_LOAD_CAR_CONFIRM_MINUTES = 2 diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index bde0d303b..508cfebf8 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -25,6 +25,7 @@ CLOUD_WINDOW_MINUTES, DYNAMIC_LOAD_CAR_CONFIRM_MINUTES, DYNAMIC_LOAD_CAR_LOAD_MINUTES, + DYNAMIC_LOAD_CAR_LOW_FACTOR, DYNAMIC_LOAD_CAR_SENSOR_MINUTES, DYNAMIC_LOAD_CAR_START_MINUTES, PREDICT_STEP, @@ -367,20 +368,50 @@ def car_charging_threshold_kw(self): 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". + battery rate and the car's low-load cut-off, 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_kw() 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)): + if (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < self.dynamic_load_car_low_kw()): return "low" return "baseline" + def dynamic_load_car_low_kw(self): + """ + The load in kW under which no car can be charging: DYNAMIC_LOAD_CAR_LOW_FACTOR of car_charging_threshold. + """ + return self.car_charging_threshold_kw() * DYNAMIC_LOAD_CAR_LOW_FACTOR + + def dynamic_load_car_live_load(self): + """ + The live house load from the load_power sensors, as (configured, kW): the total across inverters, + or None when any of them has no reading ("unknown", "unavailable", missing from HA) - part of the + load is not the load. configured is False when load_power is not set at all. + + Read from the sensors rather than the inverters: dynamic_load_car_check() runs before + fetch_inverter_data(), where their load_power is still the previous cycle's. + """ + entities = self.args.get("load_power", None) + if not entities: + return False, None + if not isinstance(entities, list): + entities = [entities] + total = 0.0 + for inverter_n, entity_id in enumerate(entities): + try: + power = float(self.resolve_arg("load_power", entity_id, default=None, quiet=True, required_unit="W")) + except (ValueError, TypeError): + return True, None + if self.get_arg("load_power_invert", default=False, index=inverter_n): + power = -power + total += power + return True, total / 1000.0 + def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch_end): """ Whether car_n, inside a dispatch running dispatch_start to dispatch_end, is not charging at minute @@ -399,6 +430,11 @@ def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch averages the PREDICT_STEP minutes up to minutes_now, so it only counts when that whole window lies inside the dispatch, and the grace is timed on that 5 minute grid. It is skipped just after midnight, when the load_today sensor resets, and without load history. + + A low load_last_period alone is not enough where load_power is set: it lags a car that is still + ramping up, and a cloud inverter's late readings, so a car at full power can read low (GH#5461). + The live load has to be low too. Live load that is not low, or has no reading, makes a low window + no evidence either way - it neither cancels the car nor resumes it. """ if car_n in self.dynamic_load_car_sensors: charging = self.car_charging_now_reading(car_n) @@ -417,7 +453,18 @@ def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now if self.minutes_now - PREDICT_STEP < dispatch_start or self.minutes_now > dispatch_end: return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now - return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now + if self.dynamic_load_classify() != "low": + return False, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now + live_configured, live_load = self.dynamic_load_car_live_load() + live_low = not live_configured or (live_load is not None and live_load < self.dynamic_load_car_low_kw()) + self.log( + "Octopus Intelligent: car {} load last period {:.2f}kW is low (under {:.2f}kW), live load {} - {}".format( + car_n, self.load_last_period, self.dynamic_load_car_low_kw(), "{:.2f}kW".format(live_load) if live_load is not None else ("unavailable" if live_configured else "not configured"), "not charging" if live_low else "no evidence" + ) + ) + if not live_low: + return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now + return True, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now def car_charging_now_reading(self, car_n): diff --git a/apps/predbat/tests/test_dynamic_load.py b/apps/predbat/tests/test_dynamic_load.py index 706f25a7e..79610dc0c 100644 --- a/apps/predbat/tests/test_dynamic_load.py +++ b/apps/predbat/tests/test_dynamic_load.py @@ -65,7 +65,7 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): # 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_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.85) 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}] diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 3ad14275f..1a4f8ff2c 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -29,6 +29,7 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" SENSOR = "binary_sensor.car_charging_now_test" +LOAD_POWER = ["sensor.load_power_test_0", "sensor.load_power_test_1"] STATE_FIELDS = ( "num_cars", @@ -984,7 +985,7 @@ def _run_confirmed_half_hour(my_predbat): _reset(my_predbat) _sensor(my_predbat, None) my_predbat.car_energy_reported_load = True - # 2.8 kW: not low (the car threshold here is 3 kW, low is under 90% of it) - a cooker, not a car + # 2.8 kW: not low (the car threshold here is 3 kW, low is under 85% of it) - a cooker, not a car my_predbat.load_last_period = 2.8 _cycle(my_predbat, 400, slots=long_slots) failed |= _check("t44 trusted", not my_predbat.dynamic_load_car_cancelled.get(0, False) and _kwh(my_predbat) == [6.0], "kwh {}".format(_kwh(my_predbat))) @@ -1222,15 +1223,148 @@ async def counting_save(module, filename, data, **kwargs): return failed +def _load_power(my_predbat, states): + """ + Point load_power at one entity per state (in watts), or remove it when states is None. + """ + for entity in LOAD_POWER: + my_predbat.ha_interface.dummy_items.pop(entity, None) + if states is None: + my_predbat.args.pop("load_power", None) + return + my_predbat.args["load_power"] = LOAD_POWER[: len(states)] + for entity, state in zip(LOAD_POWER, states): + my_predbat.ha_interface.dummy_items[entity] = state + + +def _run_live_load(my_predbat): + """ + The low-load test also needs the live load_power to be low before it counts as "not charging": the + 5 minute figure lags a car that is ramping up, and a cloud inverter's late readings (GH#5461). + """ + failed = False + dispatch = [{"start": 840, "end": 900, "kwh": 7.0, "octopus": True}] + try: + print("Test 60: a low 5 minute figure with live load at the car's rate does not cancel") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_threshold = 6.0 / 60.0 + my_predbat.battery_rate_max_discharge = 8.0 / 60.0 + # GH#5461: last period 4.80kW against a 6kW threshold, inverter load power 7410W + my_predbat.load_last_period = 4.8 + _load_power(my_predbat, [7410]) + _cycle(my_predbat, 845, slots=dispatch) + changed = _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t60 not cancelled", (not changed) and _kwh(my_predbat) == [7.0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t60 no clock", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + _cycle(my_predbat, 855, slots=dispatch) + failed |= _check("t60 still not cancelled", _kwh(my_predbat) == [7.0], "kwh {}".format(_kwh(my_predbat))) + + print("Test 61: a low 5 minute figure and low live load cancels") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_threshold = 6.0 / 60.0 + my_predbat.battery_rate_max_discharge = 8.0 / 60.0 + my_predbat.load_last_period = 0.6 + _load_power(my_predbat, [650]) + _cycle(my_predbat, 845, slots=dispatch) + changed = _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t61 cancelled", changed and _kwh(my_predbat) == [0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 62: live load is summed across inverters") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_threshold = 6.0 / 60.0 + my_predbat.battery_rate_max_discharge = 8.0 / 60.0 + my_predbat.load_last_period = 0.6 + _load_power(my_predbat, [3700, 3700]) + _cycle(my_predbat, 845, slots=dispatch) + _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t62 not cancelled", _kwh(my_predbat) == [7.0], "kwh {}".format(_kwh(my_predbat))) + + print("Test 63: an unavailable load_power is no evidence, and restarts the grace") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _load_power(my_predbat, [300]) + _cycle(my_predbat, 845, slots=dispatch) + _load_power(my_predbat, ["unavailable"]) + changed = _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t63 not cancelled", (not changed) and _kwh(my_predbat) == [7.0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t63 clock cleared", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + # One inverter of two unreadable is no total either + _load_power(my_predbat, [300, "unknown"]) + _cycle(my_predbat, 855, slots=dispatch) + failed |= _check("t63 partial reading", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + # An entity HA does not have + _load_power(my_predbat, [300]) + my_predbat.ha_interface.dummy_items.pop(LOAD_POWER[0], None) + _cycle(my_predbat, 860, slots=dispatch) + failed |= _check("t63 missing entity", my_predbat.dynamic_load_car_since.get(0) is None and _kwh(my_predbat) == [7.0], "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + + print("Test 64: without load_power the 5 minute figure decides alone") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _load_power(my_predbat, None) + _cycle(my_predbat, 845, slots=dispatch) + changed = _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t64 cancelled", changed and _kwh(my_predbat) == [0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 65: live load at the car's rate does not resume a cancelled car while the 5 minute figure is low") + _load_power(my_predbat, [7400]) + changed = _cycle(my_predbat, 855, slots=dispatch) + failed |= _check("t65 stays cancelled", (not changed) and _kwh(my_predbat) == [0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 66: low is under 85% of the car charging threshold") + _reset(my_predbat) + _sensor(my_predbat, None) + _load_power(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_threshold = 6.0 / 60.0 + my_predbat.battery_rate_max_discharge = 8.0 / 60.0 + my_predbat.load_last_period = 5.2 + failed |= _check("t66 5.2kW is not low", my_predbat.dynamic_load_classify() == "baseline", "status {}".format(my_predbat.dynamic_load_classify())) + _cycle(my_predbat, 845, slots=dispatch) + _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t66 not cancelled", _kwh(my_predbat) == [7.0], "kwh {}".format(_kwh(my_predbat))) + my_predbat.load_last_period = 5.0 + failed |= _check("t66 5.0kW is low", my_predbat.dynamic_load_classify() == "low", "status {}".format(my_predbat.dynamic_load_classify())) + # ...and the live figure is judged against the same cut-off + _load_power(my_predbat, [5200]) + _cycle(my_predbat, 855, slots=dispatch) + _cycle(my_predbat, 860, slots=dispatch) + failed |= _check("t66 live 5.2kW holds the slots", _kwh(my_predbat) == [7.0], "kwh {}".format(_kwh(my_predbat))) + _load_power(my_predbat, [5000]) + _cycle(my_predbat, 865, slots=dispatch) + _cycle(my_predbat, 870, slots=dispatch) + failed |= _check("t66 live 5.0kW cancels", _kwh(my_predbat) == [0], "kwh {}".format(_kwh(my_predbat))) + finally: + _load_power(my_predbat, None) + 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} + # The fixture's apps.yaml sets load_power, which the load test would read live: the scenarios that + # want it set it themselves (_run_live_load()), the rest judge the 5 minute figure alone + had_load_power = "load_power" in my_predbat.args + saved_load_power = my_predbat.args.pop("load_power", None) try: failed = _run(my_predbat) + failed |= _run_live_load(my_predbat) finally: + if had_load_power: + my_predbat.args["load_power"] = saved_load_power 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")) diff --git a/docs/car-charging.md b/docs/car-charging.md index a7d88ef4c..acbc0bf2e 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -608,6 +608,11 @@ Octopus schedules dispatches to the minute, and Predbat checks them against thei | **car_charging_now** | from 3 minutes into the dispatch, while the car and charger wake up, and it must last 2 minutes | 5 minutes after the dispatch starts | | House load | only for a 5-minute load reading taken entirely inside the dispatch, and two in a row must be too low for a car to be charging | 10 minutes after the dispatch starts | +The house load is too low for a car to be charging when it is under 85% of **car_charging_threshold** (5.1kW with the default of 6kW). +Where **load_power** is set in `apps.yaml`, the live load has to be under that figure as well as the 5-minute reading: the 5-minute reading lags a car that is still ramping up, +and an inverter whose data arrives late, so on its own it can read low while the car is charging at full power. +A low 5-minute reading with a live load that is not low, or is unavailable, is ignored - it neither cancels the slots nor brings them back. + A reading that shows the car charging counts straight away, and an "unknown" or "unavailable" sensor is ignored rather than taken as not charging. With **car_charging_now**, Predbat checks the sensor every 15 seconds between plan updates, so it reacts within seconds of these times. From 064f1301e492850f00cea263f9f5cd8e5c7a52ca Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 9 Oct 2026 19:39:09 +0100 Subject: [PATCH 2/2] fix(car): include an inverter's extra load_power_N sensors in the live load (#5461) Review of #5462: the live load total read load_power only, while the inverter sums load_power plus the type's extra load_power_N sensors (Solis reports the bypass load on load_power_1). A car visible only on the extra sensor could still read as low live load and have its genuine dispatch cancelled. The total is now summed as Inverter.update_status() sums it; an extra sensor that is set but unreadable leaves no total. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/plan.py | 24 ++++++++--- apps/predbat/tests/test_dynamic_load_car.py | 45 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 508cfebf8..15820c4f8 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -61,6 +61,7 @@ is_entity_id, round_out_to_period, ) +from config import INVERTER_DEF from prediction import Prediction from prediction_kernel import kernel_status_summary, set_window_start from predbat_metrics import metrics @@ -393,6 +394,10 @@ def dynamic_load_car_live_load(self): or None when any of them has no reading ("unknown", "unavailable", missing from HA) - part of the load is not the load. configured is False when load_power is not set at all. + Summed as Inverter.update_status() sums it: load_power plus the inverter type's extra load_power_N + sensors (Solis reports the bypass load on load_power_1), where one left out of apps.yaml counts as + nothing, then load_power_invert. + Read from the sensors rather than the inverters: dynamic_load_car_check() runs before fetch_inverter_data(), where their load_power is still the previous cycle's. """ @@ -402,11 +407,20 @@ def dynamic_load_car_live_load(self): if not isinstance(entities, list): entities = [entities] total = 0.0 - for inverter_n, entity_id in enumerate(entities): - try: - power = float(self.resolve_arg("load_power", entity_id, default=None, quiet=True, required_unit="W")) - except (ValueError, TypeError): - return True, None + for inverter_n in range(len(entities)): + inverter_type = self.get_arg("inverter_type", "GE", indirect=False, index=inverter_n) + num_load_entities = INVERTER_DEF.get(inverter_type, {}).get("num_load_entities", 1) + power = 0.0 + for arg in ["load_power"] + ["load_power_{}".format(extra_n) for extra_n in range(1, num_load_entities)]: + value = self.args.get(arg, None) + if isinstance(value, list): + value = value[inverter_n] if inverter_n < len(value) else None + if not value: + continue + try: + power += float(self.resolve_arg(arg, value, default=None, quiet=True, required_unit="W")) + except (ValueError, TypeError): + return True, None if self.get_arg("load_power_invert", default=False, index=inverter_n): power = -power total += power diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 1a4f8ff2c..408479d8e 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -30,6 +30,7 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" SENSOR = "binary_sensor.car_charging_now_test" LOAD_POWER = ["sensor.load_power_test_0", "sensor.load_power_test_1"] +LOAD_POWER_EXTRA = "sensor.load_power_test_bypass" STATE_FIELDS = ( "num_cars", @@ -1244,6 +1245,8 @@ def _run_live_load(my_predbat): """ failed = False dispatch = [{"start": 840, "end": 900, "kwh": 7.0, "octopus": True}] + had_inverter_type = "inverter_type" in my_predbat.args + saved_inverter_type = my_predbat.args.get("inverter_type", None) try: print("Test 60: a low 5 minute figure with live load at the car's rate does not cancel") _reset(my_predbat) @@ -1344,8 +1347,50 @@ def _run_live_load(my_predbat): _cycle(my_predbat, 865, slots=dispatch) _cycle(my_predbat, 870, slots=dispatch) failed |= _check("t66 live 5.0kW cancels", _kwh(my_predbat) == [0], "kwh {}".format(_kwh(my_predbat))) + + # Solis splits the house load across load_power (main) and load_power_1 (bypass/backup), and the + # inverter sums the two - a car on the bypass side shows only on the second (#5462 review) + print("Test 67: an inverter's extra load_power_1 sensor is part of the live load") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.car_charging_threshold = 6.0 / 60.0 + my_predbat.battery_rate_max_discharge = 8.0 / 60.0 + my_predbat.load_last_period = 0.6 + my_predbat.args["inverter_type"] = "GS" + my_predbat.args["load_power_1"] = [LOAD_POWER_EXTRA] + _load_power(my_predbat, [400]) + my_predbat.ha_interface.dummy_items[LOAD_POWER_EXTRA] = 7200 + _cycle(my_predbat, 845, slots=dispatch) + changed = _cycle(my_predbat, 850, slots=dispatch) + failed |= _check("t67 not cancelled", (not changed) and _kwh(my_predbat) == [7.0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t67 total", my_predbat.dynamic_load_car_live_load() == (True, 7.6), "live {}".format(my_predbat.dynamic_load_car_live_load())) + # An unreadable extra sensor leaves no total + my_predbat.ha_interface.dummy_items[LOAD_POWER_EXTRA] = "unavailable" + failed |= _check("t67 unavailable extra", my_predbat.dynamic_load_car_live_load() == (True, None), "live {}".format(my_predbat.dynamic_load_car_live_load())) + _cycle(my_predbat, 855, slots=dispatch) + failed |= _check("t67 no evidence", my_predbat.dynamic_load_car_since.get(0) is None and _kwh(my_predbat) == [7.0], "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + # Both low cancels + my_predbat.ha_interface.dummy_items[LOAD_POWER_EXTRA] = 100 + _cycle(my_predbat, 860, slots=dispatch) + _cycle(my_predbat, 865, slots=dispatch) + failed |= _check("t67 both low cancels", _kwh(my_predbat) == [0], "kwh {}".format(_kwh(my_predbat))) + # The extra sensor left out of apps.yaml counts as nothing, as the inverter reads it + my_predbat.args.pop("load_power_1", None) + failed |= _check("t67 extra not set", my_predbat.dynamic_load_car_live_load() == (True, 0.4), "live {}".format(my_predbat.dynamic_load_car_live_load())) + # An inverter type with one load sensor does not read load_power_1 at all + my_predbat.args["load_power_1"] = [LOAD_POWER_EXTRA] + my_predbat.ha_interface.dummy_items[LOAD_POWER_EXTRA] = 7200 + my_predbat.args["inverter_type"] = "GE" + failed |= _check("t67 single load sensor type", my_predbat.dynamic_load_car_live_load() == (True, 0.4), "live {}".format(my_predbat.dynamic_load_car_live_load())) finally: _load_power(my_predbat, None) + my_predbat.args.pop("load_power_1", None) + my_predbat.ha_interface.dummy_items.pop(LOAD_POWER_EXTRA, None) + if had_inverter_type: + my_predbat.args["inverter_type"] = saved_inverter_type + else: + my_predbat.args.pop("inverter_type", None) return failed