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,641 @@
1
+ """Provider-neutral account history and statement evidence models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from bisect import bisect_right
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from datetime import date, datetime, timedelta
12
+ from typing import Self
13
+
14
+ from ..billing import BillingPeriod
15
+ from ..billing.engine import Segment
16
+ from ..config import Config
17
+ from ..models import Supplier
18
+ from ..timeutil import to_pacific
19
+ from .errors import AccountError
20
+
21
+ _SCHEMA_VERSION = 1
22
+ SCHEMA_VERSION = _SCHEMA_VERSION
23
+ _DIGEST = re.compile(r"^[0-9a-fA-F]{64}$")
24
+ _SAFE_TEXT = re.compile(r"^[^\x00-\x1f\x7f]+$")
25
+ _MASKED_ACCOUNT = re.compile(r"^\*{4}\d{1,4}$")
26
+ _EXTRACTION_MODE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
27
+ _METER_ENTITY = re.compile(r"^[A-Za-z0-9_.]+$")
28
+
29
+
30
+ def _as_date(value: object, *, field_name: str) -> date:
31
+ if isinstance(value, datetime):
32
+ raise AccountError(f"{field_name} must be a date, not a datetime")
33
+ if isinstance(value, date):
34
+ return value
35
+ if isinstance(value, str):
36
+ try:
37
+ return date.fromisoformat(value)
38
+ except ValueError as exc:
39
+ raise AccountError(f"{field_name} must be an ISO date") from exc
40
+ raise AccountError(f"{field_name} must be an ISO date")
41
+
42
+
43
+ def _text(value: object, *, field_name: str, allow_empty: bool = False) -> str:
44
+ if not isinstance(value, str):
45
+ raise AccountError(f"{field_name} must be a non-empty string")
46
+ if not value:
47
+ if allow_empty:
48
+ return value
49
+ raise AccountError(f"{field_name} must be a non-empty string")
50
+ if not _SAFE_TEXT.fullmatch(value):
51
+ raise AccountError(f"{field_name} contains control characters")
52
+ return value
53
+
54
+
55
+ def _digest(value: object, *, field_name: str) -> str:
56
+ if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
57
+ raise AccountError(f"{field_name} must be a SHA-256 hex digest")
58
+ return value.lower()
59
+
60
+
61
+ def mask_account_digits(value: str) -> str:
62
+ """Return only a four-digit account suffix with the leading digits masked."""
63
+ if not isinstance(value, str) or not value.isdigit():
64
+ raise AccountError("account digits must contain only decimal digits")
65
+ digits = value
66
+ if not 1 <= len(digits) <= 4:
67
+ raise AccountError("account digits must contain at most four unmasked digits")
68
+ return f"****{digits}"
69
+
70
+
71
+ def _masked_account(value: object, *, field_name: str) -> str | None:
72
+ if value is None:
73
+ return None
74
+ if not isinstance(value, str) or _MASKED_ACCOUNT.fullmatch(value) is None:
75
+ raise AccountError(f"{field_name} must be masked as **** followed by at most four digits")
76
+ return value
77
+
78
+
79
+ def _validate_config_snapshot(config: Config) -> None:
80
+ if not isinstance(config, Config):
81
+ raise AccountError("each account epoch needs a Config snapshot")
82
+
83
+
84
+ def _meter_entity(value: object, *, field_name: str) -> str:
85
+ if not isinstance(value, str) or _METER_ENTITY.fullmatch(value) is None:
86
+ raise AccountError(
87
+ f"{field_name} must be a non-empty entity identifier containing only "
88
+ "letters, digits, underscores, and dots"
89
+ )
90
+ return value
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class MeterSource:
95
+ """The grid-import and grid-export entities for one meter provider."""
96
+
97
+ grid_import_entity: str
98
+ grid_export_entity: str
99
+
100
+ def __post_init__(self) -> None:
101
+ object.__setattr__(
102
+ self,
103
+ "grid_import_entity",
104
+ _meter_entity(self.grid_import_entity, field_name="grid_import_entity"),
105
+ )
106
+ object.__setattr__(
107
+ self,
108
+ "grid_export_entity",
109
+ _meter_entity(self.grid_export_entity, field_name="grid_export_entity"),
110
+ )
111
+
112
+ def to_dict(self) -> dict[str, str]:
113
+ return {
114
+ "grid_import_entity": self.grid_import_entity,
115
+ "grid_export_entity": self.grid_export_entity,
116
+ }
117
+
118
+ @classmethod
119
+ def from_dict(cls, raw: Mapping[str, object]) -> MeterSource:
120
+ _check_keys(raw, {"grid_import_entity", "grid_export_entity"}, "meter source")
121
+ if "grid_import_entity" not in raw or "grid_export_entity" not in raw:
122
+ raise AccountError(
123
+ "a meter source needs both grid_import_entity and grid_export_entity"
124
+ )
125
+ return cls(
126
+ grid_import_entity=_meter_entity(
127
+ raw["grid_import_entity"], field_name="grid_import_entity"
128
+ ),
129
+ grid_export_entity=_meter_entity(
130
+ raw["grid_export_entity"], field_name="grid_export_entity"
131
+ ),
132
+ )
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class MeterSources:
137
+ """Optional provider-specific meter mappings shared by all account epochs."""
138
+
139
+ ha: MeterSource | None = None
140
+ influx: MeterSource | None = None
141
+
142
+ def __post_init__(self) -> None:
143
+ for name in ("ha", "influx"):
144
+ value = getattr(self, name)
145
+ if value is not None and not isinstance(value, MeterSource):
146
+ raise AccountError(f"meter_sources.{name} must be a MeterSource")
147
+
148
+ @property
149
+ def home_assistant(self) -> MeterSource | None:
150
+ return self.ha
151
+
152
+ @property
153
+ def influxdb(self) -> MeterSource | None:
154
+ return self.influx
155
+
156
+ def to_dict(self) -> dict[str, object]:
157
+ return {
158
+ "ha": self.ha.to_dict() if self.ha is not None else None,
159
+ "influx": self.influx.to_dict() if self.influx is not None else None,
160
+ }
161
+
162
+ @classmethod
163
+ def from_dict(cls, raw: Mapping[str, object]) -> MeterSources:
164
+ _check_keys(raw, {"ha", "influx"}, "meter sources")
165
+ values: dict[str, MeterSource | None] = {}
166
+ for name in ("ha", "influx"):
167
+ value = raw.get(name)
168
+ if value is None:
169
+ values[name] = None
170
+ elif isinstance(value, Mapping):
171
+ values[name] = MeterSource.from_dict(value)
172
+ else:
173
+ raise AccountError(f"meter_sources.{name} must be an object or null")
174
+ return cls(ha=values["ha"], influx=values["influx"])
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class AccountEpoch:
179
+ """A complete configuration in force from ``effective`` onward."""
180
+
181
+ effective: date
182
+ config: Config
183
+ note: str = ""
184
+
185
+ def __post_init__(self) -> None:
186
+ object.__setattr__(self, "effective", _as_date(self.effective, field_name="effective"))
187
+ _validate_config_snapshot(self.config)
188
+ if not isinstance(self.note, str) or (
189
+ self.note and _SAFE_TEXT.fullmatch(self.note) is None
190
+ ):
191
+ raise AccountError("epoch note contains control characters")
192
+
193
+ def to_dict(self) -> dict[str, object]:
194
+ return {
195
+ "effective": self.effective.isoformat(),
196
+ "config": self.config.to_dict(),
197
+ "note": self.note,
198
+ }
199
+
200
+ @classmethod
201
+ def from_dict(cls, raw: Mapping[str, object]) -> AccountEpoch:
202
+ _check_keys(raw, {"effective", "config", "note"}, "epoch")
203
+ if "config" not in raw or "effective" not in raw:
204
+ raise AccountError("every account epoch needs effective and config")
205
+ config_value = raw["config"]
206
+ if not isinstance(config_value, Mapping):
207
+ raise AccountError("epoch config must be an object")
208
+ note_value = raw.get("note", "")
209
+ if not isinstance(note_value, str):
210
+ raise AccountError("epoch note must be a string")
211
+ return cls(
212
+ effective=_as_date(raw["effective"], field_name="effective"),
213
+ config=Config.from_dict(dict(config_value)),
214
+ note=note_value,
215
+ )
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class ObservedAgreement:
220
+ """Sanitized facts extracted from one service-agreement span."""
221
+
222
+ provider: str
223
+ statement_date: date
224
+ period: BillingPeriod
225
+ tariff: str | None = None
226
+ supplier: Supplier | None = None
227
+ cca_identity: str | None = None
228
+ baseline_territory: str | None = None
229
+ pcia_vintage: int | None = None
230
+ account_suffix: str | None = None
231
+ extraction_mode: str = "unknown"
232
+ source_digest: str | None = None
233
+
234
+ def __post_init__(self) -> None:
235
+ object.__setattr__(self, "provider", _text(self.provider, field_name="provider"))
236
+ object.__setattr__(
237
+ self,
238
+ "statement_date",
239
+ _as_date(self.statement_date, field_name="statement_date"),
240
+ )
241
+ if not isinstance(self.period, BillingPeriod):
242
+ raise AccountError("agreement period must be a BillingPeriod")
243
+ if self.tariff is not None:
244
+ object.__setattr__(self, "tariff", _text(self.tariff, field_name="tariff"))
245
+ if self.supplier is not None:
246
+ try:
247
+ object.__setattr__(self, "supplier", Supplier(self.supplier))
248
+ except ValueError as exc:
249
+ raise AccountError("agreement supplier is not supported") from exc
250
+ if self.cca_identity is not None:
251
+ object.__setattr__(
252
+ self, "cca_identity", _text(self.cca_identity, field_name="cca_identity")
253
+ )
254
+ if self.baseline_territory is not None:
255
+ object.__setattr__(
256
+ self,
257
+ "baseline_territory",
258
+ _text(self.baseline_territory, field_name="baseline_territory"),
259
+ )
260
+ if self.pcia_vintage is not None and (
261
+ not isinstance(self.pcia_vintage, int) or isinstance(self.pcia_vintage, bool)
262
+ ):
263
+ raise AccountError("pcia_vintage must be an integer")
264
+ object.__setattr__(
265
+ self,
266
+ "account_suffix",
267
+ _masked_account(self.account_suffix, field_name="account_suffix"),
268
+ )
269
+ if (
270
+ not isinstance(self.extraction_mode, str)
271
+ or _EXTRACTION_MODE.fullmatch(self.extraction_mode) is None
272
+ ):
273
+ raise AccountError(f"unsupported extraction_mode {self.extraction_mode!r}")
274
+ if self.source_digest is not None:
275
+ object.__setattr__(
276
+ self,
277
+ "source_digest",
278
+ _digest(self.source_digest, field_name="source_digest"),
279
+ )
280
+
281
+ def to_dict(self) -> dict[str, object]:
282
+ return {
283
+ "provider": self.provider,
284
+ "statement_date": self.statement_date.isoformat(),
285
+ "period": self.period.to_dict(),
286
+ "tariff": self.tariff,
287
+ "supplier": self.supplier.value if self.supplier is not None else None,
288
+ "cca_identity": self.cca_identity,
289
+ "baseline_territory": self.baseline_territory,
290
+ "pcia_vintage": self.pcia_vintage,
291
+ "account_suffix": self.account_suffix,
292
+ "extraction_mode": self.extraction_mode,
293
+ "source_digest": self.source_digest,
294
+ }
295
+
296
+ @classmethod
297
+ def from_dict(cls, raw: Mapping[str, object]) -> ObservedAgreement:
298
+ _check_keys(
299
+ raw,
300
+ {
301
+ "provider",
302
+ "statement_date",
303
+ "period",
304
+ "tariff",
305
+ "supplier",
306
+ "cca_identity",
307
+ "baseline_territory",
308
+ "pcia_vintage",
309
+ "account_suffix",
310
+ "extraction_mode",
311
+ "source_digest",
312
+ },
313
+ "observed agreement",
314
+ )
315
+ period_value = raw.get("period")
316
+ if not isinstance(period_value, Mapping):
317
+ raise AccountError("agreement period must be an object")
318
+ supplier_value = raw.get("supplier")
319
+ supplier = None if supplier_value is None else Supplier(str(supplier_value))
320
+ pcia_value = raw.get("pcia_vintage")
321
+ if pcia_value is not None and (
322
+ not isinstance(pcia_value, int) or isinstance(pcia_value, bool)
323
+ ):
324
+ raise AccountError("pcia_vintage must be an integer")
325
+ return cls(
326
+ provider=_text(raw.get("provider"), field_name="provider"),
327
+ statement_date=_as_date(raw.get("statement_date"), field_name="statement_date"),
328
+ period=BillingPeriod(
329
+ _as_date(period_value.get("start"), field_name="period.start"),
330
+ _as_date(period_value.get("end"), field_name="period.end"),
331
+ ),
332
+ tariff=None if raw.get("tariff") is None else str(raw["tariff"]),
333
+ supplier=supplier,
334
+ cca_identity=(None if raw.get("cca_identity") is None else str(raw["cca_identity"])),
335
+ baseline_territory=(
336
+ None if raw.get("baseline_territory") is None else str(raw["baseline_territory"])
337
+ ),
338
+ pcia_vintage=pcia_value,
339
+ account_suffix=(
340
+ None if raw.get("account_suffix") is None else str(raw["account_suffix"])
341
+ ),
342
+ extraction_mode=str(raw.get("extraction_mode", "unknown")),
343
+ source_digest=None if raw.get("source_digest") is None else str(raw["source_digest"]),
344
+ )
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class AccountObservation:
349
+ """Evidence kept separately from authoritative account epochs."""
350
+
351
+ agreements: tuple[ObservedAgreement, ...] = ()
352
+ source_digest: str | None = None
353
+ observed_at: date | None = None
354
+
355
+ def __post_init__(self) -> None:
356
+ agreements = tuple(self.agreements)
357
+ if not agreements:
358
+ raise AccountError("an account observation needs at least one agreement")
359
+ if any(not isinstance(agreement, ObservedAgreement) for agreement in agreements):
360
+ raise AccountError("observation agreements must be ObservedAgreement values")
361
+ object.__setattr__(self, "agreements", agreements)
362
+ if self.source_digest is not None:
363
+ object.__setattr__(
364
+ self,
365
+ "source_digest",
366
+ _digest(self.source_digest, field_name="source_digest"),
367
+ )
368
+ if self.observed_at is not None:
369
+ object.__setattr__(
370
+ self, "observed_at", _as_date(self.observed_at, field_name="observed_at")
371
+ )
372
+
373
+ def identity(self) -> tuple[str, ...]:
374
+ """Stable identifiers used to make importing the same evidence idempotent."""
375
+ digests = tuple(
376
+ agreement.source_digest
377
+ for agreement in self.agreements
378
+ if agreement.source_digest is not None
379
+ )
380
+ if self.source_digest is not None:
381
+ return (self.source_digest,)
382
+ if digests:
383
+ return tuple(sorted(digests))
384
+ canonical = json.dumps(
385
+ [agreement.to_dict() for agreement in self.agreements],
386
+ sort_keys=True,
387
+ separators=(",", ":"),
388
+ ).encode("utf-8")
389
+ return (f"facts:{hashlib.sha256(canonical).hexdigest()}",)
390
+
391
+ def to_dict(self) -> dict[str, object]:
392
+ return {
393
+ "agreements": [agreement.to_dict() for agreement in self.agreements],
394
+ "source_digest": self.source_digest,
395
+ "observed_at": self.observed_at.isoformat() if self.observed_at else None,
396
+ }
397
+
398
+ @classmethod
399
+ def from_dict(cls, raw: Mapping[str, object]) -> AccountObservation:
400
+ _check_keys(raw, {"agreements", "source_digest", "observed_at"}, "observation")
401
+ agreements_value = raw.get("agreements")
402
+ if not isinstance(agreements_value, list):
403
+ raise AccountError("observation agreements must be an array")
404
+ agreements = tuple(
405
+ ObservedAgreement.from_dict(value)
406
+ for value in agreements_value
407
+ if isinstance(value, Mapping)
408
+ )
409
+ if len(agreements) != len(agreements_value):
410
+ raise AccountError("observation agreements must contain objects")
411
+ return cls(
412
+ agreements=agreements,
413
+ source_digest=(None if raw.get("source_digest") is None else str(raw["source_digest"])),
414
+ observed_at=(
415
+ None
416
+ if raw.get("observed_at") is None
417
+ else _as_date(raw["observed_at"], field_name="observed_at")
418
+ ),
419
+ )
420
+
421
+
422
+ @dataclass(frozen=True, slots=True)
423
+ class AccountProfile:
424
+ """A named account's complete, effective-dated configuration history."""
425
+
426
+ epochs: tuple[AccountEpoch, ...]
427
+ name: str = ""
428
+ credential_set: str | None = None
429
+ observations: tuple[AccountObservation, ...] = ()
430
+ _revision: str | None = field(default=None, repr=False, compare=False)
431
+ meter_sources: MeterSources = field(default_factory=MeterSources)
432
+
433
+ def __post_init__(self) -> None:
434
+ epochs = tuple(self.epochs)
435
+ if not epochs:
436
+ raise AccountError("an account profile needs at least one epoch")
437
+ if any(not isinstance(epoch, AccountEpoch) for epoch in epochs):
438
+ raise AccountError("profile epochs must be AccountEpoch values")
439
+ dates = tuple(epoch.effective for epoch in epochs)
440
+ if dates != tuple(sorted(dates)):
441
+ raise AccountError("account epoch effective dates must be sorted")
442
+ if len(set(dates)) != len(dates):
443
+ raise AccountError("account epoch effective dates must be unique")
444
+ object.__setattr__(self, "epochs", epochs)
445
+ if self.name:
446
+ self._validate_slug(self.name)
447
+ if self.credential_set is not None:
448
+ self._validate_credential_set(self.credential_set)
449
+ observations = tuple(self.observations)
450
+ if any(not isinstance(observation, AccountObservation) for observation in observations):
451
+ raise AccountError("profile observations must be AccountObservation values")
452
+ unique: list[AccountObservation] = []
453
+ seen: dict[tuple[str, ...], AccountObservation] = {}
454
+ for observation in observations:
455
+ identity = observation.identity()
456
+ if identity:
457
+ previous = seen.get(identity)
458
+ if previous is not None:
459
+ if (
460
+ previous.source_digest != observation.source_digest
461
+ or previous.agreements != observation.agreements
462
+ ):
463
+ raise AccountError(
464
+ "profile observations with the same source identity conflict"
465
+ )
466
+ continue
467
+ seen[identity] = observation
468
+ unique.append(observation)
469
+ object.__setattr__(self, "observations", tuple(unique))
470
+ if not isinstance(self.meter_sources, MeterSources):
471
+ raise AccountError("profile meter_sources must be MeterSources")
472
+
473
+ @staticmethod
474
+ def _validate_slug(value: str) -> None:
475
+ if (
476
+ not isinstance(value, str)
477
+ or len(value) > 64
478
+ or re.fullmatch(r"[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?", value) is None
479
+ ):
480
+ raise AccountError("profile name must be a lowercase slug")
481
+
482
+ @staticmethod
483
+ def _validate_credential_set(value: str) -> None:
484
+ if (
485
+ not isinstance(value, str)
486
+ or len(value) > 64
487
+ or re.fullmatch(r"[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?", value) is None
488
+ ):
489
+ raise AccountError("credential_set must be a safe name")
490
+
491
+ @property
492
+ def effective_dates(self) -> tuple[date, ...]:
493
+ return tuple(epoch.effective for epoch in self.epochs)
494
+
495
+ @property
496
+ def revision(self) -> str | None:
497
+ """Storage assigns revisions; in-memory profiles do not have one."""
498
+ return self._revision
499
+
500
+ def config_at(self, moment: date | datetime) -> Config:
501
+ """Resolve the complete snapshot in force on a local Pacific date."""
502
+ if isinstance(moment, datetime):
503
+ target = to_pacific(moment).date()
504
+ else:
505
+ target = _as_date(moment, field_name="date")
506
+ index = bisect_right(self.effective_dates, target) - 1
507
+ if index < 0:
508
+ raise AccountError(
509
+ f"date {target} is before the first account epoch ({self.epochs[0].effective})"
510
+ )
511
+ return self.epochs[index].config
512
+
513
+ def epochs_in(self, period: BillingPeriod) -> tuple[AccountEpoch, ...]:
514
+ """Return epochs active at any point in an inclusive billing period."""
515
+ if not isinstance(period, BillingPeriod):
516
+ raise AccountError("period must be a BillingPeriod")
517
+ index = bisect_right(self.effective_dates, period.start) - 1
518
+ if index < 0:
519
+ raise AccountError(
520
+ f"period {period.start}..{period.end} is before the first account epoch "
521
+ f"({self.epochs[0].effective})"
522
+ )
523
+ return self.epochs[index : bisect_right(self.effective_dates, period.end)]
524
+
525
+ def segments_for(self, period: BillingPeriod) -> list[Segment]:
526
+ """Tile a billing period into segments priced by complete snapshots."""
527
+ applicable = self.epochs_in(period)
528
+ segments: list[Segment] = []
529
+ for index, epoch in enumerate(applicable):
530
+ start = max(period.start, epoch.effective)
531
+ next_start = (
532
+ applicable[index + 1].effective
533
+ if index + 1 < len(applicable)
534
+ else period.end + timedelta(days=1)
535
+ )
536
+ end = min(period.end, next_start - timedelta(days=1))
537
+ segments.append(Segment(epoch.config, BillingPeriod(start, end)))
538
+ return segments
539
+
540
+ def with_observation(self, observation: AccountObservation) -> Self:
541
+ """Add evidence without changing any authoritative epoch."""
542
+ if not isinstance(observation, AccountObservation):
543
+ raise AccountError("observation must be an AccountObservation")
544
+ return type(self)(
545
+ epochs=self.epochs,
546
+ name=self.name,
547
+ credential_set=self.credential_set,
548
+ observations=(*self.observations, observation),
549
+ _revision=self._revision,
550
+ meter_sources=self.meter_sources,
551
+ )
552
+
553
+ def to_dict(self) -> dict[str, object]:
554
+ """Return the complete versioned, JSON-compatible managed representation."""
555
+ return {
556
+ "schema_version": _SCHEMA_VERSION,
557
+ "name": self.name or None,
558
+ "credential_set": self.credential_set,
559
+ "epochs": [epoch.to_dict() for epoch in self.epochs],
560
+ "observations": [observation.to_dict() for observation in self.observations],
561
+ "meter_sources": self.meter_sources.to_dict(),
562
+ }
563
+
564
+ def to_json(self) -> str:
565
+ """Return the canonical versioned JSON form used by the repository."""
566
+ return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
567
+
568
+ @classmethod
569
+ def from_dict(cls, raw: Mapping[str, object]) -> AccountProfile:
570
+ _check_keys(
571
+ raw,
572
+ {
573
+ "schema_version",
574
+ "name",
575
+ "credential_set",
576
+ "epochs",
577
+ "observations",
578
+ "meter_sources",
579
+ },
580
+ "profile",
581
+ )
582
+ version = raw.get("schema_version")
583
+ if not isinstance(version, int) or isinstance(version, bool) or version != _SCHEMA_VERSION:
584
+ raise AccountError(f"unsupported account profile schema_version {version!r}")
585
+ epochs_value = raw.get("epochs")
586
+ if not isinstance(epochs_value, list):
587
+ raise AccountError("profile epochs must be an array")
588
+ epochs = tuple(
589
+ AccountEpoch.from_dict(value) for value in epochs_value if isinstance(value, Mapping)
590
+ )
591
+ if len(epochs) != len(epochs_value):
592
+ raise AccountError("profile epochs must contain objects")
593
+ observations_value = raw.get("observations", [])
594
+ if not isinstance(observations_value, list):
595
+ raise AccountError("profile observations must be an array")
596
+ observations = tuple(
597
+ AccountObservation.from_dict(value)
598
+ for value in observations_value
599
+ if isinstance(value, Mapping)
600
+ )
601
+ if len(observations) != len(observations_value):
602
+ raise AccountError("profile observations must contain objects")
603
+ name = raw.get("name") or ""
604
+ credential_set = raw.get("credential_set")
605
+ if credential_set is not None and not isinstance(credential_set, str):
606
+ raise AccountError("credential_set must be a string")
607
+ if "meter_sources" not in raw:
608
+ meter_sources = MeterSources()
609
+ elif isinstance(meter_sources_value := raw["meter_sources"], Mapping):
610
+ meter_sources = MeterSources.from_dict(meter_sources_value)
611
+ else:
612
+ raise AccountError("profile meter_sources must be an object")
613
+ return cls(
614
+ epochs=epochs,
615
+ name=_text(name, field_name="name", allow_empty=True),
616
+ credential_set=credential_set,
617
+ observations=observations,
618
+ meter_sources=meter_sources,
619
+ )
620
+
621
+ @classmethod
622
+ def from_json(cls, raw: str) -> AccountProfile:
623
+ """Parse a versioned JSON profile without accepting non-finite values."""
624
+ try:
625
+ value = json.loads(
626
+ raw,
627
+ parse_constant=lambda token: (_ for _ in ()).throw(
628
+ ValueError(f"non-finite JSON value {token}")
629
+ ),
630
+ )
631
+ except (json.JSONDecodeError, ValueError) as exc:
632
+ raise AccountError("profile JSON is invalid") from exc
633
+ if not isinstance(value, Mapping):
634
+ raise AccountError("profile JSON must contain an object")
635
+ return cls.from_dict(value)
636
+
637
+
638
+ def _check_keys(raw: Mapping[str, object], allowed: set[str], label: str) -> None:
639
+ unknown = set(raw) - allowed
640
+ if unknown:
641
+ raise AccountError(f"unknown {label} keys: {sorted(unknown)}")