tariffkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tariffkit/__init__.py +52 -0
- tariffkit/account/__init__.py +41 -0
- tariffkit/account/cli.py +493 -0
- tariffkit/account/errors.py +25 -0
- tariffkit/account/model.py +641 -0
- tariffkit/account/rates.py +61 -0
- tariffkit/account/repository.py +329 -0
- tariffkit/billing/__init__.py +56 -0
- tariffkit/billing/engine.py +505 -0
- tariffkit/billing/ledger.py +365 -0
- tariffkit/billing/models.py +274 -0
- tariffkit/billing/netting.py +137 -0
- tariffkit/billing/trueup.py +498 -0
- tariffkit/cca.py +122 -0
- tariffkit/cli.py +809 -0
- tariffkit/config.py +296 -0
- tariffkit/data/__init__.py +40 -0
- tariffkit/data/cca/mce/2023-01-01.toml +61 -0
- tariffkit/data/cca/mce/2026-04-01.toml +152 -0
- tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
- tariffkit/data/export/pge/nbt00.json.gz +0 -0
- tariffkit/data/export/pge/nbt23.json.gz +0 -0
- tariffkit/data/export/pge/nbt24.json.gz +0 -0
- tariffkit/data/export/pge/nbt25.json.gz +0 -0
- tariffkit/data/export/pge/nbt26.json.gz +0 -0
- tariffkit/data/holidays.toml +36 -0
- tariffkit/data/manifest.json +56 -0
- tariffkit/data/nsc/pge.toml +57 -0
- tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
- tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
- tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
- tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
- tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
- tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
- tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
- tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
- tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
- tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
- tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
- tariffkit/data/versioned.py +118 -0
- tariffkit/engine.py +82 -0
- tariffkit/errors.py +19 -0
- tariffkit/export/__init__.py +5 -0
- tariffkit/export/nbt.py +207 -0
- tariffkit/interop/__init__.py +21 -0
- tariffkit/interop/emhass.py +79 -0
- tariffkit/interop/predbat.py +102 -0
- tariffkit/interop/slots.py +63 -0
- tariffkit/models.py +164 -0
- tariffkit/mqtt/__init__.py +6 -0
- tariffkit/mqtt/discovery.py +84 -0
- tariffkit/mqtt/publisher.py +305 -0
- tariffkit/providers/__init__.py +1 -0
- tariffkit/providers/pge/__init__.py +33 -0
- tariffkit/providers/pge/reconcile.py +828 -0
- tariffkit/providers/pge/statements/__init__.py +26 -0
- tariffkit/providers/pge/statements/errors.py +20 -0
- tariffkit/providers/pge/statements/model.py +320 -0
- tariffkit/providers/pge/statements/ocr.py +193 -0
- tariffkit/providers/pge/statements/parse.py +813 -0
- tariffkit/py.typed +0 -0
- tariffkit/secrets.py +164 -0
- tariffkit/sources/__init__.py +71 -0
- tariffkit/sources/greenbutton.py +318 -0
- tariffkit/sources/homeassistant.py +342 -0
- tariffkit/sources/influx.py +359 -0
- tariffkit/sources/pge.py +1153 -0
- tariffkit/tariff/__init__.py +5 -0
- tariffkit/tariff/retail.py +271 -0
- tariffkit/timeutil.py +120 -0
- tariffkit/web/__init__.py +5 -0
- tariffkit/web/app.py +220 -0
- tariffkit-0.2.0.dist-info/METADATA +260 -0
- tariffkit-0.2.0.dist-info/RECORD +83 -0
- tariffkit-0.2.0.dist-info/WHEEL +4 -0
- tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
- tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
]
|