diff --git a/.gitignore b/.gitignore index f4e7f4768b175b..fee4c478b9807e 100644 --- a/.gitignore +++ b/.gitignore @@ -139,4 +139,5 @@ tmp_cache # results testrun*/ -results/ \ No newline at end of file +results/ +experiment_scripts/config-* \ No newline at end of file diff --git a/config/rasc_debug.yaml b/config/rasc_debug.yaml index 0fe3a311ea97c5..52999f4216f9d2 100644 --- a/config/rasc_debug.yaml +++ b/config/rasc_debug.yaml @@ -9,7 +9,8 @@ optimal_schedule_metric: "min_avg_rtn_latency" rescheduling_window: 30 routine_priority_policy: "earliest" # from shortest, longest, earliest, latest record_results: true -routine_arrival_filename: "arrival_morning.csv" +routine_arrival_filename: "arrival_debug.csv" +overhead_measurement: true # rescheduling_estimation: true # rescheduling_accuracy: "reschedule_all" # mthresh: 1 diff --git a/experiment_scripts/overhead_experiments.sh b/experiment_scripts/overhead_experiments.sh new file mode 100755 index 00000000000000..248e042033f4d6 --- /dev/null +++ b/experiment_scripts/overhead_experiments.sh @@ -0,0 +1,40 @@ +# Run overhead experiments +# create 10 copies of the config file +estimations=(mean p50 p70 p80 p90 p95 p99) +triggers=(reactive anticipatory proactive) +dataset="${1:-morning}" +case $dataset in + morning|afternoon|evening|all) + # do nothing + ;; + *) + # error + echo 'dataset is not one of morning|afternoon|evening|all' + exit 1 +esac + +rm -rf experiment_scripts/config-* + +echo "Dataset: $dataset" +for i in ${!estimations[@]} +do + for j in ${!triggers[@]} + do + echo "Running overhead experiment (${estimations[$i]}, ${triggers[$j]})" + cp -r ./config ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]} + sed -i "s/action_length_estimation: mean/action_length_estimation: ${estimations[$i]}/g" ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]}/rasc.yaml + sed -i "s/rescheduling_trigger: proactive/rescheduling_trigger: ${triggers[$j]}/g" ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]}/rasc.yaml + sed -i "s/overhead_measurement: false/overhead_measurement: true/g" ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]}/rasc.yaml + hass -c ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]} + rm -rf ./experiment_scripts/config-${estimations[$i]}-${triggers[$j]} + done +done + +# Run baseline +echo "Running overhead experiment baseline" +cp -r ./config ./experiment_scripts/config-baseline +sed -i "s/enabled: true/enabled: false/g" ./experiment_scripts/config-baseline/rasc.yaml +sed -i "s/routine_arrival_filename: arrival_morning.csv/routine_arrival_filename: arrival_$dataset.csv/g" ./experiment_scripts/config-baseline/rasc.yaml +sed -i "s/overhead_measurement: false/overhead_measurement: true/g" ./experiment_scripts/config-baseline/rasc.yaml +hass -c ./experiment_scripts/config-baseline +rm -rf ./experiment_scripts/config-baseline \ No newline at end of file diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index c5ff6bfe9d22ad..19662fefaef9b5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass +import json import logging import os from typing import Any, Optional, Protocol, cast @@ -33,6 +34,7 @@ CONF_ZONE, DOMAIN_RASCALSCHEDULER, EVENT_HOMEASSISTANT_STARTED, + OVERHEAD_MEASUREMENT, SERVICE_RELOAD, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -262,17 +264,17 @@ def trigger_automations_later( return automations = list(component.entities) - arrival_time = 10.0 + arrival_time = 0.0 routine_arrivals = dict[str, list[float]]() routine_aliases = dict[str, str]() with open(routine_arrival_pathname, encoding="utf-8") as f: for line in f: - interarrival_time, routine_id, alias = line.strip().split(",") + interarrival_time, routine_id, routine_alias = line.strip().split(",") arrival_time = arrival_time + float(interarrival_time) if routine_id not in routine_arrivals: routine_arrivals[routine_id] = [] routine_arrivals[routine_id].append(arrival_time) - routine_aliases[routine_id] = alias + routine_aliases[routine_id] = routine_alias async def trigger_automation_later( automation: BaseAutomationEntity, arrival_time: float @@ -285,7 +287,7 @@ async def trigger_automation_later( ) await asyncio.sleep(arrival_time) await automation.async_trigger({"trigger": {"platform": None}}) - if not config["rasc"].get("enabled"): + if "rasc" not in hass.data: # if rasc is not enabled, assume all routines take 30 seconds await asyncio.sleep(30) hass.bus.async_fire( @@ -308,15 +310,15 @@ async def trigger_automation_later( def handle_routine_ended(event: Event) -> None: routine_id = event.data["routine_id"].split("-")[0] remained_routines[routine_id] -= 1 - # print( - # json.dumps( - # { - # routine_aliases[routine_id]: remains - # for routine_id, remains in remained_routines.items() - # }, - # indent=2, - # ) - # ) + print( + json.dumps( + { + routine_aliases[routine_id]: remains + for routine_id, remains in remained_routines.items() + }, + indent=2, + ) + ) if all( remained_routine == 0 for remained_routine in remained_routines.values() ): @@ -410,7 +412,7 @@ async def reload_service_handler(service_call: ServiceCall) -> None: websocket_api.async_register_command(hass, websocket_config) - if config["rasc"]["overhead_measurement"]: + if config["rasc"].get(OVERHEAD_MEASUREMENT): def run_experiments(_: Event) -> None: routine_arrival_filename: str = config["rasc"][ @@ -725,7 +727,7 @@ async def async_trigger( reason = f' by {run_variables["trigger"]["description"]}' if "alias" in run_variables["trigger"]: alias = f' trigger \'{run_variables["trigger"]["alias"]}\'' - self._logger.debug("Automation%s triggered%s", alias, reason) + self._logger.debug("Automation %s triggered%s", alias, reason) # Create a new context referring to the old context. parent_id = None if context is None else context.id diff --git a/homeassistant/components/rasc/__init__.py b/homeassistant/components/rasc/__init__.py index b0529a7f968a5e..19414f8794ebcd 100644 --- a/homeassistant/components/rasc/__init__.py +++ b/homeassistant/components/rasc/__init__.py @@ -6,9 +6,12 @@ import os import shutil +import numpy as np import voluptuous as vol +from homeassistant.components.climate import SERVICE_SET_TEMPERATURE from homeassistant.const import ( + ACTION_LENGTH_ESTIMATION, ANTICIPATORY, CONF_OPTIMAL_SCHEDULE_METRIC, CONF_RECORD_RESULTS, @@ -18,10 +21,12 @@ CONF_ROUTINE_ARRIVAL_FILENAME, CONF_ROUTINE_PRIORITY_POLICY, CONF_SCHEDULING_POLICY, + DO_COMPARISON, DOMAIN_RASCALRESCHEDULER, DOMAIN_RASCALSCHEDULER, EARLIEST, EARLY_START, + EVENT_HOMEASSISTANT_STARTED, FCFS, FCFS_POST, GLOBAL_FIRST, @@ -35,6 +40,7 @@ LONGEST, MAX_AVG_PARALLELISM, MAX_P05_PARALLELISM, + MEAN_ESTIMATION, MIN_AVG_IDLE_TIME, MIN_AVG_RTN_LATENCY, MIN_AVG_RTN_WAIT_TIME, @@ -46,6 +52,13 @@ NONE, OPTIMALW, OPTIMALWO, + OVERHEAD_MEASUREMENT, + P50_ESTIMATION, + P70_ESTIMATION, + P80_ESTIMATION, + P90_ESTIMATION, + P95_ESTIMATION, + P99_ESTIMATION, PROACTIVE, REACTIVE, RESCHEDULE_ALL, @@ -68,9 +81,20 @@ CONF_RESULTS_DIR, DOMAIN, LOGGER, + RASC_ACTION, + RASC_ENTITY_ID, + RASC_EXPERIMENT_SETTING, + RASC_FIXED_HISTORY, + RASC_INTERRUPTION_MOMENT, + RASC_INTERRUPTION_TIME, RASC_SLO, + RASC_THERMOSTAT, + RASC_THERMOSTAT_START, + RASC_THERMOSTAT_TARGET, + RASC_USE_UNIFORM, RASC_WORST_Q, SUPPORTED_PLATFORMS, + CONF_ENABLED, ) from .helpers import OverheadMeasurement from .rescheduler import RascalRescheduler @@ -107,13 +131,16 @@ ] supported_routine_priority_policies = [SHORTEST, LONGEST, EARLIEST, LATEST] supported_rescheduling_accuracies = [RESCHEDULE_ALL, RESCHEDULE_SOME] +supported_action_length_estimations = [MEAN_ESTIMATION, P50_ESTIMATION, P70_ESTIMATION, P80_ESTIMATION, P90_ESTIMATION, P50_ESTIMATION, P70_ESTIMATION, P80_ESTIMATION, P90_ESTIMATION, P95_ESTIMATION, P99_ESTIMATION] CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Optional(CONF_ENABLED, default=True): bool, - vol.Optional("overhead_measurement", default=False): bool, + vol.Optional(OVERHEAD_MEASUREMENT, default=False): bool, + vol.Optional(ACTION_LENGTH_ESTIMATION, default="mean"): vol.In(supported_action_length_estimations), + vol.Optional(DO_COMPARISON, default=True): bool, vol.Optional(CONF_SCHEDULING_POLICY, default=TIMELINE): vol.In( supported_scheduling_policies ), @@ -140,6 +167,12 @@ ), vol.Optional("mthresh", default=1.0): cv.positive_float, # seconds vol.Optional("mithresh", default=2.0): cv.positive_float, # seconds + # vol.Optional(RESCHEDULING_ESTIMATION, default=True): cv.boolean, + # vol.Optional(RESCHEDULING_ACCURACY, default=RESCHEDULE_ALL): vol.In( + # supported_rescheduling_accuracies + # ), + # vol.Optional("mthresh", default=1.0): cv.positive_float, # seconds + # vol.Optional("mithresh", default=2.0): cv.positive_float, # seconds **{ vol.Optional(platform.value): vol.Schema( { @@ -149,7 +182,22 @@ ) for platform in SUPPORTED_PLATFORMS }, - }, + vol.Optional(RASC_USE_UNIFORM): cv.boolean, + vol.Optional(RASC_FIXED_HISTORY): cv.boolean, + vol.Optional(RASC_EXPERIMENT_SETTING): vol.Schema( + { + vol.Required(RASC_ENTITY_ID): cv.string, + vol.Required(RASC_ACTION): cv.string, + vol.Optional(RASC_INTERRUPTION_MOMENT): cv.positive_float, + vol.Optional(RASC_THERMOSTAT): vol.Schema( + { + vol.Required(RASC_THERMOSTAT_START): cv.string, + vol.Required(RASC_THERMOSTAT_TARGET): cv.string, + } + ), + } + ), + } ) }, extra=vol.ALLOW_EXTRA, @@ -158,6 +206,49 @@ LOGGER.level = logging.DEBUG +def run_experiments(hass: HomeAssistant, rasc: RASCAbstraction): + """Run experiments.""" + + async def wrapper(_): + settings = rasc.config[RASC_EXPERIMENT_SETTING] + key = f"{settings[RASC_ENTITY_ID]},{settings[RASC_ACTION]}" + if settings[RASC_ACTION] == SERVICE_SET_TEMPERATURE: + if RASC_THERMOSTAT not in settings: + raise ValueError("Thermostat setting not found") + key += f",{settings[RASC_THERMOSTAT][RASC_THERMOSTAT_START]},{settings[RASC_THERMOSTAT][RASC_THERMOSTAT_TARGET]}" + for i, level in enumerate(range(0, 105, 5)): + LOGGER.debug("RUN: %d, level=%d", i + 1, level) + avg_complete_time = np.mean(rasc.get_history(key)) + interruption_time = avg_complete_time * level * 0.01 + interruption_moment = settings[RASC_INTERRUPTION_MOMENT] + # a_coro, s_coro, c_coro = hass.services.rasc_call("cover", "open_cover", {"entity_id": "cover.rpi_device_door"}) + a_coro, s_coro, c_coro = hass.services.rasc_call( + "climate", + "set_temperature", + {"temperature": 69, "entity_id": "climate.rpi_device_thermostat"}, + { + RASC_INTERRUPTION_TIME: interruption_time, + RASC_INTERRUPTION_MOMENT: interruption_moment, + }, + ) + await a_coro + await s_coro + await c_coro + LOGGER.debug("complete!68->69") + # a_coro, s_coro, c_coro = hass.services.rasc_call("cover", "close_cover", {"entity_id": "cover.rpi_device_door"}) + a_coro, s_coro, c_coro = hass.services.rasc_call( + "climate", + "set_temperature", + {"temperature": 68, "entity_id": "climate.rpi_device_thermostat"}, + ) + await a_coro + await s_coro + await c_coro + LOGGER.debug("complete!69->68") + + return wrapper + + def _create_result_dir() -> str: """Create the result directory.""" if not os.path.exists(CONF_RESULTS_DIR): @@ -183,7 +274,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # cpu/memory measurement - om = OverheadMeasurement(hass.loop, config[DOMAIN]) + om = OverheadMeasurement(hass, config[DOMAIN]) hass.bus.async_listen_once("rasc_measurement_start", lambda _: om.start()) hass.bus.async_listen_once("rasc_measurement_stop", lambda _: om.stop()) @@ -209,4 +300,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_load() + if RASC_EXPERIMENT_SETTING in config[DOMAIN]: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, run_experiments(hass, component) + ) + return True diff --git a/homeassistant/components/rasc/abstraction.py b/homeassistant/components/rasc/abstraction.py index 83c806264e3328..b89ebc4b8ad7b9 100644 --- a/homeassistant/components/rasc/abstraction.py +++ b/homeassistant/components/rasc/abstraction.py @@ -14,11 +14,19 @@ from homeassistant.components import notify from homeassistant.const import ( + ACTION_LENGTH_ESTIMATION, ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_EVENT, CONF_SERVICE, CONF_SERVICE_DATA, + MEAN_ESTIMATION, + P50_ESTIMATION, + P70_ESTIMATION, + P80_ESTIMATION, + P90_ESTIMATION, + P95_ESTIMATION, + P99_ESTIMATION, ) from homeassistant.core import ( HassJobType, @@ -28,7 +36,11 @@ ServiceResponse, callback, ) -from homeassistant.helpers.dynamic_polling import get_best_distribution, get_polls +from homeassistant.helpers.dynamic_polling import ( + get_best_distribution, + get_polls, + get_uniform_polls, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -39,7 +51,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util -from .const import DEFAULT_FAILURE_TIMEOUT, LOGGER, RASC_ACK, RASC_COMPLETE, RASC_START +from .const import ( + DEFAULT_FAILURE_TIMEOUT, + LOGGER, + RASC_ACK, + RASC_COMPLETE, + RASC_FIXED_HISTORY, + RASC_SLO, + RASC_START, + RASC_USE_UNIFORM, + RASC_WORST_Q, +) from .helpers import fire _R = TypeVar("_R") @@ -102,8 +124,8 @@ def get_action_length_estimate( if not action: raise ValueError("action must be provided.") if not transition: - transition = 0.0 - key = ",".join((entity_id, action, str(transition))) + transition = 0 + key = ",".join((entity_id, action, f"{transition:g}")) histories = self._store.histories if key not in histories: histories[key] = RASCHistory() @@ -111,6 +133,17 @@ def get_action_length_estimate( if not history: return transition dist = get_best_distribution(history) + estimations = { + MEAN_ESTIMATION: dist.mean(), + P50_ESTIMATION: dist.ppf(0.5), + P70_ESTIMATION: dist.ppf(0.7), + P80_ESTIMATION: dist.ppf(0.8), + P90_ESTIMATION: dist.ppf(0.9), + P95_ESTIMATION: dist.ppf(0.95), + P99_ESTIMATION: dist.ppf(0.99), + } + if self.config[ACTION_LENGTH_ESTIMATION] in estimations: + return estimations[self.config[ACTION_LENGTH_ESTIMATION]] return dist.mean() return self._get_action_length_estimate(state) @@ -142,10 +175,12 @@ def _get_rasc_state( entity.async_get_action_target_state({CONF_EVENT: RASC_COMPLETE, **params}) ) or {} + config = { + RASC_USE_UNIFORM: self.config.get(RASC_USE_UNIFORM), + RASC_FIXED_HISTORY: self.config.get(RASC_FIXED_HISTORY), + } if entity.platform_value is not None: - config = self.config.get(entity.platform_value) - else: - config = None + config.update(self.config.get(entity.platform_value) or {}) return RASCState( self.hass, context, @@ -274,6 +309,10 @@ async def _failure_handler( notification, ) + def get_history(self, key: str) -> list[float]: + """Return history associated with the key.""" + return self._store.histories.get(key, RASCHistory()).ct_history + async def async_load(self) -> None: """Load persistent store.""" await self._store.async_load() @@ -329,6 +368,7 @@ def __init__( complete_state: dict[str, Any] | None = None, worst_Q: float | None = None, SLO: float | None = None, + uniform: bool | None = None, failure_callback: Any = None, ) -> None: """Init State Detector.""" @@ -340,10 +380,11 @@ def __init__( self._worst_Q = worst_Q or 2.0 self._slo = SLO or 0.95 + self._is_uniform = bool(uniform) # no history is found, polling statically if history is None or len(history) == 0: self._static = True - # TODO: upper bound shouldn't be None # pylint: disable=fixme + self._cur_poll = -1 self._attr_upper_bound = None return self._static = False @@ -351,17 +392,43 @@ def __init__( # only one data in history, poll exactly on that moment if len(history) == 1: self._polls = [history[0]] - # TODO: upper bound shouldn't be None # pylint: disable=fixme self._attr_upper_bound = None return + # TODO: put this in bg # pylint: disable=fixme dist: rv_continuous = get_best_distribution(history) + self._dist = dist self._attr_upper_bound = dist.ppf(0.99) - self._polls = get_polls(dist, worst_case_delta=self._worst_Q, SLO=self._slo) + if self._is_uniform: + self._polls = get_uniform_polls( + self._attr_upper_bound, worst_case_delta=self._worst_Q + ) + else: + self._polls = get_polls(dist, worst_case_delta=self._worst_Q, SLO=self._slo) LOGGER.debug("Max polls: %d", len(self._polls)) self._last_updated: Optional[float] = None self._failure_callback = failure_callback + @property + def is_warming(self) -> bool: + """Return true if warming up.""" + return self._static or self._attr_upper_bound is None + + @property + def dist(self) -> Any: + """Return distribution.""" + return self._dist + + @property + def cur_poll(self) -> int: + """Return current poll.""" + return self._cur_poll + + @property + def polls(self) -> list[float]: + """Return polls.""" + return self._polls + @property def upper_bound(self) -> float | None: """Return upper bound.""" @@ -432,7 +499,7 @@ async def _update_next_q(self): def next_interval(self) -> timedelta: """Get next interval.""" - if self._static: + if self._static or self._is_uniform: return timedelta(seconds=self._worst_Q) if self._cur_poll < len(self._polls): cur = self._cur_poll @@ -495,6 +562,26 @@ def __init__( self._store = store self._next_response = RASC_ACK # tracking + if self._service_call.service == "set_temperature" and hasattr( + self._entity, "current_temperature" + ): + key = ",".join( + ( + self._entity.entity_id, + self._service_call.service, + str(math.floor(self._entity.current_temperature)), + str(math.floor(self._service_call.data["temperature"])), + ) + ) + else: + key = ",".join( + ( + self._entity.entity_id, + self._service_call.service, + f"{self._transition:g}", + ) + ) + self._key = key self._tracking_task: asyncio.Task[Any] | None = None self._s_detector: StateDetector | None = None self._c_detector: StateDetector | None = None @@ -550,6 +637,9 @@ async def _track(self) -> None: return # let platform state polling the state next_interval = self._get_polling_interval() + LOGGER.debug( + "Next polling interval for %s: %s", self._entity.entity_id, next_interval + ) await self._platform.track_entity_state(self._entity, next_interval) self._polls_used += 1 await self.update() @@ -603,17 +693,34 @@ async def _update_current_state(self) -> None: await self._c_detector.add_progress(progress) def _update_store(self, tts: bool = False, ttc: bool = False): - key = ",".join( - (self._entity.entity_id, self._service_call.service, str(self._transition)) - ) + key = self._key histories = self._store.histories if key not in histories: histories[key] = RASCHistory() if tts: histories[key].append_s(self.time_elapsed) - if ttc: - histories[key].append_c(self.time_elapsed) + if ttc and self._c_detector is not None: + if self._c_detector.is_warming: + histories[key].append_c(self.time_elapsed) + else: + cur_poll = self._c_detector.cur_poll + polls = self._c_detector.polls + dist: rv_continuous = self._c_detector.dist + if cur_poll < len(polls): + if cur_poll - 2 < 0: + Q = dist.ppf((dist.cdf(polls[cur_poll - 1]) + dist.cdf(0)) / 2) + else: + Q = dist.ppf( + ( + dist.cdf(polls[cur_poll - 1]) + + dist.cdf(polls[cur_poll - 2]) + ) + / 2 + ) + histories[key].append_c(Q) + else: + histories[key].append_c(self.time_elapsed) self.hass.loop.create_task(self._store.async_save()) @@ -645,7 +752,8 @@ async def update(self) -> None: # fire start response if haven't if not self.started: await self.set_started() - self._update_store(tts=True) + if not self._config.get(RASC_FIXED_HISTORY): + self._update_store(tts=True) fire( self.hass, RASC_START, @@ -656,7 +764,8 @@ async def update(self) -> None: ) await self.set_completed() - self._update_store(ttc=True) + if not self._config.get(RASC_FIXED_HISTORY): + self._update_store(ttc=True) fire( self.hass, RASC_COMPLETE, @@ -668,18 +777,21 @@ async def update(self) -> None: return - start_state_matched = self._match_target_state(self._start_state) - if start_state_matched and not self.started: - fire( - self.hass, - RASC_START, - entity_id, - action, - LOGGER, - self._service_call.data, - ) - await self.set_started() - self._update_store(tts=True) + # only check for start if the action hasn't start + if not self.started: + start_state_matched = self._match_target_state(self._start_state) + if start_state_matched and not self.started: + fire( + self.hass, + RASC_START, + entity_id, + action, + LOGGER, + self._service_call.data, + ) + await self.set_started() + if not self._config.get(RASC_FIXED_HISTORY): + self._update_store(tts=True) # update current state await self._update_current_state() @@ -700,16 +812,15 @@ def start_tracking(self, platform: EntityPlatform | DataUpdateCoordinator) -> No else: self._platform = platform # retrieve history by key - key = ",".join( - (self._entity.entity_id, self._service_call.service, str(self._transition)) - ) + key = self._key history = self._store.histories.get(key, RASCHistory()) self._s_detector = StateDetector(history.st_history) self._c_detector = StateDetector( history.ct_history, self._complete_state, - worst_Q=self._config.get("worst_q"), - SLO=self._config.get("slo"), + worst_Q=self._config.get(RASC_WORST_Q), + SLO=self._config.get(RASC_SLO), + uniform=self._config.get(RASC_USE_UNIFORM), failure_callback=self.on_failure_detected, ) # fire failure if exceed upper_bound diff --git a/homeassistant/components/rasc/const.py b/homeassistant/components/rasc/const.py index d4ac7476bf42b2..3a784d2a075a6f 100644 --- a/homeassistant/components/rasc/const.py +++ b/homeassistant/components/rasc/const.py @@ -15,6 +15,16 @@ RASC_SCHEDULED = "scheduled" RASC_WORST_Q = "worst_q" RASC_SLO = "slo" +RASC_USE_UNIFORM = "use_uniform" +RASC_FIXED_HISTORY = "fixed_history" +RASC_EXPERIMENT_SETTING = "experiment_settings" +RASC_INTERRUPTION_MOMENT = "interruption_moment" +RASC_INTERRUPTION_TIME = "interruption_time" +RASC_ENTITY_ID = "entity_id" +RASC_ACTION = "action" +RASC_THERMOSTAT = "thermostat" +RASC_THERMOSTAT_START = "start" +RASC_THERMOSTAT_TARGET = "target" CONF_TRANSITION = "transition" DEFAULT_FAILURE_TIMEOUT = 30 # s diff --git a/homeassistant/components/rasc/datasets/arrival.csv b/homeassistant/components/rasc/datasets/arrival.csv new file mode 100644 index 00000000000000..f574db8327dc6c --- /dev/null +++ b/homeassistant/components/rasc/datasets/arrival.csv @@ -0,0 +1,203 @@ +0 +0.1 +0.009999999999999995 +0.009999999999999995 +0.010000000000000009 +0.010000000000000009 +0.009999999999999981 +0.010000000000000009 +0.010000000000000009 +0.009999999999999981 +0.010000000000000009 +0.010000000000000009 +1.3800000000000001 +0.010000000000000009 +0.4099999999999999 +0.009999999999999787 +0.010000000000000231 +0.020000000000000018 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +0.2799999999999998 +0.020000000000000018 +0.010000000000000231 +0.020000000000000018 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +0.5699999999999998 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +0.009999999999999787 +0.010000000000000231 +1.33 +0.009999999999999787 +0.009999999999999787 +0.8700000000000001 +0.009999999999999787 +0.010000000000000675 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000000675 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000000675 +1.0699999999999994 +0.009999999999999787 +0.010000000000000675 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000000675 +0.009999999999999787 +0.009999999999999787 +0.41000000000000014 +0.009999999999999787 +0.1299999999999999 +0.020000000000000462 +0.08000000000000007 +1.1799999999999997 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000001563 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.9700000000000006 +0.009999999999999787 +0.019999999999999574 +0.05000000000000071 +0.009999999999999787 +0.009999999999999787 +0.7200000000000006 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000001563 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.9100000000000001 +1.0099999999999998 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000001563 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +1.120000000000001 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.4399999999999995 +0.009999999999999787 +0.010000000000001563 +0.06999999999999851 +0.010000000000001563 +0.019999999999999574 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.030000000000001137 +1.2299999999999986 +0.010000000000001563 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.009999999999999787 +0.010000000000001563 +0.8999999999999986 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.7899999999999991 +0.019999999999999574 +0.010000000000001563 +0.00999999999999801 +0.019999999999999574 +0.010000000000001563 +0.010000000000001563 +0.00999999999999801 +0.879999999999999 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +1.1000000000000014 +0.00999999999999801 +0.010000000000001563 +0.019999999999999574 +0.00999999999999801 +0.010000000000001563 +0.019999999999999574 +0.41000000000000014 +0.48999999999999844 +0.010000000000001563 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.019999999999999574 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.5 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.010000000000001563 +0.00999999999999801 +0.010000000000001563 +0.8099999999999987 +0.019999999999999574 +0.019999999999999574 +0.019999999999999574 +0.010000000000001563 +0.030000000000001137 +1.4399999999999977 +0.03999999999999915 diff --git a/homeassistant/components/rasc/datasets/arrival_afternoon.csv b/homeassistant/components/rasc/datasets/arrival_afternoon.csv index f9aa43d193b361..11285295f59371 100644 --- a/homeassistant/components/rasc/datasets/arrival_afternoon.csv +++ b/homeassistant/components/rasc/datasets/arrival_afternoon.csv @@ -1,13 +1,13 @@ -3,1706641906108,Turn on Service -7,712ec364f7bd4290ad579e0bb5e3b52e,Switch lights on at sunset -7,8fc3a937a5804719a4200bb80c1ff620,FF Corridor Light Control -8,1715667183338,Whole House Fan Control -9,1715667183342,Let There Be Dark -10,1715667183344,Door Open State to Color Light -12,1715667183345,Enhanced Auto UnLock Door -12,1715667183346,Brighten Dark Places -13,1715667183353,Car Door Opened -14,1715721247625,Close and lock all door -15,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control -15,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan -20,1715667183343,Let There Be Light \ No newline at end of file +0,1706641906108,Turn on Service +0.1,712ec364f7bd4290ad579e0bb5e3b52e,Switch lights on at sunset +0.009999999999999995,8fc3a937a5804719a4200bb80c1ff620,FF Corridor Light Control +0.009999999999999995,1715667183338,Whole House Fan Control +0.010000000000000009,1715667183342,Let There Be Dark +0.010000000000000009,1715667183344,Door Open State to Color Light +0.009999999999999981,1715667183345,Enhanced Auto UnLock Door +0.010000000000000009,1715667183346,Brighten Dark Places +0.010000000000000009,1715667183353,Car Door Opened +0.009999999999999981,1715721247625,Close and lock all door +0.010000000000000009,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control +0.010000000000000009,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan +1.3800000000000001,1715667183343,Let There Be Light \ No newline at end of file diff --git a/homeassistant/components/rasc/datasets/arrival_all.csv b/homeassistant/components/rasc/datasets/arrival_all.csv new file mode 100644 index 00000000000000..535a4be802a77e --- /dev/null +++ b/homeassistant/components/rasc/datasets/arrival_all.csv @@ -0,0 +1,49 @@ +0,653ae47ad5964ee2ad013d45168e4e6b,Switch lights off at sunrise +0.1,8fc3a937a5804719a4200bb80c1ff619,Switch on xmas tree +0.009999999999999995,bc1ac4bf0b54451882ca0b7033b7cfab,MorningAction +0.009999999999999995,1715677183344,Door Opening State to Color Light +0.010000000000000009,1715697183344,Door Closing State to Color Light +0.010000000000000009,1715667183349,Lock It When I Leave +0.009999999999999981,1715667183351,Car Departs +0.010000000000000009,1715687183344,Door Closed State to Color Light +0.010000000000000009,1715667183355,Lock it at a specific time +0.009999999999999981,1715797722148,Open garage door with S/C children +0.010000000000000009,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control +0.010000000000000009,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan +1.3800000000000001,1715667183344,Door Open State to Color Light +0.010000000000000009,1715707183344,Door Jammed State to Color Light +0.4099999999999999,1706641906108,Turn on Service +0.009999999999999787,712ec364f7bd4290ad579e0bb5e3b52e,Switch lights on at sunset +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff620,FF Corridor Light Control +0.020000000000000018,1715667183338,Whole House Fan Control +0.009999999999999787,1715667183342,Let There Be Dark +0.010000000000000231,1715667183344,Door Open State to Color Light +0.009999999999999787,1715667183345,Enhanced Auto UnLock Door +0.010000000000000231,1715667183346,Brighten Dark Places +0.2799999999999998,1715667183353,Car Door Opened +0.020000000000000018,1715721247625,Close and lock all door +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control +0.020000000000000018,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan +0.009999999999999787,1715667183343,Let There Be Light +0.010000000000000231,b438cb83bf414166bf478017f3095e9e,Switch off GF lights when to bed +0.009999999999999787,1715667183352,Garage Door Opened +0.010000000000000231,60b9cb7a9c4e4311a27cd23267a427d7,Switch off indoor lights when asleep +0.5699999999999998,daa3f20af9ba46d4a41581af68a4255d,Switch off GF lights when nobody home +0.009999999999999787,1715667183350,Car Arrives +0.010000000000000231,60fc38a590204b2da454ea90c7f8fc56,Switch off FF lights when nobody home +0.009999999999999787,67e1d02ebb094f1b8fbb8633f5364939,Switch off lights when nobody home after midnight +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff624,Lights on at sunset +0.009999999999999787,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff622,Lights on at night +0.009999999999999787,8fc3a937a5804719a4200bb80c1ff623,Lights off at night +0.010000000000000231,1715667183354,Interior Door Closed +0.009999999999999787,712ec364f7bd4290ad579e0bb5e3b52e,Lights on at sunset +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff625,Harmony off lights off +1.33,a2eb5a03211e48b98c4f49f002b6833b,SunsetAction +0.009999999999999787,1715667183344,Door Open State to Color Light +0.009999999999999787,a2eb5a03211e48b98c4f49f002b6833c,Good Night House +0.8700000000000001,1715667184345,Enhanced Auto Lock Door +0.009999999999999787,1715667183347,Garage Door Opener +0.010000000000000675,1715667183348,Let There Be Light! +0.009999999999999787,1715667183320,Unlock It When I Arrive +0.009999999999999787,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control \ No newline at end of file diff --git a/homeassistant/components/rasc/datasets/arrival_debug.csv b/homeassistant/components/rasc/datasets/arrival_debug.csv index b6d27023ed9b20..2ceab2fd99b8a4 100644 --- a/homeassistant/components/rasc/datasets/arrival_debug.csv +++ b/homeassistant/components/rasc/datasets/arrival_debug.csv @@ -1 +1,5 @@ -0,1711656820736 \ No newline at end of file +0.009999999999999995,1715677183344,Door Opening State to Color Light +0.010000000000000009,1715697183344,Door Closing State to Color Light +0.010000000000000009,1715687183344,Door Closed State to Color Light +1.3800000000000001,1715667183344,Door Open State to Color Light +0.010000000000000009,1715707183344,Door Jammed State to Color Light \ No newline at end of file diff --git a/homeassistant/components/rasc/datasets/arrival_evening.csv b/homeassistant/components/rasc/datasets/arrival_evening.csv index 0a8208ebe47ba8..afe3b9038540c9 100644 --- a/homeassistant/components/rasc/datasets/arrival_evening.csv +++ b/homeassistant/components/rasc/datasets/arrival_evening.csv @@ -1,22 +1,22 @@ 0,b438cb83bf414166bf478017f3095e9e,Switch off GF lights when to bed -1,1715667183352,Garage Door Opened -2,60b9cb7a9c4e4311a27cd23267a427d7,Switch off indoor lights when asleep -2,daa3f20af9ba46d4a41581af68a4255d,Switch off GF lights when nobody home -3,1715667183350,Car Arrives -5,60fc38a590204b2da454ea90c7f8fc56,Switch off FF lights when nobody home -5,67e1d02ebb094f1b8fbb8633f5364939,Switch off lights when nobody home after midnight -6,8fc3a937a5804719a4200bb80c1ff624,Lights on at sunset -7,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan -7,8fc3a937a5804719a4200bb80c1ff622,Lights on at night -8,8fc3a937a5804719a4200bb80c1ff623,Lights off at night -9,1715667183354,Interior Door Closed -10,712ec364f7bd4290ad579e0bb5e3b52e,Lights on at sunset -15,8fc3a937a5804719a4200bb80c1ff625,Harmony off lights off -15,a2eb5a03211e48b98c4f49f002b6833b,SunsetAction -16,1715667183344,Door Open State to Color Light -18,a2eb5a03211e48b98c4f49f002b6833c,Good Night House -20,1715667184345,Enhanced Auto Lock Door -22,1715667183347,Garage Door Opener -25,1715667183348,Let There Be Light! -25,1715667183320,Unlock It When I Arrive -30,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control \ No newline at end of file +0.1,1715667183352,Garage Door Opened +0.009999999999999995,60b9cb7a9c4e4311a27cd23267a427d7,Switch off indoor lights when asleep +0.009999999999999995,daa3f20af9ba46d4a41581af68a4255d,Switch off GF lights when nobody home +0.010000000000000009,1715667183350,Car Arrives +0.010000000000000009,60fc38a590204b2da454ea90c7f8fc56,Switch off FF lights when nobody home +0.009999999999999981,67e1d02ebb094f1b8fbb8633f5364939,Switch off lights when nobody home after midnight +0.010000000000000009,8fc3a937a5804719a4200bb80c1ff624,Lights on at sunset +0.010000000000000009,a2eb5a03211e48b98c4f49f002b6833d,Whole House Fan +0.009999999999999981,8fc3a937a5804719a4200bb80c1ff622,Lights on at night +0.0100000000000000098,8fc3a937a5804719a4200bb80c1ff623,Lights off at night +0.010000000000000009,1715667183354,Interior Door Closed +1.3800000000000001,712ec364f7bd4290ad579e0bb5e3b52e,Lights on at sunset +0.010000000000000009,8fc3a937a5804719a4200bb80c1ff625,Harmony off lights off +0.4099999999999999,a2eb5a03211e48b98c4f49f002b6833b,SunsetAction +0.009999999999999787,1715667183344,Door Open State to Color Light +0.010000000000000231,a2eb5a03211e48b98c4f49f002b6833c,Good Night House +0.020000000000000018,1715667184345,Enhanced Auto Lock Door +0.009999999999999787,1715667183347,Garage Door Opener +0.010000000000000231,1715667183348,Let There Be Light! +0.009999999999999787,1715667183320,Unlock It When I Arrive +0.010000000000000231,8fc3a937a5804719a4200bb80c1ff621,CR Bed Light Control \ No newline at end of file diff --git a/homeassistant/components/rasc/entity.py b/homeassistant/components/rasc/entity.py index b7ca8a3f4940ae..706276dba38ac1 100644 --- a/homeassistant/components/rasc/entity.py +++ b/homeassistant/components/rasc/entity.py @@ -21,6 +21,9 @@ CONF_RESPONSE_VARIABLE, CONF_SERVICE, CONF_SERVICE_DATA, + RASC_ACK, + RASC_COMPLETE, + RASC_START, ) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, service @@ -255,13 +258,13 @@ def source_action_ids(self) -> list[str]: return [ action_id for action_id, action in self.actions.items() - if not action.parents + if not action.all_parents ] @property def source_actions(self) -> list[ActionEntity]: """Get source actions.""" - return [action for action in self.actions.values() if not action.parents] + return [action for action in self.actions.values() if not action.all_parents] @property def sink_action_ids(self) -> list[str]: @@ -269,7 +272,7 @@ def sink_action_ids(self) -> list[str]: return [ action_id for action_id, action in self.actions.items() - if len(action.children) == 1 + if len(action.all_children) == 1 and all(child.is_end_node for child in action.all_children) ] @@ -279,7 +282,7 @@ def sink_actions(self) -> list[ActionEntity]: return [ action for action in self.actions.values() - if len(action.children) == 1 + if len(action.all_children) == 1 and all(child.is_end_node for child in action.all_children) ] @@ -306,8 +309,16 @@ def __init__( self.action_acked = False self.action_started = False self.action_completed = False - self.parents = dict[str, set[ActionEntity]]() - self.children = dict[str, set[ActionEntity]]() + self.parents: dict[str, set[ActionEntity]] = { + RASC_ACK: set[ActionEntity](), + RASC_START: set[ActionEntity](), + RASC_COMPLETE: set[ActionEntity](), + } + self.children: dict[str, set[ActionEntity]] = { + RASC_ACK: set[ActionEntity](), + RASC_START: set[ActionEntity](), + RASC_COMPLETE: set[ActionEntity](), + } self.duration = duration self.delay = delay self.variables = variables @@ -453,6 +464,7 @@ async def attach_triggered(self, log_exceptions: bool) -> None: action = cv.determine_script_action(self.action) continue_on_error = self.action.get(CONF_CONTINUE_ON_ERROR, False) if self.start_requested: + _LOGGER.warning("Action %s already started", self.action_id) return self.start_requested = True try: diff --git a/homeassistant/components/rasc/helpers.py b/homeassistant/components/rasc/helpers.py index 1065c5258060f7..83cb114c167415 100644 --- a/homeassistant/components/rasc/helpers.py +++ b/homeassistant/components/rasc/helpers.py @@ -8,11 +8,14 @@ import logging from logging import Logger import math +import time from typing import Any import psutil from homeassistant.const import ( + ACTION_LENGTH_ESTIMATION, + ATTR_ACTION_ID, ATTR_ENTITY_ID, ATTR_SERVICE, CONF_RESCHEDULING_POLICY, @@ -21,7 +24,7 @@ CONF_SCHEDULING_POLICY, RASC_RESPONSE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from .const import CONF_ENABLED @@ -31,17 +34,28 @@ class OverheadMeasurement: """Overhead measurement component.""" - def __init__(self, loop, config: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize the measurement.""" - self._loop = loop + self._loop = hass.loop + self._hass = hass self._terminate_flag = asyncio.Event() self._cpu_usage: list[float] = [] self._mem_usage: list[float] = [] self._config = config + self._start_time = time.time() + self._reschedule_intervals: list[tuple[float, float]] = [] + self._hass.bus.async_listen("reschedule_event", self._handle_reschedule_event) + + def _handle_reschedule_event(self, event: Event): + start_time = event.data["from"] - self._start_time + end_time = event.data["to"] - self._start_time + diff = event.data["diff"] + self._reschedule_intervals.append((start_time, end_time, diff)) def start(self) -> None: """Start the measurement.""" _LOGGER.info("Start measurement") + self._start_time = time.time() self._loop.create_task(self._start()) async def _start(self) -> None: @@ -56,14 +70,21 @@ def stop(self) -> None: """Stop the measurement.""" self._terminate_flag.set() with open("results/" + str(self) + ".json", "w", encoding="utf-8") as f: - json.dump({"cpu": self._cpu_usage, "mem": self._mem_usage}, f) + json.dump( + { + "cpu": self._cpu_usage, + "mem": self._mem_usage, + "reschedule": self._reschedule_intervals, + }, + f, + ) def __str__(self) -> str: """Return measurement name.""" filename = self._config[CONF_ROUTINE_ARRIVAL_FILENAME].split(".")[0] if not self._config.get(CONF_ENABLED): - return f"overhead_measurement_{filename}" - return f"overhead_measurement_{self._config[CONF_SCHEDULING_POLICY]}_{self._config[CONF_RESCHEDULING_POLICY]}_{self._config[CONF_RESCHEDULING_TRIGGER]}_{filename}" + return f"om_{filename}" + return f"om_{self._config[CONF_SCHEDULING_POLICY]}_{self._config[CONF_RESCHEDULING_POLICY]}_{self._config[CONF_RESCHEDULING_TRIGGER]}_{self._config[ACTION_LENGTH_ESTIMATION]}_{filename}" def fire( @@ -76,7 +97,13 @@ def fire( ): """Fire rasc response.""" if logger: - logger.info("%s %s: %s", entity_id, action, rasc_type) + logger.info( + "%s %s %s: %s", + entity_id, + action, + service_data.get(ATTR_ACTION_ID, ""), + rasc_type, + ) service_data = service_data or {} hass.bus.async_fire( RASC_RESPONSE, diff --git a/homeassistant/components/rasc/metrics.py b/homeassistant/components/rasc/metrics.py index c72706fd33ed9a..fb012c03ad4ac2 100644 --- a/homeassistant/components/rasc/metrics.py +++ b/homeassistant/components/rasc/metrics.py @@ -195,7 +195,13 @@ def record_routine_arrival( def _remove_routine_remaining_action(self, action_id: str, entity_id: str) -> None: routine_id = get_routine_id(action_id) if routine_id not in self._remaining_actions: - raise ValueError(f"Routine {routine_id} has not arrived.") + if ( + routine_id not in self._arrival_times + and routine_id not in self._wait_times + ): + raise ValueError(f"Routine {routine_id} has not arrived.") + return + # raise ValueError(f"Routine {routine_id} has no remaining actions ({action_id=}, {entity_id=}).") if action_id not in self._remaining_actions[routine_id]: return self._remaining_actions[routine_id][action_id].remove(entity_id) @@ -502,6 +508,12 @@ def idle_times(self) -> dict[str, timedelta]: last_action_end = action.end_time if last_action_end and self._schedule_end: idle_times[entity_id] += self._schedule_end - last_action_end + if not self._schedule_end: + LOGGER.warning("Schedule has not ended") + return idle_times + for entity_id, last_action_end in self._last_action_end.items(): + if entity_id not in self._action_times: + idle_times[entity_id] = self._schedule_end - last_action_end return idle_times diff --git a/homeassistant/components/rasc/rescheduler.py b/homeassistant/components/rasc/rescheduler.py index 6ab0352452db8f..aa5c2ed9e488f2 100644 --- a/homeassistant/components/rasc/rescheduler.py +++ b/homeassistant/components/rasc/rescheduler.py @@ -6,6 +6,7 @@ import heapq from itertools import product import logging +import time as t from typing import Optional from homeassistant.const import ( @@ -20,6 +21,7 @@ CONF_ROUTINE_PRIORITY_POLICY, CONF_SCHEDULING_POLICY, CONF_TYPE, + DO_COMPARISON, EARLIEST, EARLY_START, LATEST, @@ -347,16 +349,12 @@ def affected_src_actions_after_len_diff( ) action = action_lock.action - if RASC_COMPLETE in action.children: - for child in action.children[RASC_COMPLETE]: - if child.is_end_node: - continue - if child.action_id in affected_action_ids: - continue - affected_action_ids.add(child.action_id) - - routine_actions = self._dependent_actions(action_id) - routine_target = self._target_entities(routine_actions) + for child in action.children[RASC_COMPLETE]: + if child.is_end_node: + continue + if child.action_id in affected_action_ids: + continue + affected_action_ids.add(child.action_id) # the next action in the same entity's schedule is affected next_action_lock = self._lineage_table.lock_queues[entity_id].next(action_id) @@ -369,16 +367,19 @@ def affected_src_actions_after_len_diff( # the actions in routines serialized after this action's routine are affected routine_id = get_routine_id(action_id) + # adding current action to detect all actions on the same entity as well + routine_actions = [action] + self._dependent_actions(action_id) + routine_target = self._target_entities(routine_actions) routines_after = self._routines_serialized_after(routine_id) for routine_after_id in routines_after: routine_after_src_actions = list( self._current_routine_source_actions(routine_after_id, time) ) - LOGGER.debug( - "Routine %s's current source actions: %s", - routine_after_id, - routine_after_src_actions, - ) + # LOGGER.debug( + # "Routine %s's current source actions: %s", + # routine_after_id, + # routine_after_src_actions, + # ) routine_after_actions = self._bfs_actions(routine_after_src_actions) routine_after_target = self._target_entities(routine_after_actions) if routine_target.isdisjoint(routine_after_target): @@ -868,17 +869,22 @@ def sjf( # noqa: C901 # while calling self.apply_serialization_order_dependencies() # and these actions will be added to the wait queues later routine_id = get_routine_id(action.action_id) + same_routine_id = True if serializability_guarantee: for parent in action.parents[RASC_COMPLETE]: parent_routine_id = get_routine_id(parent.action_id) if parent_routine_id != routine_id: - continue + same_routine_id = False + break + if not same_routine_id: + continue target_entities = get_target_entities(self._hass, action.action) for target_entity in target_entities: entity_id = get_entity_id_from_number(self._hass, target_entity) if entity_id not in wait_queues: wait_queues[entity_id] = list[tuple[timedelta, ActionEntity]]() + heapq.heapify(wait_queues[entity_id]) if entity_id not in next_slots: last_slot_start = self._lineage_table.free_slots[entity_id].end()[0] if not last_slot_start: @@ -1521,9 +1527,6 @@ def _bfs_actions(self, source_actions: list[ActionEntity]) -> list[ActionEntity] index = 0 while index < len(bfs_actions): action = bfs_actions[index] - if RASC_COMPLETE not in action.children: - index += 1 - continue for child in action.children[RASC_COMPLETE]: if child.is_end_node: continue @@ -1539,7 +1542,7 @@ def _routine_source_actions(self, routine_id: str) -> list[ActionEntity]: routine = routine_info.routine sources = [] for action in routine.actions.values(): - if not action.parents: + if not action.all_parents: sources.append(action) return sources @@ -1565,9 +1568,9 @@ def _current_routine_source_actions( visited = set[ActionEntity]() while next_batch: - LOGGER.debug( - "Candidates: %s, current_sources: %s", next_batch, current_sources - ) + # LOGGER.debug( + # "Candidates: %s, current_sources: %s", next_batch, current_sources + # ) candidates = next_batch next_batch = set[ActionEntity]() for action in candidates: @@ -1681,8 +1684,8 @@ def _max_parent_end_time( "Looking for %s's max parent end time", action.action_id ) raise ValueError( - "Action {} has not been scheduled on entity {}.".format( - parent_action_id, entity_id + "Action {}'s parent {} has not been scheduled on entity {}.".format( + action.action_id, parent_action_id, entity_id ) ) parent_lock = lock_queues[entity_id][parent_action_id] @@ -2232,7 +2235,7 @@ def _find_routine_to_move_up(self) -> Optional[str]: # unused so far next_actions: list[ActionEntity] = [] routine = routine_info.routine for action in list(routine.actions.values())[:-1]: - if not action.parents: + if not action.all_parents: next_actions.append(action) for action in next_actions: # self._find_slot_to_move_action_up_to(action.action_id) @@ -2297,6 +2300,7 @@ def __init__( # self._estimation: bool = config[RESCHEDULING_ESTIMATION] # self._resched_accuracy: str = config[RESCHEDULING_ACCURACY] self._scheduling_policy: str = config[CONF_SCHEDULING_POLICY] + self._do_comparision: bool = config[DO_COMPARISON] self._rescheduler = BaseRescheduler( self._hass, scheduler.lineage_table, @@ -2385,6 +2389,7 @@ async def _reschedule( self, entity_id: str, action_id: str, diff: timedelta ) -> None: """Reschedule the entities based on the rescheduling policy.""" + start_time = t.time() # Save the old schedule old_lt = self._scheduler.lineage_table.duplicate old_so = self._scheduler.duplicate_serialization_order @@ -2491,29 +2496,30 @@ async def _reschedule( else: descheduled_source_action_ids = affected_source_action_ids if self._resched_policy in (SJFWO, SJFW): - # compare to optimal - self._rescheduler.optimal( - descheduled_source_action_ids, - descheduled_actions, - affected_entities, - serializability, - immutable_serialization_order, - metrics, - ) - self._apply_schedule(old_lt, old_so) - - # compare to RV - if self._resched_policy in (SJFW): - success = await self._move_device_schedules(old_end_time, diff) - if not success: - raise ValueError("Failed to move device schedules.") - self._rescheduler.RV(new_end_time, metrics) + if self._do_comparision: + # compare to optimal + self._rescheduler.optimal( + descheduled_source_action_ids, + descheduled_actions, + affected_entities, + serializability, + immutable_serialization_order if serializability else None, + metrics, + ) self._apply_schedule(old_lt, old_so) - # output_lock_queues(old_lt.lock_queues) - self._rescheduler.deschedule_affected_and_later_actions( - affected_source_action_ids - ) + # compare to RV + if self._resched_policy in (SJFW): + success = await self._move_device_schedules(old_end_time, diff) + if not success: + raise ValueError("Failed to move device schedules.") + self._rescheduler.RV(new_end_time, metrics) + self._apply_schedule(old_lt, old_so) + + # output_lock_queues(old_lt.lock_queues) + self._rescheduler.deschedule_affected_and_later_actions( + affected_source_action_ids + ) new_lt, new_so = self._rescheduler.sjf( descheduled_source_action_ids, @@ -2543,6 +2549,10 @@ async def _reschedule( serialization_order=new_so, ) self._apply_schedule(new_lt, new_so) + end_time = t.time() + self._hass.bus.async_fire( + "reschedule_event", {"from": start_time, "to": end_time, "diff": diff.total_seconds()} + ) def _apply_schedule( self, @@ -2597,6 +2607,7 @@ def _get_extra_proactive() -> float: async def _handle_overtime(_: datetime) -> None: """Check if the action is about to go on overtime and adjust the schedule.""" + LOGGER.debug("Handling overtime for %s-%s", entity_id, action_id) if entity_id not in self._timer_handles: raise ValueError("Timer handle for entity %s is missing." % entity_id) saved_action_id, saved_cancel = self._timer_handles[entity_id] diff --git a/homeassistant/components/rasc/scheduler.py b/homeassistant/components/rasc/scheduler.py index 344fba232bbd19..a2377949378814 100644 --- a/homeassistant/components/rasc/scheduler.py +++ b/homeassistant/components/rasc/scheduler.py @@ -78,6 +78,7 @@ TIMEOUT = 3000 # millisecond _LOGGER = set_logger() +_LOGGER.level = logging.INFO def create_routine( @@ -111,6 +112,7 @@ def create_routine( config[CONF_STEP] = config[CONF_STEP] + 1 action_id = f"{config[CONF_ROUTINE_ID]}.{config[CONF_STEP]}" action: str = script[CONF_TYPE] + action_wo_platform = action.split(".")[1] if CONF_TRANSITION in script: transition: float | None = script[CONF_TRANSITION] _LOGGER.debug( @@ -124,7 +126,7 @@ def create_routine( entity_id = get_entity_id_from_number(hass, entity) estimated_entity_duration = ( rasc.get_action_length_estimate( - entity_id, action=action, transition=transition + entity_id, action=action_wo_platform, transition=transition ) + ACTION_LENGTH_PADDING ) @@ -250,6 +252,7 @@ def _create_routine( # noqa: C901 config[CONF_STEP] = config[CONF_STEP] + 1 action_id = f"{config[CONF_ROUTINE_ID]}.{config[CONF_STEP]}" action: str = script[CONF_SERVICE] + action_wo_platform = action.split(".")[1] if ( CONF_SERVICE_DATA in script and CONF_TRANSITION in script[CONF_SERVICE_DATA] @@ -266,7 +269,7 @@ def _create_routine( # noqa: C901 entity_id = get_entity_id_from_number(hass, target_entity) estimated_entity_duration = ( rasc.get_action_length_estimate( - entity_id, action=action, transition=transition + entity_id, action=action_wo_platform, transition=transition ) + ACTION_LENGTH_PADDING ) @@ -333,6 +336,7 @@ def _create_routine( # noqa: C901 config[CONF_STEP] = config[CONF_STEP] + 1 action_id = f"{config[CONF_ROUTINE_ID]}.{config[CONF_STEP]}" action = script[CONF_TYPE] + action_wo_platform = action.split(".")[1] if CONF_TRANSITION in script: transition = script[CONF_TRANSITION] _LOGGER.debug("The transition of action %s is %f", action_id, transition) @@ -344,7 +348,7 @@ def _create_routine( # noqa: C901 entity_id = get_entity_id_from_number(hass, target_entity) estimated_entity_duration = ( rasc.get_action_length_estimate( - entity_id, action=action, transition=transition + entity_id, action=action_wo_platform, transition=transition ) + ACTION_LENGTH_PADDING ) @@ -2937,11 +2941,12 @@ def _start_routine(self, routine: RoutineEntity) -> None: # Start the action that doesn't have the parents for action_entity in list(routine.actions.values())[:-1]: - if not action_entity.parents: + if not action_entity.all_parents: self._hass.async_create_task(self._start_action(action_entity)) async def _start_action(self, action: ActionEntity) -> None: """Start the given action.""" + target_entities = get_target_entities(self._hass, action.action) if not target_entities: raise ValueError(f"Action {action.action_id} has no target entities.") @@ -2953,11 +2958,14 @@ async def _start_action(self, action: ActionEntity) -> None: action.action_id, random_entity_id ) ) - og_start_time = action_lock.start_time if not self._is_action_ready(action): await self._async_wait_until_beginning(action.action_id) + if action.start_requested: + _LOGGER.debug("Action %s is already started", action.action_id) + return + action_lock = self.get_action_info(action.action_id, random_entity_id) if not action_lock: raise ValueError( @@ -2965,15 +2973,6 @@ async def _start_action(self, action: ActionEntity) -> None: action.action_id, random_entity_id ) ) - new_start_time = action_lock.start_time - routine_id = get_routine_id(action.action_id) - if ( - og_start_time != new_start_time - or action.start_requested - or routine_id not in self._serialization_order - ): - _LOGGER.debug("Action %s is already started", action.action_id) - return _LOGGER.info("Start the action %s", action.action_id) self._hass.async_create_task(action.attach_triggered(log_exceptions=False)) @@ -3076,15 +3075,16 @@ async def handle_event(self, event: Event) -> None: # noqa: C901 ): return - # update the action state - self._update_action_state(action_id, entity_id, event_type) - # Get the running action in the serialization action = self.get_action(action_id) if not action: return if event_type == RASC_ACK: + if not self.is_action_ack(action, entity_id): + # update the action state + self._update_action_state(action_id, entity_id, event_type) + # Check if the action is acknowledged if self._is_all_actions_ack(action) and not action.action_acked: _LOGGER.info("Group action %s is acked", action_id) @@ -3094,7 +3094,16 @@ async def handle_event(self, event: Event) -> None: # noqa: C901 self._run_next_action(action) elif event_type == RASC_START: - self._metrics.record_action_start(event.time_fired, entity_id, action_id) + if not self.is_action_start(action, entity_id): + self._metrics.record_action_start( + event.time_fired, entity_id, action_id + ) + + # update the action state + self._update_action_state(action_id, entity_id, event_type) + + _LOGGER.info("Action %s on entity %s is started", action_id, entity_id) + # Check if the action has started if self._is_all_actions_start(action) and not action.action_started: _LOGGER.info("Group action %s is started", action_id) @@ -3111,18 +3120,32 @@ async def handle_event(self, event: Event) -> None: # noqa: C901 # Emulate action's duration await self._async_wait_until(action_id, entity_id) - self._metrics.record_action_end(event.time_fired, entity_id, action_id) + # if entity_id not in self._lineage_table.lock_queues: + # raise ValueError("Entity %s has no schedule." % entity_id) + # if action_id not in self._lineage_table.lock_queues[entity_id]: + # return + + if not self.is_action_start(action, entity_id): + _LOGGER.info("Action %s on entity %s is started", action_id, entity_id) + + if not self.is_action_complete(action, entity_id): + if not self.is_action_start(action, entity_id): + self._metrics.record_action_start( + event.time_fired, entity_id, action_id + ) + _LOGGER.info("Action %s on entity %s is started", action_id, entity_id) + + self._metrics.record_action_end(event.time_fired, entity_id, action_id) + + # update the action state + self._update_action_state(action_id, entity_id, event_type) _LOGGER.info("Action %s on entity %s is completed", action_id, entity_id) output_all(_LOGGER, lock_queues=self._lineage_table.lock_queues) - # Action already completed before - if action.action_completed: - return - - # Check if the action is completed - if not self._is_all_actions_complete(action): + # Check if the action is completed on all entities + if action.action_completed or not self._is_all_actions_complete(action): return if self._scheduling_policy == FCFS_POST: @@ -3301,11 +3324,19 @@ def _is_action_state(self, action: ActionEntity, entity: str, state: str) -> boo """Check if the action is completed.""" if action.action_id is None: return False - lock = self._lineage_table.lock_queues[ + lock_queue = self._lineage_table.lock_queues[ get_entity_id_from_number(self._hass, entity) - ][action.action_id] + ] + if action.action_id not in lock_queue: + return True + lock = lock_queue[action.action_id] if lock is not None: - if lock.action_state != state: + target = { + RASC_ACK: (RASC_ACK, RASC_START, RASC_COMPLETE), + RASC_START: (RASC_START, RASC_COMPLETE), + RASC_COMPLETE: (RASC_COMPLETE), + } + if lock.action_state not in target[state]: return False return True @@ -3376,10 +3407,6 @@ def _run_next_action(self, action: ActionEntity) -> None: if not child.is_end_node: self._hass.async_create_task(self._start_action(child)) else: - _LOGGER.info( - "This is the end of the routine %s", - get_routine_id(action.action_id), - ) self._handle_end_of_routine(get_routine_id(action.action_id)) def _handle_end_of_routine(self, routine_id: str) -> None: @@ -3390,6 +3417,8 @@ def _handle_end_of_routine(self, routine_id: str) -> None: raise ValueError("Routine %s is not found in the serialization order") routine = routine_info.routine + _LOGGER.info("This is the end of routine '%s' (%s)", routine.name, routine_id) + self._remove_routine_from_lock_queues(routine) self._release_routine_locks(routine) self._remove_routine_from_serialization_order(routine_id) diff --git a/homeassistant/components/rpi_device/api/thermostat.py b/homeassistant/components/rpi_device/api/thermostat.py index c9c57528d74523..8a6992095069d3 100644 --- a/homeassistant/components/rpi_device/api/thermostat.py +++ b/homeassistant/components/rpi_device/api/thermostat.py @@ -64,9 +64,19 @@ def preset_mode(self): thermostat_state = self.thermostat_state return thermostat_state.get("preset_mode") - async def set_temperature(self, temperature: float) -> None: + async def reset(self, temperature: float | None = None): + """Reset thermostat.""" + _state = {"reset": temperature} + + themostat_state = await self._query_helper( + self.THERMOSTAT_SERVICE, self.SET_THERMOSTAT_METHOD, _state + ) + + return themostat_state + + async def set_temperature(self, temperature: float, **kwargs: Any) -> None: """Set new target temperature.""" - _state = {"temperature": temperature} + _state = {"temperature": temperature, **kwargs} themostat_state = await self._query_helper( self.THERMOSTAT_SERVICE, self.SET_THERMOSTAT_METHOD, _state diff --git a/homeassistant/components/rpi_device/climate.py b/homeassistant/components/rpi_device/climate.py index da7a0c33a16441..5204ef35258a0a 100644 --- a/homeassistant/components/rpi_device/climate.py +++ b/homeassistant/components/rpi_device/climate.py @@ -80,7 +80,8 @@ def preset_mode(self): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs[ATTR_TEMPERATURE] - await self.device.set_temperature(temperature) + del kwargs[ATTR_TEMPERATURE] + await self.device.set_temperature(temperature, **kwargs) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" diff --git a/homeassistant/components/virtual/light.py b/homeassistant/components/virtual/light.py index 9d8045f157c4ec..e5d9d32ec24d3c 100644 --- a/homeassistant/components/virtual/light.py +++ b/homeassistant/components/virtual/light.py @@ -166,6 +166,8 @@ def __init__(self, config): if config.get(CONF_SUPPORT_TRANSITION): self._attr_supported_features |= LightEntityFeature.TRANSITION + self._task: asyncio.Task = None + def _create_state(self, config): super()._create_state(config) @@ -252,9 +254,12 @@ async def async_turn_on(self, **kwargs): if brightness is not None: if ( transition is not None + and transition > 0 and self._attr_supported_features & LightEntityFeature.TRANSITION ): - self.hass.async_create_task( + if self._task is not None: + self._task.cancel() + self._task = self.hass.async_create_task( self._async_update_brightness(brightness, transition) ) else: @@ -288,17 +293,21 @@ async def _async_update_brightness( step = (brightness - self._attr_brightness) / transition try: for _ in range(math.ceil(transition)): - self._attr_brightness += math.ceil(step) - if self._attr_brightness < 0: + self._attr_brightness += ( + -math.ceil(abs(step)) if step < 0 else math.ceil(step) + ) + if self._attr_brightness <= 0: self._attr_brightness = 0 - elif self._attr_brightness > brightness: + break + if self._attr_brightness >= brightness: self._attr_brightness = brightness + break self._update_attributes() await asyncio.sleep(1) except asyncio.CancelledError: - if self._attr_brightness < 0: + if self._attr_brightness <= 0: self._attr_brightness = 0 - elif self._attr_brightness > brightness: + elif self._attr_brightness >= brightness: self._attr_brightness = brightness self._update_attributes() diff --git a/homeassistant/const.py b/homeassistant/const.py index 0e89c470bb29b1..635910ed965c66 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1295,3 +1295,14 @@ class EntityCategory(StrEnum): GLOBAL_LONGEST: Final = "global_longest" TIMELINE_UNIT: Final = 1 + +OVERHEAD_MEASUREMENT: Final = "overhead_measurement" +ACTION_LENGTH_ESTIMATION: Final = "action_length_estimation" +MEAN_ESTIMATION: Final = "mean" +P50_ESTIMATION: Final = "p50" +P70_ESTIMATION: Final = "p70" +P80_ESTIMATION: Final = "p80" +P90_ESTIMATION: Final = "p90" +P95_ESTIMATION: Final = "p95" +P99_ESTIMATION: Final = "p99" +DO_COMPARISON: Final = "do_comparison" \ No newline at end of file diff --git a/homeassistant/core.py b/homeassistant/core.py index 3f1dd2f5e527ec..0d91e0a7fc2b98 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1915,6 +1915,41 @@ def call( self._hass.loop, ).result() + def rasc_call( + self, + domain: str, + service: str, + service_data: dict[str, Any] | None = None, + config: dict[str, Any] | None = None, + ) -> tuple[ + asyncio.Task[ServiceResponse], + asyncio.Task[ServiceResponse], + asyncio.Task[ServiceResponse], + ]: + """Call a service with rasc abstraction.""" + context = Context() + service_data = service_data or {} + + try: + handler = self._services[domain][service] + except KeyError: + domain = domain.lower() + service = service.lower() + try: + handler = self._services[domain][service] + except KeyError: + raise ServiceNotFound(domain, service) from None + + processed_data = service_data + if config is not None: + processed_data.update(config) + + service_call = ServiceCall(domain, service, processed_data, context) + + rasc: RASCAbstraction = self._hass.data[DOMAIN_RASC] + coro, s_coro, c_coro = rasc.execute_service(handler, service_call) + return coro, s_coro, c_coro + async def async_call( self, domain: str, @@ -1986,6 +2021,7 @@ async def async_call( else: processed_data = service_data + processed_data = {**processed_data, **processed_data.get("params", {})} service_call = ServiceCall( domain, service, processed_data, context, return_response ) diff --git a/homeassistant/helpers/dynamic_polling.py b/homeassistant/helpers/dynamic_polling.py index 855b13a6ba85d1..d0b1ef025bbb1c 100644 --- a/homeassistant/helpers/dynamic_polling.py +++ b/homeassistant/helpers/dynamic_polling.py @@ -123,7 +123,7 @@ def get_best_distribution(data: list[float]) -> st.rv_continuous: """Get distribution based on p value.""" - if len(data) == 1: + if len(set(data)) == 1: return st.uniform(0, data[0]) dist_names = [ "uniform", @@ -331,6 +331,8 @@ def get_uniform_polls( """Get uniform polls.""" if N is not None: return [(i + 1) * upper_bound / N for i in range(N)] + if upper_bound < worst_case_delta: + return [upper_bound] polls = [ (i + 1) * worst_case_delta for i in range(math.floor(upper_bound / worst_case_delta))