Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Numeric derivative of data coming from a source sensor over time."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime, timedelta
6 from decimal import Decimal, DecimalException
7 import logging
8 
9 import voluptuous as vol
10 
12  ATTR_STATE_CLASS,
13  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
14  RestoreSensor,
15  SensorEntity,
16  SensorStateClass,
17 )
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import (
20  ATTR_UNIT_OF_MEASUREMENT,
21  CONF_NAME,
22  CONF_SOURCE,
23  STATE_UNAVAILABLE,
24  STATE_UNKNOWN,
25  UnitOfTime,
26 )
27 from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
28 from homeassistant.helpers import config_validation as cv, entity_registry as er
29 from homeassistant.helpers.device import async_device_info_to_link_from_entity
30 from homeassistant.helpers.device_registry import DeviceInfo
31 from homeassistant.helpers.entity_platform import AddEntitiesCallback
32 from homeassistant.helpers.event import async_track_state_change_event
33 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
34 
35 from .const import (
36  CONF_ROUND_DIGITS,
37  CONF_TIME_WINDOW,
38  CONF_UNIT,
39  CONF_UNIT_PREFIX,
40  CONF_UNIT_TIME,
41 )
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 ATTR_SOURCE_ID = "source"
46 
47 # SI Metric prefixes
48 UNIT_PREFIXES = {
49  None: 1,
50  "n": 1e-9,
51  "ยต": 1e-6,
52  "m": 1e-3,
53  "k": 1e3,
54  "M": 1e6,
55  "G": 1e9,
56  "T": 1e12,
57 }
58 
59 # SI Time prefixes
60 UNIT_TIME = {
61  UnitOfTime.SECONDS: 1,
62  UnitOfTime.MINUTES: 60,
63  UnitOfTime.HOURS: 60 * 60,
64  UnitOfTime.DAYS: 24 * 60 * 60,
65 }
66 
67 DEFAULT_ROUND = 3
68 DEFAULT_TIME_WINDOW = 0
69 
70 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
71  {
72  vol.Optional(CONF_NAME): cv.string,
73  vol.Required(CONF_SOURCE): cv.entity_id,
74  vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
75  vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
76  vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME),
77  vol.Optional(CONF_UNIT): cv.string,
78  vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period,
79  }
80 )
81 
82 
84  hass: HomeAssistant,
85  config_entry: ConfigEntry,
86  async_add_entities: AddEntitiesCallback,
87 ) -> None:
88  """Initialize Derivative config entry."""
89  registry = er.async_get(hass)
90  # Validate + resolve entity registry id to entity_id
91  source_entity_id = er.async_validate_entity_id(
92  registry, config_entry.options[CONF_SOURCE]
93  )
94 
96  hass,
97  source_entity_id,
98  )
99 
100  if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
101  # Before we had support for optional selectors, "none" was used for selecting nothing
102  unit_prefix = None
103 
104  derivative_sensor = DerivativeSensor(
105  name=config_entry.title,
106  round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
107  source_entity=source_entity_id,
108  time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]),
109  unique_id=config_entry.entry_id,
110  unit_of_measurement=None,
111  unit_prefix=unit_prefix,
112  unit_time=config_entry.options[CONF_UNIT_TIME],
113  device_info=device_info,
114  )
115 
116  async_add_entities([derivative_sensor])
117 
118 
120  hass: HomeAssistant,
121  config: ConfigType,
122  async_add_entities: AddEntitiesCallback,
123  discovery_info: DiscoveryInfoType | None = None,
124 ) -> None:
125  """Set up the derivative sensor."""
126  derivative = DerivativeSensor(
127  name=config.get(CONF_NAME),
128  round_digits=config[CONF_ROUND_DIGITS],
129  source_entity=config[CONF_SOURCE],
130  time_window=config[CONF_TIME_WINDOW],
131  unit_of_measurement=config.get(CONF_UNIT),
132  unit_prefix=config[CONF_UNIT_PREFIX],
133  unit_time=config[CONF_UNIT_TIME],
134  unique_id=None,
135  )
136 
137  async_add_entities([derivative])
138 
139 
141  """Representation of a derivative sensor."""
142 
143  _attr_translation_key = "derivative"
144  _attr_should_poll = False
145 
146  def __init__(
147  self,
148  *,
149  name: str | None,
150  round_digits: int,
151  source_entity: str,
152  time_window: timedelta,
153  unit_of_measurement: str | None,
154  unit_prefix: str | None,
155  unit_time: UnitOfTime,
156  unique_id: str | None,
157  device_info: DeviceInfo | None = None,
158  ) -> None:
159  """Initialize the derivative sensor."""
160  self._attr_unique_id_attr_unique_id = unique_id
161  self._attr_device_info_attr_device_info = device_info
162  self._sensor_source_id_sensor_source_id = source_entity
163  self._round_digits_round_digits = round_digits
164  self._attr_native_value_attr_native_value = round(Decimal(0), round_digits)
165  # List of tuples with (timestamp_start, timestamp_end, derivative)
166  self._state_list_state_list: list[tuple[datetime, datetime, Decimal]] = []
167 
168  self._attr_name_attr_name = name if name is not None else f"{source_entity} derivative"
169  self._attr_extra_state_attributes_attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity}
170 
171  if unit_of_measurement is None:
172  final_unit_prefix = "" if unit_prefix is None else unit_prefix
173  self._unit_template_unit_template = f"{final_unit_prefix}{{}}/{unit_time}"
174  # we postpone the definition of unit_of_measurement to later
175  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = None
176  else:
177  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = unit_of_measurement
178 
179  self._unit_prefix_unit_prefix = UNIT_PREFIXES[unit_prefix]
180  self._unit_time_unit_time = UNIT_TIME[unit_time]
181  self._time_window_time_window = time_window.total_seconds()
182 
183  async def async_added_to_hass(self) -> None:
184  """Handle entity which will be added."""
185  await super().async_added_to_hass()
186  restored_data = await self.async_get_last_sensor_dataasync_get_last_sensor_data()
187  if restored_data:
188  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = (
189  restored_data.native_unit_of_measurement
190  )
191  try:
192  self._attr_native_value_attr_native_value = round(
193  Decimal(restored_data.native_value), # type: ignore[arg-type]
194  self._round_digits_round_digits,
195  )
196  except SyntaxError as err:
197  _LOGGER.warning("Could not restore last state: %s", err)
198 
199  @callback
200  def calc_derivative(event: Event[EventStateChangedData]) -> None:
201  """Handle the sensor state changes."""
202  if (
203  (old_state := event.data["old_state"]) is None
204  or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
205  or (new_state := event.data["new_state"]) is None
206  or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
207  ):
208  return
209 
210  if self.native_unit_of_measurementnative_unit_of_measurement is None:
211  unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
212  self._attr_native_unit_of_measurement_attr_native_unit_of_measurement = self._unit_template_unit_template.format(
213  "" if unit is None else unit
214  )
215 
216  # filter out all derivatives older than `time_window` from our window list
217  self._state_list_state_list = [
218  (time_start, time_end, state)
219  for time_start, time_end, state in self._state_list_state_list
220  if (new_state.last_updated - time_end).total_seconds()
221  < self._time_window_time_window
222  ]
223 
224  try:
225  elapsed_time = (
226  new_state.last_updated - old_state.last_updated
227  ).total_seconds()
228  delta_value = Decimal(new_state.state) - Decimal(old_state.state)
229  new_derivative = (
230  delta_value
231  / Decimal(elapsed_time)
232  / Decimal(self._unit_prefix_unit_prefix)
233  * Decimal(self._unit_time_unit_time)
234  )
235 
236  except ValueError as err:
237  _LOGGER.warning("While calculating derivative: %s", err)
238  except DecimalException as err:
239  _LOGGER.warning(
240  "Invalid state (%s > %s): %s", old_state.state, new_state.state, err
241  )
242  except AssertionError as err:
243  _LOGGER.error("Could not calculate derivative: %s", err)
244 
245  # For total inreasing sensors, the value is expected to continuously increase.
246  # A negative derivative for a total increasing sensor likely indicates the
247  # sensor has been reset. To prevent inaccurate data, discard this sample.
248  if (
249  new_state.attributes.get(ATTR_STATE_CLASS)
250  == SensorStateClass.TOTAL_INCREASING
251  and new_derivative < 0
252  ):
253  return
254 
255  # add latest derivative to the window list
256  self._state_list_state_list.append(
257  (old_state.last_updated, new_state.last_updated, new_derivative)
258  )
259 
260  def calculate_weight(
261  start: datetime, end: datetime, now: datetime
262  ) -> float:
263  window_start = now - timedelta(seconds=self._time_window_time_window)
264  if start < window_start:
265  weight = (end - window_start).total_seconds() / self._time_window_time_window
266  else:
267  weight = (end - start).total_seconds() / self._time_window_time_window
268  return weight
269 
270  # If outside of time window just report derivative (is the same as modeling it in the window),
271  # otherwise take the weighted average with the previous derivatives
272  if elapsed_time > self._time_window_time_window:
273  derivative = new_derivative
274  else:
275  derivative = Decimal(0.00)
276  for start, end, value in self._state_list_state_list:
277  weight = calculate_weight(start, end, new_state.last_updated)
278  derivative = derivative + (value * Decimal(weight))
279  self._attr_native_value_attr_native_value = round(derivative, self._round_digits_round_digits)
280  self.async_write_ha_stateasync_write_ha_state()
281 
282  self.async_on_removeasync_on_remove(
284  self.hasshass, self._sensor_source_id_sensor_source_id, calc_derivative
285  )
286  )
None __init__(self, *str|None name, int round_digits, str source_entity, timedelta time_window, str|None unit_of_measurement, str|None unit_prefix, UnitOfTime unit_time, str|None unique_id, DeviceInfo|None device_info=None)
Definition: sensor.py:158
SensorExtraStoredData|None async_get_last_sensor_data(self)
Definition: __init__.py:934
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:124
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:87
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
Definition: device.py:28
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314