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.
Files changed (83) hide show
  1. tariffkit/__init__.py +52 -0
  2. tariffkit/account/__init__.py +41 -0
  3. tariffkit/account/cli.py +493 -0
  4. tariffkit/account/errors.py +25 -0
  5. tariffkit/account/model.py +641 -0
  6. tariffkit/account/rates.py +61 -0
  7. tariffkit/account/repository.py +329 -0
  8. tariffkit/billing/__init__.py +56 -0
  9. tariffkit/billing/engine.py +505 -0
  10. tariffkit/billing/ledger.py +365 -0
  11. tariffkit/billing/models.py +274 -0
  12. tariffkit/billing/netting.py +137 -0
  13. tariffkit/billing/trueup.py +498 -0
  14. tariffkit/cca.py +122 -0
  15. tariffkit/cli.py +809 -0
  16. tariffkit/config.py +296 -0
  17. tariffkit/data/__init__.py +40 -0
  18. tariffkit/data/cca/mce/2023-01-01.toml +61 -0
  19. tariffkit/data/cca/mce/2026-04-01.toml +152 -0
  20. tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
  21. tariffkit/data/export/pge/nbt00.json.gz +0 -0
  22. tariffkit/data/export/pge/nbt23.json.gz +0 -0
  23. tariffkit/data/export/pge/nbt24.json.gz +0 -0
  24. tariffkit/data/export/pge/nbt25.json.gz +0 -0
  25. tariffkit/data/export/pge/nbt26.json.gz +0 -0
  26. tariffkit/data/holidays.toml +36 -0
  27. tariffkit/data/manifest.json +56 -0
  28. tariffkit/data/nsc/pge.toml +57 -0
  29. tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
  30. tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
  31. tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
  32. tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
  33. tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
  34. tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
  35. tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
  36. tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
  37. tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
  38. tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
  39. tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
  40. tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
  41. tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
  42. tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
  43. tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
  44. tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
  45. tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
  46. tariffkit/data/versioned.py +118 -0
  47. tariffkit/engine.py +82 -0
  48. tariffkit/errors.py +19 -0
  49. tariffkit/export/__init__.py +5 -0
  50. tariffkit/export/nbt.py +207 -0
  51. tariffkit/interop/__init__.py +21 -0
  52. tariffkit/interop/emhass.py +79 -0
  53. tariffkit/interop/predbat.py +102 -0
  54. tariffkit/interop/slots.py +63 -0
  55. tariffkit/models.py +164 -0
  56. tariffkit/mqtt/__init__.py +6 -0
  57. tariffkit/mqtt/discovery.py +84 -0
  58. tariffkit/mqtt/publisher.py +305 -0
  59. tariffkit/providers/__init__.py +1 -0
  60. tariffkit/providers/pge/__init__.py +33 -0
  61. tariffkit/providers/pge/reconcile.py +828 -0
  62. tariffkit/providers/pge/statements/__init__.py +26 -0
  63. tariffkit/providers/pge/statements/errors.py +20 -0
  64. tariffkit/providers/pge/statements/model.py +320 -0
  65. tariffkit/providers/pge/statements/ocr.py +193 -0
  66. tariffkit/providers/pge/statements/parse.py +813 -0
  67. tariffkit/py.typed +0 -0
  68. tariffkit/secrets.py +164 -0
  69. tariffkit/sources/__init__.py +71 -0
  70. tariffkit/sources/greenbutton.py +318 -0
  71. tariffkit/sources/homeassistant.py +342 -0
  72. tariffkit/sources/influx.py +359 -0
  73. tariffkit/sources/pge.py +1153 -0
  74. tariffkit/tariff/__init__.py +5 -0
  75. tariffkit/tariff/retail.py +271 -0
  76. tariffkit/timeutil.py +120 -0
  77. tariffkit/web/__init__.py +5 -0
  78. tariffkit/web/app.py +220 -0
  79. tariffkit-0.2.0.dist-info/METADATA +260 -0
  80. tariffkit-0.2.0.dist-info/RECORD +83 -0
  81. tariffkit-0.2.0.dist-info/WHEEL +4 -0
  82. tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
  83. 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})