From 69d935b7bd9f107ba474e6a4c5fa580617bccfb6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Oct 2022 10:32:01 +0200 Subject: [PATCH] Teach long term statistics that unit 'rpm' is same as 'RPM' (#80012) * Teach long term statistics that unit 'rpm' is same as 'RPM' * Add tests --- homeassistant/components/sensor/recorder.py | 24 ++- tests/components/sensor/test_recorder.py | 197 ++++++++++++++++++++ 2 files changed, 217 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index eee13c09813..c2aa99659cd 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -23,7 +23,7 @@ from homeassistant.components.recorder.models import ( StatisticMetaData, StatisticResult, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, REVOLUTIONS_PER_MINUTE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources @@ -47,6 +47,10 @@ DEFAULT_STATISTICS = { STATE_CLASS_TOTAL_INCREASING: {"sum"}, } +EQUIVALENT_UNITS = { + "RPM": REVOLUTIONS_PER_MINUTE, +} + # Keep track of entities for which a warning about decreasing value has been logged SEEN_DIP = "sensor_seen_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip" @@ -113,10 +117,20 @@ def _time_weighted_average( def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: - """Return True if all states have the same unit.""" + """Return a set of all units.""" return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} +def _equivalent_units(units: set[str | None]) -> bool: + """Return True if the units are equivalent.""" + if len(units) == 1: + return True + units = { + EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit for unit in units + } + return len(units) == 1 + + def _parse_float(state: str) -> float: """Parse a float string, throw on inf or nan.""" fstate = float(state) @@ -165,7 +179,7 @@ def _normalize_states( # The unit used by this sensor doesn't support unit conversion all_units = _get_units(fstates) - if len(all_units) > 1: + if not _equivalent_units(all_units): if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: @@ -442,7 +456,9 @@ def _compile_statistics( # noqa: C901 ) in to_process: # Check metadata if old_metadata := old_metadatas.get(entity_id): - if old_metadata[1]["unit_of_measurement"] != statistics_unit: + if not _equivalent_units( + {old_metadata[1]["unit_of_measurement"], statistics_unit} + ): if WARN_UNSTABLE_UNIT not in hass.data: hass.data[WARN_UNSTABLE_UNIT] = set() if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 3f15b35d7b1..73804e83d94 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2192,6 +2192,203 @@ def test_compile_hourly_statistics_changing_units_3( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, mean, mean2, min, max", + [ + (None, "RPM", "rpm", None, 13.050847, 13.333333, -10, 30), + (None, "rpm", "RPM", None, 13.050847, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_1( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + mean, + mean2, + min, + max, +): + """Test compiling hourly statistics where units change from one hour to the next.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + assert "can not be converted to the unit of previously" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit2, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(minutes=10) + ), + "end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=15)), + "mean": approx(mean2), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + "device_class, state_unit, state_unit2, unit_class, mean, min, max", + [ + (None, "RPM", "rpm", None, 13.333333, -10, 30), + (None, "rpm", "RPM", None, 13.333333, -10, 30), + ], +) +def test_compile_hourly_statistics_equivalent_units_2( + hass_recorder, + caplog, + device_class, + state_unit, + state_unit2, + unit_class, + mean, + min, + max, +): + """Test compiling hourly statistics where units change during an hour.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + do_adhoc_statistics(hass, start=zero + timedelta(seconds=30 * 5)) + wait_recording_done(hass) + assert "The unit of sensor.test1 is changing" not in caplog.text + assert "and matches the unit of already compiled statistics" not in caplog.text + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": state_unit, + "unit_class": unit_class, + }, + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 5) + ), + "end": process_timestamp_to_utc_isoformat( + zero + timedelta(seconds=30 * 15) + ), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( "device_class, state_unit, statistic_unit, unit_class, mean1, mean2, min, max", [