mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 17:31:15 -04:00
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:
co-authored by
Claude Fable 5
Markus Tuominen
parent
0ee2592709
commit
2db9ed1e71
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user