diff --git a/apps/predbat/const.py b/apps/predbat/const.py index 9fd8c9ce0..734428c1b 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 +# 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 # 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 diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 3129aaad0..5ab97a54e 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -19,7 +19,7 @@ from datetime import datetime, timedelta, timezone from predbat_metrics import record_api_call from const import TIME_FORMAT, TIME_FORMAT_OCTOPUS -from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data, filter_payment_method, is_edge_block_body, token_mint_backoff_seconds, TOKEN_MINT_BACKOFF_LOG_INTERVAL_SECONDS +from utils import str2time, minutes_to_time, dp1, dp2, dp4, minute_data, round_out_to_period, filter_payment_method, is_edge_block_body, token_mint_backoff_seconds, TOKEN_MINT_BACKOFF_LOG_INTERVAL_SECONDS from component_base import ComponentBase from mock_base import MockBase as SharedMockBase import aiohttp @@ -3688,8 +3688,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): 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) + # what the tariff charged, and today's cost is built from them. None when the car is not cancelled. + strip_from = self.dynamic_load_car_strip_from(car_n) if octopus_slots: # Add in IO slots @@ -3712,8 +3712,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # Round slots to 30 minute boundary # Floor the start (round down) and ceiling the end (round up) # This ensures any partial overlap with a 30-min slot marks the entire slot as off-peak - start_minutes = (start_minutes // plan_interval_minutes) * plan_interval_minutes - end_minutes = ((end_minutes + plan_interval_minutes - 1) // plan_interval_minutes) * plan_interval_minutes + start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes, plan_interval_minutes) start_minutes = max(start_minutes, -96 * 60) # Allow for previous 2 days end_minutes = min(end_minutes, self.forecast_minutes) @@ -3727,7 +3726,7 @@ 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: + if strip_from is not None and minute >= strip_from: continue # Car is not charging, its dispatch is not trusted as cheap if minute in saved_slots: @@ -3802,7 +3801,8 @@ def dynamic_load_car_strip_feed_rates(self, rates): 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. + shared by every car. The rest of a half hour a cancelled car was seen charging in is still trusted + (see dynamic_load_car_strip_from()), whatever another cancelled car's dispatch covers. """ 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: @@ -3814,14 +3814,18 @@ def dynamic_load_car_strip_feed_rates(self, rates): 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 + strip_from = self.dynamic_load_car_strip_from(car_n) 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)) + start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) + start_minutes = max(start_minutes, self.minutes_now) + if strip_from is None: + trusted_minutes.update(range(start_minutes, end_minutes)) + else: + trusted_minutes.update(range(start_minutes, min(end_minutes, strip_from))) + cancelled_minutes.update(range(max(start_minutes, strip_from), end_minutes)) window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] window_start = window["start"][0] * 60 + window["start"][1] diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index d77ed3336..2a1e25a38 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -18,11 +18,12 @@ call to the C++ prediction kernel, which is where the threading now lives. """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from multiprocessing import cpu_count from const import ( CLOUD_FACTOR_PV10, CLOUD_WINDOW_MINUTES, + DYNAMIC_LOAD_CAR_CONFIRM_MINUTES, DYNAMIC_LOAD_CAR_LOAD_MINUTES, DYNAMIC_LOAD_CAR_SENSOR_MINUTES, DYNAMIC_LOAD_CAR_START_MINUTES, @@ -57,6 +58,7 @@ export_limit_exports_no_battery, export_limit_is_full_discharge, is_entity_id, + round_out_to_period, ) from prediction import Prediction from prediction_kernel import kernel_status_summary, set_window_start @@ -234,7 +236,7 @@ def dynamic_load(self): self.car_charging_now_slots = [[] for car_n in range(self.num_cars)] threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000 - threshold_car = self.car_charging_threshold * MINUTE_WATT / 1000 + threshold_car = self.car_charging_threshold_kw() # Last period load analysis self.load_last_status = self.dynamic_load_classify() @@ -356,6 +358,12 @@ def car_charging_slots_model(self): # Always a fresh list per car, so a caller that edits the model can never edit the published plan return [(now_slots[car_n] if car_n < len(now_slots) else []) + list(slots) for car_n, slots in enumerate(self.car_charging_slots)] + def car_charging_threshold_kw(self): + """ + car_charging_threshold in kW, the unit of load_last_period - it is held in kWh per minute. + """ + return self.car_charging_threshold * MINUTE_WATT / 1000 + def dynamic_load_classify(self): """ Classify load_last_period as "high" (above the battery's discharge rate), "low" (below both the @@ -366,7 +374,7 @@ def dynamic_load_classify(self): 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 + 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)): @@ -484,6 +492,29 @@ def dynamic_load_car_active(self, car_n): """ return self.octopus_intelligent_dynamic and self.dynamic_load_car_is_octopus(car_n) + def dynamic_load_car_strip_from(self, car_n): + """ + The first minute (since midnight_utc) from which car_n's dispatches lose their cheap rate, or None + when the car is not cancelled this cycle and they keep it. The one place that decides it, for + everything that removes the rate (rate_add_io_slots() and dynamic_load_car_strip_feed_rates()). + + Normally now, but not before the end of the half hour the car was last seen charging in: once a + dispatch has started, Octopus bills that whole half hour off-peak, so a car that reaches its + planned kWh and stops part-way through it leaves the rest still cheap for the house (GH#5316). + The car's own slots are cancelled as usual - it is no longer charging. + + The confirmation is never cleared, only outlived: it is a time, so once that half hour is over this + is simply now again. It has to outlast the dispatch, which can end (or be cut short by Octopus as + the car finishes) part-way through the half hour, leaving the car outside any dispatch and, with + octopus_intelligent_trust_slots Off, not trusted for the rest of it. + """ + if not self.dynamic_load_car_effective.get(car_n, False): + return None + confirmed = self.dynamic_load_car_confirmed.get(car_n) + if confirmed is None or self.midnight_utc is None: + return self.minutes_now + return max(self.minutes_now, int((confirmed - self.midnight_utc).total_seconds() // 60)) + def dynamic_load_car_target(self, car_n, minute, now, record=True): """ Whether car_n's slots should be cancelled at minute and now (both on the exact clock). @@ -521,6 +552,23 @@ def dynamic_load_car_target(self, car_n, minute, now, record=True): if not_charging is False: if record: self.dynamic_load_car_since.pop(car_n, None) + # The dispatch has started, so Octopus bills the whole of this half hour off-peak even if the car + # finishes early in it (GH#5316) - keep the house's cheap rate to the end of it whatever follows. + # The half hour is the one the evidence covers: the load test averages the PREDICT_STEP minutes + # before minutes_now, so a cycle just past a boundary saw the previous one. A sensor reads now, but + # can still show charging for a car that stopped just before the boundary, so it only confirms + # from DYNAMIC_LOAD_CAR_CONFIRM_MINUTES into the half hour - the slot is trusted straight away + # either way. The load test only needs load that is not low to trust the slot, which a cooker or + # hot tub also gives; keeping the cheap rate needs load at the car's own charging rate + if car_n in self.dynamic_load_car_sensors: + seen_minute = minute if minute % 30 >= DYNAMIC_LOAD_CAR_CONFIRM_MINUTES else None + elif self.load_last_period >= self.car_charging_threshold_kw(): + seen_minute = self.minutes_now - 1 + else: + seen_minute = None + if seen_minute is not None: + period_end = self.midnight_utc + timedelta(minutes=round_out_to_period(int(seen_minute), int(seen_minute) + 1)[1]) + self.dynamic_load_car_confirmed[car_n] = max(self.dynamic_load_car_confirmed.get(car_n, period_end), period_end) return False if not_charging: since = self.dynamic_load_car_since.setdefault(car_n, timed_at) if record else self.dynamic_load_car_since.get(car_n) @@ -532,6 +580,112 @@ def dynamic_load_car_target(self, car_n, minute, now, record=True): self.dynamic_load_car_since.pop(car_n, None) return cancelled + def dynamic_load_car_state(self): + """ + The dispatch-check state worth keeping across a restart, in a form storage can hold, keyed by car: + - confirmed: the end of the half hour the car was last seen charging in (dynamic_load_car_strip_from()), + while that half hour is still to come; + - run: the dispatch the car is in, with the earliest start seen for it - the start band is timed from it; + - cancelled: the decision so far, which stands while there is no evidence either way. + + All times are absolute, as minutes since midnight_utc mean something else after midnight. The "not + charging since" clock is deliberately not kept: a restart is a stretch with no evidence, after which + the running check starts the grace period afresh too (see dynamic_load_car_target()) - the car may + have charged in it - and the clock changes with every flap of an unreliable sensor, which would + rewrite the store every 15 seconds. The rest (which cars have a sensor, what has been warned about, + this cycle's decision) is rebuilt every cycle. + """ + now_minute = self.dynamic_load_car_minute(self.now_utc_real) + confirmed_cars = {car_n: confirmed for car_n, confirmed in self.dynamic_load_car_confirmed.items() if self.dynamic_load_car_minutes_of(confirmed) > now_minute} + cars = {} + for car_n in sorted(set(confirmed_cars) | set(self.dynamic_load_car_run)): + confirmed = confirmed_cars.get(car_n) + run = self.dynamic_load_car_run.get(car_n) + cars[str(car_n)] = { + "confirmed": confirmed.isoformat() if confirmed else None, + "run": {key: (self.midnight_utc + timedelta(minutes=run[key])).isoformat() for key in ("start", "end")} if run else None, + "cancelled": bool(self.dynamic_load_car_cancelled.get(car_n, False)), + } + return cars + + def dynamic_load_car_minutes_of(self, when): + """ + A time saved by the dispatch check as minutes since midnight_utc, the axis of dynamic_load_car_minute(): + the times are built from that axis, so it includes clock_skew and must be compared on it, not with + now_utc_real. + """ + return (when - self.midnight_utc).total_seconds() / 60 + + def dynamic_load_car_save(self): + """ + Save the dispatch-check state (see dynamic_load_car_state()) to storage when it has changed since + the last save, so a restart part-way through a dispatch carries on from it. Called every cycle and + from the 15 second poll, so it must cost nothing when nothing has changed - and an install with no + Octopus Intelligent car has no state, so never writes. + """ + state = self.dynamic_load_car_state() + if state == self.dynamic_load_car_saved: + return + storage = self.components.get_component("storage") if self.components else None + if not storage: + return + from ha import run_async + + # Recorded whether or not the write works: a store that is failing is not retried every 15 seconds + self.dynamic_load_car_saved = state + try: + # Expiry is on the real clock, as storage.load() checks it (see save_plan()). It only tidies up: + # dynamic_load_car_load() decides what is still current + expiry = datetime.now(timezone.utc) + timedelta(days=2) + if not run_async(storage.save("predbat", "dynamic_load_car", {"cars": state}, format="json", expiry=expiry)): + self.log("Warn: Octopus Intelligent: failed to save the car dispatch state") + except Exception as e: + self.log("Warn: Octopus Intelligent: failed to save the car dispatch state: {}".format(e)) + + def dynamic_load_car_load(self): + """ + Restore the dispatch-check state saved by dynamic_load_car_save(), at start up. + + Only what is still current is taken: a confirmed half hour that has not ended yet, and the run and + decision of a dispatch that has not ended yet. Anything older belongs to a dispatch the car has since + left, where the running check would have dropped it too. + """ + storage = self.components.get_component("storage") if self.components else None + if not storage or self.midnight_utc is None: + return + from ha import run_async + + try: + data = run_async(storage.load("predbat", "dynamic_load_car")) + except Exception as e: + self.log("Warn: Octopus Intelligent: failed to load the car dispatch state: {}".format(e)) + return + cars = data.get("cars") if isinstance(data, dict) else None + if not isinstance(cars, dict): + return + + now_minute = self.dynamic_load_car_minute(self.now_utc_real) + restored = [] + for key, car in cars.items(): + try: + car_n = int(key) + confirmed = datetime.fromisoformat(car["confirmed"]) if car.get("confirmed") else None + run = {name: self.dynamic_load_car_minutes_of(datetime.fromisoformat(car["run"][name])) for name in ("start", "end")} if car.get("run") else None + confirmed_current = confirmed is not None and self.dynamic_load_car_minutes_of(confirmed) > now_minute + run_current = run is not None and run["end"] > now_minute + except (ValueError, TypeError, KeyError, AttributeError): + continue + if confirmed_current: + self.dynamic_load_car_confirmed[car_n] = confirmed + if run_current: + # Whole minutes stay ints, as the slots they are compared with are + self.dynamic_load_car_run[car_n] = {name: int(value) if value == int(value) else value for name, value in run.items()} + self.dynamic_load_car_cancelled[car_n] = bool(car.get("cancelled", False)) + if confirmed_current or run_current: + restored.append(car_n) + if restored: + self.log("Octopus Intelligent: restored the dispatch state of car(s) {} from storage".format(restored)) + 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 @@ -612,6 +766,7 @@ def dynamic_load_car_check(self, save=True): minute = self.dynamic_load_car_minute(self.now_utc_real) for car_n in range(self.num_cars): cancelled = self.dynamic_load_car_target(car_n, minute, self.now_utc_real, record=save) + self.dynamic_load_car_effective[car_n] = cancelled if save: was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) if cancelled != was_cancelled: @@ -622,9 +777,11 @@ def dynamic_load_car_check(self, save=True): reason = "has not been seen charging in its dispatches yet, not trusting them" else: reason = "slots resumed" + strip_from = self.dynamic_load_car_strip_from(car_n) + if strip_from is not None and strip_from > self.minutes_now: + reason += " - keeping the dispatch rate until {} as it charged in this half hour".format(self.time_abs_str(strip_from)) self.log("Octopus Intelligent: 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]: @@ -636,6 +793,8 @@ def dynamic_load_car_check(self, save=True): # self-consistent if "cost" in slot: slot["cost"] = 0 + if save: + self.dynamic_load_car_save() return changed def dynamic_load_car_poll(self, now=None): @@ -662,6 +821,8 @@ def dynamic_load_car_poll(self, now=None): continue if self.dynamic_load_car_target(car_n, minute, now) != self.dynamic_load_car_cancelled.get(car_n, False): due = True + # The poll starts the grace clock and confirms half hours too + self.dynamic_load_car_save() if due: self.log("Octopus Intelligent: car charging state changed, will re-plan") diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 044f03d8a..3f8d5806a 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -555,6 +555,8 @@ def reset(self): self.dynamic_load_car_warned_iog_off = False self.dynamic_load_car_warned_dynamic_off = False self.dynamic_load_car_run = {} + self.dynamic_load_car_confirmed = {} + self.dynamic_load_car_saved = {} self.dynamic_load_car_sensors = {} self.dynamic_load_car_effective = {} self.dynamic_load_car_stripped = 0 @@ -2081,6 +2083,8 @@ def initialize(self): # Restore the last saved plan so it is immediately active before the first calculation self.load_plan() + # And the Octopus Intelligent dispatch state, so a restart part-way through a dispatch carries on from it + self.dynamic_load_car_load() except Exception as e: self.log("Error: Exception raised {}".format(e)) diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 5f032985e..3ad14275f 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -21,8 +21,12 @@ Intelligent charging built are affected - Predbat-led charging is never cancelled. """ import copy +import shutil +import tempfile from datetime import timedelta +from tests.test_infra import run_async, MockStorageComponents, make_test_storage + TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" SENSOR = "binary_sensor.car_charging_now_test" @@ -38,6 +42,8 @@ "metric_dynamic_load_adjust", "octopus_intelligent_dynamic", "dynamic_load_car_run", + "dynamic_load_car_confirmed", + "dynamic_load_car_saved", "octopus_slots", "io_adjusted", "rate_max_base", @@ -105,6 +111,8 @@ def _reset(my_predbat): my_predbat.metric_dynamic_load_adjust = False my_predbat.octopus_intelligent_dynamic = True my_predbat.dynamic_load_car_run = {} + my_predbat.dynamic_load_car_confirmed = {} + my_predbat.dynamic_load_car_saved = {} 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 @@ -358,6 +366,8 @@ def _run(my_predbat): failed |= _run_poll(my_predbat) failed |= _run_untrusted(my_predbat) failed |= _run_kwh_cancelled(my_predbat) + failed |= _run_confirmed_half_hour(my_predbat) + failed |= _run_persistence(my_predbat) finally: if had_sensor: my_predbat.args["car_charging_now"] = saved_sensor @@ -888,6 +898,330 @@ def _run_kwh_cancelled(my_predbat): return failed +def _run_confirmed_half_hour(my_predbat): + """ + A car seen charging in a dispatch that then stops part-way through a half hour - it reached its + planned kWh - still has its slots cancelled, but the house keeps the cheap rate to the end of that + half hour, which Octopus bills off-peak once the dispatch has started (GH#5316). Later half hours of + the dispatch lose it as before. + """ + 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 40: a car that charged then stopped in a half hour keeps that half hour cheap") + _reset(my_predbat) + _sensor(my_predbat, "on") + 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 + # One dispatch 06:30-07:30, as Octopus plans a long one + slots = [{"start": 390, "end": 450, "kwh": 3.0, "octopus": True}] + _cycle(my_predbat, 395, slots=slots) + failed |= _check("t40 charging trusted", not my_predbat.dynamic_load_car_cancelled.get(0, False)) + _sensor(my_predbat, "off") + _cycle(my_predbat, 402, slots=slots) + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + changed = _cycle(my_predbat, 404, 30, slots=slots) + finally: + my_predbat.log = saved_log + keep_logs = [message for message in logged if "keeping the dispatch rate until" in message] + failed |= _check("t40 logs the kept half hour", len(keep_logs) == 1, "logged {}".format(logged)) + failed |= _check("t40 car's slots still cancelled", changed and my_predbat.dynamic_load_car_cancelled.get(0) and _kwh(my_predbat) == [0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t40 strip from 07:00", my_predbat.dynamic_load_car_strip_from(0) == 420, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 390, 450)]) + failed |= _check("t40 rest of the half hour cheap", all(rates[minute] == 4.0 for minute in range(400, 420)), "rates {}".format(sorted(set(rates[minute] for minute in range(400, 420))))) + failed |= _check("t40 next half hour not cheap", all(rates[minute] == 10.0 for minute in range(420, 450)), "rates {}".format(sorted(set(rates[minute] for minute in range(420, 450))))) + + my_predbat.octopus_slots = [[_dispatch(my_predbat, 390, 450)]] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + my_predbat.io_adjusted = {} + for minute in range(390, 450): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t40 feed keeps the rest of the half hour", rates[410] == 7.0 and my_predbat.io_adjusted.get(410), "rate {}".format(rates[410])) + failed |= _check("t40 feed strips the next half hour", rates[425] == 30.0 and 425 not in my_predbat.io_adjusted, "rate {}".format(rates[425])) + + print("Test 41: once that half hour is over the rate is stripped from now") + _cycle(my_predbat, 425, slots=slots) + failed |= _check("t41 strip from now", my_predbat.dynamic_load_car_strip_from(0) == 425, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 390, 450)]) + failed |= _check("t41 rest of the dispatch not cheap", all(rates[minute] == 10.0 for minute in range(425, 450)), "rates {}".format(sorted(set(rates[minute] for minute in range(425, 450))))) + _cycle(my_predbat, 460, slots=slots) + failed |= _check("t41 nothing stripped once the dispatch is over", my_predbat.dynamic_load_car_strip_from(0) is None, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + + print("Test 42: a comparison run does not record the confirmation") + _reset(my_predbat) + _sensor(my_predbat, "on") + _cycle(my_predbat, 395, slots=slots, save=False) + failed |= _check("t42 not recorded", my_predbat.dynamic_load_car_confirmed.get(0) is None, "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + + print("Test 43: the load test confirms the half hour its reading covers, not the one it is read in") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + # Dispatch 06:30-08:00; the car charges until 07:00, and the 07:00:20 cycle reads 06:55-07:00's load + long_slots = [{"start": 390, "end": 480, "kwh": 6.0, "octopus": True}] + my_predbat.load_last_period = 4.0 + _cycle(my_predbat, 420, 20, slots=long_slots) + failed |= _check("t43 confirms 06:30-07:00", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 425, slots=long_slots) + changed = _cycle(my_predbat, 435, slots=long_slots) + failed |= _check("t43 cancelled", changed and my_predbat.dynamic_load_car_cancelled.get(0), "changed {}".format(changed)) + failed |= _check("t43 07:00-07:30 not kept", my_predbat.dynamic_load_car_strip_from(0) == 435, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + + print("Test 44: load that is not low but below the car's charging rate trusts the slot without keeping the half hour") + _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 + 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))) + failed |= _check("t44 not confirmed", my_predbat.dynamic_load_car_confirmed.get(0) is None, "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + + print("Test 45: trust Off - a dispatch that ends part-way through the half hour the car charged in keeps it cheap") + _reset(my_predbat) + my_predbat.octopus_intelligent_trust_slots = False + _sensor(my_predbat, "on") + # Octopus shortens the dispatch to 06:30-06:45 as the car finishes, so by 06:46 the car is outside any + # dispatch and, with trust Off, back to not trusted + short_slots = [{"start": 390, "end": 405, "kwh": 1.3, "octopus": True}] + _cycle(my_predbat, 395, slots=short_slots) + failed |= _check("t45 charging trusted", not my_predbat.dynamic_load_car_cancelled.get(0, False)) + _sensor(my_predbat, "off") + _cycle(my_predbat, 406, slots=short_slots) + failed |= _check("t45 untrusted outside the dispatch", my_predbat.dynamic_load_car_cancelled.get(0) is True, "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) + failed |= _check("t45 strip from 07:00", my_predbat.dynamic_load_car_strip_from(0) == 420, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 390, 405)]) + failed |= _check("t45 rest of the half hour cheap", all(rates[minute] == 4.0 for minute in range(405, 420)), "rates {}".format(sorted(set(rates[minute] for minute in range(405, 420))))) + + print("Test 46: the 15 second poll confirms the half hour too, between plan cycles") + _reset(my_predbat) + _sensor(my_predbat, "on") + # The dispatch starts at 06:31, after the 06:30 cycle, and the car has stopped again by the 06:35 one: + # only the poll ever sees it charging + late_slots = [{"start": 391, "end": 450, "kwh": 3.0, "octopus": True}] + _cycle(my_predbat, 390, slots=late_slots) + failed |= _check("t46 not confirmed before the dispatch", my_predbat.dynamic_load_car_confirmed.get(0) is None, "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=392)) + failed |= _check("t46 poll confirms 06:30-07:00", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + _sensor(my_predbat, "off") + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=394, seconds=30)) + changed = _cycle(my_predbat, 397, slots=late_slots) + failed |= _check("t46 cancelled", changed and my_predbat.dynamic_load_car_cancelled.get(0), "changed {}".format(changed)) + failed |= _check("t46 strip from 07:00", my_predbat.dynamic_load_car_strip_from(0) == 420, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + + print("Test 47: two cancelled cars - a half hour one of them charged in stays cheap whatever the other's dispatch covers") + _reset(my_predbat) + _at(my_predbat, 404) + my_predbat.num_cars = 2 + # Both cars have a 06:30-07:30 dispatch and both are cancelled; only car 0 was seen charging, until 06:41 + my_predbat.dynamic_load_car_effective = {0: True, 1: True} + my_predbat.dynamic_load_car_confirmed = {0: my_predbat.midnight_utc + timedelta(minutes=420)} + failed |= _check( + "t47 strip from", my_predbat.dynamic_load_car_strip_from(0) == 420 and my_predbat.dynamic_load_car_strip_from(1) == 400, "strip from {} {}".format(my_predbat.dynamic_load_car_strip_from(0), my_predbat.dynamic_load_car_strip_from(1)) + ) + both = [_dispatch(my_predbat, 390, 450)] + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(1, rates, both) + failed |= _check("t47 car 1 alone gives no cheap rate", all(rates[minute] == 10.0 for minute in range(400, 450)), "rates {}".format(sorted(set(rates[minute] for minute in range(400, 450))))) + rates = my_predbat.rate_add_io_slots(0, rates, both) + failed |= _check( + "t47 car 0 keeps its half hour cheap", all(rates[minute] == 4.0 for minute in range(400, 420)) and all(rates[minute] == 10.0 for minute in range(420, 450)), "rates {}".format(sorted(set(rates[minute] for minute in range(400, 450)))) + ) + + my_predbat.octopus_slots = [both, both] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + my_predbat.io_adjusted = {} + for minute in range(390, 450): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t47 feed keeps the rest of the half hour", rates[410] == 7.0 and my_predbat.io_adjusted.get(410), "rate {}".format(rates[410])) + failed |= _check("t47 feed strips the next half hour", rates[425] == 30.0 and 425 not in my_predbat.io_adjusted, "rate {}".format(rates[425])) + # A third car that is not cancelled at all is still trusted for its own dispatch, as before + my_predbat.num_cars = 3 + my_predbat.octopus_slots = [both, both, [_dispatch(my_predbat, 420, 450)]] + for minute in range(390, 450): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t47 a trusted car's dispatch is kept", rates[425] == 7.0 and my_predbat.io_adjusted.get(425), "rate {}".format(rates[425])) + + print("Test 48: a charging reading in the first 2 minutes of a half hour trusts the slot but does not confirm the half hour") + _reset(my_predbat) + _sensor(my_predbat, "on") + _cycle(my_predbat, 410, slots=slots) + failed |= _check("t48 06:30-07:00 confirmed", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + # The car stopped at 06:59:50 but the sensor still shows it charging at 07:00:05 and 07:01:50 + for seconds in (5, 110): + _cycle(my_predbat, 420, seconds, slots=slots) + failed |= _check("t48 trusted at 07:00:{:02d}".format(seconds), not my_predbat.dynamic_load_car_cancelled.get(0, False)) + failed |= _check("t48 07:00-07:30 not confirmed at {}s".format(seconds), my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + _sensor(my_predbat, "off") + _cycle(my_predbat, 422, 30, slots=slots) + changed = _cycle(my_predbat, 425, slots=slots) + failed |= _check("t48 cancelled", changed and my_predbat.dynamic_load_car_cancelled.get(0), "changed {}".format(changed)) + failed |= _check("t48 stripped from now", my_predbat.dynamic_load_car_strip_from(0) == 425, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + # Still charging 2 minutes in is the car, not the sensor lagging + _reset(my_predbat) + _sensor(my_predbat, "on") + _cycle(my_predbat, 422, slots=slots) + failed |= _check("t48 confirmed 2 minutes in", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=450), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + 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 _restart(my_predbat): + """ + Lose the in-memory dispatch-check state, as a restart does, then restore it from storage. + """ + my_predbat.dynamic_load_car_confirmed = {} + my_predbat.dynamic_load_car_since = {} + my_predbat.dynamic_load_car_cancelled = {} + my_predbat.dynamic_load_car_run = {} + my_predbat.dynamic_load_car_saved = {} + my_predbat.dynamic_load_car_load() + + +def _run_persistence(my_predbat): + """ + The dispatch-check state is saved to storage whenever it changes and restored after a restart, so a + restart part-way through a dispatch neither loses the kept half hour (GH#5316) nor restarts the start + band or the cancellation. The grace period does start again, as after any stretch with no evidence. + """ + failed = False + saved_components = my_predbat.components + tmpdir = tempfile.mkdtemp() + try: + storage = make_test_storage(my_predbat, tmpdir) + saves = [] + backend_save = storage.backend.save + + async def counting_save(module, filename, data, **kwargs): + """Count the writes, then make them.""" + saves.append(filename) + return await backend_save(module, filename, data, **kwargs) + + storage.backend.save = counting_save + slots = [{"start": 390, "end": 450, "kwh": 3.0, "octopus": True}] + + print("Test 49: the confirmed half hour and the dispatch run survive a restart, the grace clock starts again") + _reset(my_predbat) + my_predbat.components = MockStorageComponents(storage) + _sensor(my_predbat, "on") + _cycle(my_predbat, 395, slots=slots) + _sensor(my_predbat, "off") + _cycle(my_predbat, 402, slots=slots) + since = my_predbat.dynamic_load_car_since.get(0) + failed |= _check("t49 grace clock started", since is not None) + _at(my_predbat, 403) + _restart(my_predbat) + failed |= _check("t49 confirmed restored", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + failed |= _check("t49 grace clock not restored", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since)) + failed |= _check("t49 run restored", my_predbat.dynamic_load_car_run.get(0) == {"start": 390, "end": 450}, "run {}".format(my_predbat.dynamic_load_car_run)) + failed |= _check("t49 still trusted", my_predbat.dynamic_load_car_cancelled.get(0) is False, "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) + # The car may have charged while Predbat was down, so the grace runs from the first reading after the restart + changed = _cycle(my_predbat, 404, 30, slots=slots) + failed |= _check("t49 not cancelled on the old clock", not changed and not my_predbat.dynamic_load_car_cancelled.get(0), "changed {}".format(changed)) + changed = _cycle(my_predbat, 406, 30, slots=slots) + failed |= _check("t49 cancelled after a fresh grace", changed and my_predbat.dynamic_load_car_cancelled.get(0), "changed {}".format(changed)) + failed |= _check("t49 strip from 07:00", my_predbat.dynamic_load_car_strip_from(0) == 420, "strip from {}".format(my_predbat.dynamic_load_car_strip_from(0))) + + print("Test 50: a cancellation survives a restart, with no evidence either way after it") + _sensor(my_predbat, "unavailable") + _at(my_predbat, 407) + _restart(my_predbat) + failed |= _check("t50 cancelled restored", my_predbat.dynamic_load_car_cancelled.get(0) is True, "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) + _cycle(my_predbat, 408, slots=slots) + failed |= _check("t50 still cancelled", my_predbat.dynamic_load_car_cancelled.get(0) and _kwh(my_predbat) == [0], "kwh {}".format(_kwh(my_predbat))) + + print("Test 51: state is only written when it changes - not for a sensor flapping between unavailable and off") + count = len(saves) + _cycle(my_predbat, 409, slots=slots) + my_predbat.dynamic_load_car_poll(my_predbat.now_utc_real) + failed |= _check("t51 no write when unchanged", len(saves) == count, "saves {} was {}".format(len(saves), count)) + for step, state in enumerate(["off", "unavailable", "off", "unavailable"]): + _sensor(my_predbat, state) + my_predbat.dynamic_load_car_poll(my_predbat.midnight_utc + timedelta(minutes=409, seconds=15 * (step + 1))) + failed |= _check("t51 no write for a flapping sensor", len(saves) == count, "saves {} was {}".format(len(saves), count)) + + print("Test 52: state from a dispatch that is over is not restored, and a confirmation is not kept once its half hour is over") + _cycle(my_predbat, 425, slots=slots) + saved = run_async(storage.load("predbat", "dynamic_load_car")) + failed |= _check("t52 expired confirmation not saved", saved["cars"]["0"]["confirmed"] is None and saved["cars"]["0"]["run"] is not None, "saved {}".format(saved)) + _at(my_predbat, 455) + _restart(my_predbat) + failed |= _check( + "t52 nothing restored", + not my_predbat.dynamic_load_car_confirmed and not my_predbat.dynamic_load_car_cancelled and not my_predbat.dynamic_load_car_run and not my_predbat.dynamic_load_car_since, + "confirmed {} cancelled {} run {}".format(my_predbat.dynamic_load_car_confirmed, my_predbat.dynamic_load_car_cancelled, my_predbat.dynamic_load_car_run), + ) + + print("Test 53: what is still current is judged on the clock_skew clock the times were recorded on") + saved_skew = my_predbat.args.get("clock_skew", None) + try: + _reset(my_predbat) + my_predbat.components = MockStorageComponents(storage) + _sensor(my_predbat, "on") + # With the clock 5 minutes fast, 06:52 real is 06:57 on the plan's clock + my_predbat.args["clock_skew"] = 5 + _cycle(my_predbat, 387, slots=slots) + failed |= _check("t53 confirmed 06:30-07:00 on the skewed clock", my_predbat.dynamic_load_car_confirmed.get(0) == my_predbat.midnight_utc + timedelta(minutes=420), "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + # Real 06:57 is 07:02 on the plan's clock: that half hour is over + _at(my_predbat, 417) + _restart(my_predbat) + failed |= _check("t53 over on the skewed clock is not restored", my_predbat.dynamic_load_car_confirmed.get(0) is None, "confirmed {}".format(my_predbat.dynamic_load_car_confirmed)) + finally: + if saved_skew is None: + my_predbat.args.pop("clock_skew", None) + else: + my_predbat.args["clock_skew"] = saved_skew + + print("Test 54: a comparison run, a car with no dispatch and a missing or broken store write and restore nothing") + tmpdir2 = tempfile.mkdtemp() + try: + storage2 = make_test_storage(my_predbat, tmpdir2) + _reset(my_predbat) + my_predbat.components = MockStorageComponents(storage2) + _sensor(my_predbat, "on") + _cycle(my_predbat, 395, slots=slots, save=False) + _cycle(my_predbat, 395, slots=[]) + failed |= _check("t54 nothing written", run_async(storage2.load("predbat", "dynamic_load_car")) is None) + run_async(storage2.save("predbat", "dynamic_load_car", ["not", "a", "dict"], format="json")) + _restart(my_predbat) + run_async(storage2.save("predbat", "dynamic_load_car", {"cars": {"x": 1, "0": {"confirmed": "junk", "run": {"start": "junk"}}}}, format="json")) + _restart(my_predbat) + failed |= _check("t54 junk ignored", not my_predbat.dynamic_load_car_confirmed and not my_predbat.dynamic_load_car_run, "confirmed {} run {}".format(my_predbat.dynamic_load_car_confirmed, my_predbat.dynamic_load_car_run)) + for components in (None, MockStorageComponents(None)): + my_predbat.components = components + _cycle(my_predbat, 395, slots=slots) + _restart(my_predbat) + finally: + shutil.rmtree(tmpdir2, ignore_errors=True) + finally: + my_predbat.components = saved_components + shutil.rmtree(tmpdir, ignore_errors=True) + 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. diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index cd432a4dc..fb7fa1a23 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -50,6 +50,32 @@ def run_async(coro): return loop.run_until_complete(coro) +class MockStorageComponents: + """ + Minimal components mock returning a pre-configured storage component, for tests of code that saves + and loads through Storage. + """ + + def __init__(self, storage): + """Initialise with a storage instance (may be None to simulate unavailable).""" + self._storage = storage + + def get_component(self, name): + """Return the mocked storage for 'storage', None for everything else.""" + if name == "storage": + return self._storage + return None + + +def make_test_storage(predbat, tmpdir): + """Create a StorageComponent backed by a real local-file backend in tmpdir.""" + from storage import StorageComponent, StorageLocalFiles + + storage = StorageComponent(predbat) + storage.backend = StorageLocalFiles(tmpdir, predbat.log) + return storage + + def create_aiohttp_mock_response(status=200, json_data=None, json_exception=None): """Create a mock aiohttp response object""" mock_response = MagicMock() diff --git a/apps/predbat/tests/test_plan_persistence.py b/apps/predbat/tests/test_plan_persistence.py index 26ef538ed..7691ba38b 100644 --- a/apps/predbat/tests/test_plan_persistence.py +++ b/apps/predbat/tests/test_plan_persistence.py @@ -12,31 +12,9 @@ import tempfile from datetime import datetime, timedelta, timezone -from storage import StorageComponent, StorageLocalFiles from utils import pack_export_limit from const import EXPORT_MODE_TARGET -from tests.test_infra import run_async - - -class _MockComponents: - """Minimal components mock returning a pre-configured storage component.""" - - def __init__(self, storage): - """Initialise with a storage instance (may be None to simulate unavailable).""" - self._storage = storage - - def get_component(self, name): - """Return the mocked storage for 'storage', None for everything else.""" - if name == "storage": - return self._storage - return None - - -def _make_storage(predbat, tmpdir): - """Create a StorageComponent backed by a real local-file backend in tmpdir.""" - storage = StorageComponent(predbat) - storage.backend = StorageLocalFiles(tmpdir, predbat.log) - return storage +from tests.test_infra import run_async, MockStorageComponents, make_test_storage def test_plan_persistence(my_predbat): @@ -46,8 +24,8 @@ def test_plan_persistence(my_predbat): tmpdir = tempfile.mkdtemp() try: - storage = _make_storage(my_predbat, tmpdir) - my_predbat.components = _MockComponents(storage) + storage = make_test_storage(my_predbat, tmpdir) + my_predbat.components = MockStorageComponents(storage) # Drive the fixture clock a long way from the real one for the round trip below. Both # clocks agreeing is what let the wrong-clock bug hide: an expiry of now_utc + 8h is @@ -122,7 +100,7 @@ def test_plan_persistence(my_predbat): print(" Test 2: load_plan() with no saved plan") tmpdir2 = tempfile.mkdtemp() try: - my_predbat.components = _MockComponents(_make_storage(my_predbat, tmpdir2)) + my_predbat.components = MockStorageComponents(make_test_storage(my_predbat, tmpdir2)) my_predbat.plan_valid = False my_predbat.load_plan() if my_predbat.plan_valid: @@ -135,8 +113,8 @@ def test_plan_persistence(my_predbat): print(" Test 2b: load_plan() ignores non-dict stored plan") tmpdir2b = tempfile.mkdtemp() try: - storage2b = _make_storage(my_predbat, tmpdir2b) - my_predbat.components = _MockComponents(storage2b) + storage2b = make_test_storage(my_predbat, tmpdir2b) + my_predbat.components = MockStorageComponents(storage2b) run_async(storage2b.save("predbat", "plan", ["not", "a", "dict"], format="json")) my_predbat.plan_valid = False try: @@ -152,7 +130,7 @@ def test_plan_persistence(my_predbat): # 3. load_plan() with storage component unavailable is a safe no-op print(" Test 3: load_plan() with storage unavailable") - my_predbat.components = _MockComponents(None) + my_predbat.components = MockStorageComponents(None) my_predbat.plan_valid = False my_predbat.load_plan() if my_predbat.plan_valid: @@ -161,7 +139,7 @@ def test_plan_persistence(my_predbat): # 4. save_plan() with storage component unavailable is a safe no-op print(" Test 4: save_plan() with storage unavailable") - my_predbat.components = _MockComponents(None) + my_predbat.components = MockStorageComponents(None) try: my_predbat.save_plan() except Exception as e: @@ -172,8 +150,8 @@ def test_plan_persistence(my_predbat): print(" Test 5: load_plan() with expired stored plan") tmpdir3 = tempfile.mkdtemp() try: - storage3 = _make_storage(my_predbat, tmpdir3) - my_predbat.components = _MockComponents(storage3) + storage3 = make_test_storage(my_predbat, tmpdir3) + my_predbat.components = MockStorageComponents(storage3) # Opposite pin to Test 1: a fixture clock in the future, so building the "already # expired" timestamp from now_utc would put it ahead of real time and the plan @@ -211,8 +189,8 @@ def test_plan_persistence(my_predbat): print(" Test 6: GitHub URL cache save/load round-trip") tmpdir4 = tempfile.mkdtemp() try: - storage4 = _make_storage(my_predbat, tmpdir4) - my_predbat.components = _MockComponents(storage4) + storage4 = make_test_storage(my_predbat, tmpdir4) + my_predbat.components = MockStorageComponents(storage4) my_predbat.github_url_cache_loaded = False my_predbat.github_url_cache = {} diff --git a/apps/predbat/utils.py b/apps/predbat/utils.py index 34186a651..135b5d254 100644 --- a/apps/predbat/utils.py +++ b/apps/predbat/utils.py @@ -1831,6 +1831,14 @@ def in_iboost_slot(minute, iboost_plan): return load_amount +def round_out_to_period(start_minutes, end_minutes, period=30): + """ + Round a span of minutes out to whole periods: the start down and the end up, so any overlap with a + period takes the whole of it. Octopus prices and bills a dispatch by whole half hours. + """ + return (start_minutes // period) * period, ((end_minutes + period - 1) // period) * period + + def in_car_slot(minute, num_cars, car_charging_slots, slot_cap=None, slot_cap_period=None): """ Is the given minute inside a car slot diff --git a/docs/car-charging.md b/docs/car-charging.md index ed97beb6a..ac177fc3b 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -541,6 +541,8 @@ With **car_charging_now**, Predbat checks the sensor every 15 seconds between pl **What happens when the car is not charging** - That dispatch slot and every later one for the car are cancelled: Predbat no longer holds the battery for the car ("Hold for car"), no longer predicts the car's load, and no longer uses the dispatch's cheap rate for the house battery. The overnight 23:30-05:30 rate stays cheap, as that is part of the tariff. +- If the car was seen charging earlier in the same half hour (for example it finished its planned kWh early), the house keeps the cheap rate until the end of that half hour: once a dispatch has started, Octopus bills the whole half hour off-peak. Later half hours of the dispatch lose it as above. A **car_charging_now** reading only counts for this from 2 minutes into the half hour, as the sensor can still show charging for a car that stopped just before it. Without **car_charging_now**, this needs the house load to have reached **car_charging_threshold**, so a cooker or hot tub alone does not count. +- This is remembered across a Predbat restart, as is which cars are currently cancelled, so restarting part-way through a dispatch does not lose the kept half hour or briefly trust a slot the car has stopped charging in. - The slots come back as soon as the car starts charging again, or the dispatch ends. - Cancelled slots 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 schedule even though the plan is not counting on it. - The log shows `Octopus Intelligent: car 0 is in a dispatch but not charging, cancelling its slots`, and later `Octopus Intelligent: car 0 slots resumed`.