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,828 @@
1
+ """Turn validated PG&E statement evidence into safe account updates.
2
+
3
+ The statement parser establishes what PG&E printed. This module is the
4
+ provider boundary: it hashes the source PDF, keeps only masked account facts,
5
+ and never treats an unprinted account attribute as an inference.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, replace
15
+ from datetime import date, timedelta
16
+ from enum import StrEnum
17
+ from itertools import pairwise
18
+ from pathlib import Path
19
+ from typing import Final
20
+
21
+ from tariffkit.account import (
22
+ AccountEpoch,
23
+ AccountObservation,
24
+ AccountProfile,
25
+ ObservedAgreement,
26
+ mask_account_digits,
27
+ )
28
+ from tariffkit.account.errors import AccountError
29
+ from tariffkit.billing import BillingPeriod
30
+ from tariffkit.config import Config
31
+ from tariffkit.models import Supplier
32
+
33
+ from .statements import Statement, StatementAgreement, StatementError, read_statement
34
+
35
+ SUPPORTED_TARIFFS: Final[frozenset[str]] = frozenset({"E-ELEC", "E-TOU-C", "EV2-A"})
36
+ _DIGEST = re.compile(r"^[0-9a-fA-F]{64}$")
37
+ _PROVIDER = "pge"
38
+
39
+ type PdfSource = bytes | bytearray | memoryview | str | Path
40
+
41
+
42
+ class ReconciliationError(AccountError):
43
+ """Statement evidence cannot be reconciled safely."""
44
+
45
+
46
+ class RevisionMismatchError(ReconciliationError):
47
+ """The profile is not the revision used to create a change set."""
48
+
49
+
50
+ class ChangeOutcome(StrEnum):
51
+ """The semantic result for one observed account fact."""
52
+
53
+ ADD = "add"
54
+ CONFIRM = "confirm"
55
+ CONFLICT = "conflict"
56
+ MISSING_REQUIRED = "missing-required"
57
+
58
+
59
+ def _json_value(value: object) -> object:
60
+ if isinstance(value, date):
61
+ return value.isoformat()
62
+ if isinstance(value, Supplier):
63
+ return value.value
64
+ if isinstance(value, BillingPeriod):
65
+ return {"start": value.start.isoformat(), "end": value.end.isoformat()}
66
+ if isinstance(value, Mapping):
67
+ return {str(key): _json_value(item) for key, item in sorted(value.items(), key=str)}
68
+ if isinstance(value, (tuple, list)):
69
+ return [_json_value(item) for item in value]
70
+ return value
71
+
72
+
73
+ def _profile_revision(profile: AccountProfile) -> str | None:
74
+ return profile.revision
75
+
76
+
77
+ def _profile_fingerprint(profile: AccountProfile) -> str:
78
+ raw = json.dumps(
79
+ profile.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
80
+ ).encode("utf-8")
81
+ return hashlib.sha256(raw).hexdigest()
82
+
83
+
84
+ def _digest(value: str, *, label: str) -> str:
85
+ if _DIGEST.fullmatch(value) is None:
86
+ raise ReconciliationError(f"{label} must be a SHA-256 hex digest")
87
+ return value.lower()
88
+
89
+
90
+ def hash_pdf(source: PdfSource) -> str:
91
+ """Hash PDF bytes or a local PDF path without retaining its contents."""
92
+ if isinstance(source, bytes):
93
+ contents = source
94
+ elif isinstance(source, (bytearray, memoryview)):
95
+ contents = bytes(source)
96
+ else:
97
+ path = Path(source)
98
+ try:
99
+ contents = path.read_bytes()
100
+ except OSError as exc:
101
+ raise ReconciliationError(f"could not read statement PDF {path.name!r}") from exc
102
+ return hashlib.sha256(contents).hexdigest()
103
+
104
+
105
+ def normalize_cca_identity(printed: str) -> str:
106
+ """Normalize PG&E's marketing name to the identity used by rate cards."""
107
+ words = re.sub(r"[^a-z0-9]+", " ", printed.casefold()).split()
108
+ if not words:
109
+ raise ReconciliationError("PG&E printed an empty CCA identity")
110
+ normalized = " ".join(words)
111
+ if normalized in {"mce", "marin clean energy"}:
112
+ return "MCE"
113
+ return " ".join(word.upper() for word in words)
114
+
115
+
116
+ def _masked_suffix(value: str) -> str:
117
+ try:
118
+ return mask_account_digits(value)
119
+ except AccountError as exc:
120
+ raise ReconciliationError("PG&E printed an invalid account suffix") from exc
121
+
122
+
123
+ def _validated_digest(
124
+ statement: Statement,
125
+ *,
126
+ pdf: PdfSource | None,
127
+ pdf_sha256: str | None,
128
+ ) -> str:
129
+ if pdf is not None and pdf_sha256 is not None:
130
+ raise ReconciliationError("provide either pdf or pdf_sha256, not both")
131
+ if pdf_sha256 is not None:
132
+ return _digest(pdf_sha256, label="pdf_sha256")
133
+ if pdf is not None:
134
+ return hash_pdf(pdf)
135
+ if statement.source:
136
+ candidate = Path(statement.source)
137
+ if candidate.is_file():
138
+ return hash_pdf(candidate)
139
+ raise ReconciliationError(
140
+ "a PDF SHA-256 is required; pass pdf=, pdf_sha256=, or a statement source path"
141
+ )
142
+
143
+
144
+ def _statement_is_valid(statement: Statement) -> None:
145
+ problems = statement.self_check()
146
+ if problems:
147
+ raise StatementError(
148
+ f"{statement.source or 'statement'} failed its self-check ({len(problems)} problems)"
149
+ )
150
+ if not statement.agreements:
151
+ raise StatementError("statement has no exact PG&E service-agreement spans")
152
+
153
+
154
+ def _agreement_suffix(agreement: StatementAgreement, statement: Statement) -> str | None:
155
+ agreement_suffix = (
156
+ _masked_suffix(agreement.account_masked) if agreement.account_masked else None
157
+ )
158
+ statement_suffix = (
159
+ _masked_suffix(statement.account_masked) if statement.account_masked else None
160
+ )
161
+ if agreement_suffix and statement_suffix and agreement_suffix != statement_suffix:
162
+ raise ReconciliationError("PG&E statement and agreement account suffixes disagree")
163
+ return agreement_suffix or statement_suffix
164
+
165
+
166
+ def observe_statement(
167
+ statement: Statement,
168
+ *,
169
+ pdf: PdfSource | None = None,
170
+ pdf_sha256: str | None = None,
171
+ observed_at: date | None = None,
172
+ ) -> AccountObservation:
173
+ """Normalize a validated statement into sanitized account evidence."""
174
+ _statement_is_valid(statement)
175
+ digest = _validated_digest(statement, pdf=pdf, pdf_sha256=pdf_sha256)
176
+ is_cca = bool(statement.cca_name or statement.cca_rate_schedule)
177
+ cca_identity = normalize_cca_identity(statement.cca_name) if statement.cca_name else None
178
+ mode = "ocr" if statement.recognised else "text"
179
+
180
+ normalized: list[ObservedAgreement] = []
181
+ for agreement in statement.agreements:
182
+ if agreement.tariff not in SUPPORTED_TARIFFS:
183
+ raise ReconciliationError(
184
+ f"unsupported PG&E tariff {agreement.tariff!r} in statement agreement"
185
+ )
186
+ normalized.append(
187
+ ObservedAgreement(
188
+ provider=_PROVIDER,
189
+ statement_date=statement.statement_date,
190
+ period=agreement.period,
191
+ tariff=agreement.tariff,
192
+ supplier=Supplier.CCA if is_cca else Supplier.BUNDLED,
193
+ cca_identity=cca_identity,
194
+ baseline_territory=agreement.baseline_territory or None,
195
+ pcia_vintage=agreement.pcia_vintage,
196
+ account_suffix=_agreement_suffix(agreement, statement),
197
+ extraction_mode=mode,
198
+ source_digest=digest,
199
+ )
200
+ )
201
+
202
+ ordered = sorted(normalized, key=_agreement_key)
203
+ if ordered[0].period.start != statement.period.start:
204
+ raise ReconciliationError(
205
+ "PG&E service-agreement evidence does not start at the statement's exact cycle start"
206
+ )
207
+ if ordered[-1].period.end != statement.period.end:
208
+ raise ReconciliationError(
209
+ "PG&E service-agreement evidence does not end at the statement's exact cycle end"
210
+ )
211
+ for previous, current in pairwise(ordered):
212
+ if current.period.start != previous.period.end + timedelta(days=1):
213
+ raise ReconciliationError(
214
+ "PG&E service-agreement evidence must cover the cycle without gaps or overlaps"
215
+ )
216
+
217
+ return AccountObservation(
218
+ agreements=tuple(ordered),
219
+ source_digest=digest,
220
+ observed_at=observed_at or statement.statement_date,
221
+ )
222
+
223
+
224
+ def import_statement(
225
+ path: str | Path,
226
+ *,
227
+ observed_at: date | None = None,
228
+ ) -> AccountObservation:
229
+ """Read and hash one local PG&E PDF, returning only sanitized evidence."""
230
+ source = Path(path)
231
+ return observe_statement(
232
+ read_statement(source),
233
+ pdf=source,
234
+ observed_at=observed_at,
235
+ )
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class AccountChange:
240
+ """One stable semantic difference between evidence and a profile."""
241
+
242
+ outcome: ChangeOutcome
243
+ effective: date | None
244
+ field: str
245
+ before: object = None
246
+ after: object = None
247
+ reason: str = ""
248
+
249
+ def __post_init__(self) -> None:
250
+ if not self.field or any(char in self.field for char in "\r\n"):
251
+ raise ReconciliationError("change field must be a single stable name")
252
+
253
+ def to_dict(self) -> dict[str, object]:
254
+ return {
255
+ "outcome": self.outcome.value,
256
+ "effective": self.effective.isoformat() if self.effective else None,
257
+ "field": self.field,
258
+ "before": _json_value(self.before),
259
+ "after": _json_value(self.after),
260
+ "reason": self.reason,
261
+ }
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class AccountChangeSet:
266
+ """A revision-bound, non-persistent account update proposal."""
267
+
268
+ profile_revision: str | None
269
+ observation: AccountObservation
270
+ changes: tuple[AccountChange, ...] = ()
271
+ proposed_epochs: tuple[AccountEpoch, ...] = ()
272
+ profile_fingerprint: str | None = None
273
+
274
+ @property
275
+ def can_apply(self) -> bool:
276
+ return not any(
277
+ change.outcome in (ChangeOutcome.CONFLICT, ChangeOutcome.MISSING_REQUIRED)
278
+ for change in self.changes
279
+ )
280
+
281
+ @property
282
+ def has_changes(self) -> bool:
283
+ return bool(self.changes)
284
+
285
+ def to_dict(self) -> dict[str, object]:
286
+ ordered = sorted(
287
+ self.changes,
288
+ key=lambda change: (
289
+ change.effective or date.min,
290
+ change.field,
291
+ change.outcome.value,
292
+ json.dumps(_json_value(change.after), sort_keys=True),
293
+ ),
294
+ )
295
+ return {
296
+ "profile_revision": self.profile_revision,
297
+ "changes": [change.to_dict() for change in ordered],
298
+ }
299
+
300
+ def json_diff(self) -> str:
301
+ """Return the canonical JSON semantic diff."""
302
+ return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
303
+
304
+ def to_json(self) -> str:
305
+ """Serialize the canonical JSON semantic diff."""
306
+ return self.json_diff()
307
+
308
+ def human_diff(self) -> str:
309
+ """Return a stable, line-oriented semantic diff for humans."""
310
+ ordered = sorted(
311
+ self.changes,
312
+ key=lambda change: (
313
+ change.effective or date.min,
314
+ change.field,
315
+ change.outcome.value,
316
+ json.dumps(_json_value(change.after), sort_keys=True),
317
+ change.reason,
318
+ ),
319
+ )
320
+ if not ordered:
321
+ return "No account changes."
322
+ lines: list[str] = []
323
+ for change in ordered:
324
+ when = change.effective.isoformat() if change.effective else "-"
325
+ before = json.dumps(_json_value(change.before), sort_keys=True)
326
+ after = json.dumps(_json_value(change.after), sort_keys=True)
327
+ line = f"{change.outcome.value.upper()} {when} {change.field}: {before} -> {after}"
328
+ if change.reason:
329
+ line += f" ({change.reason})"
330
+ lines.append(line)
331
+ return "\n".join(lines)
332
+
333
+ def apply(self, profile: AccountProfile) -> AccountProfile:
334
+ """Apply this proposal only to the exact profile it inspected."""
335
+ if profile.revision != self.profile_revision or (
336
+ self.profile_fingerprint is not None
337
+ and _profile_fingerprint(profile) != self.profile_fingerprint
338
+ ):
339
+ raise RevisionMismatchError(
340
+ "profile revision differs from the revision used for this proposal"
341
+ )
342
+ if not self.can_apply:
343
+ raise ReconciliationError(
344
+ "cannot apply a change set containing conflicts or missing values"
345
+ )
346
+ updated = AccountProfile(
347
+ epochs=self.proposed_epochs or profile.epochs,
348
+ name=profile.name,
349
+ credential_set=profile.credential_set,
350
+ observations=(*profile.observations, self.observation),
351
+ meter_sources=profile.meter_sources,
352
+ )
353
+ _validate_complete_profile(updated)
354
+ return updated
355
+
356
+
357
+ def _validate_complete_config(config: Config) -> tuple[str, ...]:
358
+ missing: list[str] = []
359
+ if config.supplier is Supplier.CCA:
360
+ if config.cca is None:
361
+ missing.extend(("cca", "cca.option", "cca.rate_card_or_generation_rates"))
362
+ else:
363
+ if not config.cca.option:
364
+ missing.append("cca.option")
365
+ if not config.cca.complete:
366
+ if not config.cca.generation_rates and config.cca.rate_card is None:
367
+ missing.append("cca.rate_card_or_generation_rates")
368
+ if config.cca.pcia_rate is None and config.cca.pcia_vintage is None:
369
+ missing.append("cca.pcia_rate_or_vintage")
370
+ if config.cca.franchise_fee_surcharge is None and config.cca.pcia_vintage is None:
371
+ missing.append("cca.franchise_fee_surcharge")
372
+ return tuple(missing)
373
+
374
+
375
+ def _config_fact(config: Config, field: str) -> object:
376
+ if field == "tariff":
377
+ return config.tariff
378
+ if field == "supplier":
379
+ return config.supplier
380
+ if field == "cca_identity":
381
+ return (
382
+ config.cca.name
383
+ if config.supplier is Supplier.CCA and config.cca and config.cca.name
384
+ else None
385
+ )
386
+ if field == "baseline_territory":
387
+ return config.baseline_territory
388
+ if field == "pcia_vintage":
389
+ return config.cca.pcia_vintage if config.supplier is Supplier.CCA and config.cca else None
390
+ raise ReconciliationError(f"unsupported observed field {field!r}")
391
+
392
+
393
+ def _observed_facts(agreement: ObservedAgreement) -> tuple[tuple[str, object], ...]:
394
+ facts: list[tuple[str, object]] = []
395
+ fields: tuple[str, ...] = ("tariff", "supplier", "cca_identity", "baseline_territory")
396
+ if agreement.supplier is Supplier.CCA:
397
+ fields += ("pcia_vintage",)
398
+ for field in fields:
399
+ value = getattr(agreement, field)
400
+ if value is not None:
401
+ facts.append((field, value))
402
+ return tuple(facts)
403
+
404
+
405
+ def _agreement_key(agreement: ObservedAgreement) -> tuple[date, date]:
406
+ return agreement.period.start, agreement.period.end
407
+
408
+
409
+ def _validate_observation_shape(
410
+ observation: AccountObservation,
411
+ *,
412
+ known_suffixes: tuple[str, ...] = (),
413
+ known_agreements: tuple[ObservedAgreement, ...] = (),
414
+ ) -> tuple[tuple[ObservedAgreement, ...], tuple[AccountChange, ...]]:
415
+ agreements = tuple(sorted(observation.agreements, key=_agreement_key))
416
+ changes: list[AccountChange] = []
417
+ for agreement in agreements:
418
+ if agreement.provider != _PROVIDER:
419
+ changes.append(
420
+ AccountChange(
421
+ ChangeOutcome.CONFLICT,
422
+ agreement.period.start,
423
+ "provider",
424
+ before=_PROVIDER,
425
+ after=agreement.provider,
426
+ reason="evidence belongs to a different provider",
427
+ )
428
+ )
429
+ if agreement.tariff not in SUPPORTED_TARIFFS:
430
+ changes.append(
431
+ AccountChange(
432
+ ChangeOutcome.CONFLICT,
433
+ agreement.period.start,
434
+ "tariff",
435
+ before=sorted(SUPPORTED_TARIFFS),
436
+ after=agreement.tariff,
437
+ reason="PG&E tariff is not supported by this adapter",
438
+ )
439
+ )
440
+ suffixes = sorted(
441
+ {agreement.account_suffix for agreement in agreements if agreement.account_suffix}
442
+ )
443
+ if len(suffixes) > 1:
444
+ changes.append(
445
+ AccountChange(
446
+ ChangeOutcome.CONFLICT,
447
+ None,
448
+ "account_suffix",
449
+ before=suffixes[0],
450
+ after=suffixes[1:],
451
+ reason="service-agreement spans print different account suffixes",
452
+ )
453
+ )
454
+ if suffixes and known_suffixes and set(suffixes).isdisjoint(known_suffixes):
455
+ changes.append(
456
+ AccountChange(
457
+ ChangeOutcome.CONFLICT,
458
+ None,
459
+ "account_suffix",
460
+ before=known_suffixes,
461
+ after=suffixes,
462
+ reason="statement account suffix differs from established profile evidence",
463
+ )
464
+ )
465
+
466
+ for previous, current in pairwise(agreements):
467
+ if current.period.start > previous.period.end + timedelta(days=1):
468
+ changes.append(
469
+ AccountChange(
470
+ ChangeOutcome.MISSING_REQUIRED,
471
+ current.period.start,
472
+ "agreement_period",
473
+ before=previous.period.end,
474
+ after=current.period.start,
475
+ reason="service-agreement spans are not contiguous",
476
+ )
477
+ )
478
+ elif current.period.start <= previous.period.end:
479
+ previous_facts = dict(_observed_facts(previous))
480
+ current_facts = dict(_observed_facts(current))
481
+ conflicting = sorted(
482
+ field
483
+ for field in set(previous_facts) & set(current_facts)
484
+ if previous_facts[field] != current_facts[field]
485
+ )
486
+ if conflicting:
487
+ changes.append(
488
+ AccountChange(
489
+ ChangeOutcome.CONFLICT,
490
+ current.period.start,
491
+ "agreement_overlap",
492
+ before={field: previous_facts[field] for field in conflicting},
493
+ after={field: current_facts[field] for field in conflicting},
494
+ reason="overlapping spans print contradictory facts",
495
+ )
496
+ )
497
+ for current in agreements:
498
+ for previous in known_agreements:
499
+ if (
500
+ current.period.start <= previous.period.end
501
+ and previous.period.start <= current.period.end
502
+ ):
503
+ previous_facts = dict(_observed_facts(previous))
504
+ current_facts = dict(_observed_facts(current))
505
+ conflicting = sorted(
506
+ field
507
+ for field in set(previous_facts) & set(current_facts)
508
+ if previous_facts[field] != current_facts[field]
509
+ )
510
+ if conflicting:
511
+ changes.append(
512
+ AccountChange(
513
+ ChangeOutcome.CONFLICT,
514
+ current.period.start,
515
+ "agreement_overlap",
516
+ before={field: previous_facts[field] for field in conflicting},
517
+ after={field: current_facts[field] for field in conflicting},
518
+ reason="statement overlaps contradictory established evidence",
519
+ )
520
+ )
521
+ return agreements, tuple(changes)
522
+
523
+
524
+ def _merge_observed(config: Config, agreement: ObservedAgreement) -> tuple[Config, tuple[str, ...]]:
525
+ """Merge only facts printed by the agreement into an existing snapshot."""
526
+ changes: dict[str, object] = {}
527
+ if agreement.tariff is not None:
528
+ changes["tariff"] = agreement.tariff
529
+ if agreement.supplier is Supplier.BUNDLED:
530
+ changes["supplier"] = Supplier.BUNDLED
531
+ changes["cca"] = None
532
+ elif agreement.supplier is Supplier.CCA:
533
+ if config.cca is None:
534
+ return config, ("cca", "cca.rate_card_or_generation_rates")
535
+ cca = config.cca
536
+ if not cca.option:
537
+ return config, ("cca.option",)
538
+ if not cca.generation_rates and cca.rate_card is None:
539
+ return config, ("cca.rate_card_or_generation_rates",)
540
+ if agreement.cca_identity is not None and not cca.name:
541
+ cca = replace(cca, name=agreement.cca_identity)
542
+ if agreement.pcia_vintage is not None:
543
+ cca = replace(cca, pcia_vintage=agreement.pcia_vintage)
544
+ changes["supplier"] = Supplier.CCA
545
+ changes["cca"] = cca
546
+ if agreement.baseline_territory is not None:
547
+ changes["baseline_territory"] = agreement.baseline_territory
548
+ return config.with_(**changes), ()
549
+
550
+
551
+ def _changes_for_facts(
552
+ config: Config,
553
+ agreement: ObservedAgreement,
554
+ *,
555
+ outcome: ChangeOutcome,
556
+ effective: date,
557
+ ) -> tuple[AccountChange, ...]:
558
+ changes: list[AccountChange] = []
559
+ for field, observed in _observed_facts(agreement):
560
+ current = _config_fact(config, field)
561
+ normalized_current = (
562
+ normalize_cca_identity(str(current)) if field == "cca_identity" and current else current
563
+ )
564
+ normalized_observed = (
565
+ normalize_cca_identity(str(observed))
566
+ if field == "cca_identity" and observed
567
+ else observed
568
+ )
569
+ if normalized_current == normalized_observed:
570
+ actual_outcome = ChangeOutcome.CONFIRM
571
+ elif normalized_current in (None, ""):
572
+ # A profile can be complete without optional facts such as the
573
+ # printed CCA identity or baseline territory. A statement may
574
+ # establish those facts at an existing epoch; do not turn an
575
+ # unestablished value into a false conflict.
576
+ actual_outcome = ChangeOutcome.ADD
577
+ elif outcome is ChangeOutcome.CONFIRM:
578
+ actual_outcome = ChangeOutcome.CONFLICT
579
+ else:
580
+ actual_outcome = ChangeOutcome.ADD
581
+ if actual_outcome is ChangeOutcome.CONFLICT:
582
+ changes.append(
583
+ AccountChange(
584
+ actual_outcome,
585
+ effective,
586
+ field,
587
+ before=current,
588
+ after=observed,
589
+ reason="authoritative profile epoch disagrees with the statement",
590
+ )
591
+ )
592
+ else:
593
+ changes.append(
594
+ AccountChange(actual_outcome, effective, field, before=current, after=observed)
595
+ )
596
+ return tuple(changes)
597
+
598
+
599
+ def reconcile(
600
+ profile: AccountProfile,
601
+ observation: AccountObservation,
602
+ ) -> AccountChangeSet:
603
+ """Compare sanitized evidence with a profile and build a safe proposal."""
604
+ if not isinstance(profile, AccountProfile):
605
+ raise ReconciliationError("profile must be an AccountProfile")
606
+ if not isinstance(observation, AccountObservation):
607
+ raise ReconciliationError("observation must be an AccountObservation")
608
+
609
+ revision = _profile_revision(profile)
610
+ fingerprint = _profile_fingerprint(profile)
611
+ matching_evidence = next(
612
+ (
613
+ evidence
614
+ for evidence in profile.observations
615
+ if evidence.identity() == observation.identity()
616
+ ),
617
+ None,
618
+ )
619
+ if matching_evidence is not None:
620
+ if (
621
+ matching_evidence.source_digest != observation.source_digest
622
+ or matching_evidence.agreements != observation.agreements
623
+ ):
624
+ return AccountChangeSet(
625
+ revision,
626
+ observation,
627
+ (
628
+ AccountChange(
629
+ ChangeOutcome.CONFLICT,
630
+ None,
631
+ "observation",
632
+ before=matching_evidence.to_dict(),
633
+ after=observation.to_dict(),
634
+ reason="the same source digest has different extracted facts",
635
+ ),
636
+ ),
637
+ profile.epochs,
638
+ profile_fingerprint=fingerprint,
639
+ )
640
+ return AccountChangeSet(
641
+ revision,
642
+ observation,
643
+ (),
644
+ profile.epochs,
645
+ profile_fingerprint=fingerprint,
646
+ )
647
+
648
+ known_suffixes = tuple(
649
+ sorted(
650
+ {
651
+ agreement.account_suffix
652
+ for evidence in profile.observations
653
+ for agreement in evidence.agreements
654
+ if agreement.account_suffix
655
+ }
656
+ )
657
+ )
658
+ known_agreements = tuple(
659
+ agreement for evidence in profile.observations for agreement in evidence.agreements
660
+ )
661
+ agreements, shape_changes = _validate_observation_shape(
662
+ observation,
663
+ known_suffixes=known_suffixes,
664
+ known_agreements=known_agreements,
665
+ )
666
+ changes = list(shape_changes)
667
+ working = list(profile.epochs)
668
+
669
+ for agreement in agreements:
670
+ effective = agreement.period.start
671
+ index = next(
672
+ (position for position, epoch in enumerate(working) if epoch.effective == effective),
673
+ None,
674
+ )
675
+ active_index = max(
676
+ (position for position, epoch in enumerate(working) if epoch.effective <= effective),
677
+ default=-1,
678
+ )
679
+ if active_index < 0:
680
+ changes.append(
681
+ AccountChange(
682
+ ChangeOutcome.MISSING_REQUIRED,
683
+ effective,
684
+ "config",
685
+ after=None,
686
+ reason="no complete prior Config exists before the printed agreement start",
687
+ )
688
+ )
689
+ continue
690
+
691
+ active = working[active_index].config
692
+
693
+ if index is not None:
694
+ current_epoch = working[index]
695
+ fact_changes = _changes_for_facts(
696
+ current_epoch.config,
697
+ agreement,
698
+ outcome=ChangeOutcome.CONFIRM,
699
+ effective=effective,
700
+ )
701
+ changes.extend(fact_changes)
702
+ if not any(change.outcome is ChangeOutcome.CONFLICT for change in fact_changes):
703
+ merged, merge_missing = _merge_observed(current_epoch.config, agreement)
704
+ missing = tuple(dict.fromkeys((*merge_missing, *_validate_complete_config(merged))))
705
+ if missing:
706
+ changes.extend(
707
+ AccountChange(
708
+ ChangeOutcome.MISSING_REQUIRED,
709
+ effective,
710
+ field,
711
+ before=None,
712
+ after=None,
713
+ reason="a missing account input must be explicitly established",
714
+ )
715
+ for field in missing
716
+ )
717
+ else:
718
+ working[index] = AccountEpoch(effective, merged, current_epoch.note)
719
+ continue
720
+
721
+ merged, merge_missing = _merge_observed(active, agreement)
722
+ missing = tuple(dict.fromkeys((*merge_missing, *_validate_complete_config(merged))))
723
+ if missing:
724
+ changes.extend(
725
+ AccountChange(
726
+ ChangeOutcome.MISSING_REQUIRED,
727
+ effective,
728
+ field,
729
+ before=None,
730
+ after=None,
731
+ reason="a new CCA epoch cannot guess its generation configuration",
732
+ )
733
+ for field in missing
734
+ )
735
+ continue
736
+
737
+ observed_differences = tuple(
738
+ field
739
+ for field, observed in _observed_facts(agreement)
740
+ if _json_value(_config_fact(active, field)) != _json_value(observed)
741
+ )
742
+ if not observed_differences:
743
+ changes.extend(
744
+ _changes_for_facts(
745
+ active,
746
+ agreement,
747
+ outcome=ChangeOutcome.CONFIRM,
748
+ effective=effective,
749
+ )
750
+ )
751
+ continue
752
+
753
+ working.append(AccountEpoch(effective, merged))
754
+ working.sort(key=lambda epoch: epoch.effective)
755
+ changes.extend(
756
+ _changes_for_facts(
757
+ active,
758
+ agreement,
759
+ outcome=ChangeOutcome.ADD,
760
+ effective=effective,
761
+ )
762
+ )
763
+
764
+ return AccountChangeSet(
765
+ revision,
766
+ observation,
767
+ _unique_changes(changes),
768
+ tuple(working),
769
+ profile_fingerprint=fingerprint,
770
+ )
771
+
772
+
773
+ def _validate_complete_profile(profile: AccountProfile) -> None:
774
+ for epoch in profile.epochs:
775
+ missing = _validate_complete_config(epoch.config)
776
+ if missing:
777
+ raise ReconciliationError(
778
+ f"updated profile has unestablished fields at {epoch.effective}: "
779
+ f"{', '.join(missing)}"
780
+ )
781
+
782
+
783
+ def _unique_changes(changes: list[AccountChange]) -> tuple[AccountChange, ...]:
784
+ unique: list[AccountChange] = []
785
+ seen: set[str] = set()
786
+ for change in changes:
787
+ identity = json.dumps(change.to_dict(), sort_keys=True, separators=(",", ":"))
788
+ if identity not in seen:
789
+ seen.add(identity)
790
+ unique.append(change)
791
+ return tuple(unique)
792
+
793
+
794
+ def reconcile_statement(
795
+ profile: AccountProfile,
796
+ statement: Statement,
797
+ *,
798
+ pdf: PdfSource | None = None,
799
+ pdf_sha256: str | None = None,
800
+ observed_at: date | None = None,
801
+ ) -> AccountChangeSet:
802
+ """Hash, normalize, and reconcile one PG&E statement in one operation."""
803
+ return reconcile(
804
+ profile,
805
+ observe_statement(
806
+ statement,
807
+ pdf=pdf,
808
+ pdf_sha256=pdf_sha256,
809
+ observed_at=observed_at,
810
+ ),
811
+ )
812
+
813
+
814
+ __all__ = [
815
+ "SUPPORTED_TARIFFS",
816
+ "AccountChange",
817
+ "AccountChangeSet",
818
+ "ChangeOutcome",
819
+ "PdfSource",
820
+ "ReconciliationError",
821
+ "RevisionMismatchError",
822
+ "hash_pdf",
823
+ "import_statement",
824
+ "normalize_cca_identity",
825
+ "observe_statement",
826
+ "reconcile",
827
+ "reconcile_statement",
828
+ ]