Fix ZeroDivisionError for inverse unit conversions in recorder statistics (#176320)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
This commit is contained in:
TowyTowy
2026-09-03 19:49:35 +03:00
committed by GitHub
co-authored by Claude Fable 5 Markus Tuominen
parent 0ee2592709
commit 2db9ed1e71
4 changed files with 155 additions and 13 deletions
@@ -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
+9 -3
View File
@@ -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))
@@ -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")
+83
View File
@@ -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",