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
tariffkit/cli.py ADDED
@@ -0,0 +1,809 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import getpass
8
+ import json
9
+ import logging
10
+ import sys
11
+ from datetime import date, datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from . import __version__
16
+ from .config import Config
17
+ from .engine import RateEngine
18
+ from .errors import ConfigError, TariffKitError
19
+ from .models import PriceCurve, PricePoint
20
+ from .secrets import (
21
+ SECRET_NAMES,
22
+ configured_named_secrets,
23
+ configured_secrets,
24
+ delete_named_secret,
25
+ delete_secret,
26
+ set_named_secret,
27
+ set_secret,
28
+ )
29
+ from .timeutil import PACIFIC, to_pacific
30
+
31
+
32
+ def build_parser() -> argparse.ArgumentParser:
33
+ parser = argparse.ArgumentParser(
34
+ prog="tariffkit",
35
+ description="PG&E E-ELEC import/export prices under NEM 3.0.",
36
+ )
37
+ parser.add_argument("--version", action="version", version=f"tariffkit {__version__}")
38
+ parser.add_argument("--config", type=Path, help="path to a config TOML file")
39
+ parser.add_argument("--account", help="named account profile to use")
40
+ parser.add_argument("-v", "--verbose", action="store_true", help="log to stderr")
41
+ sub = parser.add_subparsers(dest="command", required=True)
42
+
43
+ credentials = sub.add_parser(
44
+ "credentials",
45
+ help="store credentials in the operating-system keyring",
46
+ )
47
+ credential_commands = credentials.add_subparsers(dest="credential_command", required=True)
48
+ credential_set = credential_commands.add_parser("set", help="prompt for and store a secret")
49
+ credential_set.add_argument("--set", dest="credential_set", metavar="NAME")
50
+ credential_set.add_argument("name", choices=SECRET_NAMES)
51
+ credential_delete = credential_commands.add_parser("delete", help="delete a stored secret")
52
+ credential_delete.add_argument("--set", dest="credential_set", metavar="NAME")
53
+ credential_delete.add_argument("name", choices=SECRET_NAMES)
54
+ credential_list = credential_commands.add_parser(
55
+ "list", help="list configured names without values"
56
+ )
57
+ credential_list.add_argument("--set", dest="credential_set", metavar="NAME")
58
+
59
+ account = sub.add_parser("account", help="manage named account profiles")
60
+ account_commands = account.add_subparsers(dest="account_command", required=True)
61
+ account_init = account_commands.add_parser("init", help="create a named account profile")
62
+ account_init.add_argument("name")
63
+ account_init.add_argument("--config", type=Path, default=argparse.SUPPRESS)
64
+ account_init.add_argument("--effective", type=date.fromisoformat)
65
+ account_init.add_argument("--config-json", type=Path)
66
+ account_init.add_argument("--credential-set")
67
+ account_init.add_argument("--audit-file", type=Path)
68
+ account_init.add_argument("--json", action="store_true")
69
+ account_commands.add_parser("list", help="list named account profiles").add_argument(
70
+ "--json", action="store_true"
71
+ )
72
+ account_show = account_commands.add_parser("show", help="show a named account profile")
73
+ account_show.add_argument("name")
74
+ account_show.add_argument("--json", action="store_true")
75
+ account_history = account_commands.add_parser(
76
+ "history", help="show account epochs and evidence"
77
+ )
78
+ account_history.add_argument("name")
79
+ account_history.add_argument("--json", action="store_true")
80
+ account_update = account_commands.add_parser("update", help="add or replace an account epoch")
81
+ account_update.add_argument("name")
82
+ account_update.add_argument("--config", type=Path, default=argparse.SUPPRESS)
83
+ account_update.add_argument("--effective", required=True, type=date.fromisoformat)
84
+ account_update.add_argument("--config-json", type=Path)
85
+ account_update.add_argument("--credential-set")
86
+ account_update.add_argument("--tariff")
87
+ account_update.add_argument("--supplier")
88
+ account_update.add_argument("--interconnection-year", type=int, dest="interconnection_year")
89
+ account_update.add_argument("--pto-date", type=date.fromisoformat)
90
+ account_update.add_argument("--vintage")
91
+ account_update.add_argument("--acc-plus-segment", dest="acc_plus_segment")
92
+ account_update.add_argument("--discount")
93
+ account_update.add_argument("--base-services-charge-tier", type=int)
94
+ account_update.add_argument("--baseline-territory", dest="baseline_territory")
95
+ account_update.add_argument("--baseline-code", dest="baseline_code")
96
+ account_update.add_argument("--nsc-rate", type=float, dest="nsc_rate")
97
+ account_update.add_argument("--cca-json")
98
+ account_update.add_argument("--note")
99
+ account_update.add_argument("--apply", action="store_true")
100
+ account_update.add_argument("--json", action="store_true")
101
+ account_import = account_commands.add_parser(
102
+ "import-statement", help="import one or more local PG&E statement PDFs"
103
+ )
104
+ account_import.add_argument("name")
105
+ account_import.add_argument("pdf", nargs="+", type=Path)
106
+ account_import.add_argument("--apply", action="store_true")
107
+ account_import.add_argument("--json", action="store_true")
108
+ account_sync = account_commands.add_parser("sync", help="sync statements from the PG&E portal")
109
+ account_sync.add_argument("name")
110
+ account_sync.add_argument("--config", type=Path, default=argparse.SUPPRESS)
111
+ account_sync.add_argument("--since", type=date.fromisoformat)
112
+ account_sync.add_argument("--apply", action="store_true")
113
+ account_sync.add_argument("--keep-statements", action="store_true")
114
+ account_sync.add_argument("--json", action="store_true")
115
+ account_export = account_commands.add_parser(
116
+ "export", help="export a sanitized account profile"
117
+ )
118
+ account_export.add_argument("name")
119
+ account_export.add_argument("--output", type=Path)
120
+ account_export.add_argument("--json", action="store_true")
121
+ account_source = account_commands.add_parser(
122
+ "source", help="manage profile-scoped grid meter entities"
123
+ )
124
+ account_source.add_argument("name")
125
+ source_commands = account_source.add_subparsers(dest="source_command", required=True)
126
+ source_show = source_commands.add_parser("show", help="show one provider's meter entities")
127
+ source_show.add_argument("provider", choices=("ha", "influx"))
128
+ source_show.add_argument("--json", action="store_true")
129
+ source_set = source_commands.add_parser("set", help="set one provider's meter entities")
130
+ source_set.add_argument("provider", choices=("ha", "influx"))
131
+ source_set.add_argument(
132
+ "--grid-import-entity",
133
+ "--import-entity",
134
+ dest="grid_import_entity",
135
+ required=True,
136
+ help="entity measuring energy consumed from the grid",
137
+ )
138
+ source_set.add_argument(
139
+ "--grid-export-entity",
140
+ "--export-entity",
141
+ dest="grid_export_entity",
142
+ required=True,
143
+ help="entity measuring energy exported to the grid",
144
+ )
145
+ source_set.add_argument("--apply", action="store_true")
146
+ source_set.add_argument("--json", action="store_true")
147
+
148
+ now = sub.add_parser("now", help="current import and export price")
149
+ now.add_argument("--config", type=Path, default=argparse.SUPPRESS)
150
+ now.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
151
+ now.add_argument("--json", action="store_true", help="emit JSON")
152
+
153
+ forecast = sub.add_parser("forecast", help="upcoming hourly prices")
154
+ forecast.add_argument("--config", type=Path, default=argparse.SUPPRESS)
155
+ forecast.add_argument(
156
+ "--account", default=argparse.SUPPRESS, help="named account profile to use"
157
+ )
158
+ forecast.add_argument("--hours", type=int, default=24)
159
+ forecast.add_argument("--start", type=datetime.fromisoformat, help="ISO 8601 with offset")
160
+ forecast.add_argument("--format", choices=("table", "json", "csv"), default="table")
161
+
162
+ info_parser = sub.add_parser("info", help="which data is loaded, and from where")
163
+ info_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
164
+ info_parser.add_argument(
165
+ "--account", default=argparse.SUPPRESS, help="named account profile to use"
166
+ )
167
+
168
+ bill = sub.add_parser("bill", help="compute a bill from interval meter data")
169
+ bill.add_argument("--config", type=Path, default=argparse.SUPPRESS)
170
+ bill.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
171
+ bill.add_argument(
172
+ "csv",
173
+ type=Path,
174
+ nargs="?",
175
+ metavar="GREEN_BUTTON_CSV",
176
+ help="PG&E Green Button CSV ('Download my data'); '-' for stdin. "
177
+ "Omit with --source ha or --source influx",
178
+ )
179
+ bill.add_argument(
180
+ "--source",
181
+ # "csv" stays accepted so existing invocations keep working, but it is
182
+ # not the documented spelling: it says nothing about which CSV.
183
+ choices=("green-button", "csv", "ha", "influx"),
184
+ default="green-button",
185
+ help="where the readings come from (default: green-button)",
186
+ )
187
+ bill.add_argument("--start", type=date.fromisoformat, help="cycle start (meter read date)")
188
+ bill.add_argument("--end", type=date.fromisoformat, help="cycle end, inclusive")
189
+ bill.add_argument("--json", action="store_true")
190
+ bill.add_argument("--no-check", dest="check", action="store_false", help="skip coverage checks")
191
+ bill.add_argument("--ha-import-entity", help="override the grid-import entity")
192
+ bill.add_argument("--ha-export-entity", help="override the grid-export entity")
193
+ bill.add_argument("--influx-import-entity", help="override the grid-import series")
194
+ bill.add_argument("--influx-export-entity", help="override the grid-export series")
195
+ bill.add_argument(
196
+ "--influx-resolution",
197
+ type=int,
198
+ default=60,
199
+ metavar="MINUTES",
200
+ help="interval length for InfluxDB counters (default: 60; finer needs dense sampling)",
201
+ )
202
+ bill.add_argument(
203
+ "--ha-resolution",
204
+ choices=("auto", "5minute", "hour"),
205
+ default="auto",
206
+ help="statistics resolution; auto prefers 5-minute where it still exists",
207
+ )
208
+
209
+ mqtt = sub.add_parser("mqtt", help="publish to MQTT every hour")
210
+ mqtt.add_argument("--config", type=Path, default=argparse.SUPPRESS)
211
+ mqtt.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
212
+ mqtt.add_argument("--broker")
213
+ mqtt.add_argument("--port", type=int)
214
+ mqtt.add_argument("--username")
215
+ mqtt.add_argument("--topic-prefix")
216
+ mqtt.add_argument("--forecast-hours", type=int)
217
+ mqtt.add_argument("--tls", action="store_true", default=None)
218
+ mqtt.add_argument(
219
+ "--no-discovery",
220
+ dest="discovery",
221
+ action="store_false",
222
+ default=None,
223
+ help="skip Home Assistant discovery config",
224
+ )
225
+ mqtt.add_argument("--once", action="store_true", help="publish once and exit")
226
+
227
+ serve = sub.add_parser("serve", help="run the REST API")
228
+ serve.add_argument("--config", type=Path, default=argparse.SUPPRESS)
229
+ serve.add_argument("--account", default=argparse.SUPPRESS, help="named account profile to use")
230
+ serve.add_argument("--host", default="127.0.0.1")
231
+ serve.add_argument("--port", type=int, default=8000)
232
+
233
+ return parser
234
+
235
+
236
+ def _midnight(day: date) -> datetime:
237
+ """Local midnight starting ``day`` -- where a billing cycle boundary falls.
238
+
239
+ Callers add ``timedelta(days=1)`` to get the end of a cycle, and that is
240
+ deliberately wall-clock arithmetic: a cycle closes at the next local
241
+ midnight, 23 real hours later across the spring transition and 25 across the
242
+ autumn one. Converting to absolute time first would hold the window at 24
243
+ hours and land it an hour off on those two days -- the opposite of what
244
+ coverage checking needs, where elapsed time is the right measure.
245
+ """
246
+ return datetime(day.year, day.month, day.day, tzinfo=PACIFIC)
247
+
248
+
249
+ def _format_point(point: PricePoint) -> str:
250
+ lines = [
251
+ # %Z on both ends: across the fall-back transition the two sides carry
252
+ # different offsets, and "01:00 PDT - 01:00" reads as a zero-length hour.
253
+ f"{point.start:%Y-%m-%d %H:%M %Z} - {point.end:%H:%M %Z}",
254
+ f" import {point.import_price.total:>9.5f} $/kWh"
255
+ f" ({point.import_price.season}/{point.import_price.period})",
256
+ f" export {point.export_price.total:>9.5f} $/kWh"
257
+ f" ({point.export_price.vintage}/{point.export_price.day_type})",
258
+ f" spread {point.spread:>+9.5f} $/kWh",
259
+ ]
260
+ if point.spread > 0:
261
+ lines.append(" exporting beats self-consumption this hour")
262
+ for note in _caveats(point):
263
+ lines.append(f" note: {note}")
264
+ return "\n".join(lines)
265
+
266
+
267
+ def _caveats(point: PricePoint) -> list[str]:
268
+ notes = []
269
+ if not point.export_price.locked:
270
+ notes.append("past the 9-year rate lock; PG&E publishes this for illustration only")
271
+ if not point.export_price.exact:
272
+ notes.append("upstream hour labels drift this far out; value may be off by one hour slot")
273
+ if not point.export_price.complete:
274
+ notes.append("delivery only; configure your CCA's export rate for a full credit")
275
+ if not point.import_price.complete:
276
+ notes.append("delivery only; configure your CCA's generation rate card")
277
+ return notes
278
+
279
+
280
+ def _write_csv(curve: PriceCurve, stream: Any) -> None:
281
+ writer = csv.writer(stream)
282
+ writer.writerow(["start", "end", "import", "export", "spread", "season", "period", "locked"])
283
+ for point in curve:
284
+ writer.writerow(
285
+ [
286
+ point.start.isoformat(),
287
+ point.end.isoformat(),
288
+ f"{point.import_price.total:.5f}",
289
+ f"{point.export_price.total:.5f}",
290
+ f"{point.spread:.5f}",
291
+ point.import_price.season,
292
+ point.import_price.period,
293
+ point.export_price.locked,
294
+ ]
295
+ )
296
+
297
+
298
+ def _print_table(curve: PriceCurve) -> None:
299
+ print(f"{'hour':<17} {'import':>9} {'export':>9} {'spread':>9} period")
300
+ print("-" * 62)
301
+ best = max(p.export_price.total for p in curve)
302
+ for point in curve:
303
+ marker = " *" if point.export_price.total == best else ""
304
+ print(
305
+ f"{point.start:%Y-%m-%d %H:%M} "
306
+ f"{point.import_price.total:>9.5f} {point.export_price.total:>9.5f} "
307
+ f"{point.spread:>+9.5f} {point.import_price.period}{marker}"
308
+ )
309
+ print(f"\n* highest export credit in this window ({best:.5f} $/kWh)")
310
+
311
+
312
+ def _print_bill(bill: Any) -> None:
313
+ p = bill.period
314
+ print(f"Billing period {p.start} to {p.end} ({p.days} days)\n")
315
+ print(f"{'':<11} {'imported':>10} {'$':>8} {'exported':>10} {'$':>8}")
316
+ print("-" * 54)
317
+ for b in bill.buckets:
318
+ print(
319
+ f"{b.period!s:<11} {b.imported:>10.3f} {b.import_charge:>8.2f} "
320
+ f"{b.exported:>10.3f} {b.export_credit:>8.2f}"
321
+ )
322
+ print("-" * 54)
323
+ print(
324
+ f"{'totals':<11} {bill.imported_kwh:>10.3f} {bill.energy_charges:>8.2f} "
325
+ f"{bill.exported_kwh:>10.3f} {bill.export_credits:>8.2f}"
326
+ )
327
+
328
+ print("\ncharges")
329
+ for name, amount in sorted(bill.import_components.items(), key=lambda kv: -abs(kv[1])):
330
+ print(f" {name:<34} {amount:>+9.2f}")
331
+ print("\ncredits")
332
+ for name, amount in sorted(bill.export_components.items(), key=lambda kv: -abs(kv[1])):
333
+ print(f" {name:<34} {amount:>+9.2f}")
334
+ print("\nfixed")
335
+ for name, amount in bill.fixed_components.items():
336
+ print(f" {name:<34} {amount:>+9.2f}")
337
+
338
+ print(f"\n {'TOTAL':<34} {bill.total:>+9.2f}")
339
+ if bill.effective_import_rate:
340
+ print(f" {'effective $/kWh imported':<34} {bill.effective_import_rate:>9.5f}")
341
+ for warning in bill.warnings:
342
+ print(f"\n warning: {warning}")
343
+ if not bill.complete:
344
+ print(" note: some prices were incomplete or inexact; treat the total as an estimate")
345
+
346
+
347
+ def _account_repository() -> Any:
348
+ from .account import NamedProfileRepository
349
+
350
+ return NamedProfileRepository()
351
+
352
+
353
+ def _selected_profile_name(args: Any) -> str | None:
354
+ explicit = cast(str | None, getattr(args, "account", None))
355
+ config_path = getattr(args, "config", None)
356
+ if explicit is not None and config_path is not None:
357
+ raise ConfigError("--account cannot be combined with --config")
358
+ if explicit is not None:
359
+ return explicit
360
+ if config_path is not None:
361
+ return None
362
+ from .account import configured_profile_name
363
+
364
+ return configured_profile_name()
365
+
366
+
367
+ def _pricing_context(args: Any) -> tuple[Any, Config | None, str | None, Any | None]:
368
+ """Load either a stateless Config engine or a named profile engine."""
369
+ from .account import AccountRateEngine
370
+
371
+ profile_name = _selected_profile_name(args)
372
+ if profile_name is not None:
373
+ repository = _account_repository()
374
+ profile = repository.load(profile_name)
375
+ return AccountRateEngine(profile), None, profile_name, repository
376
+ config = Config.load(args.config)
377
+ return RateEngine(config), config, None, None
378
+
379
+
380
+ def _print_profile(profile: Any, *, json_output: bool) -> None:
381
+ from .account.cli import profile_summary
382
+
383
+ if json_output:
384
+ print(json.dumps(profile.to_dict(), indent=2, default=str))
385
+ return
386
+ summary = profile_summary(profile)
387
+ print(f"name: {summary['name']}")
388
+ if summary["credential_set"]:
389
+ print(f"credential set: {summary['credential_set']}")
390
+ print("epochs")
391
+ for epoch in summary["epochs"]:
392
+ print(
393
+ f" {epoch['effective']} {epoch['tariff']} / {epoch['supplier']}"
394
+ + (f" {epoch['note']}" if epoch["note"] else "")
395
+ )
396
+ print(f"observations: {summary['observations']}")
397
+
398
+
399
+ def _run_account_command(args: Any) -> int:
400
+ from .account.cli import (
401
+ config_changes,
402
+ import_statements,
403
+ init_profile,
404
+ sync_profile,
405
+ update_profile,
406
+ )
407
+
408
+ repository = _account_repository()
409
+ command = args.account_command
410
+ if command == "list":
411
+ names = repository.names()
412
+ if args.json:
413
+ print(json.dumps(list(names), indent=2))
414
+ else:
415
+ for name in names:
416
+ print(name)
417
+ return 0
418
+
419
+ if command == "init":
420
+ profile = init_profile(
421
+ repository,
422
+ args.name,
423
+ config_path=args.config,
424
+ config_json=args.config_json,
425
+ effective=args.effective,
426
+ credential_set=args.credential_set,
427
+ audit_path=args.audit_file,
428
+ )
429
+ _print_profile(profile, json_output=args.json)
430
+ return 0
431
+
432
+ profile = repository.load(args.name)
433
+ if command == "show":
434
+ _print_profile(profile, json_output=args.json)
435
+ return 0
436
+
437
+ if command == "history":
438
+ if args.json:
439
+ print(json.dumps(profile.to_dict(), indent=2, default=str))
440
+ else:
441
+ _print_profile(profile, json_output=False)
442
+ for index, observation in enumerate(profile.observations, start=1):
443
+ agreements = ", ".join(
444
+ f"{agreement.period.start}..{agreement.period.end} "
445
+ f"{agreement.tariff or 'unknown'}"
446
+ for agreement in observation.agreements
447
+ )
448
+ print(f"evidence {index}: {agreements}")
449
+ return 0
450
+
451
+ if command == "update":
452
+ changes = config_changes(args)
453
+ updated = update_profile(
454
+ repository,
455
+ args.name,
456
+ effective=args.effective,
457
+ config_path=args.config,
458
+ config_json=args.config_json,
459
+ changes=changes,
460
+ note=args.note,
461
+ credential_set=args.credential_set,
462
+ apply=args.apply,
463
+ )
464
+ if args.json:
465
+ print(
466
+ json.dumps(
467
+ {"profile": updated.to_dict(), "applied": args.apply},
468
+ indent=2,
469
+ default=str,
470
+ )
471
+ )
472
+ else:
473
+ print(f"updated {args.name} at {args.effective}")
474
+ if not args.apply:
475
+ print("preview only; pass --apply to save")
476
+ return 0
477
+
478
+ if command == "import-statement":
479
+ _updated, proposals = import_statements(
480
+ repository,
481
+ args.name,
482
+ args.pdf,
483
+ apply=args.apply,
484
+ )
485
+ payload = {"profile": args.name, "applied": args.apply, "proposals": proposals}
486
+ if args.json:
487
+ print(json.dumps(payload, indent=2, default=str))
488
+ else:
489
+ for path, proposal in zip(args.pdf, proposals, strict=True):
490
+ print(f"{path.name}:")
491
+ proposal_changes = cast(list[dict[str, Any]], proposal["changes"])
492
+ if proposal_changes:
493
+ for change in proposal_changes:
494
+ print(
495
+ f" {change['outcome'].upper()} {change['effective']} {change['field']}"
496
+ )
497
+ else:
498
+ print(" no account changes")
499
+ if not args.apply:
500
+ print("preview only; pass --apply to save")
501
+ return 0
502
+
503
+ if command == "sync":
504
+ _updated, proposals = sync_profile(
505
+ repository,
506
+ args.name,
507
+ since=args.since,
508
+ apply=args.apply,
509
+ keep_statements=args.keep_statements,
510
+ config_path=args.config,
511
+ )
512
+ payload = {"profile": args.name, "applied": args.apply, "proposals": proposals}
513
+ if args.json:
514
+ print(json.dumps(payload, indent=2, default=str))
515
+ else:
516
+ print(f"received {len(proposals)} statement update(s)")
517
+ for proposal in proposals:
518
+ proposal_changes = cast(list[dict[str, Any]], proposal["changes"])
519
+ print(f" {len(proposal_changes)} change(s)" + ("" if args.apply else " (preview)"))
520
+ if not args.apply:
521
+ print("preview only; pass --apply to save")
522
+ return 0
523
+
524
+ if command == "export":
525
+ raw = json.dumps(profile.to_dict(), ensure_ascii=False, sort_keys=True, indent=2) + "\n"
526
+ if args.output is None:
527
+ print(raw, end="")
528
+ else:
529
+ args.output.write_text(raw, encoding="utf-8")
530
+ args.output.chmod(0o600)
531
+ if args.json:
532
+ print(json.dumps({"profile": args.name, "output": str(args.output)}))
533
+ return 0
534
+
535
+ if command == "source":
536
+ from .account.cli import meter_source_summary, set_meter_source
537
+
538
+ if args.source_command == "show":
539
+ summary = meter_source_summary(profile, args.provider)
540
+ if args.json:
541
+ print(json.dumps(summary, indent=2))
542
+ elif summary["configured"]:
543
+ print(f"profile: {summary['profile']}")
544
+ print(f"source: {summary['source']}")
545
+ print(f"grid import: {summary['grid_import_entity']}")
546
+ print(f"grid export: {summary['grid_export_entity']}")
547
+ else:
548
+ print(f"profile: {summary['profile']}")
549
+ print(f"source: {summary['source']}")
550
+ print("not configured; the source default will be used")
551
+ return 0
552
+
553
+ updated = set_meter_source(
554
+ repository,
555
+ args.name,
556
+ provider=args.provider,
557
+ grid_import_entity=args.grid_import_entity,
558
+ grid_export_entity=args.grid_export_entity,
559
+ apply=args.apply,
560
+ )
561
+ summary = meter_source_summary(updated, args.provider)
562
+ payload = {**summary, "applied": args.apply}
563
+ if args.json:
564
+ print(json.dumps(payload, indent=2))
565
+ else:
566
+ print(f"profile: {args.name}")
567
+ print(f"source: {args.provider}")
568
+ print(f"grid import: {summary['grid_import_entity']}")
569
+ print(f"grid export: {summary['grid_export_entity']}")
570
+ if not args.apply:
571
+ print("preview only; pass --apply to save")
572
+ return 0
573
+
574
+ raise AssertionError(f"unhandled account command {command}")
575
+
576
+
577
+ def _mqtt_settings(args: Any, *, config: Config | None, profile_name: str | None) -> Any:
578
+ """Build MQTT settings, keeping ``--config``'s stateless choice authoritative.
579
+
580
+ ``MqttSettings.load`` independently falls back to a configured default
581
+ profile (from the config file's ``[account]`` table or the
582
+ ``TARIFFKIT_ACCOUNT``/``TARIFFKIT_PROFILE`` environment variables) whenever
583
+ it is not told a profile explicitly. That fallback is correct for callers
584
+ of ``MqttSettings.load`` directly, but here ``_pricing_context`` already
585
+ resolved precedence: a non-``None`` ``config`` means the caller chose
586
+ ``--config`` and no profile should apply, even if one is configured
587
+ elsewhere. ``profile=None`` is indistinguishable from "not specified" once
588
+ it reaches ``load``'s override handling, so that decision has to be
589
+ re-asserted here instead.
590
+ """
591
+ from dataclasses import replace
592
+
593
+ from .mqtt import MqttSettings
594
+
595
+ settings = MqttSettings.load(
596
+ config_path=args.config,
597
+ broker=args.broker,
598
+ port=args.port,
599
+ username=args.username,
600
+ topic_prefix=args.topic_prefix,
601
+ discovery=args.discovery,
602
+ forecast_hours=args.forecast_hours,
603
+ tls=args.tls,
604
+ profile=profile_name,
605
+ )
606
+ if config is not None and settings.profile is not None:
607
+ settings = replace(settings, profile=None)
608
+ return settings
609
+
610
+
611
+ def main(argv: list[str] | None = None) -> int:
612
+ parser = build_parser()
613
+ args = parser.parse_args(argv)
614
+
615
+ logging.basicConfig(
616
+ level=logging.INFO if args.verbose else logging.WARNING,
617
+ format="%(asctime)s %(levelname)s %(message)s",
618
+ )
619
+
620
+ try:
621
+ if args.command == "credentials":
622
+ if args.credential_command == "set":
623
+ value = getpass.getpass(f"{args.name}: ")
624
+ if args.credential_set:
625
+ set_named_secret(args.credential_set, args.name, value)
626
+ else:
627
+ set_secret(args.name, value)
628
+ print(f"stored {args.name}")
629
+ elif args.credential_command == "delete":
630
+ if args.credential_set:
631
+ delete_named_secret(args.credential_set, args.name)
632
+ else:
633
+ delete_secret(args.name)
634
+ print(f"deleted {args.name}")
635
+ else:
636
+ names = (
637
+ configured_named_secrets(args.credential_set)
638
+ if args.credential_set
639
+ else configured_secrets()
640
+ )
641
+ for name in names:
642
+ print(name)
643
+ return 0
644
+
645
+ if args.command == "account":
646
+ return _run_account_command(args)
647
+
648
+ engine, config, profile_name, profile_repository = _pricing_context(args)
649
+
650
+ if args.command == "now":
651
+ point = engine.price_now()
652
+ print(json.dumps(point.to_dict(), indent=2) if args.json else _format_point(point))
653
+ return 0
654
+
655
+ if args.command == "forecast":
656
+ start = to_pacific(args.start) if args.start else None
657
+ curve = engine.forecast(hours=args.hours, start=start)
658
+ if args.format == "json":
659
+ print(json.dumps(curve.to_dict(), indent=2))
660
+ elif args.format == "csv":
661
+ _write_csv(curve, sys.stdout)
662
+ else:
663
+ _print_table(curve)
664
+ return 0
665
+
666
+ if args.command == "info":
667
+ info = dict(engine.describe())
668
+ if hasattr(engine, "export_rates"):
669
+ info["exact_through"] = engine.export_rates.exact_through
670
+ info["daily_fixed_charge"] = engine.daily_fixed_charge()
671
+ print(json.dumps(info, indent=2, default=str))
672
+ return 0
673
+
674
+ if args.command == "bill":
675
+ from .billing import BillEngine, BillingPeriod
676
+ from .sources import read_green_button
677
+
678
+ account_profile = None
679
+ if profile_name is not None:
680
+ from .account import AccountRateEngine
681
+
682
+ if not isinstance(engine, AccountRateEngine):
683
+ raise AssertionError("selected account did not produce an account rate engine")
684
+ account_profile = engine.profile
685
+
686
+ note = ""
687
+ if args.source == "ha":
688
+ from .sources import HaSettings, describe_resolution, read_statistics
689
+
690
+ if not (args.start and args.end):
691
+ raise ConfigError("--source ha requires --start and --end")
692
+ ha_settings = HaSettings.load(
693
+ config_path=args.config,
694
+ profile_source=(
695
+ account_profile.meter_sources.ha if account_profile is not None else None
696
+ ),
697
+ import_entity=args.ha_import_entity,
698
+ export_entity=args.ha_export_entity,
699
+ )
700
+ readings = read_statistics(
701
+ ha_settings,
702
+ _midnight(args.start),
703
+ _midnight(args.end) + timedelta(days=1),
704
+ resolution=args.ha_resolution,
705
+ )
706
+ note = f" source: Home Assistant statistics ({describe_resolution(readings)})"
707
+ elif args.source == "influx":
708
+ from .sources import InfluxSettings, read_counters
709
+
710
+ if not (args.start and args.end):
711
+ raise ConfigError("--source influx requires --start and --end")
712
+ influx_settings = InfluxSettings.load(
713
+ config_path=args.config,
714
+ profile_source=(
715
+ account_profile.meter_sources.influx
716
+ if account_profile is not None
717
+ else None
718
+ ),
719
+ import_entity=args.influx_import_entity,
720
+ export_entity=args.influx_export_entity,
721
+ )
722
+ step = timedelta(minutes=args.influx_resolution)
723
+ readings = read_counters(
724
+ influx_settings,
725
+ _midnight(args.start),
726
+ _midnight(args.end) + timedelta(days=1),
727
+ step,
728
+ )
729
+ note = (
730
+ f" source: InfluxDB counters ({len(readings)} x {args.influx_resolution}min; "
731
+ f"totals are exact, distribution follows sample density)"
732
+ )
733
+ elif args.csv is None:
734
+ raise ConfigError(
735
+ "give a Green Button CSV path, or use --source ha or --source influx"
736
+ )
737
+ else:
738
+ readings = read_green_button(sys.stdin if str(args.csv) == "-" else args.csv)
739
+ note = f" source: Green Button CSV ({len(readings)} intervals)"
740
+
741
+ period = (
742
+ BillingPeriod(args.start, args.end)
743
+ if args.start and args.end
744
+ else BillingPeriod.from_readings(readings)
745
+ )
746
+ if profile_name is not None:
747
+ from .billing.engine import compute_segments
748
+
749
+ assert account_profile is not None
750
+ result = compute_segments(
751
+ account_profile.segments_for(period),
752
+ readings,
753
+ check=args.check,
754
+ )
755
+ else:
756
+ result = BillEngine(engine).compute(readings, period, check=args.check)
757
+ if args.json:
758
+ print(json.dumps(result.to_dict(), indent=2))
759
+ else:
760
+ _print_bill(result)
761
+ if note:
762
+ print(note)
763
+ return 0
764
+
765
+ if args.command == "mqtt":
766
+ from .mqtt import MqttPublisher
767
+
768
+ settings = _mqtt_settings(args, config=config, profile_name=profile_name)
769
+ publisher = MqttPublisher(engine, settings)
770
+ if args.once:
771
+ publisher.connect()
772
+ try:
773
+ publisher.publish_now()
774
+ finally:
775
+ publisher.close()
776
+ else:
777
+ publisher.run_forever()
778
+ return 0
779
+
780
+ if args.command == "serve":
781
+ import uvicorn
782
+
783
+ from .web import create_app
784
+
785
+ uvicorn.run(
786
+ create_app(
787
+ config,
788
+ profile_name=profile_name,
789
+ profile_repository=profile_repository,
790
+ config_path=args.config,
791
+ ),
792
+ host=args.host,
793
+ port=args.port,
794
+ )
795
+ return 0
796
+
797
+ except TariffKitError as exc:
798
+ print(f"error: {exc}", file=sys.stderr)
799
+ return 1
800
+ except KeyboardInterrupt: # pragma: no cover
801
+ return 130
802
+
803
+ # Unreachable: argparse enforces `required=True` on the subparser, so an
804
+ # unknown command exits before we get here.
805
+ raise AssertionError(f"unhandled command {args.command}")
806
+
807
+
808
+ if __name__ == "__main__": # pragma: no cover
809
+ sys.exit(main())