tariffkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tariffkit/__init__.py +52 -0
- tariffkit/account/__init__.py +41 -0
- tariffkit/account/cli.py +493 -0
- tariffkit/account/errors.py +25 -0
- tariffkit/account/model.py +641 -0
- tariffkit/account/rates.py +61 -0
- tariffkit/account/repository.py +329 -0
- tariffkit/billing/__init__.py +56 -0
- tariffkit/billing/engine.py +505 -0
- tariffkit/billing/ledger.py +365 -0
- tariffkit/billing/models.py +274 -0
- tariffkit/billing/netting.py +137 -0
- tariffkit/billing/trueup.py +498 -0
- tariffkit/cca.py +122 -0
- tariffkit/cli.py +809 -0
- tariffkit/config.py +296 -0
- tariffkit/data/__init__.py +40 -0
- tariffkit/data/cca/mce/2023-01-01.toml +61 -0
- tariffkit/data/cca/mce/2026-04-01.toml +152 -0
- tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
- tariffkit/data/export/pge/nbt00.json.gz +0 -0
- tariffkit/data/export/pge/nbt23.json.gz +0 -0
- tariffkit/data/export/pge/nbt24.json.gz +0 -0
- tariffkit/data/export/pge/nbt25.json.gz +0 -0
- tariffkit/data/export/pge/nbt26.json.gz +0 -0
- tariffkit/data/holidays.toml +36 -0
- tariffkit/data/manifest.json +56 -0
- tariffkit/data/nsc/pge.toml +57 -0
- tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
- tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
- tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
- tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
- tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
- tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
- tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
- tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
- tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
- tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
- tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
- tariffkit/data/versioned.py +118 -0
- tariffkit/engine.py +82 -0
- tariffkit/errors.py +19 -0
- tariffkit/export/__init__.py +5 -0
- tariffkit/export/nbt.py +207 -0
- tariffkit/interop/__init__.py +21 -0
- tariffkit/interop/emhass.py +79 -0
- tariffkit/interop/predbat.py +102 -0
- tariffkit/interop/slots.py +63 -0
- tariffkit/models.py +164 -0
- tariffkit/mqtt/__init__.py +6 -0
- tariffkit/mqtt/discovery.py +84 -0
- tariffkit/mqtt/publisher.py +305 -0
- tariffkit/providers/__init__.py +1 -0
- tariffkit/providers/pge/__init__.py +33 -0
- tariffkit/providers/pge/reconcile.py +828 -0
- tariffkit/providers/pge/statements/__init__.py +26 -0
- tariffkit/providers/pge/statements/errors.py +20 -0
- tariffkit/providers/pge/statements/model.py +320 -0
- tariffkit/providers/pge/statements/ocr.py +193 -0
- tariffkit/providers/pge/statements/parse.py +813 -0
- tariffkit/py.typed +0 -0
- tariffkit/secrets.py +164 -0
- tariffkit/sources/__init__.py +71 -0
- tariffkit/sources/greenbutton.py +318 -0
- tariffkit/sources/homeassistant.py +342 -0
- tariffkit/sources/influx.py +359 -0
- tariffkit/sources/pge.py +1153 -0
- tariffkit/tariff/__init__.py +5 -0
- tariffkit/tariff/retail.py +271 -0
- tariffkit/timeutil.py +120 -0
- tariffkit/web/__init__.py +5 -0
- tariffkit/web/app.py +220 -0
- tariffkit-0.2.0.dist-info/METADATA +260 -0
- tariffkit-0.2.0.dist-info/RECORD +83 -0
- tariffkit-0.2.0.dist-info/WHEEL +4 -0
- tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
- tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Interval readings from Home Assistant's long-term statistics.
|
|
2
|
+
|
|
3
|
+
The meter reader -- a Rainforest Eagle-100 on the smart meter -- publishes
|
|
4
|
+
cumulative kWh counters for grid import and export. Home Assistant records those
|
|
5
|
+
as long-term statistics, which is the only place a full billing cycle survives:
|
|
6
|
+
the states history behind ``/api/history`` is purged on the recorder's schedule,
|
|
7
|
+
typically ten days, while statistics are kept indefinitely.
|
|
8
|
+
|
|
9
|
+
Statistics are only reachable over the WebSocket API, so this module needs
|
|
10
|
+
``websockets`` and lives outside :mod:`tariffkit.billing`, which is deliberately
|
|
11
|
+
stdlib-only. It produces ``IntervalReading`` objects that the billing engine
|
|
12
|
+
consumes exactly as it consumes a CSV.
|
|
13
|
+
|
|
14
|
+
Two resolutions exist and they are kept for different lengths of time:
|
|
15
|
+
|
|
16
|
+
* ``5minute`` -- roughly the recorder's retention window, so recent cycles only.
|
|
17
|
+
* ``hour`` -- kept indefinitely.
|
|
18
|
+
|
|
19
|
+
The default asks for both and prefers the finer one wherever it exists. That
|
|
20
|
+
matters beyond tidiness: import and export are metered separately, so a slot
|
|
21
|
+
carrying both directions is real rather than un-netted gross data, and the
|
|
22
|
+
coarser the slot the more often that happens. On one real week, 42% of active
|
|
23
|
+
hours carried both directions against 12% of five-minute slots.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import tomllib
|
|
33
|
+
from dataclasses import dataclass, replace
|
|
34
|
+
from datetime import UTC, datetime, timedelta
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Literal
|
|
37
|
+
|
|
38
|
+
from ..account.model import MeterSource
|
|
39
|
+
from ..billing.models import IntervalReading
|
|
40
|
+
from ..config import default_config_path
|
|
41
|
+
from ..errors import ConfigError, DataError
|
|
42
|
+
from ..secrets import get_secret
|
|
43
|
+
from ..timeutil import to_pacific
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
Resolution = Literal["auto", "5minute", "hour"]
|
|
48
|
+
|
|
49
|
+
#: Home Assistant's own period names, finest first.
|
|
50
|
+
PERIODS: dict[str, timedelta] = {"5minute": timedelta(minutes=5), "hour": timedelta(hours=1)}
|
|
51
|
+
|
|
52
|
+
#: The Rainforest Eagle-100 pair, monotonic-filtered. The unfiltered entities are
|
|
53
|
+
#: named ``..._total_energy_delivered`` and drop to zero several times a day when
|
|
54
|
+
#: the device re-establishes its meter session, so they are the wrong default
|
|
55
|
+
#: despite the more official-looking name.
|
|
56
|
+
DEFAULT_IMPORT_ENTITY = "sensor.eagle_100_energy_delivered"
|
|
57
|
+
DEFAULT_EXPORT_ENTITY = "sensor.eagle_100_energy_received"
|
|
58
|
+
|
|
59
|
+
#: Ceiling on implied power for one interval, in kW. Anything above it is a
|
|
60
|
+
#: counter artefact rather than energy.
|
|
61
|
+
#:
|
|
62
|
+
#: Statistics restart their running ``sum`` when recording is interrupted, and
|
|
63
|
+
#: the first point of the new epoch reports the whole accumulated total as its
|
|
64
|
+
#: ``change``. One real instance put 543.663 kWh in a five-minute slot -- about
|
|
65
|
+
#: 6,500 kW, against a 200 A service that tops out near 48 kW. Set well above any
|
|
66
|
+
#: residential service so it only ever catches the impossible.
|
|
67
|
+
MAX_INTERVAL_KW = 100.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_dotenv(path: str | Path = ".env") -> dict[str, str]:
|
|
71
|
+
"""Parse a ``.env`` file leniently, returning what it defines.
|
|
72
|
+
|
|
73
|
+
Tolerates ``KEY = "value"`` with spaces around the equals and quotes around
|
|
74
|
+
the value, which is how these files are usually written by hand. Missing
|
|
75
|
+
files yield nothing rather than raising: a token may equally come from the
|
|
76
|
+
environment.
|
|
77
|
+
"""
|
|
78
|
+
found: dict[str, str] = {}
|
|
79
|
+
file = Path(path)
|
|
80
|
+
if not file.is_file():
|
|
81
|
+
return found
|
|
82
|
+
for line in file.read_text(encoding="utf-8").splitlines():
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
85
|
+
continue
|
|
86
|
+
key, value = line.split("=", 1)
|
|
87
|
+
found[key.strip()] = value.strip().strip('"').strip("'")
|
|
88
|
+
return found
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class HaSettings:
|
|
93
|
+
"""Where to reach Home Assistant, and which entities carry grid exchange."""
|
|
94
|
+
|
|
95
|
+
host: str
|
|
96
|
+
token: str
|
|
97
|
+
import_entity: str = DEFAULT_IMPORT_ENTITY
|
|
98
|
+
export_entity: str = DEFAULT_EXPORT_ENTITY
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def websocket_url(self) -> str:
|
|
102
|
+
scheme = "wss" if self.host.startswith("https") else "ws"
|
|
103
|
+
base = self.host.split("://", 1)[-1].rstrip("/")
|
|
104
|
+
return f"{scheme}://{base}/api/websocket"
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def load(
|
|
108
|
+
cls,
|
|
109
|
+
config_path: str | Path | None = None,
|
|
110
|
+
dotenv_path: str | Path = ".env",
|
|
111
|
+
profile_source: MeterSource | None = None,
|
|
112
|
+
**overrides: str | None,
|
|
113
|
+
) -> HaSettings:
|
|
114
|
+
"""Resolve settings from config file, ``.env``, environment, and args.
|
|
115
|
+
|
|
116
|
+
Later wins: ``[home_assistant]`` in the config file, then ``.env``, then
|
|
117
|
+
real environment variables, then a named profile's grid-import/grid-
|
|
118
|
+
export mapping, then explicit overrides.
|
|
119
|
+
|
|
120
|
+
The token is deliberately not read from the config file. Entity ids are
|
|
121
|
+
configuration and belong somewhere shareable; a long-lived access token
|
|
122
|
+
is not, and the config file is not gitignored.
|
|
123
|
+
"""
|
|
124
|
+
values: dict[str, str] = {}
|
|
125
|
+
path = Path(config_path) if config_path else default_config_path()
|
|
126
|
+
if path.is_file():
|
|
127
|
+
table = tomllib.loads(path.read_text(encoding="utf-8")).get("home_assistant", {})
|
|
128
|
+
for key in ("host", "import_entity", "export_entity"):
|
|
129
|
+
if key in table:
|
|
130
|
+
values[key] = str(table[key])
|
|
131
|
+
|
|
132
|
+
env = {**load_dotenv(dotenv_path), **os.environ}
|
|
133
|
+
if host := env.get("HA_HOST"):
|
|
134
|
+
values["host"] = host
|
|
135
|
+
if token := env.get("HA_TOKEN") or get_secret("home_assistant.token"):
|
|
136
|
+
values["token"] = token
|
|
137
|
+
for key, name in (
|
|
138
|
+
("import_entity", "TARIFFKIT_HA_IMPORT_ENTITY"),
|
|
139
|
+
("export_entity", "TARIFFKIT_HA_EXPORT_ENTITY"),
|
|
140
|
+
):
|
|
141
|
+
if value := env.get(name):
|
|
142
|
+
values[key] = value
|
|
143
|
+
|
|
144
|
+
if profile_source is not None:
|
|
145
|
+
if not isinstance(profile_source, MeterSource):
|
|
146
|
+
raise ConfigError("profile_source must be a MeterSource")
|
|
147
|
+
values["import_entity"] = profile_source.grid_import_entity
|
|
148
|
+
values["export_entity"] = profile_source.grid_export_entity
|
|
149
|
+
|
|
150
|
+
values.update({k: v for k, v in overrides.items() if v})
|
|
151
|
+
|
|
152
|
+
missing = [k for k in ("host", "token") if not values.get(k)]
|
|
153
|
+
if missing:
|
|
154
|
+
raise ConfigError(
|
|
155
|
+
f"Home Assistant {' and '.join(missing)} not set; put HA_HOST and "
|
|
156
|
+
f"HA_TOKEN in the environment, or store home_assistant.token with "
|
|
157
|
+
f"`tariffkit credentials set`"
|
|
158
|
+
)
|
|
159
|
+
return cls(
|
|
160
|
+
host=values["host"],
|
|
161
|
+
token=values["token"],
|
|
162
|
+
import_entity=values.get("import_entity", DEFAULT_IMPORT_ENTITY),
|
|
163
|
+
export_entity=values.get("export_entity", DEFAULT_EXPORT_ENTITY),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _readings_from(
|
|
168
|
+
series: dict[str, list[dict[str, Any]]],
|
|
169
|
+
settings: HaSettings,
|
|
170
|
+
duration: timedelta,
|
|
171
|
+
max_kw: float = MAX_INTERVAL_KW,
|
|
172
|
+
) -> dict[int, IntervalReading]:
|
|
173
|
+
"""Turn one resolution's statistics into readings keyed by epoch-ms start.
|
|
174
|
+
|
|
175
|
+
Uses each point's ``change`` -- the energy within that period -- rather than
|
|
176
|
+
differencing ``sum`` between points. Both are available, but ``change`` is
|
|
177
|
+
already aligned to its own period, so it needs no off-by-one correction and
|
|
178
|
+
yields a value for the first point instead of discarding it.
|
|
179
|
+
"""
|
|
180
|
+
imported = {p["start"]: p.get("change") or 0.0 for p in series.get(settings.import_entity, [])}
|
|
181
|
+
exported = {p["start"]: p.get("change") or 0.0 for p in series.get(settings.export_entity, [])}
|
|
182
|
+
|
|
183
|
+
ceiling = max_kw * duration.total_seconds() / 3600
|
|
184
|
+
readings: dict[int, IntervalReading] = {}
|
|
185
|
+
for stamp in sorted(set(imported) | set(exported)):
|
|
186
|
+
start = to_pacific(datetime.fromtimestamp(stamp / 1000, tz=UTC))
|
|
187
|
+
# A counter that goes backwards is a device or restart artefact, not
|
|
188
|
+
# energy flowing the other way; clamp rather than raise on it.
|
|
189
|
+
into = max(imported.get(stamp, 0.0), 0.0)
|
|
190
|
+
out = max(exported.get(stamp, 0.0), 0.0)
|
|
191
|
+
if into > ceiling or out > ceiling:
|
|
192
|
+
# Dropped rather than clamped: the reading is not merely large, it is
|
|
193
|
+
# not a reading at all, and leaving a hole lets coverage checking
|
|
194
|
+
# report it instead of quietly inventing a plausible number.
|
|
195
|
+
log.warning(
|
|
196
|
+
"discarding %s statistics point: %.3f kWh in / %.3f kWh out over %s "
|
|
197
|
+
"implies more than %.0f kW, so the running sum restarted here",
|
|
198
|
+
start.isoformat(),
|
|
199
|
+
into,
|
|
200
|
+
out,
|
|
201
|
+
duration,
|
|
202
|
+
max_kw,
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
readings[stamp] = IntervalReading(
|
|
206
|
+
start=start, imported=into, exported=out, duration=duration
|
|
207
|
+
)
|
|
208
|
+
return readings
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _fetch(
|
|
212
|
+
settings: HaSettings, start: datetime, end: datetime, periods: list[str]
|
|
213
|
+
) -> dict[str, dict[str, list[dict[str, Any]]]]:
|
|
214
|
+
try:
|
|
215
|
+
import websockets
|
|
216
|
+
except ImportError as exc: # pragma: no cover - exercised by packaging
|
|
217
|
+
raise RuntimeError(
|
|
218
|
+
"the Home Assistant source requires the 'ha' extra: pip install 'tariffkit[ha]'"
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
out: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
|
222
|
+
async with websockets.connect(settings.websocket_url, max_size=None, open_timeout=30) as ws:
|
|
223
|
+
await ws.recv()
|
|
224
|
+
await ws.send(json.dumps({"type": "auth", "access_token": settings.token}))
|
|
225
|
+
if json.loads(await ws.recv()).get("type") != "auth_ok":
|
|
226
|
+
raise ConfigError("Home Assistant rejected HA_TOKEN")
|
|
227
|
+
for index, period in enumerate(periods, start=1):
|
|
228
|
+
await ws.send(
|
|
229
|
+
json.dumps(
|
|
230
|
+
{
|
|
231
|
+
"id": index,
|
|
232
|
+
"type": "recorder/statistics_during_period",
|
|
233
|
+
"start_time": start.astimezone(UTC).isoformat(),
|
|
234
|
+
"end_time": end.astimezone(UTC).isoformat(),
|
|
235
|
+
"statistic_ids": [settings.import_entity, settings.export_entity],
|
|
236
|
+
"period": period,
|
|
237
|
+
"types": ["change"],
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
# Match on id rather than taking the next frame: Home Assistant may
|
|
242
|
+
# interleave other messages, and pairing the wrong payload with a
|
|
243
|
+
# period would silently bill one resolution's data as another's.
|
|
244
|
+
while True:
|
|
245
|
+
message = json.loads(await ws.recv())
|
|
246
|
+
if message.get("id") == index:
|
|
247
|
+
break
|
|
248
|
+
if not message.get("success"):
|
|
249
|
+
error = (message.get("error") or {}).get("message", "unknown error")
|
|
250
|
+
raise DataError(f"Home Assistant refused the {period} statistics request: {error}")
|
|
251
|
+
out[period] = message.get("result") or {}
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def read_statistics_async(
|
|
256
|
+
settings: HaSettings,
|
|
257
|
+
start: datetime,
|
|
258
|
+
end: datetime,
|
|
259
|
+
resolution: Resolution = "auto",
|
|
260
|
+
max_kw: float = MAX_INTERVAL_KW,
|
|
261
|
+
) -> list[IntervalReading]:
|
|
262
|
+
"""Interval readings for ``[start, end)`` from Home Assistant statistics.
|
|
263
|
+
|
|
264
|
+
``auto`` asks for both resolutions and prefers five-minute wherever it
|
|
265
|
+
exists, falling back to hourly for the rest of the window. One run can
|
|
266
|
+
therefore mix the two; :func:`describe_resolution` reports what was used, so
|
|
267
|
+
a caller can say so rather than implying uniformity.
|
|
268
|
+
"""
|
|
269
|
+
if resolution not in ("auto", *PERIODS):
|
|
270
|
+
raise ConfigError(f"unknown resolution {resolution!r}; use auto, 5minute or hour")
|
|
271
|
+
for name, moment in (("start", start), ("end", end)):
|
|
272
|
+
if moment.tzinfo is None:
|
|
273
|
+
# Naive datetimes do not raise here, they silently assume the
|
|
274
|
+
# machine's timezone -- so the window would be quietly wrong rather
|
|
275
|
+
# than obviously broken.
|
|
276
|
+
raise ConfigError(f"{name} must be timezone-aware; got {moment.isoformat()}")
|
|
277
|
+
if end <= start:
|
|
278
|
+
raise ConfigError(f"end {end.isoformat()} is not after start {start.isoformat()}")
|
|
279
|
+
|
|
280
|
+
periods: list[str] = ["5minute", "hour"] if resolution == "auto" else [resolution]
|
|
281
|
+
fetched = await _fetch(settings, start, end, periods)
|
|
282
|
+
|
|
283
|
+
hourly = _readings_from(fetched.get("hour", {}), settings, PERIODS["hour"], max_kw)
|
|
284
|
+
fine = _readings_from(fetched.get("5minute", {}), settings, PERIODS["5minute"], max_kw)
|
|
285
|
+
|
|
286
|
+
# Drop any hour the fine series already accounts for, or the same energy is
|
|
287
|
+
# counted twice. Tested per hour rather than over the fine series' overall
|
|
288
|
+
# span, so a hole in it falls back to hourly instead of vanishing.
|
|
289
|
+
hour_ms = int(PERIODS["hour"].total_seconds() * 1000)
|
|
290
|
+
fine_hours = {stamp - stamp % hour_ms for stamp in fine}
|
|
291
|
+
readings = {stamp: r for stamp, r in hourly.items() if stamp not in fine_hours}
|
|
292
|
+
readings.update(fine)
|
|
293
|
+
|
|
294
|
+
if not readings:
|
|
295
|
+
raise DataError(
|
|
296
|
+
f"no statistics for {settings.import_entity} / {settings.export_entity} "
|
|
297
|
+
f"between {start.isoformat()} and {end.isoformat()}"
|
|
298
|
+
)
|
|
299
|
+
return [readings[stamp] for stamp in sorted(readings)]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def read_statistics(
|
|
303
|
+
settings: HaSettings,
|
|
304
|
+
start: datetime,
|
|
305
|
+
end: datetime,
|
|
306
|
+
resolution: Resolution = "auto",
|
|
307
|
+
max_kw: float = MAX_INTERVAL_KW,
|
|
308
|
+
) -> list[IntervalReading]:
|
|
309
|
+
"""Blocking wrapper around :func:`read_statistics_async`.
|
|
310
|
+
|
|
311
|
+
Refuses to run inside an existing event loop rather than letting
|
|
312
|
+
``asyncio.run`` raise a bare RuntimeError, which says nothing about what to
|
|
313
|
+
do instead. Async callers -- the REST app among them -- want the coroutine.
|
|
314
|
+
"""
|
|
315
|
+
try:
|
|
316
|
+
asyncio.get_running_loop()
|
|
317
|
+
except RuntimeError:
|
|
318
|
+
pass
|
|
319
|
+
else:
|
|
320
|
+
raise ConfigError(
|
|
321
|
+
"read_statistics() blocks and cannot run inside an event loop; "
|
|
322
|
+
"await read_statistics_async() instead"
|
|
323
|
+
)
|
|
324
|
+
return asyncio.run(read_statistics_async(settings, start, end, resolution, max_kw))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def describe_resolution(readings: list[IntervalReading]) -> str:
|
|
328
|
+
"""One line naming the resolutions present, for the caller to report."""
|
|
329
|
+
counts: dict[timedelta, int] = {}
|
|
330
|
+
for r in readings:
|
|
331
|
+
counts[r.duration] = counts.get(r.duration, 0) + 1
|
|
332
|
+
names = {v: k for k, v in PERIODS.items()}
|
|
333
|
+
parts = [
|
|
334
|
+
f"{count} x {names.get(duration, str(duration))}"
|
|
335
|
+
for duration, count in sorted(counts.items())
|
|
336
|
+
]
|
|
337
|
+
return ", ".join(parts)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def settings_with(settings: HaSettings, **overrides: str | None) -> HaSettings:
|
|
341
|
+
"""Apply non-empty overrides to ``settings``."""
|
|
342
|
+
return replace(settings, **{k: v for k, v in overrides.items() if v})
|