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,61 @@
1
+ """Rate facade that resolves account history for every priced timestamp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_right
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from ..engine import RateEngine
9
+ from ..models import PriceCurve, PricePoint
10
+ from ..timeutil import hour_floor, now_pacific, to_pacific
11
+ from .model import AccountProfile
12
+
13
+
14
+ class AccountRateEngine:
15
+ """Price a profile while selecting its epoch independently for each moment."""
16
+
17
+ def __init__(self, profile: AccountProfile) -> None:
18
+ self.profile = profile
19
+ self._engines: dict[int, RateEngine] = {}
20
+
21
+ def _engine_at(self, moment: datetime) -> RateEngine:
22
+ pacific_date = to_pacific(moment).date()
23
+ index = bisect_right(self.profile.effective_dates, pacific_date) - 1
24
+ if index < 0:
25
+ self.profile.config_at(moment)
26
+ raise AssertionError("config_at should have raised for prehistory")
27
+ engine = self._engines.get(index)
28
+ if engine is None:
29
+ engine = RateEngine(self.profile.epochs[index].config)
30
+ self._engines[index] = engine
31
+ return engine
32
+
33
+ def price_at(self, moment: datetime) -> PricePoint:
34
+ """Price the clock hour containing ``moment`` under its active epoch."""
35
+ return self._engine_at(moment).price_at(moment)
36
+
37
+ def price_now(self) -> PricePoint:
38
+ return self.price_at(now_pacific())
39
+
40
+ def forecast(self, hours: int = 24, start: datetime | None = None) -> PriceCurve:
41
+ """Forecast across transitions without applying one config to all hours."""
42
+ if hours < 1:
43
+ raise ValueError("hours must be >= 1")
44
+ cursor = hour_floor(to_pacific(start) if start else now_pacific())
45
+ base = cursor.astimezone(UTC)
46
+ return PriceCurve(
47
+ tuple(self.price_at(base + timedelta(hours=offset)) for offset in range(hours))
48
+ )
49
+
50
+ def daily_fixed_charge(self, moment: datetime | None = None) -> float:
51
+ """Return the fixed charge from the epoch active at ``moment``."""
52
+ when = moment or now_pacific()
53
+ return self._engine_at(when).daily_fixed_charge(when)
54
+
55
+ def describe(self, moment: datetime | None = None) -> dict[str, object]:
56
+ """Return rate provenance plus the active profile epoch."""
57
+ when = moment or now_pacific()
58
+ description = self._engine_at(when).describe(when)
59
+ description["account_profile"] = self.profile.name or None
60
+ description["account_effective"] = self.profile.config_at(when).to_dict()
61
+ return description
@@ -0,0 +1,329 @@
1
+ """Private, versioned JSON storage for named account profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import tempfile
12
+ import threading
13
+ import tomllib
14
+ from collections.abc import Iterator, Mapping
15
+ from contextlib import contextmanager, suppress
16
+ from pathlib import Path
17
+
18
+ from ..config import default_config_path
19
+ from ..errors import TariffKitError
20
+ from .errors import (
21
+ ProfileConflictError,
22
+ ProfileNameError,
23
+ ProfileNotFoundError,
24
+ ProfileStorageError,
25
+ )
26
+ from .model import AccountProfile
27
+
28
+ _NAME = re.compile(r"^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$")
29
+ _MODE_DIR = 0o700
30
+ _MODE_FILE = 0o600
31
+ _THREAD_LOCKS: dict[Path, threading.Lock] = {}
32
+ _THREAD_LOCKS_GUARD = threading.Lock()
33
+
34
+
35
+ def validate_profile_name(name: str) -> str:
36
+ """Validate a profile slug before it participates in path construction."""
37
+ if not isinstance(name, str) or _NAME.fullmatch(name) is None:
38
+ raise ProfileNameError("profile name must be a lowercase slug")
39
+ return name
40
+
41
+
42
+ def configured_profile_name(config_path: str | Path | None = None) -> str | None:
43
+ """Return the configured default profile without loading any credentials.
44
+
45
+ Profile selection is deliberately kept separate from ``Config`` parsing:
46
+ the main TOML file may contain integration sections that are not pricing
47
+ fields, while a stateless ``Config`` must continue to reject unknown
48
+ pricing keys.
49
+ """
50
+ for environment_name in ("TARIFFKIT_ACCOUNT", "TARIFFKIT_PROFILE"):
51
+ if value := os.environ.get(environment_name):
52
+ return validate_profile_name(value)
53
+
54
+ path = Path(config_path) if config_path is not None else default_config_path()
55
+ if not path.is_file():
56
+ return None
57
+ try:
58
+ with path.open("rb") as handle:
59
+ table = tomllib.load(handle)
60
+ except (OSError, tomllib.TOMLDecodeError) as exc:
61
+ raise ProfileStorageError(f"could not read profile selection from {path}") from exc
62
+
63
+ account = table.get("account")
64
+ candidates: list[object] = []
65
+ if isinstance(account, Mapping):
66
+ candidates.extend(account.get(key) for key in ("default_profile", "profile", "default"))
67
+ elif account is not None:
68
+ candidates.append(account)
69
+ candidates.extend(table.get(key) for key in ("default_profile", "profile", "account_profile"))
70
+ for candidate in candidates:
71
+ if candidate is None:
72
+ continue
73
+ if not isinstance(candidate, str):
74
+ raise ProfileStorageError("configured profile name must be a string")
75
+ return validate_profile_name(candidate)
76
+ return None
77
+
78
+
79
+ def _config_home() -> Path:
80
+ value = os.environ.get("XDG_CONFIG_HOME")
81
+ return Path(value) if value else Path.home() / ".config"
82
+
83
+
84
+ def _json_bytes(profile: AccountProfile, *, name: str) -> bytes:
85
+ if profile.name not in ("", name):
86
+ raise ProfileStorageError(f"profile is named {profile.name!r}, not the requested {name!r}")
87
+ stored = (
88
+ profile
89
+ if profile.name == name
90
+ else AccountProfile(
91
+ epochs=profile.epochs,
92
+ name=name,
93
+ credential_set=profile.credential_set,
94
+ observations=profile.observations,
95
+ meter_sources=profile.meter_sources,
96
+ )
97
+ )
98
+ try:
99
+ payload = (
100
+ json.dumps(
101
+ stored.to_dict(),
102
+ ensure_ascii=False,
103
+ allow_nan=False,
104
+ sort_keys=True,
105
+ separators=(",", ":"),
106
+ ).encode("utf-8")
107
+ + b"\n"
108
+ )
109
+ except (TypeError, ValueError) as exc:
110
+ raise ProfileStorageError("profile contains values that are not JSON-compatible") from exc
111
+ return payload
112
+
113
+
114
+ def _revision(raw: bytes) -> str:
115
+ return hashlib.sha256(raw).hexdigest()
116
+
117
+
118
+ class NamedProfileRepository:
119
+ """Manage user-only profile files below the XDG configuration directory."""
120
+
121
+ def __init__(self, config_home: str | Path | None = None) -> None:
122
+ base = Path(config_home) if config_home is not None else _config_home()
123
+ self.directory = base / "tariffkit" / "accounts"
124
+ self._check_no_symlink_components(base)
125
+ self._ensure_directory()
126
+
127
+ def _check_no_symlink(self, path: Path) -> None:
128
+ if path.is_symlink():
129
+ raise ProfileStorageError(f"refusing symlink in profile path: {path}")
130
+
131
+ def _check_no_symlink_components(self, path: Path) -> None:
132
+ """Reject an escaping symlink anywhere below the configured root."""
133
+ current = Path(path.anchor) if path.is_absolute() else Path()
134
+ for part in path.parts:
135
+ if path.is_absolute() and part == path.anchor:
136
+ continue
137
+ current /= part
138
+ self._check_no_symlink(current)
139
+
140
+ def _ensure_directory(self) -> None:
141
+ try:
142
+ root = self.directory
143
+ base = root.parent.parent
144
+ self._check_no_symlink_components(base)
145
+ base.mkdir(mode=_MODE_DIR, parents=True, exist_ok=True)
146
+ for current in (root.parent, root):
147
+ self._check_no_symlink_components(current)
148
+ current.mkdir(mode=_MODE_DIR, exist_ok=True)
149
+ self._check_no_symlink_components(current)
150
+ current.chmod(_MODE_DIR)
151
+ except OSError as exc:
152
+ raise ProfileStorageError(f"could not create private profile directory {root}") from exc
153
+
154
+ def path_for(self, name: str) -> Path:
155
+ """Return a safe profile path after validating the name."""
156
+ validated = validate_profile_name(name)
157
+ self._check_private_directory()
158
+ path = self.directory / f"{validated}.json"
159
+ self._check_no_symlink_components(path)
160
+ return path
161
+
162
+ def names(self) -> tuple[str, ...]:
163
+ """List valid regular profile files, refusing unsafe entries."""
164
+ self._check_private_directory()
165
+ result: list[str] = []
166
+ for path in self.directory.iterdir():
167
+ self._check_no_symlink_components(path)
168
+ if path.suffix != ".json" or not path.is_file():
169
+ continue
170
+ name = path.stem
171
+ validate_profile_name(name)
172
+ result.append(name)
173
+ return tuple(sorted(result))
174
+
175
+ def load(self, name: str) -> AccountProfile:
176
+ path = self.path_for(name)
177
+ if not path.exists():
178
+ raise ProfileNotFoundError(f"profile {name!r} does not exist")
179
+ if path.is_symlink() or not path.is_file():
180
+ raise ProfileStorageError(f"profile path is not a regular file: {path}")
181
+ if stat.S_IMODE(path.stat().st_mode) != _MODE_FILE:
182
+ raise ProfileStorageError(f"profile {name!r} is not private (expected mode 0600)")
183
+ try:
184
+ raw = path.read_bytes()
185
+ parsed = json.loads(
186
+ raw.decode("utf-8"),
187
+ parse_constant=lambda value: (_ for _ in ()).throw(
188
+ ValueError(f"non-finite JSON value {value}")
189
+ ),
190
+ )
191
+ except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
192
+ raise ProfileStorageError(f"profile {name!r} is not valid JSON") from exc
193
+ if not isinstance(parsed, Mapping):
194
+ raise ProfileStorageError("profile JSON must contain an object")
195
+ try:
196
+ profile = AccountProfile.from_dict(parsed)
197
+ except (TariffKitError, TypeError, ValueError) as exc:
198
+ raise ProfileStorageError(f"profile {name!r} failed validation: {exc}") from exc
199
+ if profile.name != name:
200
+ raise ProfileStorageError(f"profile name does not match filename {name!r}")
201
+ profile = AccountProfile(
202
+ epochs=profile.epochs,
203
+ name=profile.name,
204
+ credential_set=profile.credential_set,
205
+ observations=profile.observations,
206
+ meter_sources=profile.meter_sources,
207
+ )
208
+ return _with_revision(profile, _revision(raw))
209
+
210
+ def save(
211
+ self,
212
+ name: str,
213
+ profile: AccountProfile,
214
+ *,
215
+ expected_revision: str | None = None,
216
+ ) -> AccountProfile:
217
+ """Atomically save a validated profile with optimistic concurrency."""
218
+ path = self.path_for(name)
219
+ replacement = _json_bytes(profile, name=name)
220
+ with self._profile_lock(name):
221
+ original = self._read_existing(path)
222
+ original_revision = None if original is None else _revision(original)
223
+ expected = expected_revision
224
+ if expected is None:
225
+ expected = getattr(profile, "_revision", None)
226
+ if original_revision != expected:
227
+ raise ProfileConflictError(f"profile {name!r} changed; reload it before saving")
228
+ fd, temporary_name = tempfile.mkstemp(
229
+ prefix=f".{name}.", suffix=".tmp", dir=self.directory
230
+ )
231
+ temporary = Path(temporary_name)
232
+ try:
233
+ os.fchmod(fd, _MODE_FILE)
234
+ with os.fdopen(fd, "wb") as handle:
235
+ handle.write(replacement)
236
+ handle.flush()
237
+ os.fsync(handle.fileno())
238
+ temporary.replace(path)
239
+ self._check_no_symlink(path)
240
+ path.chmod(_MODE_FILE)
241
+ self._fsync_directory()
242
+ except OSError as exc:
243
+ raise ProfileStorageError(f"could not save profile {name!r}") from exc
244
+ finally:
245
+ with suppress(FileNotFoundError):
246
+ temporary.unlink()
247
+ saved = AccountProfile(
248
+ epochs=profile.epochs,
249
+ name=name,
250
+ credential_set=profile.credential_set,
251
+ observations=profile.observations,
252
+ meter_sources=profile.meter_sources,
253
+ )
254
+ return _with_revision(saved, _revision(replacement))
255
+
256
+ def delete(self, name: str, *, expected_revision: str | None = None) -> None:
257
+ """Delete a profile only if its revision is the one the caller read."""
258
+ path = self.path_for(name)
259
+ with self._profile_lock(name):
260
+ original = self._read_existing(path)
261
+ if original is None:
262
+ raise ProfileNotFoundError(f"profile {name!r} does not exist")
263
+ revision = _revision(original)
264
+ if expected_revision is not None and revision != expected_revision:
265
+ raise ProfileConflictError(f"profile {name!r} changed; reload it before deleting")
266
+ try:
267
+ path.unlink()
268
+ self._fsync_directory()
269
+ except OSError as exc:
270
+ raise ProfileStorageError(f"could not delete profile {name!r}") from exc
271
+
272
+ @contextmanager
273
+ def _profile_lock(self, name: str) -> Iterator[None]:
274
+ """Serialize revision checks and mutations across threads and processes."""
275
+ lock_path = self.directory / f".{validate_profile_name(name)}.lock"
276
+ self._check_no_symlink_components(lock_path)
277
+ resolved = lock_path.resolve()
278
+ with _THREAD_LOCKS_GUARD:
279
+ thread_lock = _THREAD_LOCKS.setdefault(resolved, threading.Lock())
280
+ with thread_lock:
281
+ flags = os.O_CREAT | os.O_RDWR
282
+ if hasattr(os, "O_NOFOLLOW"):
283
+ flags |= os.O_NOFOLLOW
284
+ descriptor: int | None = None
285
+ try:
286
+ descriptor = os.open(lock_path, flags, _MODE_FILE)
287
+ os.fchmod(descriptor, _MODE_FILE)
288
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
289
+ raise ProfileStorageError(f"profile lock is not a regular file: {lock_path}")
290
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
291
+ yield
292
+ except OSError as exc:
293
+ raise ProfileStorageError(f"could not lock profile {name!r}") from exc
294
+ finally:
295
+ if descriptor is not None:
296
+ with suppress(OSError):
297
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
298
+ os.close(descriptor)
299
+
300
+ def _read_existing(self, path: Path) -> bytes | None:
301
+ self._check_no_symlink_components(path)
302
+ try:
303
+ return path.read_bytes() if path.exists() else None
304
+ except OSError as exc:
305
+ raise ProfileStorageError(f"could not read profile path {path}") from exc
306
+
307
+ def _check_private_directory(self) -> None:
308
+ self._check_no_symlink(self.directory)
309
+ try:
310
+ mode = stat.S_IMODE(self.directory.stat().st_mode)
311
+ except OSError as exc:
312
+ raise ProfileStorageError("could not inspect profile directory") from exc
313
+ if mode != _MODE_DIR:
314
+ raise ProfileStorageError("profile directory is not private (expected mode 0700)")
315
+
316
+ def _fsync_directory(self) -> None:
317
+ flags = os.O_RDONLY
318
+ if hasattr(os, "O_DIRECTORY"):
319
+ flags |= os.O_DIRECTORY
320
+ descriptor = os.open(self.directory, flags)
321
+ try:
322
+ os.fsync(descriptor)
323
+ finally:
324
+ os.close(descriptor)
325
+
326
+
327
+ def _with_revision(profile: AccountProfile, revision: str) -> AccountProfile:
328
+ object.__setattr__(profile, "_revision", revision)
329
+ return profile
@@ -0,0 +1,56 @@
1
+ """Bill computation from interval meter data.
2
+
3
+ Pure and dependency-free: readings in, decomposed charges out. See
4
+ ``tariffkit.billing.engine`` for what is deliberately out of scope.
5
+ """
6
+
7
+ from .engine import BillEngine, hourly
8
+ from .ledger import (
9
+ CreditBalances,
10
+ CreditBucket,
11
+ Ledger,
12
+ LedgerEntry,
13
+ apply_credits,
14
+ credits_earned,
15
+ run_ledger,
16
+ )
17
+ from .models import Bill, BillingPeriod, IntervalReading, UsageBucket
18
+ from .netting import check_coverage, find_gaps, find_overlaps, net_intervals
19
+ from .trueup import (
20
+ TrueUp,
21
+ TrueUpKind,
22
+ cash_out_periods,
23
+ mce_cash_out,
24
+ pge_true_up,
25
+ published_nsc_rate,
26
+ relevant_period_end,
27
+ run_true_ups,
28
+ )
29
+
30
+ __all__ = [
31
+ "Bill",
32
+ "BillEngine",
33
+ "BillingPeriod",
34
+ "CreditBalances",
35
+ "CreditBucket",
36
+ "IntervalReading",
37
+ "Ledger",
38
+ "LedgerEntry",
39
+ "TrueUp",
40
+ "TrueUpKind",
41
+ "UsageBucket",
42
+ "apply_credits",
43
+ "cash_out_periods",
44
+ "check_coverage",
45
+ "credits_earned",
46
+ "find_gaps",
47
+ "find_overlaps",
48
+ "hourly",
49
+ "mce_cash_out",
50
+ "net_intervals",
51
+ "pge_true_up",
52
+ "published_nsc_rate",
53
+ "relevant_period_end",
54
+ "run_ledger",
55
+ "run_true_ups",
56
+ ]