Skip to content

Commit a3b67d5

Browse files
authored
Add support to sensor statistics for changing unit_class (home-assistant#154130)
1 parent 76a0b2d commit a3b67d5

File tree

2 files changed

+152
-44
lines changed

2 files changed

+152
-44
lines changed

homeassistant/components/sensor/recorder.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,19 +280,27 @@ def _normalize_states(
280280
state_unit: str | None = None
281281
statistics_unit: str | None
282282
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
283+
device_class = fstates[0][1].attributes.get(ATTR_DEVICE_CLASS)
283284
old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None
284285
if not old_metadata:
285286
# We've not seen this sensor before, the first valid state determines the unit
286287
# used for statistics
287288
statistics_unit = state_unit
288-
unit_class = _get_unit_class(
289-
fstates[0][1].attributes.get(ATTR_DEVICE_CLASS),
290-
state_unit,
291-
)
289+
unit_class = _get_unit_class(device_class, state_unit)
292290
else:
293291
# We have seen this sensor before, use the unit from metadata
294292
statistics_unit = old_metadata["unit_of_measurement"]
295293
unit_class = old_metadata["unit_class"]
294+
# Check if the unit class has changed
295+
if (
296+
(new_unit_class := _get_unit_class(device_class, state_unit)) != unit_class
297+
and (new_converter := _get_unit_converter(new_unit_class))
298+
and state_unit in new_converter.VALID_UNITS
299+
and statistics_unit in new_converter.VALID_UNITS
300+
):
301+
# The new unit class supports conversion between the units in metadata
302+
# and the unit in the state, so we can use the new unit class
303+
unit_class = new_unit_class
296304

297305
if not (converter := _get_unit_converter(unit_class)):
298306
# The unit used by this sensor doesn't support unit conversion

tests/components/sensor/test_recorder.py

Lines changed: 140 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4062,33 +4062,130 @@ async def test_compile_hourly_statistics_equivalent_units_2(
40624062
@pytest.mark.parametrize(
40634063
(
40644064
"device_class",
4065-
"state_unit",
4066-
"statistic_unit",
4067-
"unit_class",
4065+
"unit_1",
4066+
"unit_2",
4067+
"unit_3",
4068+
"unit_class_1",
4069+
"unit_class_2",
4070+
"factor_2",
4071+
"factor_3",
40684072
"mean1",
40694073
"mean2",
40704074
"min",
40714075
"max",
40724076
),
40734077
[
4074-
("power", "kW", "kW", "power", 13.050847, 13.333333, -10, 30),
4078+
(
4079+
"power",
4080+
"kW",
4081+
"kW",
4082+
"kW",
4083+
"power",
4084+
"power",
4085+
1,
4086+
1,
4087+
13.050847,
4088+
13.333333,
4089+
-10,
4090+
30,
4091+
),
4092+
(
4093+
"carbon_monoxide",
4094+
"ppm",
4095+
"ppm",
4096+
"ppm",
4097+
"unitless",
4098+
"carbon_monoxide",
4099+
1,
4100+
1,
4101+
13.050847,
4102+
13.333333,
4103+
-10,
4104+
30,
4105+
),
4106+
# Valid change of unit class from unitless to carbon_monoxide
4107+
(
4108+
"carbon_monoxide",
4109+
"ppm",
4110+
"ppm",
4111+
"mg/m³",
4112+
"unitless",
4113+
"carbon_monoxide",
4114+
1,
4115+
1.164409,
4116+
13.050847,
4117+
13.333333,
4118+
-10,
4119+
30,
4120+
),
4121+
# Valid change of unit class from unitless to carbon_monoxide
4122+
(
4123+
"carbon_monoxide",
4124+
"ppm",
4125+
"mg/m³",
4126+
"mg/m³",
4127+
"unitless",
4128+
"carbon_monoxide",
4129+
1.164409,
4130+
1.164409,
4131+
13.050847,
4132+
13.333333,
4133+
-10,
4134+
30,
4135+
),
4136+
# Valid change of unit class from concentration to carbon_monoxide
4137+
(
4138+
"carbon_monoxide",
4139+
"mg/m³",
4140+
"mg/m³",
4141+
"ppm",
4142+
"concentration",
4143+
"carbon_monoxide",
4144+
1,
4145+
1 / 1.164409,
4146+
13.050847,
4147+
13.333333,
4148+
-10,
4149+
30,
4150+
),
4151+
# Valid change of unit class from concentration to carbon_monoxide
4152+
(
4153+
"carbon_monoxide",
4154+
"mg/m³",
4155+
"ppm",
4156+
"ppm",
4157+
"concentration",
4158+
"carbon_monoxide",
4159+
1 / 1.164409,
4160+
1 / 1.164409,
4161+
13.050847,
4162+
13.333333,
4163+
-10,
4164+
30,
4165+
),
40754166
],
40764167
)
40774168
async def test_compile_hourly_statistics_changing_device_class_1(
40784169
hass: HomeAssistant,
40794170
caplog: pytest.LogCaptureFixture,
40804171
device_class,
4081-
state_unit,
4082-
statistic_unit,
4083-
unit_class,
4172+
unit_1,
4173+
unit_2,
4174+
unit_3,
4175+
unit_class_1,
4176+
unit_class_2,
4177+
factor_2,
4178+
factor_3,
40844179
mean1,
40854180
mean2,
40864181
min,
40874182
max,
40884183
) -> None:
40894184
"""Test compiling hourly statistics where device class changes from one hour to the next.
40904185
4091-
Device class is ignored, meaning changing device class should not influence the statistics.
4186+
In this test, the device class is first None, then set to a specific device class.
4187+
4188+
Changing device class may influence the unit class.
40924189
"""
40934190
zero = get_start_time(dt_util.utcnow())
40944191
await async_setup_component(hass, "sensor", {})
@@ -4098,7 +4195,7 @@ async def test_compile_hourly_statistics_changing_device_class_1(
40984195
# Record some states for an initial period, the entity has no device class
40994196
attributes = {
41004197
"state_class": "measurement",
4101-
"unit_of_measurement": state_unit,
4198+
"unit_of_measurement": unit_1,
41024199
}
41034200
with freeze_time(zero) as freezer:
41044201
four, states = await async_record_states(
@@ -4113,14 +4210,14 @@ async def test_compile_hourly_statistics_changing_device_class_1(
41134210
assert statistic_ids == [
41144211
{
41154212
"statistic_id": "sensor.test1",
4116-
"display_unit_of_measurement": state_unit,
4213+
"display_unit_of_measurement": unit_1,
41174214
"has_mean": True,
41184215
"mean_type": StatisticMeanType.ARITHMETIC,
41194216
"has_sum": False,
41204217
"name": None,
41214218
"source": "recorder",
4122-
"statistics_unit_of_measurement": state_unit,
4123-
"unit_class": unit_class,
4219+
"statistics_unit_of_measurement": unit_1,
4220+
"unit_class": unit_class_1,
41244221
},
41254222
]
41264223
stats = statistics_during_period(hass, zero, period="5minute")
@@ -4139,15 +4236,17 @@ async def test_compile_hourly_statistics_changing_device_class_1(
41394236
]
41404237
}
41414238

4142-
# Update device class and record additional states in the original UoM
4239+
# Update device class and record additional states in a different UoM
41434240
attributes["device_class"] = device_class
4241+
attributes["unit_of_measurement"] = unit_2
4242+
seq = [x * factor_2 for x in (-10, 15, 30)]
41444243
with freeze_time(zero) as freezer:
41454244
four, _states = await async_record_states(
4146-
hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes
4245+
hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes, seq
41474246
)
41484247
states["sensor.test1"] += _states["sensor.test1"]
41494248
four, _states = await async_record_states(
4150-
hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes
4249+
hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes, seq
41514250
)
41524251
await async_wait_recording_done(hass)
41534252
states["sensor.test1"] += _states["sensor.test1"]
@@ -4163,14 +4262,14 @@ async def test_compile_hourly_statistics_changing_device_class_1(
41634262
assert statistic_ids == [
41644263
{
41654264
"statistic_id": "sensor.test1",
4166-
"display_unit_of_measurement": state_unit,
4265+
"display_unit_of_measurement": unit_2,
41674266
"has_mean": True,
41684267
"mean_type": StatisticMeanType.ARITHMETIC,
41694268
"has_sum": False,
41704269
"name": None,
41714270
"source": "recorder",
4172-
"statistics_unit_of_measurement": state_unit,
4173-
"unit_class": unit_class,
4271+
"statistics_unit_of_measurement": unit_1,
4272+
"unit_class": unit_class_2,
41744273
},
41754274
]
41764275
stats = statistics_during_period(hass, zero, period="5minute")
@@ -4179,19 +4278,19 @@ async def test_compile_hourly_statistics_changing_device_class_1(
41794278
{
41804279
"start": process_timestamp(zero).timestamp(),
41814280
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
4182-
"mean": pytest.approx(mean1),
4183-
"min": pytest.approx(min),
4184-
"max": pytest.approx(max),
4281+
"mean": pytest.approx(mean1 * factor_2),
4282+
"min": pytest.approx(min * factor_2),
4283+
"max": pytest.approx(max * factor_2),
41854284
"last_reset": None,
41864285
"state": None,
41874286
"sum": None,
41884287
},
41894288
{
41904289
"start": process_timestamp(zero + timedelta(minutes=10)).timestamp(),
41914290
"end": process_timestamp(zero + timedelta(minutes=15)).timestamp(),
4192-
"mean": pytest.approx(mean2),
4193-
"min": pytest.approx(min),
4194-
"max": pytest.approx(max),
4291+
"mean": pytest.approx(mean2 * factor_2),
4292+
"min": pytest.approx(min * factor_2),
4293+
"max": pytest.approx(max * factor_2),
41954294
"last_reset": None,
41964295
"state": None,
41974296
"sum": None,
@@ -4200,14 +4299,15 @@ async def test_compile_hourly_statistics_changing_device_class_1(
42004299
}
42014300

42024301
# Update device class and record additional states in a different UoM
4203-
attributes["unit_of_measurement"] = statistic_unit
4302+
attributes["unit_of_measurement"] = unit_3
4303+
seq = [x * factor_3 for x in (-10, 15, 30)]
42044304
with freeze_time(zero) as freezer:
42054305
four, _states = await async_record_states(
4206-
hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes
4306+
hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes, seq
42074307
)
42084308
states["sensor.test1"] += _states["sensor.test1"]
42094309
four, _states = await async_record_states(
4210-
hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes
4310+
hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes, seq
42114311
)
42124312
await async_wait_recording_done(hass)
42134313
states["sensor.test1"] += _states["sensor.test1"]
@@ -4223,14 +4323,14 @@ async def test_compile_hourly_statistics_changing_device_class_1(
42234323
assert statistic_ids == [
42244324
{
42254325
"statistic_id": "sensor.test1",
4226-
"display_unit_of_measurement": state_unit,
4326+
"display_unit_of_measurement": unit_3,
42274327
"has_mean": True,
42284328
"mean_type": StatisticMeanType.ARITHMETIC,
42294329
"has_sum": False,
42304330
"name": None,
42314331
"source": "recorder",
4232-
"statistics_unit_of_measurement": state_unit,
4233-
"unit_class": unit_class,
4332+
"statistics_unit_of_measurement": unit_1,
4333+
"unit_class": unit_class_2,
42344334
},
42354335
]
42364336
stats = statistics_during_period(hass, zero, period="5minute")
@@ -4239,29 +4339,29 @@ async def test_compile_hourly_statistics_changing_device_class_1(
42394339
{
42404340
"start": process_timestamp(zero).timestamp(),
42414341
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
4242-
"mean": pytest.approx(mean1),
4243-
"min": pytest.approx(min),
4244-
"max": pytest.approx(max),
4342+
"mean": pytest.approx(mean1 * factor_3),
4343+
"min": pytest.approx(min * factor_3),
4344+
"max": pytest.approx(max * factor_3),
42454345
"last_reset": None,
42464346
"state": None,
42474347
"sum": None,
42484348
},
42494349
{
42504350
"start": process_timestamp(zero + timedelta(minutes=10)).timestamp(),
42514351
"end": process_timestamp(zero + timedelta(minutes=15)).timestamp(),
4252-
"mean": pytest.approx(mean2),
4253-
"min": pytest.approx(min),
4254-
"max": pytest.approx(max),
4352+
"mean": pytest.approx(mean2 * factor_3),
4353+
"min": pytest.approx(min * factor_3),
4354+
"max": pytest.approx(max * factor_3),
42554355
"last_reset": None,
42564356
"state": None,
42574357
"sum": None,
42584358
},
42594359
{
42604360
"start": process_timestamp(zero + timedelta(minutes=20)).timestamp(),
42614361
"end": process_timestamp(zero + timedelta(minutes=25)).timestamp(),
4262-
"mean": pytest.approx(mean2),
4263-
"min": pytest.approx(min),
4264-
"max": pytest.approx(max),
4362+
"mean": pytest.approx(mean2 * factor_3),
4363+
"min": pytest.approx(min * factor_3),
4364+
"max": pytest.approx(max * factor_3),
42654365
"last_reset": None,
42664366
"state": None,
42674367
"sum": None,
@@ -4302,7 +4402,7 @@ async def test_compile_hourly_statistics_changing_device_class_2(
43024402
) -> None:
43034403
"""Test compiling hourly statistics where device class changes from one hour to the next.
43044404
4305-
Device class is ignored, meaning changing device class should not influence the statistics.
4405+
In this test, the device class is first set to a specific device class, then set to None.
43064406
"""
43074407
zero = get_start_time(dt_util.utcnow())
43084408
await async_setup_component(hass, "sensor", {})

0 commit comments

Comments
 (0)