From 88ad97f112bd1088a767d3d8181bad52541cc561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:12:45 -1000 Subject: [PATCH] Fix generating statistics for time periods smaller than we can measure (#90069) If the time period for the mean/time weighted average was smaller than we can measure (less than one microsecond), generating statistics would fail with a divide by zero error. This is likely only happens if the database schema precision is incorrect. --- homeassistant/components/sensor/recorder.py | 11 +- tests/components/sensor/test_recorder.py | 334 ++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c0df642ed36..21fbf453ac3 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -119,7 +119,16 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - return accumulated / (end - start).total_seconds() + period_seconds = (end - start).total_seconds() + if period_seconds == 0: + # If the only state changed that happened was at the exact moment + # at the end of the period, we can't calculate a meaningful average + # so we return 0.0 since it represents a time duration smaller than + # we can measure. This probably means the precision of statistics + # column schema in the database is incorrect but it is actually possible + # to happen if the state change event fired at the exact microsecond + return 0.0 + return accumulated / period_seconds def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8881bef8edc..f3e373f5a63 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -193,6 +193,340 @@ def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 27.796610169491526, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 27.796610169491526, -10, 60), + ], +) +def test_compile_hourly_statistics_with_some_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + + # Record two states at the exact same time + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.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", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ], +) +def test_compile_hourly_statistics_with_all_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the all of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.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", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), + ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ], +) +def test_compile_hourly_statistics_only_state_is_and_end_of_period( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics when the only state at end of period.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + end = zero + timedelta(minutes=5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.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", "display_unit", "statistics_unit", "unit_class"), [