Add circular mean statistics and sensor state class MEASUREMENT_ANGLE (#138453)

* Add circular mean statistics

* fixes

* Add has_circular_mean and fix tests

* Fix mypy

* Rename to MEASUREMENT_ANGLE

* Fix kitchen_sink tests

* Fix sensor tests

* for testing only

* Revert ws command change

* Apply suggestions

* test only

* add custom handling for postgres

* fix recursion limit

* Check if column is already available

* Set default false and not nullable for has_circular_mean

* Proper fix to be backwards compatible

* Fix value is None

* Align with schema

* Remove has_circular_mean from test schemas as it's not required anymore

* fix wrong column type

* Use correct variable to reduce stats

* Add guard that the uom is matching a valid one from the state class

* Add some tests

* Fix tests again

* Use mean_type in StatisticsMetato difference between different mean type algorithms

* Fix leftovers

* Fix kitchen_sink tests

* Fix postgres

* Add circular mean test

* Add mean_type_changed stats issue

* Align the attributes with unit_changed

* Fix mean_type_change stats issue

* Add missing sensor recorder tests

* Add test_statistic_during_period_circular_mean

* Add mean_weight

* Add test_statistic_during_period_hole_circular_mean

* Use seperate migration step to null has_mean

* Typo ARITHMETIC

* Implement requested changes

* Implement requested changes

* Split into #141444

* Add StatisticMeanType.NONE and forbid that mean_type can be None

* Fix mean_type

* Implement requested changes

* Small leftover of latest StatisticMeanType changes
This commit is contained in:
Robert Resch 2025-03-26 13:15:58 +01:00 committed by GitHub
parent 4a6d2c91da
commit e3f2f30395
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2337 additions and 444 deletions

View File

@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy
from aiohttp import ClientError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
)
consumption_metadata = StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Consumption",
source=DOMAIN,

View File

@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast
from elvia import Elvia, error as ElviaError
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@ -144,7 +148,7 @@ class ElviaImporter:
async_add_external_statistics(
hass=self.hass,
metadata=StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{self.metering_point_id} Consumption",
source=DOMAIN,

View File

@ -8,6 +8,7 @@ import datetime
from enum import StrEnum
import logging
from homeassistant.components.recorder.models import StatisticMeanType
from homeassistant.components.recorder.models.statistics import (
StatisticData,
StatisticMetaData,
@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity):
]
metadata: StatisticMetaData = {
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": f"{self.device_entry.name} {self.name}",
"source": DOMAIN,

View File

@ -12,14 +12,24 @@ from random import random
import voluptuous as vol
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
async_import_statistics,
get_last_statistics,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume
from homeassistant.const import (
DEGREE,
Platform,
UnitOfEnergy,
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@ -72,6 +82,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set the config entry up."""
if "recorder" in hass.config.components:
# Insert stats for mean_type_changed issue
await _insert_wrong_wind_direction_statistics(hass)
# Set up demo platforms with config entry
await hass.config_entries.async_forward_entry_setups(
entry, COMPONENTS_WITH_DEMO_PLATFORM
@ -233,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Outdoor temperature",
"statistic_id": f"{DOMAIN}:temperature_outdoor",
"unit_of_measurement": UnitOfTemperature.CELSIUS,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@ -246,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Energy consumption 1",
"statistic_id": f"{DOMAIN}:energy_consumption_kwh",
"unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR,
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1)
@ -258,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Energy consumption 2",
"statistic_id": f"{DOMAIN}:energy_consumption_mwh",
"unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR,
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(
@ -272,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Gas consumption 1",
"statistic_id": f"{DOMAIN}:gas_consumption_m3",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(
@ -286,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Gas consumption 2",
"statistic_id": f"{DOMAIN}:gas_consumption_ft3",
"unit_of_measurement": UnitOfVolume.CUBIC_FEET,
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15)
@ -298,7 +312,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": None,
"statistic_id": "sensor.statistics_issues_issue_1",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@ -310,7 +324,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": None,
"statistic_id": "sensor.statistics_issues_issue_2",
"unit_of_measurement": "cats",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@ -322,7 +336,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": None,
"statistic_id": "sensor.statistics_issues_issue_3",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@ -334,8 +348,28 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": None,
"statistic_id": "sensor.statistics_issues_issue_4",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
async_import_statistics(hass, metadata, statistics)
async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None:
"""Insert some fake wind direction statistics."""
now = dt_util.now()
yesterday = now - datetime.timedelta(days=1)
yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
today_midnight = yesterday_midnight + datetime.timedelta(days=1)
# Add some statistics required to raise the mean_type_changed issue later
metadata: StatisticMetaData = {
"source": RECORDER_DOMAIN,
"name": None,
"statistic_id": "sensor.statistics_issues_issue_5",
"unit_of_measurement": DEGREE,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360)
async_import_statistics(hass, metadata, statistics)

View File

@ -8,7 +8,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPower
from homeassistant.const import DEGREE, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -87,6 +87,16 @@ async def async_setup_entry(
state_class=None,
unit_of_measurement=UnitOfPower.WATT,
),
DemoSensor(
device_unique_id="statistics_issues",
unique_id="statistics_issue_5",
device_name="Statistics issues",
entity_name="Issue 5",
state=100,
device_class=SensorDeviceClass.WIND_DIRECTION,
state_class=SensorStateClass.MEASUREMENT_ANGLE,
unit_of_measurement=DEGREE,
),
]
)

View File

@ -16,7 +16,11 @@ from opower import (
from opower.exceptions import ApiException, CannotConnect, InvalidAuth
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@ -201,7 +205,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
f"{account.meter_type.name.lower()} {account.utility_account_id}"
)
cost_metadata = StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} cost",
source=DOMAIN,
@ -209,7 +213,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
unit_of_measurement=None,
)
consumption_metadata = StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} consumption",
source=DOMAIN,

View File

@ -54,6 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
LAST_REPORTED_SCHEMA_VERSION = 43
CIRCULAR_MEAN_SCHEMA_VERSION = 49
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@ -79,7 +79,13 @@ from .db_schema import (
StatisticsShortTerm,
)
from .executor import DBInterruptibleThreadPoolExecutor
from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect
from .models import (
DatabaseEngine,
StatisticData,
StatisticMeanType,
StatisticMetaData,
UnsupportedDialect,
)
from .pool import POOL_SIZE, MutexPool, RecorderPool
from .table_managers.event_data import EventDataManager
from .table_managers.event_types import EventTypeManager
@ -611,6 +617,17 @@ class Recorder(threading.Thread):
table: type[Statistics | StatisticsShortTerm],
) -> None:
"""Schedule import of statistics."""
if "mean_type" not in metadata:
# Backwards compatibility for old metadata format
# Can be removed after 2026.4
metadata["mean_type"] = ( # type: ignore[unreachable]
StatisticMeanType.ARITHMETIC
if metadata.get("has_mean")
else StatisticMeanType.NONE
)
# Remove deprecated has_mean as it's not needed anymore in core
metadata.pop("has_mean", None)
self.queue_task(ImportStatisticsTask(metadata, stats, table))
@callback

View File

@ -58,6 +58,7 @@ from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
from .models import (
StatisticData,
StatisticDataTimestamp,
StatisticMeanType,
StatisticMetaData,
bytes_to_ulid_or_none,
bytes_to_uuid_hex_or_none,
@ -77,7 +78,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 48
SCHEMA_VERSION = 50
_LOGGER = logging.getLogger(__name__)
@ -719,6 +720,7 @@ class StatisticsBase:
start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True)
mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
min: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
max: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
@ -740,6 +742,7 @@ class StatisticsBase:
start=None,
start_ts=stats["start"].timestamp(),
mean=stats.get("mean"),
mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
@ -763,6 +766,7 @@ class StatisticsBase:
start=None,
start_ts=stats["start_ts"],
mean=stats.get("mean"),
mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
@ -848,6 +852,9 @@ class _StatisticsMeta:
has_mean: Mapped[bool | None] = mapped_column(Boolean)
has_sum: Mapped[bool | None] = mapped_column(Boolean)
name: Mapped[str | None] = mapped_column(String(255))
mean_type: Mapped[StatisticMeanType] = mapped_column(
SmallInteger, nullable=False, default=StatisticMeanType.NONE.value
) # See StatisticMeanType
@staticmethod
def from_meta(meta: StatisticMetaData) -> StatisticsMeta:

View File

@ -81,7 +81,7 @@ from .db_schema import (
StatisticsRuns,
StatisticsShortTerm,
)
from .models import process_timestamp
from .models import StatisticMeanType, process_timestamp
from .models.time import datetime_to_timestamp_or_none
from .queries import (
batch_cleanup_entity_ids,
@ -144,24 +144,32 @@ class _ColumnTypesForDialect:
big_int_type: str
timestamp_type: str
context_bin_type: str
small_int_type: str
double_type: str
_MYSQL_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER(20)",
timestamp_type=DOUBLE_PRECISION_TYPE_SQL,
context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})",
small_int_type="SMALLINT",
double_type=DOUBLE_PRECISION_TYPE_SQL,
)
_POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER",
timestamp_type=DOUBLE_PRECISION_TYPE_SQL,
context_bin_type="BYTEA",
small_int_type="SMALLINT",
double_type=DOUBLE_PRECISION_TYPE_SQL,
)
_SQLITE_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER",
timestamp_type="FLOAT",
context_bin_type="BLOB",
small_int_type="INTEGER",
double_type="FLOAT",
)
_COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = {
@ -1993,6 +2001,42 @@ class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48):
_migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine)
class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49):
def _apply_update(self) -> None:
"""Version specific update method."""
_add_columns(
self.session_maker,
"statistics_meta",
[
f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}"
],
)
for table in ("statistics", "statistics_short_term"):
_add_columns(
self.session_maker,
table,
[f"mean_weight {self.column_types.double_type}"],
)
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
connection.execute(
text(
"UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true"
),
{"mean_type": StatisticMeanType.ARITHMETIC.value},
)
class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
def _apply_update(self) -> None:
"""Version specific update method."""
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
connection.execute(text("UPDATE statistics_meta SET has_mean=NULL"))
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,

View File

@ -17,6 +17,7 @@ from .statistics import (
RollingWindowStatisticPeriod,
StatisticData,
StatisticDataTimestamp,
StatisticMeanType,
StatisticMetaData,
StatisticPeriod,
StatisticResult,
@ -37,6 +38,7 @@ __all__ = [
"RollingWindowStatisticPeriod",
"StatisticData",
"StatisticDataTimestamp",
"StatisticMeanType",
"StatisticMetaData",
"StatisticPeriod",
"StatisticResult",

View File

@ -3,7 +3,8 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Literal, TypedDict
from enum import IntEnum
from typing import Literal, NotRequired, TypedDict
class StatisticResult(TypedDict):
@ -36,6 +37,7 @@ class StatisticMixIn(TypedDict, total=False):
min: float
max: float
mean: float
mean_weight: float
class StatisticData(StatisticDataBase, StatisticMixIn, total=False):
@ -50,10 +52,20 @@ class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=F
last_reset_ts: float | None
class StatisticMeanType(IntEnum):
"""Statistic mean type."""
NONE = 0
ARITHMETIC = 1
CIRCULAR = 2
class StatisticMetaData(TypedDict):
"""Statistic meta data class."""
has_mean: bool
# has_mean is deprecated, use mean_type instead. has_mean will be removed in 2026.4
has_mean: NotRequired[bool]
mean_type: StatisticMeanType
has_sum: bool
name: str | None
source: str

View File

@ -9,12 +9,23 @@ from datetime import datetime, timedelta
from functools import lru_cache, partial
from itertools import chain, groupby
import logging
import math
from operator import itemgetter
import re
from time import time as time_time
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast
from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text
from sqlalchemy import (
Label,
Select,
and_,
bindparam,
case,
func,
lambda_stmt,
select,
text,
)
from sqlalchemy.engine.row import Row
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.session import Session
@ -29,6 +40,7 @@ from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
from homeassistant.util.collection import chunked_or_all
from homeassistant.util.enum import try_parse_enum
from homeassistant.util.unit_conversion import (
AreaConverter,
BaseUnitConverter,
@ -74,6 +86,7 @@ from .db_schema import (
from .models import (
StatisticData,
StatisticDataTimestamp,
StatisticMeanType,
StatisticMetaData,
StatisticResult,
datetime_to_timestamp_or_none,
@ -113,11 +126,54 @@ QUERY_STATISTICS_SHORT_TERM = (
StatisticsShortTerm.sum,
)
def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]:
"""Return the sqlalchemy function for circular mean and the mean_weight.
The result must be modulo 360 to normalize the result [0, 360].
"""
# Postgres doesn't support modulo for double precision and
# the other dbs return the remainder instead of the modulo
# meaning negative values are possible. For these reason
# we need to normalize the result to be in the range [0, 360)
# in Python.
# https://en.wikipedia.org/wiki/Circular_mean
radians = func.radians(table.mean)
weight = func.sqrt(
func.power(func.sum(func.sin(radians) * table.mean_weight), 2)
+ func.power(func.sum(func.cos(radians) * table.mean_weight), 2)
)
return (
func.degrees(
func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians)))
).label("mean"),
weight.label("mean_weight"),
)
QUERY_STATISTICS_SUMMARY_MEAN = (
StatisticsShortTerm.metadata_id,
func.avg(StatisticsShortTerm.mean),
func.min(StatisticsShortTerm.min),
func.max(StatisticsShortTerm.max),
case(
(
StatisticsMeta.mean_type == StatisticMeanType.ARITHMETIC,
func.avg(StatisticsShortTerm.mean),
),
(
StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR,
query_circular_mean(StatisticsShortTerm)[0],
),
else_=None,
),
case(
(
StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR,
query_circular_mean(StatisticsShortTerm)[1],
),
else_=None,
),
StatisticsMeta.mean_type,
)
QUERY_STATISTICS_SUMMARY_SUM = (
@ -180,6 +236,24 @@ def mean(values: list[float]) -> float | None:
return sum(values) / len(values)
DEG_TO_RAD = math.pi / 180
RAD_TO_DEG = 180 / math.pi
def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float:
"""Return the weighted circular mean of the values."""
sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values)
cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values)
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
def circular_mean(values: list[float]) -> float:
"""Return the circular mean of the values."""
sin_sum = sum(math.sin(x * DEG_TO_RAD) for x in values)
cos_sum = sum(math.cos(x * DEG_TO_RAD) for x in values)
return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360
_LOGGER = logging.getLogger(__name__)
@ -372,11 +446,19 @@ def _compile_hourly_statistics_summary_mean_stmt(
start_time_ts: float, end_time_ts: float
) -> StatementLambdaElement:
"""Generate the summary mean statement for hourly statistics."""
# Due the fact that we support different mean type (See StatisticMeanType)
# we need to join here with the StatisticsMeta table to get the mean type
# and then use a case statement to compute the mean based on the mean type.
# As we use the StatisticsMeta.mean_type in the select case statement we need
# to group by it as well.
return lambda_stmt(
lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN)
.filter(StatisticsShortTerm.start_ts >= start_time_ts)
.filter(StatisticsShortTerm.start_ts < end_time_ts)
.group_by(StatisticsShortTerm.metadata_id)
.join(
StatisticsMeta, and_(StatisticsShortTerm.metadata_id == StatisticsMeta.id)
)
.group_by(StatisticsShortTerm.metadata_id, StatisticsMeta.mean_type)
.order_by(StatisticsShortTerm.metadata_id)
)
@ -418,10 +500,17 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None:
if stats:
for stat in stats:
metadata_id, _mean, _min, _max = stat
metadata_id, _min, _max, _mean, _mean_weight, _mean_type = stat
if (
try_parse_enum(StatisticMeanType, _mean_type)
is StatisticMeanType.CIRCULAR
):
# Normalize the circular mean to be in the range [0, 360)
_mean = _mean % 360
summary[metadata_id] = {
"start_ts": start_time_ts,
"mean": _mean,
"mean_weight": _mean_weight,
"min": _min,
"max": _max,
}
@ -827,7 +916,7 @@ def _statistic_by_id_from_metadata(
"display_unit_of_measurement": get_display_unit(
hass, meta["statistic_id"], meta["unit_of_measurement"]
),
"has_mean": meta["has_mean"],
"mean_type": meta["mean_type"],
"has_sum": meta["has_sum"],
"name": meta["name"],
"source": meta["source"],
@ -846,7 +935,9 @@ def _flatten_list_statistic_ids_metadata_result(
{
"statistic_id": _id,
"display_unit_of_measurement": info["display_unit_of_measurement"],
"has_mean": info["has_mean"],
"has_mean": info["mean_type"]
== StatisticMeanType.ARITHMETIC, # Can be removed with 2026.4
"mean_type": info["mean_type"],
"has_sum": info["has_sum"],
"name": info.get("name"),
"source": info["source"],
@ -901,7 +992,7 @@ def list_statistic_ids(
continue
result[key] = {
"display_unit_of_measurement": meta["unit_of_measurement"],
"has_mean": meta["has_mean"],
"mean_type": meta["mean_type"],
"has_sum": meta["has_sum"],
"name": meta["name"],
"source": meta["source"],
@ -919,6 +1010,7 @@ def _reduce_statistics(
period_start_end: Callable[[float], tuple[float, float]],
period: timedelta,
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to daily or monthly statistics."""
result: dict[str, list[StatisticsRow]] = defaultdict(list)
@ -946,7 +1038,13 @@ def _reduce_statistics(
"end": end,
}
if _want_mean:
row["mean"] = mean(mean_values) if mean_values else None
row["mean"] = None
if mean_values:
match metadata[statistic_id][1]["mean_type"]:
case StatisticMeanType.ARITHMETIC:
row["mean"] = mean(mean_values)
case StatisticMeanType.CIRCULAR:
row["mean"] = circular_mean(mean_values)
mean_values.clear()
if _want_min:
row["min"] = min(min_values) if min_values else None
@ -963,8 +1061,9 @@ def _reduce_statistics(
result[statistic_id].append(row)
if _want_max and (_max := statistic.get("max")) is not None:
max_values.append(_max)
if _want_mean and (_mean := statistic.get("mean")) is not None:
mean_values.append(_mean)
if _want_mean:
if (_mean := statistic.get("mean")) is not None:
mean_values.append(_mean)
if _want_min and (_min := statistic.get("min")) is not None:
min_values.append(_min)
prev_stat = statistic
@ -1011,11 +1110,12 @@ def reduce_day_ts_factory() -> tuple[
def _reduce_statistics_per_day(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to daily statistics."""
_same_day_ts, _day_start_end_ts = reduce_day_ts_factory()
return _reduce_statistics(
stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types
stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types, metadata
)
@ -1059,11 +1159,12 @@ def reduce_week_ts_factory() -> tuple[
def _reduce_statistics_per_week(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to weekly statistics."""
_same_week_ts, _week_start_end_ts = reduce_week_ts_factory()
return _reduce_statistics(
stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types
stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types, metadata
)
@ -1112,11 +1213,12 @@ def reduce_month_ts_factory() -> tuple[
def _reduce_statistics_per_month(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to monthly statistics."""
_same_month_ts, _month_start_end_ts = reduce_month_ts_factory()
return _reduce_statistics(
stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types
stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types, metadata
)
@ -1160,27 +1262,41 @@ def _generate_max_mean_min_statistic_in_sub_period_stmt(
return stmt
class _MaxMinMeanStatisticSubPeriod(TypedDict, total=False):
max: float
mean_acc: float
min: float
duration: float
circular_means: Required[list[tuple[float, float]]]
def _get_max_mean_min_statistic_in_sub_period(
session: Session,
result: dict[str, float],
result: _MaxMinMeanStatisticSubPeriod,
start_time: datetime | None,
end_time: datetime | None,
table: type[StatisticsBase],
types: set[Literal["max", "mean", "min", "change"]],
metadata_id: int,
metadata: tuple[int, StatisticMetaData],
) -> None:
"""Return max, mean and min during the period."""
# Calculate max, mean, min
mean_type = metadata[1]["mean_type"]
columns = select()
if "max" in types:
columns = columns.add_columns(func.max(table.max))
if "mean" in types:
columns = columns.add_columns(func.avg(table.mean))
columns = columns.add_columns(func.count(table.mean))
match mean_type:
case StatisticMeanType.ARITHMETIC:
columns = columns.add_columns(func.avg(table.mean))
columns = columns.add_columns(func.count(table.mean))
case StatisticMeanType.CIRCULAR:
columns = columns.add_columns(*query_circular_mean(table))
if "min" in types:
columns = columns.add_columns(func.min(table.min))
stmt = _generate_max_mean_min_statistic_in_sub_period_stmt(
columns, start_time, end_time, table, metadata_id
columns, start_time, end_time, table, metadata[0]
)
stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt))
if not stats:
@ -1188,11 +1304,21 @@ def _get_max_mean_min_statistic_in_sub_period(
if "max" in types and (new_max := stats[0].max) is not None:
old_max = result.get("max")
result["max"] = max(new_max, old_max) if old_max is not None else new_max
if "mean" in types and stats[0].avg is not None:
if "mean" in types:
# https://github.com/sqlalchemy/sqlalchemy/issues/9127
duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator]
result["duration"] = result.get("duration", 0.0) + duration
result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration
match mean_type:
case StatisticMeanType.ARITHMETIC:
duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator]
if stats[0].avg is not None:
result["duration"] = result.get("duration", 0.0) + duration
result["mean_acc"] = (
result.get("mean_acc", 0.0) + stats[0].avg * duration
)
case StatisticMeanType.CIRCULAR:
if (new_circular_mean := stats[0].mean) is not None and (
weight := stats[0].mean_weight
) is not None:
result["circular_means"].append((new_circular_mean, weight))
if "min" in types and (new_min := stats[0].min) is not None:
old_min = result.get("min")
result["min"] = min(new_min, old_min) if old_min is not None else new_min
@ -1207,15 +1333,15 @@ def _get_max_mean_min_statistic(
tail_start_time: datetime | None,
tail_end_time: datetime | None,
tail_only: bool,
metadata_id: int,
metadata: tuple[int, StatisticMetaData],
types: set[Literal["max", "mean", "min", "change"]],
) -> dict[str, float | None]:
"""Return max, mean and min during the period.
The mean is a time weighted average, combining hourly and 5-minute statistics if
The mean is time weighted, combining hourly and 5-minute statistics if
necessary.
"""
max_mean_min: dict[str, float] = {}
max_mean_min = _MaxMinMeanStatisticSubPeriod(circular_means=[])
result: dict[str, float | None] = {}
if tail_start_time is not None:
@ -1227,7 +1353,7 @@ def _get_max_mean_min_statistic(
tail_end_time,
StatisticsShortTerm,
types,
metadata_id,
metadata,
)
if not tail_only:
@ -1238,7 +1364,7 @@ def _get_max_mean_min_statistic(
main_end_time,
Statistics,
types,
metadata_id,
metadata,
)
if head_start_time is not None:
@ -1249,16 +1375,23 @@ def _get_max_mean_min_statistic(
head_end_time,
StatisticsShortTerm,
types,
metadata_id,
metadata,
)
if "max" in types:
result["max"] = max_mean_min.get("max")
if "mean" in types:
if "mean_acc" not in max_mean_min:
result["mean"] = None
else:
result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"]
mean_value = None
match metadata[1]["mean_type"]:
case StatisticMeanType.CIRCULAR:
if circular_means := max_mean_min["circular_means"]:
mean_value = weighted_circular_mean(circular_means)
case StatisticMeanType.ARITHMETIC:
if (mean_value := max_mean_min.get("mean_acc")) is not None and (
duration := max_mean_min.get("duration")
) is not None:
mean_value = mean_value / duration
result["mean"] = mean_value
if "min" in types:
result["min"] = max_mean_min.get("min")
return result
@ -1559,7 +1692,7 @@ def statistic_during_period(
tail_start_time,
tail_end_time,
tail_only,
metadata_id,
metadata,
types,
)
@ -1642,7 +1775,7 @@ def _extract_metadata_and_discard_impossible_columns(
has_sum = False
for metadata_id, stats_metadata in metadata.values():
metadata_ids.append(metadata_id)
has_mean |= stats_metadata["has_mean"]
has_mean |= stats_metadata["mean_type"] is not StatisticMeanType.NONE
has_sum |= stats_metadata["has_sum"]
if not has_mean:
types.discard("mean")
@ -1798,13 +1931,13 @@ def _statistics_during_period_with_session(
)
if period == "day":
result = _reduce_statistics_per_day(result, types)
result = _reduce_statistics_per_day(result, types, metadata)
if period == "week":
result = _reduce_statistics_per_week(result, types)
result = _reduce_statistics_per_week(result, types, metadata)
if period == "month":
result = _reduce_statistics_per_month(result, types)
result = _reduce_statistics_per_month(result, types, metadata)
if "change" in _types:
_augment_result_with_change(

View File

@ -4,16 +4,18 @@ from __future__ import annotations
import logging
import threading
from typing import TYPE_CHECKING, Final, Literal
from typing import TYPE_CHECKING, Any, Final, Literal
from lru import LRU
from sqlalchemy import lambda_stmt, select
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.expression import true
from sqlalchemy.sql.lambdas import StatementLambdaElement
from ..const import CIRCULAR_MEAN_SCHEMA_VERSION
from ..db_schema import StatisticsMeta
from ..models import StatisticMetaData
from ..models import StatisticMeanType, StatisticMetaData
from ..util import execute_stmt_lambda_element
if TYPE_CHECKING:
@ -28,7 +30,6 @@ QUERY_STATISTIC_META = (
StatisticsMeta.statistic_id,
StatisticsMeta.source,
StatisticsMeta.unit_of_measurement,
StatisticsMeta.has_mean,
StatisticsMeta.has_sum,
StatisticsMeta.name,
)
@ -37,24 +38,38 @@ INDEX_ID: Final = 0
INDEX_STATISTIC_ID: Final = 1
INDEX_SOURCE: Final = 2
INDEX_UNIT_OF_MEASUREMENT: Final = 3
INDEX_HAS_MEAN: Final = 4
INDEX_HAS_SUM: Final = 5
INDEX_NAME: Final = 6
INDEX_HAS_SUM: Final = 4
INDEX_NAME: Final = 5
INDEX_MEAN_TYPE: Final = 6
def _generate_get_metadata_stmt(
statistic_ids: set[str] | None = None,
statistic_type: Literal["mean", "sum"] | None = None,
statistic_source: str | None = None,
schema_version: int = 0,
) -> StatementLambdaElement:
"""Generate a statement to fetch metadata."""
stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META))
"""Generate a statement to fetch metadata with the passed filters.
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
"""
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type)
else:
columns.append(StatisticsMeta.has_mean)
stmt = lambda_stmt(lambda: select(*columns))
if statistic_ids:
stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids))
if statistic_source is not None:
stmt += lambda q: q.where(StatisticsMeta.source == statistic_source)
if statistic_type == "mean":
stmt += lambda q: q.where(StatisticsMeta.has_mean == true())
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
stmt += lambda q: q.where(
StatisticsMeta.mean_type != StatisticMeanType.NONE
)
else:
stmt += lambda q: q.where(StatisticsMeta.has_mean == true())
elif statistic_type == "sum":
stmt += lambda q: q.where(StatisticsMeta.has_sum == true())
return stmt
@ -100,14 +115,34 @@ class StatisticsMetaManager:
for row in execute_stmt_lambda_element(
session,
_generate_get_metadata_stmt(
statistic_ids, statistic_type, statistic_source
statistic_ids,
statistic_type,
statistic_source,
self.recorder.schema_version,
),
orm_rows=False,
):
statistic_id = row[INDEX_STATISTIC_ID]
row_id = row[INDEX_ID]
if self.recorder.schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
try:
mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE])
except ValueError:
_LOGGER.warning(
"Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping",
statistic_id,
row[INDEX_MEAN_TYPE],
)
continue
else:
mean_type = (
StatisticMeanType.ARITHMETIC
if row[INDEX_MEAN_TYPE]
else StatisticMeanType.NONE
)
meta = {
"has_mean": row[INDEX_HAS_MEAN],
"has_mean": mean_type is StatisticMeanType.ARITHMETIC,
"mean_type": mean_type,
"has_sum": row[INDEX_HAS_SUM],
"name": row[INDEX_NAME],
"source": row[INDEX_SOURCE],
@ -157,9 +192,18 @@ class StatisticsMetaManager:
This call is not thread-safe and must be called from the
recorder thread.
"""
if "mean_type" not in new_metadata:
# To maintain backward compatibility after adding 'mean_type' in schema version 49,
# we must still check for its presence. Even though type hints suggest it should always exist,
# custom integrations might omit it, so we need to guard against that.
new_metadata["mean_type"] = ( # type: ignore[unreachable]
StatisticMeanType.ARITHMETIC
if new_metadata["has_mean"]
else StatisticMeanType.NONE
)
metadata_id, old_metadata = old_metadata_dict[statistic_id]
if not (
old_metadata["has_mean"] != new_metadata["has_mean"]
old_metadata["mean_type"] != new_metadata["mean_type"]
or old_metadata["has_sum"] != new_metadata["has_sum"]
or old_metadata["name"] != new_metadata["name"]
or old_metadata["unit_of_measurement"]
@ -170,7 +214,7 @@ class StatisticsMetaManager:
self._assert_in_recorder_thread()
session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update(
{
StatisticsMeta.has_mean: new_metadata["has_mean"],
StatisticsMeta.mean_type: new_metadata["mean_type"],
StatisticsMeta.has_sum: new_metadata["has_sum"],
StatisticsMeta.name: new_metadata["name"],
StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"],

View File

@ -37,7 +37,7 @@ from homeassistant.util.unit_conversion import (
VolumeFlowRateConverter,
)
from .models import StatisticPeriod
from .models import StatisticMeanType, StatisticPeriod
from .statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_add_external_statistics,
@ -532,6 +532,10 @@ def ws_import_statistics(
) -> None:
"""Import statistics."""
metadata = msg["metadata"]
# The WS command will be changed in a follow up PR
metadata["mean_type"] = (
StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE
)
stats = msg["stats"]
if valid_entity_id(metadata["statistic_id"]):

View File

@ -491,6 +491,9 @@ class SensorStateClass(StrEnum):
MEASUREMENT = "measurement"
"""The state represents a measurement in present time."""
MEASUREMENT_ANGLE = "measurement_angle"
"""The state represents a angle measurement in present time. Currently only degrees are supported."""
TOTAL = "total"
"""The state represents a total amount.
@ -693,6 +696,6 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
},
SensorDeviceClass.WIND_DIRECTION: set(),
SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE},
SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT},
}

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Iterable
from contextlib import suppress
from dataclasses import dataclass
import datetime
import itertools
import logging
@ -21,6 +22,7 @@ from homeassistant.components.recorder import (
)
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
StatisticResult,
)
@ -52,10 +54,22 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
@dataclass
class _StatisticsConfig:
types: set[str]
mean_type: StatisticMeanType = StatisticMeanType.NONE
DEFAULT_STATISTICS = {
SensorStateClass.MEASUREMENT: {"mean", "min", "max"},
SensorStateClass.TOTAL: {"sum"},
SensorStateClass.TOTAL_INCREASING: {"sum"},
SensorStateClass.MEASUREMENT: _StatisticsConfig(
{"mean", "min", "max"}, StatisticMeanType.ARITHMETIC
),
SensorStateClass.MEASUREMENT_ANGLE: _StatisticsConfig(
{"mean"}, StatisticMeanType.CIRCULAR
),
SensorStateClass.TOTAL: _StatisticsConfig({"sum"}),
SensorStateClass.TOTAL_INCREASING: _StatisticsConfig({"sum"}),
}
EQUIVALENT_UNITS = {
@ -76,10 +90,15 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega
# Keep track of entities for which a warning about unsupported unit has been logged
WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit")
WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit")
# Keep track of entities for which a warning about statistics mean algorithm change has been logged
WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey(
f"{DOMAIN}_warn_statistics_mean_change"
)
# Link to dev statistics where issues around LTS can be fixed
LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics"
STATE_CLASS_REMOVED_ISSUE = "state_class_removed"
UNITS_CHANGED_ISSUE = "units_changed"
MEAN_TYPE_CHANGED_ISSUE = "mean_type_changed"
def _get_sensor_states(hass: HomeAssistant) -> list[State]:
@ -101,7 +120,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]:
]
def _time_weighted_average(
def _time_weighted_arithmetic_mean(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float:
"""Calculate a time weighted average.
@ -139,6 +158,43 @@ def _time_weighted_average(
return accumulated / (end - start).total_seconds()
def _time_weighted_circular_mean(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float:
"""Calculate a time weighted circular mean.
The circular mean is calculated by weighting the states by duration in seconds between
state changes.
Note: there's no interpolation of values between state changes.
"""
old_fstate: float | None = None
old_start_time: datetime.datetime | None = None
values: list[tuple[float, float]] = []
for fstate, state in fstates:
# The recorder will give us the last known state, which may be well
# before the requested start time for the statistics
start_time = max(state.last_updated, start)
if old_start_time is None:
# Adjust start time, if there was no last known state
start = start_time
else:
duration = (start_time - old_start_time).total_seconds()
assert old_fstate is not None
values.append((old_fstate, duration))
old_fstate = fstate
old_start_time = start_time
if old_fstate is not None:
# Add last value weighted by duration until end of the period
assert old_start_time is not None
duration = (end - old_start_time).total_seconds()
values.append((old_fstate, duration))
return statistics.weighted_circular_mean(values)
def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]:
"""Return a set of all units."""
return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates}
@ -364,7 +420,7 @@ def reset_detected(
return fstate < 0.9 * previous_fstate
def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]:
def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]:
"""Prepare a dict with wanted statistics for entities."""
return {
state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]]
@ -408,7 +464,9 @@ def compile_statistics( # noqa: C901
wanted_statistics = _wanted_statistics(sensor_states)
# Get history between start and end
entities_full_history = [
i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id]
i.entity_id
for i in sensor_states
if "sum" in wanted_statistics[i.entity_id].types
]
history_list: dict[str, list[State]] = {}
if entities_full_history:
@ -423,7 +481,7 @@ def compile_statistics( # noqa: C901
entities_significant_history = [
i.entity_id
for i in sensor_states
if "sum" not in wanted_statistics[i.entity_id]
if "sum" not in wanted_statistics[i.entity_id].types
]
if entities_significant_history:
_history_list = history.get_full_significant_states_with_session(
@ -473,7 +531,7 @@ def compile_statistics( # noqa: C901
continue
state_class: str = _state.attributes[ATTR_STATE_CLASS]
to_process.append((entity_id, statistics_unit, state_class, valid_float_states))
if "sum" in wanted_statistics[entity_id]:
if "sum" in wanted_statistics[entity_id].types:
to_query.add(entity_id)
last_stats = statistics.get_latest_short_term_statistics_with_session(
@ -485,6 +543,10 @@ def compile_statistics( # noqa: C901
state_class,
valid_float_states,
) in to_process:
mean_type = StatisticMeanType.NONE
if "mean" in wanted_statistics[entity_id].types:
mean_type = wanted_statistics[entity_id].mean_type
# Check metadata
if old_metadata := old_metadatas.get(entity_id):
if not _equivalent_units(
@ -510,10 +572,34 @@ def compile_statistics( # noqa: C901
)
continue
if (
mean_type is not StatisticMeanType.NONE
and (old_mean_type := old_metadata[1]["mean_type"])
is not StatisticMeanType.NONE
and mean_type != old_mean_type
):
if WARN_STATISTICS_MEAN_CHANGED not in hass.data:
hass.data[WARN_STATISTICS_MEAN_CHANGED] = set()
if entity_id not in hass.data[WARN_STATISTICS_MEAN_CHANGED]:
hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id)
_LOGGER.warning(
(
"The statistics mean algorithm for %s have changed from %s to %s."
" Generation of long term statistics will be suppressed"
" unless it changes back or go to %s to delete the old"
" statistics"
),
entity_id,
old_mean_type.name,
mean_type.name,
LINK_DEV_STATISTICS,
)
continue
# Set meta data
meta: StatisticMetaData = {
"has_mean": "mean" in wanted_statistics[entity_id],
"has_sum": "sum" in wanted_statistics[entity_id],
"mean_type": mean_type,
"has_sum": "sum" in wanted_statistics[entity_id].types,
"name": None,
"source": RECORDER_DOMAIN,
"statistic_id": entity_id,
@ -522,19 +608,26 @@ def compile_statistics( # noqa: C901
# Make calculations
stat: StatisticData = {"start": start}
if "max" in wanted_statistics[entity_id]:
if "max" in wanted_statistics[entity_id].types:
stat["max"] = max(
*itertools.islice(zip(*valid_float_states, strict=False), 1)
)
if "min" in wanted_statistics[entity_id]:
if "min" in wanted_statistics[entity_id].types:
stat["min"] = min(
*itertools.islice(zip(*valid_float_states, strict=False), 1)
)
if "mean" in wanted_statistics[entity_id]:
stat["mean"] = _time_weighted_average(valid_float_states, start, end)
match mean_type:
case StatisticMeanType.ARITHMETIC:
stat["mean"] = _time_weighted_arithmetic_mean(
valid_float_states, start, end
)
case StatisticMeanType.CIRCULAR:
stat["mean"] = _time_weighted_circular_mean(
valid_float_states, start, end
)
if "sum" in wanted_statistics[entity_id]:
if "sum" in wanted_statistics[entity_id].types:
last_reset = old_last_reset = None
new_state = old_state = None
_sum = 0.0
@ -658,18 +751,25 @@ def list_statistic_ids(
attributes = state.attributes
state_class = attributes[ATTR_STATE_CLASS]
provided_statistics = DEFAULT_STATISTICS[state_class]
if statistic_type is not None and statistic_type not in provided_statistics:
if (
statistic_type is not None
and statistic_type not in provided_statistics.types
):
continue
if (
(has_sum := "sum" in provided_statistics)
(has_sum := "sum" in provided_statistics.types)
and ATTR_LAST_RESET not in attributes
and state_class == SensorStateClass.MEASUREMENT
):
continue
mean_type = StatisticMeanType.NONE
if "mean" in provided_statistics.types:
mean_type = provided_statistics.mean_type
result[entity_id] = {
"has_mean": "mean" in provided_statistics,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": RECORDER_DOMAIN,
@ -734,6 +834,23 @@ def _update_issues(
},
)
if (
(metadata_mean_type := metadata[1]["mean_type"]) is not None
and state_class
and (state_mean_type := DEFAULT_STATISTICS[state_class].mean_type)
!= metadata_mean_type
):
# The mean type has changed and the old statistics are not valid anymore
report_issue(
MEAN_TYPE_CHANGED_ISSUE,
entity_id,
{
"statistic_id": entity_id,
"metadata_mean_type": metadata_mean_type,
"state_mean_type": state_mean_type,
},
)
def update_statistics_issues(
hass: HomeAssistant,
@ -756,7 +873,11 @@ def update_statistics_issues(
issue.domain != DOMAIN
or not (issue_data := issue.data)
or issue_data.get("issue_type")
not in (STATE_CLASS_REMOVED_ISSUE, UNITS_CHANGED_ISSUE)
not in (
STATE_CLASS_REMOVED_ISSUE,
UNITS_CHANGED_ISSUE,
MEAN_TYPE_CHANGED_ISSUE,
)
):
continue
issues.add(issue.issue_id)

View File

@ -309,6 +309,10 @@
}
},
"issues": {
"mean_type_changed": {
"title": "The mean type of {statistic_id} has changed",
"description": ""
},
"state_class_removed": {
"title": "{statistic_id} no longer has a state class",
"description": ""

View File

@ -9,7 +9,11 @@ from typing import cast
import tibber
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@ -159,7 +163,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
)
metadata = StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{home.name} {sensor_type}",
source=TIBBER_DOMAIN,

View File

@ -48,5 +48,15 @@
'type': 'no_state',
}),
]),
'sensor.statistics_issues_issue_5': list([
dict({
'data': dict({
'metadata_mean_type': 1,
'state_mean_type': 2,
'statistic_id': 'sensor.statistics_issues_issue_5',
}),
'type': 'mean_type_changed',
}),
]),
})
# ---

View File

@ -29,6 +29,20 @@
'last_updated': <ANY>,
'state': '1500',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_direction',
'friendly_name': 'Statistics issues Issue 5',
'state_class': <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
'unit_of_measurement': '°',
}),
'context': <ANY>,
'entity_id': 'sensor.statistics_issues_issue_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Statistics issues Issue 1',
@ -99,6 +113,20 @@
'last_updated': <ANY>,
'state': '1500',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'wind_direction',
'friendly_name': 'Statistics issues Issue 5',
'state_class': <SensorStateClass.MEASUREMENT_ANGLE: 'measurement_angle'>,
'unit_of_measurement': '°',
}),
'context': <ANY>,
'entity_id': 'sensor.statistics_issues_issue_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Sensor test',

View File

@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.statistics import (
StatisticMeanType,
async_add_external_statistics,
get_last_statistics,
list_statistic_ids,
@ -45,6 +46,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None:
assert {
"display_unit_of_measurement": "°C",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": "Outdoor temperature",
"source": DOMAIN,
@ -55,6 +57,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None:
assert {
"display_unit_of_measurement": "kWh",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Energy consumption 1",
"source": DOMAIN,

View File

@ -87,6 +87,7 @@ async def test_validate_db_schema_fix_float_issue(
"created_ts DOUBLE PRECISION",
"start_ts DOUBLE PRECISION",
"mean DOUBLE PRECISION",
"mean_weight DOUBLE PRECISION",
"min DOUBLE PRECISION",
"max DOUBLE PRECISION",
"last_reset_ts DOUBLE PRECISION",

View File

@ -35,7 +35,8 @@ from homeassistant.components.recorder.db_schema import (
StatesMeta,
)
from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask
from homeassistant.const import UnitOfTemperature
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import DEGREE, UnitOfTemperature
from homeassistant.core import Event, HomeAssistant, State
from homeassistant.helpers import recorder as recorder_helper
from homeassistant.util import dt as dt_util
@ -290,6 +291,7 @@ def record_states(
sns2 = "sensor.test2"
sns3 = "sensor.test3"
sns4 = "sensor.test4"
sns5 = "sensor.wind_direction"
sns1_attr = {
"device_class": "temperature",
"state_class": "measurement",
@ -302,6 +304,11 @@ def record_states(
}
sns3_attr = {"device_class": "temperature"}
sns4_attr = {}
sns5_attr = {
"device_class": SensorDeviceClass.WIND_DIRECTION,
"state_class": SensorStateClass.MEASUREMENT_ANGLE,
"unit_of_measurement": DEGREE,
}
def set_state(entity_id, state, **kwargs):
"""Set the state."""
@ -315,7 +322,7 @@ def record_states(
three = two + timedelta(seconds=30 * 5)
four = three + timedelta(seconds=14 * 5)
states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []}
states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []}
with freeze_time(one) as freezer:
states[mp].append(
set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
@ -324,6 +331,7 @@ def record_states(
states[sns2].append(set_state(sns2, "10", attributes=sns2_attr))
states[sns3].append(set_state(sns3, "10", attributes=sns3_attr))
states[sns4].append(set_state(sns4, "10", attributes=sns4_attr))
states[sns5].append(set_state(sns5, "10", attributes=sns5_attr))
freezer.move_to(one + timedelta(microseconds=1))
states[mp].append(
@ -335,12 +343,14 @@ def record_states(
states[sns2].append(set_state(sns2, "15", attributes=sns2_attr))
states[sns3].append(set_state(sns3, "15", attributes=sns3_attr))
states[sns4].append(set_state(sns4, "15", attributes=sns4_attr))
states[sns5].append(set_state(sns5, "350", attributes=sns5_attr))
freezer.move_to(three)
states[sns1].append(set_state(sns1, "20", attributes=sns1_attr))
states[sns2].append(set_state(sns2, "20", attributes=sns2_attr))
states[sns3].append(set_state(sns3, "20", attributes=sns3_attr))
states[sns4].append(set_state(sns4, "20", attributes=sns4_attr))
states[sns5].append(set_state(sns5, "5", attributes=sns5_attr))
return zero, four, states

View File

@ -583,6 +583,8 @@ class StatisticsBase:
last_reset_ts = Column(TIMESTAMP_TYPE)
state = Column(DOUBLE_TYPE)
sum = Column(DOUBLE_TYPE)
# *** Not originally in v32, only added for tests. Added in v49
mean_weight = Column(DOUBLE_TYPE)
@classmethod
def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self:

View File

@ -1538,6 +1538,7 @@ async def test_stats_timestamp_conversion_is_reentrant(
"last_reset_ts": one_year_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": process_timestamp(one_year_ago).replace(tzinfo=None),
@ -1553,6 +1554,7 @@ async def test_stats_timestamp_conversion_is_reentrant(
"last_reset_ts": six_months_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1568,6 +1570,7 @@ async def test_stats_timestamp_conversion_is_reentrant(
"last_reset_ts": one_month_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": process_timestamp(one_month_ago).replace(tzinfo=None),
@ -1705,6 +1708,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": one_year_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1720,6 +1724,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": six_months_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1735,6 +1740,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": one_month_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1758,6 +1764,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": one_year_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1773,6 +1780,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": six_months_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1788,6 +1796,7 @@ async def test_stats_timestamp_with_one_by_one(
"last_reset_ts": one_month_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1932,6 +1941,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates(
"last_reset_ts": one_year_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1947,6 +1957,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates(
"last_reset_ts": six_months_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1962,6 +1973,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates(
"last_reset_ts": one_month_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,
@ -1985,6 +1997,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates(
"last_reset_ts": six_months_ago.timestamp(),
"max": None,
"mean": None,
"mean_weight": None,
"metadata_id": 1000,
"min": None,
"start": None,

View File

@ -12,6 +12,7 @@ from homeassistant.components import recorder
from homeassistant.components.recorder import Recorder, history, statistics
from homeassistant.components.recorder.db_schema import StatisticsShortTerm
from homeassistant.components.recorder.models import (
StatisticMeanType,
datetime_to_timestamp_or_none,
process_timestamp,
)
@ -123,32 +124,38 @@ async def test_compile_hourly_statistics(
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1"},
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}):
for kwargs in ({}, {"statistic_ids": ["sensor.test1", "sensor.wind_direction"]}):
stats = statistics_during_period(hass, zero, period="5minute", **kwargs)
assert stats == {}
stats = get_last_short_term_statistics(
hass,
0,
"sensor.test1",
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
for sensor in ("sensor.test1", "sensor.wind_direction"):
stats = get_last_short_term_statistics(
hass,
0,
sensor,
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
do_adhoc_statistics(hass, start=zero)
do_adhoc_statistics(hass, start=four)
await async_wait_recording_done(hass)
metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"})
assert metadata["sensor.test1"][1]["has_mean"] is True
assert metadata["sensor.test1"][1]["has_sum"] is False
assert metadata["sensor.test2"][1]["has_mean"] is True
assert metadata["sensor.test2"][1]["has_sum"] is False
metadata = get_metadata(
hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}
)
for sensor, mean_type in (
("sensor.test1", StatisticMeanType.ARITHMETIC),
("sensor.test2", StatisticMeanType.ARITHMETIC),
("sensor.wind_direction", StatisticMeanType.CIRCULAR),
):
assert metadata[sensor][1]["mean_type"] is mean_type
assert metadata[sensor][1]["has_sum"] is False
expected_1 = {
"start": process_timestamp(zero).timestamp(),
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
@ -168,11 +175,39 @@ async def test_compile_hourly_statistics(
expected_stats1 = [expected_1, expected_2]
expected_stats2 = [expected_1, expected_2]
expected_stats_wind_direction1 = {
"start": process_timestamp(zero).timestamp(),
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
"mean": pytest.approx(358.6387003873801),
"min": None,
"max": None,
"last_reset": None,
}
expected_stats_wind_direction2 = {
"start": process_timestamp(four).timestamp(),
"end": process_timestamp(four + timedelta(minutes=5)).timestamp(),
"mean": pytest.approx(5),
"min": None,
"max": None,
"last_reset": None,
}
expected_stats_wind_direction = [
expected_stats_wind_direction1,
expected_stats_wind_direction2,
]
# Test statistics_during_period
stats = statistics_during_period(
hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"}
hass,
zero,
period="5minute",
statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"},
)
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
# Test statistics_during_period with a far future start and end date
future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00"))
@ -181,7 +216,7 @@ async def test_compile_hourly_statistics(
future,
end_time=future,
period="5minute",
statistic_ids={"sensor.test1", "sensor.test2"},
statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"},
)
assert stats == {}
@ -191,9 +226,13 @@ async def test_compile_hourly_statistics(
zero,
end_time=future,
period="5minute",
statistic_ids={"sensor.test1", "sensor.test2"},
statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"},
)
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
stats = statistics_during_period(
hass, zero, statistic_ids={"sensor.test2"}, period="5minute"
@ -206,32 +245,39 @@ async def test_compile_hourly_statistics(
assert stats == {}
# Test get_last_short_term_statistics and get_latest_short_term_statistics
stats = get_last_short_term_statistics(
hass,
0,
"sensor.test1",
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
for sensor, expected in (
("sensor.test1", expected_2),
("sensor.wind_direction", expected_stats_wind_direction2),
):
stats = get_last_short_term_statistics(
hass,
0,
sensor,
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
stats = get_last_short_term_statistics(
hass,
1,
"sensor.test1",
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {"sensor.test1": [expected_2]}
stats = get_last_short_term_statistics(
hass,
1,
sensor,
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {sensor: [expected]}
with session_scope(hass=hass, read_only=True) as session:
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1"},
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {"sensor.test1": [expected_2]}
assert stats == {
"sensor.test1": [expected_2],
"sensor.wind_direction": [expected_stats_wind_direction2],
}
# Now wipe the latest_short_term_statistics_ids table and test again
# to make sure we can rebuild the missing data
@ -241,13 +287,15 @@ async def test_compile_hourly_statistics(
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1"},
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {"sensor.test1": [expected_2]}
assert stats == {
"sensor.test1": [expected_2],
"sensor.wind_direction": [expected_stats_wind_direction2],
}
metadata = get_metadata(hass, statistic_ids={"sensor.test1"})
with session_scope(hass=hass, read_only=True) as session:
stats = get_latest_short_term_statistics_with_session(
hass,
@ -258,23 +306,44 @@ async def test_compile_hourly_statistics(
)
assert stats == {"sensor.test1": [expected_2]}
stats = get_last_short_term_statistics(
hass,
2,
"sensor.test1",
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
# Test with multiple metadata ids
metadata = get_metadata(
hass, statistic_ids={"sensor.test1", "sensor.wind_direction"}
)
assert stats == {"sensor.test1": expected_stats1[::-1]}
with session_scope(hass=hass, read_only=True) as session:
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
metadata=metadata,
)
assert stats == {
"sensor.test1": [expected_2],
"sensor.wind_direction": [expected_stats_wind_direction2],
}
stats = get_last_short_term_statistics(
hass,
3,
"sensor.test1",
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {"sensor.test1": expected_stats1[::-1]}
for sensor, expected in (
("sensor.test1", expected_stats1[::-1]),
("sensor.wind_direction", expected_stats_wind_direction[::-1]),
):
stats = get_last_short_term_statistics(
hass,
2,
sensor,
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {sensor: expected}
stats = get_last_short_term_statistics(
hass,
3,
sensor,
True,
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {sensor: expected}
stats = get_last_short_term_statistics(
hass,
@ -291,7 +360,7 @@ async def test_compile_hourly_statistics(
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1"},
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
@ -306,7 +375,7 @@ async def test_compile_hourly_statistics(
stats = get_latest_short_term_statistics_with_session(
hass,
session,
{"sensor.test1"},
{"sensor.test1", "sensor.wind_direction"},
{"last_reset", "max", "mean", "min", "state", "sum"},
)
assert stats == {}
@ -460,15 +529,35 @@ async def test_rename_entity(
expected_stats1 = [expected_1]
expected_stats2 = [expected_1]
expected_stats99 = [expected_1]
expected_stats_wind_direction = [
{
"start": process_timestamp(zero).timestamp(),
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
"mean": pytest.approx(358.6387003873801),
"min": None,
"max": None,
"last_reset": None,
"state": None,
"sum": None,
}
]
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99")
await async_wait_recording_done(hass)
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test99": expected_stats99,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
async def test_statistics_during_period_set_back_compat(
@ -544,9 +633,25 @@ async def test_rename_entity_collision(
}
expected_stats1 = [expected_1]
expected_stats2 = [expected_1]
expected_stats_wind_direction = [
{
"start": process_timestamp(zero).timestamp(),
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
"mean": pytest.approx(358.6387003873801),
"min": None,
"max": None,
"last_reset": None,
"state": None,
"sum": None,
}
]
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
# Insert metadata for sensor.test99
metadata_1 = {
@ -567,7 +672,11 @@ async def test_rename_entity_collision(
# Statistics failed to migrate due to the collision
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
# Verify the safeguard in the states meta manager was hit
assert (
@ -631,9 +740,25 @@ async def test_rename_entity_collision_states_meta_check_disabled(
}
expected_stats1 = [expected_1]
expected_stats2 = [expected_1]
expected_stats_wind_direction = [
{
"start": process_timestamp(zero).timestamp(),
"end": process_timestamp(zero + timedelta(minutes=5)).timestamp(),
"mean": pytest.approx(358.6387003873801),
"min": None,
"max": None,
"last_reset": None,
"state": None,
"sum": None,
}
]
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
# Insert metadata for sensor.test99
metadata_1 = {
@ -660,7 +785,11 @@ async def test_rename_entity_collision_states_meta_check_disabled(
# Statistics failed to migrate due to the collision
stats = statistics_during_period(hass, zero, period="5minute")
assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2}
assert stats == {
"sensor.test1": expected_stats1,
"sensor.test2": expected_stats2,
"sensor.wind_direction": expected_stats_wind_direction,
}
# Verify the filter_unique_constraint_integrity_error safeguard was hit
assert "Blocked attempt to insert duplicated statistic rows" in caplog.text
@ -786,6 +915,7 @@ async def test_import_statistics(
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy",
@ -800,6 +930,7 @@ async def test_import_statistics(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy",
"source": source,
@ -876,6 +1007,7 @@ async def test_import_statistics(
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy renamed",
@ -890,6 +1022,7 @@ async def test_import_statistics(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy renamed",
"source": source,

View File

@ -1,11 +1,14 @@
"""The tests for sensor recorder platform."""
from collections.abc import Iterable
import datetime
from datetime import timedelta
import math
from statistics import fmean
import sys
from unittest.mock import ANY, patch
from _pytest.python_api import ApproxBase
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
@ -13,7 +16,14 @@ import pytest
from homeassistant.components import recorder
from homeassistant.components.recorder import Recorder
from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
DEG_TO_RAD,
RAD_TO_DEG,
async_add_external_statistics,
get_last_statistics,
get_latest_short_term_statistics_with_session,
@ -24,6 +34,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.components.recorder.util import session_scope
from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA
from homeassistant.components.sensor import UNIT_CONVERTERS
from homeassistant.const import DEGREE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import recorder as recorder_helper
from homeassistant.setup import async_setup_component
@ -247,12 +258,12 @@ async def test_statistics_during_period(
@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC))
@pytest.mark.usefixtures("recorder_mock")
@pytest.mark.parametrize("offset", [0, 1, 2])
async def test_statistic_during_period(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
offset,
offset: int,
) -> None:
"""Test statistic_during_period."""
now = dt_util.utcnow()
@ -307,7 +318,7 @@ async def test_statistic_during_period(
)
imported_metadata = {
"has_mean": False,
"has_mean": True,
"has_sum": True,
"name": "Total imported energy",
"source": "recorder",
@ -655,7 +666,7 @@ async def test_statistic_during_period(
hass,
session,
{"sensor.test"},
{"last_reset", "max", "mean", "min", "state", "sum"},
{"last_reset", "state", "sum"},
)
start = imported_stats_5min[-1]["start"].timestamp()
end = start + (5 * 60)
@ -672,18 +683,376 @@ async def test_statistic_during_period(
}
def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]:
sin_sum = 0
cos_sum = 0
for x in values:
mean = x.get("mean")
assert mean is not None
sin_sum += math.sin(mean * DEG_TO_RAD)
cos_sum += math.cos(mean * DEG_TO_RAD)
return {
"mean": (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360,
"mean_weight": math.sqrt(sin_sum**2 + cos_sum**2),
}
def _circular_mean_approx(values: Iterable[StatisticData]) -> ApproxBase:
return pytest.approx(_circular_mean(values)["mean"])
@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC))
@pytest.mark.usefixtures("recorder_mock")
@pytest.mark.parametrize("offset", [0, 1, 2])
async def test_statistic_during_period_circular_mean(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
offset: int,
) -> None:
"""Test statistic_during_period."""
now = dt_util.utcnow()
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
zero = now
start = zero.replace(minute=offset * 5, second=0, microsecond=0) + timedelta(
hours=-3
)
imported_stats_5min: list[StatisticData] = [
{
"start": (start + timedelta(minutes=5 * i)),
"mean": (123.456 * i) % 360,
"mean_weight": 1,
}
for i in range(39)
]
imported_stats = []
slice_end = 12 - offset
imported_stats.append(
{
"start": imported_stats_5min[0]["start"].replace(minute=0),
**_circular_mean(imported_stats_5min[0:slice_end]),
}
)
for i in range(2):
slice_start = i * 12 + (12 - offset)
slice_end = (i + 1) * 12 + (12 - offset)
assert imported_stats_5min[slice_start]["start"].minute == 0
imported_stats.append(
{
"start": imported_stats_5min[slice_start]["start"],
**_circular_mean(imported_stats_5min[slice_start:slice_end]),
}
)
imported_metadata: StatisticMetaData = {
"mean_type": StatisticMeanType.CIRCULAR,
"has_sum": False,
"name": "Wind direction",
"source": "recorder",
"statistic_id": "sensor.test",
"unit_of_measurement": DEGREE,
}
recorder.get_instance(hass).async_import_statistics(
imported_metadata,
imported_stats,
Statistics,
)
recorder.get_instance(hass).async_import_statistics(
imported_metadata,
imported_stats_5min,
StatisticsShortTerm,
)
await async_wait_recording_done(hass)
metadata = get_metadata(hass, statistic_ids={"sensor.test"})
metadata_id = metadata["sensor.test"][0]
run_cache = get_short_term_statistics_run_cache(hass)
# Verify the import of the short term statistics
# also updates the run cache
assert run_cache.get_latest_ids({metadata_id}) is not None
# No data for this period yet
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"start_time": now.isoformat(),
"end_time": now.isoformat(),
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"max": None,
"mean": None,
"min": None,
"change": None,
}
# This should include imported_statistics_5min[:]
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_statistics_5min[:]
start_time = (
dt_util.parse_datetime("2022-10-21T04:00:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
end_time = (
dt_util.parse_datetime("2022-10-21T07:15:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_statistics_5min[:]
start_time = (
dt_util.parse_datetime("2022-10-21T04:00:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
end_time = (
dt_util.parse_datetime("2022-10-21T08:20:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min),
"max": None,
"min": None,
"change": None,
}
# This should include imported_statistics_5min[26:]
start_time = (
dt_util.parse_datetime("2022-10-21T06:10:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
assert imported_stats_5min[26]["start"].isoformat() == start_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"start_time": start_time,
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[26:]),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_statistics_5min[26:]
start_time = (
dt_util.parse_datetime("2022-10-21T06:09:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"start_time": start_time,
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[26:]),
"max": None,
"min": None,
"change": None,
}
# This should include imported_statistics_5min[:26]
end_time = (
dt_util.parse_datetime("2022-10-21T06:10:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
assert imported_stats_5min[26]["start"].isoformat() == end_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"end_time": end_time,
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[:26]),
"max": None,
"min": None,
"change": None,
}
# This should include imported_statistics_5min[26:32] (less than a full hour)
start_time = (
dt_util.parse_datetime("2022-10-21T06:10:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
assert imported_stats_5min[26]["start"].isoformat() == start_time
end_time = (
dt_util.parse_datetime("2022-10-21T06:40:00+00:00")
+ timedelta(minutes=5 * offset)
).isoformat()
assert imported_stats_5min[32]["start"].isoformat() == end_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[26:32]),
"max": None,
"min": None,
"change": None,
}
# This should include imported_statistics[2:] + imported_statistics_5min[36:]
start_time = "2022-10-21T06:00:00+00:00"
assert imported_stats_5min[24 - offset]["start"].isoformat() == start_time
assert imported_stats[2]["start"].isoformat() == start_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"fixed_period": {
"start_time": start_time,
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[24 - offset :]),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_statistics[2:] + imported_statistics_5min[36:]
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"rolling_window": {
"duration": {"hours": 1, "minutes": 25},
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[24 - offset :]),
"max": None,
"min": None,
"change": None,
}
# This should include imported_statistics[2:3]
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"rolling_window": {
"duration": {"hours": 1},
"offset": {"minutes": -25},
},
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
slice_start = 24 - offset
slice_end = 36 - offset
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min[slice_start:slice_end]),
"max": None,
"min": None,
"change": None,
}
# Test we can get only selected types
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"types": ["mean"],
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats_5min),
}
@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC))
async def test_statistic_during_period_hole(
recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test statistic_during_period when there are holes in the data."""
stat_id = 1
def next_id():
nonlocal stat_id
stat_id += 1
return stat_id
now = dt_util.utcnow()
await async_recorder_block_till_done(hass)
@ -704,7 +1073,7 @@ async def test_statistic_during_period_hole(
]
imported_metadata = {
"has_mean": False,
"has_mean": True,
"has_sum": True,
"name": "Total imported energy",
"source": "recorder",
@ -830,6 +1199,156 @@ async def test_statistic_during_period_hole(
}
@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC))
@pytest.mark.usefixtures("recorder_mock")
async def test_statistic_during_period_hole_circular_mean(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test statistic_during_period when there are holes in the data."""
now = dt_util.utcnow()
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
zero = now
start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-18)
imported_stats: list[StatisticData] = [
{
"start": (start + timedelta(hours=3 * i)),
"mean": (123.456 * i) % 360,
"mean_weight": 1,
}
for i in range(6)
]
imported_metadata: StatisticMetaData = {
"mean_type": StatisticMeanType.CIRCULAR,
"has_sum": False,
"name": "Wind direction",
"source": "recorder",
"statistic_id": "sensor.test",
"unit_of_measurement": DEGREE,
}
recorder.get_instance(hass).async_import_statistics(
imported_metadata,
imported_stats,
Statistics,
)
await async_wait_recording_done(hass)
# This should include imported_stats[:]
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats[:]),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_stats[:]
start_time = "2022-10-20T13:00:00+00:00"
end_time = "2022-10-21T05:00:00+00:00"
assert imported_stats[0]["start"].isoformat() == start_time
assert imported_stats[-1]["start"].isoformat() < end_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats[:]),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_stats[:]
start_time = "2022-10-20T13:00:00+00:00"
end_time = "2022-10-21T08:20:00+00:00"
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats[:]),
"max": None,
"min": None,
"change": None,
}
# This should include imported_stats[1:4]
start_time = "2022-10-20T16:00:00+00:00"
end_time = "2022-10-20T23:00:00+00:00"
assert imported_stats[1]["start"].isoformat() == start_time
assert imported_stats[3]["start"].isoformat() < end_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats[1:4]),
"max": None,
"min": None,
"change": None,
}
# This should also include imported_stats[1:4]
start_time = "2022-10-20T15:00:00+00:00"
end_time = "2022-10-21T00:00:00+00:00"
assert imported_stats[1]["start"].isoformat() > start_time
assert imported_stats[3]["start"].isoformat() < end_time
await client.send_json_auto_id(
{
"type": "recorder/statistic_during_period",
"statistic_id": "sensor.test",
"fixed_period": {
"start_time": start_time,
"end_time": end_time,
},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"mean": _circular_mean_approx(imported_stats[1:4]),
"max": None,
"min": None,
"change": None,
}
@pytest.mark.parametrize(
"frozen_time",
[
@ -897,7 +1416,7 @@ async def test_statistic_during_period_partial_overlap(
statId = "sensor.test_overlapping"
imported_metadata = {
"has_mean": False,
"has_mean": True,
"has_sum": True,
"name": "Total imported energy overlapping",
"source": "recorder",
@ -1766,6 +2285,7 @@ async def test_list_statistic_ids(
"""Test list_statistic_ids."""
now = get_start_time(dt_util.utcnow())
has_mean = attributes["state_class"] == "measurement"
mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE
has_sum = not has_mean
hass.config.units = units
@ -1791,6 +2311,7 @@ async def test_list_statistic_ids(
"statistic_id": "sensor.test",
"display_unit_of_measurement": display_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -1813,6 +2334,7 @@ async def test_list_statistic_ids(
"statistic_id": "sensor.test",
"display_unit_of_measurement": display_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -1838,6 +2360,7 @@ async def test_list_statistic_ids(
"statistic_id": "sensor.test",
"display_unit_of_measurement": display_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -1859,6 +2382,7 @@ async def test_list_statistic_ids(
"statistic_id": "sensor.test",
"display_unit_of_measurement": display_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -1939,6 +2463,7 @@ async def test_list_statistic_ids_unit_change(
"""Test list_statistic_ids."""
now = get_start_time(dt_util.utcnow())
has_mean = attributes["state_class"] == "measurement"
mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE
has_sum = not has_mean
await async_setup_component(hass, "sensor", {})
@ -1966,6 +2491,7 @@ async def test_list_statistic_ids_unit_change(
"statistic_id": "sensor.test",
"display_unit_of_measurement": statistics_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -1987,6 +2513,7 @@ async def test_list_statistic_ids_unit_change(
"statistic_id": "sensor.test",
"display_unit_of_measurement": display_unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -2208,6 +2735,7 @@ async def test_update_statistics_metadata(
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2235,6 +2763,7 @@ async def test_update_statistics_metadata(
"statistic_id": "sensor.test",
"display_unit_of_measurement": new_display_unit,
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2324,6 +2853,7 @@ async def test_change_statistics_unit(
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2375,6 +2905,7 @@ async def test_change_statistics_unit(
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2428,6 +2959,7 @@ async def test_change_statistics_unit(
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2455,6 +2987,7 @@ async def test_change_statistics_unit_errors(
"statistic_id": "sensor.test",
"display_unit_of_measurement": "kW",
"has_mean": True,
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
@ -2774,6 +3307,7 @@ async def test_get_statistics_metadata(
"""Test get_statistics_metadata."""
now = get_start_time(dt_util.utcnow())
has_mean = attributes["state_class"] == "measurement"
mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE
has_sum = not has_mean
hass.config.units = units
@ -2843,6 +3377,7 @@ async def test_get_statistics_metadata(
"statistic_id": "test:total_gas",
"display_unit_of_measurement": unit,
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": "Total imported energy",
"source": "test",
@ -2874,6 +3409,7 @@ async def test_get_statistics_metadata(
"statistic_id": "sensor.test",
"display_unit_of_measurement": attributes["unit_of_measurement"],
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -2901,6 +3437,7 @@ async def test_get_statistics_metadata(
"statistic_id": "sensor.test",
"display_unit_of_measurement": attributes["unit_of_measurement"],
"has_mean": has_mean,
"mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": "recorder",
@ -2995,6 +3532,7 @@ async def test_import_statistics(
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy",
@ -3009,6 +3547,7 @@ async def test_import_statistics(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy",
"source": source,
@ -3213,6 +3752,7 @@ async def test_adjust_sum_statistics_energy(
{
"display_unit_of_measurement": "kWh",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy",
@ -3227,6 +3767,7 @@ async def test_adjust_sum_statistics_energy(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy",
"source": source,
@ -3406,6 +3947,7 @@ async def test_adjust_sum_statistics_gas(
{
"display_unit_of_measurement": "",
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy",
@ -3420,6 +3962,7 @@ async def test_adjust_sum_statistics_gas(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy",
"source": source,
@ -3617,6 +4160,7 @@ async def test_adjust_sum_statistics_errors(
{
"display_unit_of_measurement": state_unit,
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"statistic_id": statistic_id,
"name": "Total imported energy",
@ -3631,6 +4175,7 @@ async def test_adjust_sum_statistics_errors(
1,
{
"has_mean": False,
"mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": "Total imported energy",
"source": source,

File diff suppressed because it is too large Load Diff