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,305 @@
1
+ """Publish current and forecast prices to an MQTT broker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import threading
11
+ import tomllib
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from types import FrameType
16
+ from typing import Any
17
+
18
+ from ..account import (
19
+ AccountProfile,
20
+ AccountRateEngine,
21
+ NamedProfileRepository,
22
+ configured_profile_name,
23
+ )
24
+ from ..config import default_config_path
25
+ from ..engine import RateEngine
26
+ from ..errors import ConfigError
27
+ from ..interop import forecast_lists, predbat_payload
28
+ from ..models import PricePoint
29
+ from ..secrets import get_secret
30
+ from ..sources.homeassistant import load_dotenv
31
+ from ..timeutil import next_hour, now_pacific
32
+ from .discovery import discovery_payloads
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ ONLINE = "online"
37
+ OFFLINE = "offline"
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class MqttSettings:
42
+ broker: str
43
+ port: int = 1883
44
+ username: str | None = None
45
+ password: str | None = None
46
+ topic_prefix: str = "tariffkit"
47
+ discovery: bool = True
48
+ discovery_prefix: str = "homeassistant"
49
+ forecast_hours: int = 48
50
+ client_id: str = "tariffkit"
51
+ tls: bool = False
52
+ profile: str | None = None
53
+ account: str | None = None
54
+
55
+ def __post_init__(self) -> None:
56
+ if self.profile is not None and self.account is not None and self.profile != self.account:
57
+ raise ConfigError("profile and account selections disagree")
58
+ if self.profile is None:
59
+ object.__setattr__(self, "profile", self.account)
60
+
61
+ @classmethod
62
+ def load(
63
+ cls,
64
+ config_path: str | Path | None = None,
65
+ dotenv_path: str | Path = ".env",
66
+ **overrides: Any,
67
+ ) -> MqttSettings:
68
+ """Resolve non-secrets from config and credentials from env or keyring."""
69
+ values: dict[str, Any] = {}
70
+ path = Path(config_path) if config_path else default_config_path()
71
+ if path.is_file():
72
+ table = tomllib.loads(path.read_text(encoding="utf-8")).get("mqtt", {})
73
+ for key in (
74
+ "broker",
75
+ "port",
76
+ "topic_prefix",
77
+ "discovery",
78
+ "discovery_prefix",
79
+ "forecast_hours",
80
+ "client_id",
81
+ "tls",
82
+ "profile",
83
+ "account",
84
+ ):
85
+ if key in table:
86
+ values[key] = table[key]
87
+ configured_aliases = {str(values[key]) for key in ("profile", "account") if values.get(key)}
88
+ if len(configured_aliases) > 1:
89
+ raise ConfigError("profile and account selections disagree")
90
+ values.pop("account", None)
91
+ if configured_aliases:
92
+ values["profile"] = configured_aliases.pop()
93
+
94
+ env = {**load_dotenv(dotenv_path), **os.environ}
95
+ for key, name in (
96
+ ("broker", "TARIFFKIT_MQTT_BROKER"),
97
+ ("port", "TARIFFKIT_MQTT_PORT"),
98
+ ("topic_prefix", "TARIFFKIT_MQTT_TOPIC_PREFIX"),
99
+ ("username", "TARIFFKIT_MQTT_USERNAME"),
100
+ ("password", "TARIFFKIT_MQTT_PASSWORD"),
101
+ ):
102
+ if value := env.get(name):
103
+ values[key] = value
104
+ for name in ("TARIFFKIT_ACCOUNT", "TARIFFKIT_PROFILE"):
105
+ if value := env.get(name):
106
+ values["profile"] = value
107
+ break
108
+ if not values.get("profile"):
109
+ values["profile"] = configured_profile_name(config_path)
110
+ if not values.get("username"):
111
+ values["username"] = get_secret("mqtt.username")
112
+ if not values.get("password"):
113
+ values["password"] = get_secret("mqtt.password")
114
+ profile_overrides = {
115
+ key: overrides.pop(key)
116
+ for key in ("profile", "account", "profile_name")
117
+ if key in overrides and overrides[key] is not None
118
+ }
119
+ selected_profiles = {str(value) for value in profile_overrides.values()}
120
+ if len(selected_profiles) > 1:
121
+ raise ConfigError("profile, account, and profile_name must select the same profile")
122
+ if selected_profiles:
123
+ overrides["profile"] = selected_profiles.pop()
124
+ values.update({key: value for key, value in overrides.items() if value is not None})
125
+ if not values.get("broker"):
126
+ raise ConfigError(
127
+ "MQTT broker not set; configure [mqtt].broker, TARIFFKIT_MQTT_BROKER, or --broker"
128
+ )
129
+ for key in ("port", "forecast_hours"):
130
+ if key in values:
131
+ values[key] = int(values[key])
132
+ return cls(**values)
133
+
134
+
135
+ class MqttPublisher:
136
+ """Publishes at each hour boundary, retained.
137
+
138
+ Retaining means a subscriber that connects later immediately receives the
139
+ current price rather than waiting up to an hour for the next tick.
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ engine: RateEngine | AccountRateEngine,
145
+ settings: MqttSettings,
146
+ client: Any | None = None,
147
+ *,
148
+ profile: AccountProfile | None = None,
149
+ profile_repository: NamedProfileRepository | None = None,
150
+ ) -> None:
151
+ if profile is not None and settings.profile is not None:
152
+ raise ConfigError("choose either an MQTT profile name or an in-memory profile")
153
+ if profile is not None:
154
+ self.engine: RateEngine | AccountRateEngine = AccountRateEngine(profile)
155
+ elif settings.profile is not None:
156
+ repository = profile_repository or NamedProfileRepository()
157
+ self.engine = AccountRateEngine(repository.load(settings.profile))
158
+ else:
159
+ self.engine = engine
160
+ self.settings = settings
161
+ self._stop = threading.Event()
162
+ self._client = self._configure(client if client is not None else self._build_client())
163
+
164
+ def _build_client(self) -> Any:
165
+ try:
166
+ import paho.mqtt.client as mqtt
167
+ except ImportError as exc: # pragma: no cover - exercised by packaging
168
+ raise RuntimeError(
169
+ "MQTT support requires the 'mqtt' extra: pip install 'tariffkit[mqtt]'"
170
+ ) from exc
171
+
172
+ return mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.settings.client_id)
173
+
174
+ def _configure(self, client: Any) -> Any:
175
+ """Apply auth, TLS, and the last will.
176
+
177
+ Kept separate from construction to allow dependency injection of the client
178
+ while still exercising this logic.
179
+ """
180
+ if self.settings.username:
181
+ client.username_pw_set(self.settings.username, self.settings.password)
182
+ if self.settings.tls:
183
+ client.tls_set()
184
+ # Last will, so subscribers see the sensors go unavailable if we die.
185
+ client.will_set(self._topic("status"), OFFLINE, retain=True)
186
+ return client
187
+
188
+ def _topic(self, suffix: str) -> str:
189
+ return f"{self.settings.topic_prefix}/{suffix}"
190
+
191
+ def _publish(self, suffix: str, payload: Any) -> None:
192
+ body = payload if isinstance(payload, str) else json.dumps(payload, default=str)
193
+ self._client.publish(self._topic(suffix), body, retain=True)
194
+
195
+ def connect(self) -> None:
196
+ self._client.connect(self.settings.broker, self.settings.port, keepalive=60)
197
+ self._client.loop_start()
198
+ self._publish("status", ONLINE)
199
+ if self.settings.discovery:
200
+ self.publish_discovery()
201
+
202
+ def publish_discovery(self) -> None:
203
+ for topic, payload in discovery_payloads(
204
+ self.engine.describe(),
205
+ topic_prefix=self.settings.topic_prefix,
206
+ discovery_prefix=self.settings.discovery_prefix,
207
+ ):
208
+ self._client.publish(topic, json.dumps(payload), retain=True)
209
+ log.info("published Home Assistant discovery config")
210
+
211
+ def publish_now(self, moment: datetime | None = None) -> PricePoint:
212
+ now = moment or now_pacific()
213
+ point = self.engine.price_at(now)
214
+ curve = self.engine.forecast(self.settings.forecast_hours, start=point.start)
215
+
216
+ self._publish("import_price", f"{point.import_price.total:.5f}")
217
+ self._publish("export_price", f"{point.export_price.total:.5f}")
218
+ self._publish("spread", f"{point.spread:.5f}")
219
+ self._publish("tou_period", str(point.import_price.period))
220
+
221
+ # Component breakdown plus the payloads other energy systems read, so the
222
+ # broker path is as interoperable as the custom component. raw_today and
223
+ # raw_tomorrow are cents (Predbat assumes pence); everything else dollars.
224
+ # Trim against the real moment, not the hour floor: `--once` from cron
225
+ # can land at any minute, and EMHASS's positional lists must start at the
226
+ # 30-minute slot it is actually in.
227
+ emhass = forecast_lists(curve, since=now)
228
+ predbat = predbat_payload(self.engine, now)
229
+ self._publish(
230
+ "import_price/attributes",
231
+ {
232
+ **point.import_price.to_dict(),
233
+ "load_cost_forecast": emhass["load_cost_forecast"],
234
+ "prediction_horizon": emhass["prediction_horizon"],
235
+ **predbat["import"],
236
+ },
237
+ )
238
+ self._publish(
239
+ "export_price/attributes",
240
+ {
241
+ **point.export_price.to_dict(),
242
+ "prod_price_forecast": emhass["prod_price_forecast"],
243
+ "prediction_horizon": emhass["prediction_horizon"],
244
+ **predbat["export"],
245
+ },
246
+ )
247
+ self._publish(
248
+ "spread/attributes",
249
+ {
250
+ "start": point.start.isoformat(),
251
+ "end": point.end.isoformat(),
252
+ # Planners such as EMHASS expect a flat list of hourly entries.
253
+ "forecast": [
254
+ {
255
+ "start": p.start.isoformat(),
256
+ "import": p.import_price.total,
257
+ "export": p.export_price.total,
258
+ "spread": round(p.spread, 6),
259
+ }
260
+ for p in curve
261
+ ],
262
+ },
263
+ )
264
+ self._publish("forecast", curve.to_dict())
265
+ log.info(
266
+ "published %s import=%.5f export=%.5f",
267
+ point.start.isoformat(),
268
+ point.import_price.total,
269
+ point.export_price.total,
270
+ )
271
+ return point
272
+
273
+ def run_forever(self) -> None:
274
+ """Publish immediately, then once at the top of every hour."""
275
+ self.connect()
276
+ self._install_signal_handlers()
277
+ try:
278
+ while not self._stop.is_set():
279
+ self.publish_now()
280
+ delay = (next_hour(now_pacific()) - now_pacific()).total_seconds()
281
+ # Sleep until the boundary rather than polling; wakes early on stop.
282
+ self._stop.wait(max(delay, 1.0))
283
+ finally:
284
+ self.close()
285
+
286
+ def _install_signal_handlers(self) -> None:
287
+ def handle(signum: int, _frame: FrameType | None) -> None:
288
+ log.info("received signal %s, shutting down", signum)
289
+ self.stop()
290
+
291
+ for sig in (signal.SIGINT, signal.SIGTERM):
292
+ # Raises off the main thread, where signal handling is not ours to
293
+ # install; the caller then drives shutdown via stop().
294
+ with contextlib.suppress(ValueError):
295
+ signal.signal(sig, handle)
296
+
297
+ def stop(self) -> None:
298
+ self._stop.set()
299
+
300
+ def close(self) -> None:
301
+ try:
302
+ self._publish("status", OFFLINE)
303
+ finally:
304
+ self._client.loop_stop()
305
+ self._client.disconnect()
@@ -0,0 +1 @@
1
+ """Provider-specific integrations."""
@@ -0,0 +1,33 @@
1
+ """PG&E provider integrations."""
2
+
3
+ from .reconcile import (
4
+ SUPPORTED_TARIFFS,
5
+ AccountChange,
6
+ AccountChangeSet,
7
+ ChangeOutcome,
8
+ PdfSource,
9
+ ReconciliationError,
10
+ RevisionMismatchError,
11
+ hash_pdf,
12
+ import_statement,
13
+ normalize_cca_identity,
14
+ observe_statement,
15
+ reconcile,
16
+ reconcile_statement,
17
+ )
18
+
19
+ __all__ = [
20
+ "SUPPORTED_TARIFFS",
21
+ "AccountChange",
22
+ "AccountChangeSet",
23
+ "ChangeOutcome",
24
+ "PdfSource",
25
+ "ReconciliationError",
26
+ "RevisionMismatchError",
27
+ "hash_pdf",
28
+ "import_statement",
29
+ "normalize_cca_identity",
30
+ "observe_statement",
31
+ "reconcile",
32
+ "reconcile_statement",
33
+ ]