diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7a7225cfafe9..284beb185686 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -382,8 +382,7 @@ def _get_statistic_to_display_unit_converter( statistic_unit: str | None, state_unit: str | None, requested_units: dict[str, str] | None, - allow_none: bool = True, -) -> Callable[[float | None], float | None] | Callable[[float], float] | None: +) -> Callable[[float | None], float | None] | None: """Prepare a converter from the statistics unit to display unit.""" if (converter := _get_unit_converter(unit_class, statistic_unit)) is None: return None @@ -402,11 +401,9 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - if allow_none: - return converter.converter_factory_allow_none( - from_unit=statistic_unit, to_unit=display_unit - ) - return converter.converter_factory(from_unit=statistic_unit, to_unit=display_unit) + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) def _get_display_to_statistic_unit_converter_func( @@ -2566,7 +2563,7 @@ def _build_sum_converted_stats( table_duration_seconds: float, start_ts_idx: int, sum_idx: int, - convert: Callable[[float | None], float | None] | Callable[[float], float], + convert: Callable[[float | None], float | None], ) -> list[StatisticsRow]: """Build a list of sum statistics.""" return [ @@ -2618,7 +2615,7 @@ def _build_converted_stats( table_duration_seconds: float, start_ts_idx: int, row_mapping: tuple[tuple[str, int], ...], - convert: Callable[[float | None], float | None] | Callable[[float], float], + convert: Callable[[float | None], float | None], ) -> list[StatisticsRow]: """Build a list of statistics with unit conversion.""" return [ @@ -2694,7 +2691,7 @@ def _sorted_statistics_to_dict( EntityStateAttribute.UNIT_OF_MEASUREMENT ) convert = _get_statistic_to_display_unit_converter( - unit_class, unit, state_unit, units, allow_none=False + unit_class, unit, state_unit, units ) else: convert = None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 79181b65359b..e80b96391bbf 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -358,7 +358,7 @@ def _normalize_states( return unit_class, state_unit, fstates valid_fstates: list[tuple[float, State]] = [] - convert: Callable[[float], float] | None = None + convert: Callable[[float | None], float | None] | None = None last_unit: str | UndefinedType | None = UNDEFINED valid_units = converter.VALID_UNITS @@ -391,11 +391,17 @@ def _normalize_states( if state_unit == statistics_unit: convert = None else: - convert = converter.converter_factory(state_unit, statistics_unit) + convert = converter.converter_factory_allow_none( + state_unit, statistics_unit + ) last_unit = state_unit if convert is not None: - fstate = convert(fstate) + if (converted_fstate := convert(fstate)) is None: + # Exclude states which can't be converted, e.g. converting 0 + # between kWh/100km and km/kWh would divide by zero + continue + fstate = converted_fstate valid_fstates.append((fstate, state)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 12eec944ba51..0c8a60e2a90b 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1522,6 +1522,62 @@ async def test_update_statistics_metadata_error( } +@pytest.mark.parametrize( + ("state", "converted_value"), + [ + pytest.param(0, None, id="zero"), + pytest.param(20, 5.0, id="non-zero"), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_statistics_during_period_display_inverse_unit( + hass: HomeAssistant, + state: int, + converted_value: float | None, +) -> None: + """Test fetching statistics with a display unit which is an inverse unit. + + A zero value has no representation in the inverse unit and should be + converted to None instead of raising ZeroDivisionError. + """ + now = get_start_time(dt_util.utcnow()) + + attributes = { + "device_class": "energy_distance", + "state_class": "measurement", + "unit_of_measurement": "kWh/100km", + } + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set( + "sensor.test", state, attributes=attributes, timestamp=now.timestamp() + ) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=now) + await async_wait_recording_done(hass) + + assert statistics_during_period( + hass, + now, + period="5minute", + statistic_ids={"sensor.test"}, + units={"energy_distance": "km/kWh"}, + ) == { + "sensor.test": [ + { + "end": (now + timedelta(minutes=5)).timestamp(), + "last_reset": None, + "max": converted_value, + "mean": converted_value, + "min": converted_value, + "start": now.timestamp(), + } + ], + } + + @pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index b92411ee1225..a4c40e7ee7a0 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3843,6 +3843,89 @@ async def test_compile_hourly_statistics_convert_units_1( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_convert_zero_to_inverse_unit( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling statistics when a sensor changes to an inverse unit. + + A zero value has no representation in the inverse unit used for the + previously compiled statistics and should be skipped instead of raising + ZeroDivisionError. + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, DOMAIN, {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + attributes = { + "device_class": "energy_distance", + "state_class": "measurement", + "unit_of_measurement": "kWh/100km", + } + with freeze_time(zero) as freezer: + await async_record_states( + hass, freezer, zero, "sensor.test1", attributes, seq=[16, 16, None] + ) + attributes["unit_of_measurement"] = "km/kWh" + await async_record_states( + hass, + freezer, + zero + timedelta(minutes=5), + "sensor.test1", + attributes, + seq=[0, 20, 20], + ) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=zero) + do_adhoc_statistics(hass, start=zero + timedelta(minutes=5)) + await async_wait_recording_done(hass) + + assert "Error while processing event StatisticsTask" not in caplog.text + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": "km/kWh", + "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": "kWh/100km", + "unit_class": "energy_distance", + }, + ] + # The zero state at the start of the second period is skipped as it has no + # representation in kWh/100km, the 20 km/kWh states convert to 5 kWh/100km. + # The stored statistics are displayed converted to the current state unit + 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(100 / 16), + "min": pytest.approx(100 / 16), + "max": pytest.approx(100 / 16), + "last_reset": None, + "state": None, + "sum": None, + }, + { + "start": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=10)).timestamp(), + "mean": pytest.approx(20.0), + "min": pytest.approx(20.0), + "max": pytest.approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + }, + ] + } + + @pytest.mark.parametrize( ( "device_class",