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,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
|
+
]
|