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,813 @@
1
+ """Turning a statement PDF into a :class:`Statement`.
2
+
3
+ PG&E's statements carry a real text layer, so this is extraction rather than
4
+ recognition -- ``extract_text(extraction_mode="layout")`` preserves the column
5
+ positions, and a charge row is a label followed by two or more spaces and a
6
+ right-aligned amount. That is the whole trick.
7
+
8
+ Two things stop it being that simple, and both are handled by finding the column
9
+ a section starts in rather than by hoping:
10
+
11
+ * **Pages are multi-column.** The unbundled breakdown shares its rows with a
12
+ column of safety notices and rate-assistance prose, so a naive "first field is
13
+ the label" split reads the prose. Each section is therefore sliced to the
14
+ column its own heading starts in.
15
+ * **Detail pages carry a right-hand sidebar** of service information and
16
+ messages. Those never precede the amount, so taking the *first* money-shaped
17
+ field after the label is enough to ignore them.
18
+
19
+ The split between :func:`read_statement` and :func:`parse_statement` is
20
+ load-bearing. Everything interesting happens in the second, which takes page text
21
+ and no file, so the whole parser is testable against synthetic fixtures and only
22
+ one thin function ever needs a PDF. Real statements carry a name, an address and
23
+ an account number, so none is committed; they are read where they already sit and
24
+ nothing derived from them is stored.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from collections.abc import Sequence
31
+ from dataclasses import replace
32
+ from datetime import date, timedelta
33
+ from pathlib import Path
34
+
35
+ from tariffkit.billing import BillingPeriod
36
+
37
+ from .errors import StatementAmbiguityError, StatementError
38
+ from .model import (
39
+ Section,
40
+ Statement,
41
+ StatementAgreement,
42
+ StatementLine,
43
+ StatementSection,
44
+ )
45
+
46
+ #: A money field: "$333.87", "-1.96", "-$0.10084", "1,234.56".
47
+ MONEY = re.compile(r"^-?\$?-?\d[\d,]*\.\d{2,6}$")
48
+ #: A bare quantity, e.g. "982.126000".
49
+ QUANTITY = re.compile(r"^\d[\d,]*\.?\d*$")
50
+ #: A quantity and its unit in one field, e.g. "138.984000 kWh". Whether these
51
+ #: arrive as one field or two depends on how wide the printed gap between them
52
+ #: came out, and on a recognised page that is not predictable.
53
+ METERED_FIELD = re.compile(r"^\d[\d,]*\.?\d*\s+(kwh|days)$", re.I)
54
+
55
+ #: Where each section begins. Ordered: the first match on a page opens it, and
56
+ #: the next heading anywhere closes it, so a section running onto the following
57
+ #: page keeps collecting rows without needing to know it did.
58
+ #: Whitespace between words is written ``\s+`` throughout, never a literal
59
+ #: space. Recognised pages space words by pixel geometry, so "Your Account
60
+ #: Summary" comes back as "Your Account Summary" -- and an anchor that
61
+ #: assumes single spaces silently matches nothing on every recognised
62
+ #: statement, which presents as "no total amount due found".
63
+ ANCHORS: tuple[tuple[Section, re.Pattern[str]], ...] = (
64
+ (Section.SUMMARY, re.compile(r"Your\s+Account\s+Summary")),
65
+ # Two headings, one section. Once solar is interconnected the account moves
66
+ # to the Solar Billing Plan and the utility retitles this page -- so a
67
+ # parser that knows only the first heading silently finds no delivery
68
+ # detail at all on every post-PTO statement, and reports the whole section
69
+ # as missing rather than as renamed.
70
+ (
71
+ Section.PGE_DELIVERY,
72
+ re.compile(
73
+ r"Details\s+of\s+PG&E\s+(?:Electric\s+Delivery|Solar\s+Billing\s+Plan)\s+Charges"
74
+ ),
75
+ ),
76
+ (
77
+ Section.CCA_GENERATION,
78
+ re.compile(r"Details\s+of\s+(?P<cca>[A-Z][A-Za-z& ]+?)\s+Electric\s+Generation"),
79
+ ),
80
+ (Section.PGE_BREAKDOWN, re.compile(r"Your\s+Electric\s+Charges\s+Breakdown")),
81
+ )
82
+
83
+ #: The row that ends a section by stating its total. Everything below it on the
84
+ #: page belongs to something else -- on the delivery detail, a monthly-history
85
+ #: chart whose bars are money-shaped and would otherwise be read as charges.
86
+ #:
87
+ #: The indent bound is doing real work. A section's own total sits hard against
88
+ #: its left edge, while the right-hand sidebar has a "Total Usage" line more than
89
+ #: a hundred columns in; without the bound that sidebar closes the section
90
+ #: two-thirds of the way through and the rest of the charges vanish.
91
+ TOTAL_ROW = re.compile(r"^ {0,10}Total\b", re.I)
92
+
93
+ #: A row whose label is printed on the line *below* it, marked with a lone dot in
94
+ #: the label column. PG&E uses it for charges that carry their own quantity and
95
+ #: rate, such as the Base Services Charge and the baseline allowance.
96
+ DEFERRED_LABEL = re.compile(r"^\s*\.\s")
97
+
98
+ #: How far past a wrapped "Total ..." label to keep looking for its amount.
99
+ #: Three is enough for the sidebar lines seen interleaved on the 2025 layout.
100
+ TOTAL_WRAP_LINES = 3
101
+
102
+ #: "12/30/2025 to 01/29/2026 (31 billing days)" -- the cycle.
103
+ #:
104
+ #: Both separators are real, and the older one is an *en dash*, not a hyphen:
105
+ #: statements through 2025 separate the dates with an en dash (U+2013) while
106
+ #: the redesign switched to "to". Matching only "to" rejects every older
107
+ #: statement with "no billing cycle found", and matching a plain hyphen still
108
+ #: misses them, which reads as a broken PDF rather than as a layout nobody
109
+ #: taught this parser.
110
+ CYCLE = re.compile(
111
+ r"(\d{2}/\d{2}/\d{4})\s+(?:to|[-\u2013\u2014])\s+(\d{2}/\d{2}/\d{4})"
112
+ r"(?:\s*\((\d+)\s+billing\s+days\))?"
113
+ )
114
+ #: A sub-period block heading inside the delivery detail: the same shape as the
115
+ #: cycle header, minus the day count, which is how the two are told apart.
116
+ SUBPERIOD = re.compile(
117
+ r"^\s*(\d{2}/\d{2}/\d{4})\s+(?:to|[-\u2013\u2014])\s+(\d{2}/\d{2}/\d{4})(?:\s|$)"
118
+ )
119
+
120
+ #: Sub-headings that qualify the rows beneath them. They carry no amount of
121
+ #: their own, which is how they are told apart from charges.
122
+ #: Not anchored at the end: the right-hand sidebar bleeds a stray character
123
+ #: onto these rows, so requiring the line to contain nothing else never matches.
124
+ #: Carrying no amount is the real test, and it is applied separately.
125
+ BLOCK_HEADING = re.compile(
126
+ r"^\s*(Energy Produced|Energy Delivered|Other Charges, Credits and Taxes)\b"
127
+ )
128
+
129
+ #: What the Solar Billing Plan calls its section total. It does not begin with
130
+ #: "Total", so the usual row never matches and the section is left with no
131
+ #: printed total to check its rows against.
132
+ SBP_TOTAL = re.compile(r"^\s*Solar\s+Billing\s+Plan\s+Charges\s")
133
+
134
+ #: Gas, on a combined statement. Taken from the gas section's own total rather
135
+ #: than from the summary: the summary prints "Current Gas Charges" with the
136
+ #: amount in a column that extraction drops entirely, so the only place the
137
+ #: number survives is the detail page.
138
+ GAS_TOTAL = re.compile(r"Total\s+Gas\s+Charges\s+\$?(-?[\d,]+\.\d{2})")
139
+
140
+ #: How many tariffs priced this statement, counted by delivery detail pages
141
+ #: rather than by "Service Agreement ID:" rows -- every statement carries at
142
+ #: least two of those, one for PG&E and one for the CCA, so counting them calls
143
+ #: an ordinary CCA statement a split one. A second *delivery* page is the thing
144
+ #: that only happens when the account changed tariff mid-cycle.
145
+ DELIVERY_PAGE = ANCHORS[1][1]
146
+
147
+ STATEMENT_DATE = re.compile(r"Statement\s+Date:\s*(\d{2}/\d{2}/\d{4})")
148
+ ACCOUNT = re.compile(r"Account\s+N(?:o|umber)[.:]?\s*(\d[\d-]+)")
149
+ USAGE = re.compile(r"Electric\s+Usage\s+This\s+Period:\s*([\d,]+\.?\d*)\s*kWh,?\s*(\d+)\s+billing")
150
+ RATE_SCHEDULE = re.compile(r"Rate\s+Schedule:\s*(.+?)\s*$", re.M)
151
+ BASELINE_TERRITORY = re.compile(r"Baseline\s+Territory\s+([A-Z])\b")
152
+ PCIA_VINTAGE = re.compile(r"(\d{4})\s+Vintaged\s+Power\s+Charge")
153
+
154
+ # PG&E prints marketing names on delivery pages, while the pricing library
155
+ # uses tariff codes. Keep this correspondence in the provider adapter rather
156
+ # than making the core tariff model understand utility-specific wording.
157
+ PRINTED_TARIFFS: tuple[tuple[re.Pattern[str], str], ...] = (
158
+ (re.compile(r"EV\s*2-?A", re.I), "EV2-A"),
159
+ (re.compile(r"E-?ELEC|Electric\s+Home", re.I), "E-ELEC"),
160
+ (re.compile(r"E-?TOU-?C|ETOUC", re.I), "E-TOU-C"),
161
+ (re.compile(r"Time-of-Use.*4\s*-\s*9", re.I), "E-TOU-C"),
162
+ )
163
+
164
+ #: Rows that are structure rather than charges.
165
+ SKIP_LABELS = re.compile(
166
+ r"^(energy charges|baseline allowance|service for|rate schedule|account n|statement date|"
167
+ r"due date|total )",
168
+ re.I,
169
+ )
170
+
171
+
172
+ def _money(field: str) -> float | None:
173
+ """A printed amount as a float, or None if this field is not one."""
174
+ text = field.strip()
175
+ if not MONEY.match(text):
176
+ return None
177
+ negative = text.startswith("-")
178
+ return (-1.0 if negative else 1.0) * float(text.lstrip("-").lstrip("$").replace(",", ""))
179
+
180
+
181
+ def _fields(line: str) -> list[str]:
182
+ """Split a laid-out row into its columns. Two or more spaces is a gap."""
183
+ return [part for part in re.split(r"\s{2,}", line.strip()) if part]
184
+
185
+
186
+ def _parse_date(text: str) -> date:
187
+ month, day, year = (int(part) for part in text.split("/"))
188
+ return date(year, month, day)
189
+
190
+
191
+ def normalize_tariff(printed: str) -> str | None:
192
+ """Return the tariff code named by a PG&E printed schedule."""
193
+ for pattern, tariff in PRINTED_TARIFFS:
194
+ if pattern.search(printed):
195
+ return tariff
196
+ return None
197
+
198
+
199
+ def read_statement(path: str | Path) -> Statement:
200
+ """Read a statement PDF.
201
+
202
+ The only function here that touches a file or needs pypdf.
203
+ """
204
+ source = Path(path)
205
+ try:
206
+ from pypdf import PdfReader
207
+ except ImportError as exc: # pragma: no cover - exercised by the extra's absence
208
+ raise StatementError(
209
+ "reading a statement PDF needs pypdf: install 'tariffkit[statements]'"
210
+ ) from exc
211
+
212
+ try:
213
+ reader = PdfReader(source)
214
+ pages: list[str] = []
215
+ for page in reader.pages:
216
+ try:
217
+ pages.append(page.extract_text(extraction_mode="layout") or "")
218
+ except KeyError:
219
+ # A page with no content stream. Real: PG&E pads statements with a
220
+ # trailing blank page, and pypdf raises rather than returning "".
221
+ pages.append("")
222
+ except Exception as exc:
223
+ raise StatementError(f"{source.name} could not be read as a PDF") from exc
224
+
225
+ if not any(text.strip() for text in pages):
226
+ raise StatementError(
227
+ f"{source.name} has no text layer, so it is a scan or a print-to-PDF export "
228
+ f"rather than the statement PG&E serves; download it again from the portal"
229
+ )
230
+
231
+ if _glyphs_are_spaces(reader):
232
+ # PG&E's pre-November-2025 statements draw every character with a Type 3
233
+ # font whose ToUnicode map calls most glyphs spaces, so extraction
234
+ # returns exactly that and no reader does better. The pixels are the
235
+ # only honest source left. Recognition can misread a digit into a
236
+ # plausible amount, which is why `self_check` gates reconciliation: a
237
+ # statement prints its totals twice and a misread almost always breaks
238
+ # the arithmetic between them.
239
+ from .ocr import readings
240
+
241
+ # Keep only a reading that survives the statement's own checks. OCR can
242
+ # turn a digit into a plausible amount, so an incomplete parse must not
243
+ # become evidence merely because it produced a Statement object.
244
+ scored: list[Statement] = []
245
+ failures: list[StatementError] = []
246
+ for pages in readings(source):
247
+ try:
248
+ candidate = parse_statement(pages, source=source.name, recognised=True)
249
+ except StatementError as exc:
250
+ failures.append(exc)
251
+ continue
252
+ if not candidate.self_check():
253
+ scored.append(candidate)
254
+ if not scored:
255
+ if failures and all(
256
+ isinstance(failure, StatementAmbiguityError) for failure in failures
257
+ ):
258
+ raise failures[0]
259
+ raise StatementError(f"{source.name} OCR did not produce a self-checking statement")
260
+ return scored[0]
261
+ statement = parse_statement(pages, source=source.name)
262
+ problems = statement.self_check()
263
+ if problems:
264
+ raise StatementError(f"{source.name} failed its self-check ({len(problems)} problems)")
265
+ return statement
266
+
267
+
268
+ #: Above this share of glyphs mapping to U+0020, the document is not text.
269
+ #: Measured: PG&E's 2025 Type 3 statements sit at ~56%, the readable ones at 3%.
270
+ SPACE_GLYPH_SHARE = 0.25
271
+
272
+ _BFCHAR = re.compile(r"<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]{4})>")
273
+
274
+
275
+ def _glyphs_are_spaces(reader: object) -> bool:
276
+ """Whether the fonts claim most of their glyphs are spaces.
277
+
278
+ Distinguishes "this statement is in a layout nobody taught the parser" from
279
+ "this statement carries no readable text at all", which need completely
280
+ different responses -- the first is a parser gap, the second is not fixable
281
+ here at any effort.
282
+ """
283
+ total = blank = 0
284
+ for page in reader.pages: # type: ignore[attr-defined]
285
+ resources = page.get("/Resources") or {}
286
+ for ref in (resources.get("/Font") or {}).values():
287
+ font = ref.get_object()
288
+ if "/ToUnicode" not in font:
289
+ continue
290
+ try:
291
+ cmap = font["/ToUnicode"].get_data().decode("latin-1", "replace")
292
+ except Exception:
293
+ # An unreadable CMap proves nothing either way, so it should
294
+ # not count toward the verdict.
295
+ continue
296
+ for _, unicode_point in _BFCHAR.findall(cmap):
297
+ total += 1
298
+ blank += unicode_point.lower() == "0020"
299
+ return total > 0 and blank / total > SPACE_GLYPH_SHARE
300
+
301
+
302
+ def _delivery_pages(pages: Sequence[str]) -> int:
303
+ """How many delivery detail pages the statement opens.
304
+
305
+ Counted as headings, not as occurrences. The same words appear mid-sentence
306
+ in the statement's own prose -- "See the 'Generation Credit' on the 'Details
307
+ of PG&E Electric Delivery Charges' page" -- and counting that made an
308
+ ordinary single-agreement statement look like a split one, which silently
309
+ suppressed its meter cross-check. A heading begins its line; a
310
+ cross-reference does not.
311
+ """
312
+ return sum(
313
+ 1 for page in pages for line in page.splitlines() if DELIVERY_PAGE.match(line.lstrip())
314
+ )
315
+
316
+
317
+ def _scalar(text: str, pattern: re.Pattern[str]) -> float | None:
318
+ found = pattern.search(text)
319
+ return _money(found.group(1)) if found else None
320
+
321
+
322
+ def _summary_amount(summary: StatementSection, label: str) -> float | None:
323
+ """One named line out of the running balance, or None if absent."""
324
+ matches = summary.find(label)
325
+ return matches[0].amount if matches else None
326
+
327
+
328
+ def _agreement_spans(page: str) -> tuple[BillingPeriod, ...]:
329
+ """Return the agreement-level date spans printed on one page."""
330
+ matches = [
331
+ (_parse_date(start), _parse_date(end), days) for start, end, days in CYCLE.findall(page)
332
+ ]
333
+ dated = [match for match in matches if match[2]]
334
+ candidates = dated or matches
335
+ return tuple(
336
+ BillingPeriod(start, end)
337
+ for start, end, _ in sorted(set(candidates), key=lambda value: value[:2])
338
+ )
339
+
340
+
341
+ def _agreements(pages: Sequence[str]) -> tuple[StatementAgreement, ...]:
342
+ """Map each delivery span to tariff evidence from that same page."""
343
+ diagnostics: list[str] = []
344
+ found: dict[BillingPeriod, StatementAgreement] = {}
345
+
346
+ for page_number, page in enumerate(pages):
347
+ delivery = any(DELIVERY_PAGE.match(line.lstrip()) for line in page.splitlines())
348
+ schedules = tuple(
349
+ dict.fromkeys(schedule.strip() for schedule in RATE_SCHEDULE.findall(page))
350
+ )
351
+ spans = _agreement_spans(page) if delivery else ()
352
+
353
+ if delivery and not schedules and not spans:
354
+ diagnostics.append(
355
+ f"page {page_number + 1} has a delivery heading but no exact date span "
356
+ "or printed schedule"
357
+ )
358
+ continue
359
+ if delivery and schedules and not spans:
360
+ diagnostics.append(f"page {page_number + 1} prints a schedule without a date span")
361
+ continue
362
+ if not delivery or not spans:
363
+ continue
364
+ if not schedules:
365
+ diagnostics.append(f"page {page_number + 1} has a date span without a schedule")
366
+ continue
367
+ if len(spans) != 1:
368
+ diagnostics.append(
369
+ f"page {page_number + 1} prints {len(spans)} date spans for one delivery schedule"
370
+ )
371
+ continue
372
+ if len(schedules) != 1:
373
+ diagnostics.append(
374
+ f"page {page_number + 1} prints {len(schedules)} schedules for one date span"
375
+ )
376
+ continue
377
+
378
+ printed = schedules[0]
379
+ tariff = normalize_tariff(printed)
380
+ if tariff is None:
381
+ diagnostics.append(f"page {page_number + 1} prints an unsupported tariff")
382
+ continue
383
+
384
+ period = spans[0]
385
+ account = ACCOUNT.search(page)
386
+ territory = BASELINE_TERRITORY.search(page)
387
+ vintage = PCIA_VINTAGE.search(page)
388
+ candidate = StatementAgreement(
389
+ period,
390
+ printed,
391
+ tariff,
392
+ page_number,
393
+ account_masked=re.sub(r"\D", "", account.group(1))[-4:] if account else "",
394
+ baseline_territory=territory.group(1) if territory else "",
395
+ pcia_vintage=int(vintage.group(1)) if vintage else None,
396
+ )
397
+ previous = found.get(period)
398
+ if previous is None:
399
+ found[period] = candidate
400
+ continue
401
+
402
+ if (previous.printed_schedule, previous.tariff) != (printed, tariff):
403
+ diagnostics.append(
404
+ f"date span {period.start}..{period.end} has conflicting delivery schedules"
405
+ )
406
+ continue
407
+
408
+ facts = (
409
+ ("account suffix", previous.account_masked, candidate.account_masked),
410
+ ("baseline territory", previous.baseline_territory, candidate.baseline_territory),
411
+ (
412
+ "PCIA vintage",
413
+ previous.pcia_vintage,
414
+ candidate.pcia_vintage,
415
+ ),
416
+ )
417
+ conflicts = [name for name, old, new in facts if old and new and old != new]
418
+ if conflicts:
419
+ diagnostics.append(
420
+ f"date span {period.start}..{period.end} has conflicting delivery "
421
+ f"account facts ({', '.join(conflicts)})"
422
+ )
423
+ continue
424
+ found[period] = replace(
425
+ previous,
426
+ account_masked=previous.account_masked or candidate.account_masked,
427
+ baseline_territory=previous.baseline_territory or candidate.baseline_territory,
428
+ pcia_vintage=previous.pcia_vintage or candidate.pcia_vintage,
429
+ )
430
+
431
+ if diagnostics:
432
+ raise StatementAmbiguityError(
433
+ "could not map PG&E service agreements one-to-one",
434
+ diagnostics=diagnostics,
435
+ )
436
+ return tuple(sorted(found.values(), key=lambda agreement: (agreement.start, agreement.end)))
437
+
438
+
439
+ def parse_statement(
440
+ pages: Sequence[str], *, source: str = "", recognised: bool = False
441
+ ) -> Statement:
442
+ """Parse already-extracted page text. No file, no network, no clock."""
443
+ joined = "\n".join(pages)
444
+
445
+ stamp = STATEMENT_DATE.search(joined)
446
+ if not stamp:
447
+ raise StatementError(f"{source or 'statement'}: no statement date found")
448
+
449
+ # Chained, not unioned. A statement prints more than one dated header and
450
+ # they are not all the electric cycle: a combined bill carries the gas cycle
451
+ # too, offset by a day (09/29-10/28 electric against 09/30-10/29 gas), and a
452
+ # statement covering two service agreements carries one header each
453
+ # (06/01-06/02 then 06/03-06/29). Taking the first header calls a 29-day
454
+ # cycle a 2-day one; taking the union swallows the gas cycle and calls a
455
+ # 30-day cycle 60 days. Following only headers that continue the previous
456
+ # one -- starting the day after it ended -- separates the two cases without
457
+ # needing to know which service each belongs to.
458
+ spans = sorted(
459
+ {(_parse_date(start), _parse_date(end), days) for start, end, days in CYCLE.findall(joined)}
460
+ )
461
+ if not spans:
462
+ raise StatementError(f"{source or 'statement'}: no billing cycle found")
463
+
464
+ dated = [s for s in spans if s[2]] or spans
465
+ chain = [dated[0]]
466
+ for span in dated[1:]:
467
+ if span[0] == chain[-1][1] + timedelta(days=1):
468
+ chain.append(span)
469
+ period = BillingPeriod(chain[0][0], chain[-1][1])
470
+ billed_days = sum(int(s[2]) for s in chain if s[2]) or None
471
+
472
+ sections = _sections(pages)
473
+
474
+ summary = next((s for s in sections if s.name is Section.SUMMARY), None)
475
+ if summary is None or summary.printed_total is None:
476
+ raise StatementError(f"{source or 'statement'}: no total amount due found")
477
+
478
+ # Summed over distinct blocks, not the first found. A statement covering
479
+ # two service agreements states usage once per agreement -- 2 days then 27
480
+ # -- and taking the first reports the cycle as having used a fraction of
481
+ # what it did, which surfaces as the meter and the statement disagreeing by
482
+ # hundreds of kilowatt-hours. Deduplicated because the utility's page and
483
+ # the CCA's page each print the same figures.
484
+ # Keyed on the parsed value, not the printed text: the same figure appears
485
+ # as "621.046" in one place and "621.046000" in another, and deduplicating
486
+ # the strings keeps both and doubles the cycle's usage.
487
+ usage_blocks = {
488
+ (round(float(kwh.replace(",", "")), 3), int(days)) for kwh, days in USAGE.findall(joined)
489
+ }
490
+ account = ACCOUNT.search(joined)
491
+ schedules = RATE_SCHEDULE.findall(joined)
492
+ territory = BASELINE_TERRITORY.search(joined)
493
+ vintage = PCIA_VINTAGE.search(joined)
494
+ cca = ANCHORS[2][1].search(joined)
495
+ agreements = _agreements(pages)
496
+ cca_schedules = tuple(
497
+ schedule.strip()
498
+ for page in pages
499
+ if ANCHORS[2][1].search(page)
500
+ for schedule in RATE_SCHEDULE.findall(page)
501
+ )
502
+
503
+ return Statement(
504
+ statement_date=_parse_date(stamp.group(1)),
505
+ period=period,
506
+ amount_due=summary.printed_total,
507
+ account_masked=re.sub(r"\D", "", account.group(1))[-4:] if account else "",
508
+ billed_days=billed_days,
509
+ billed_kwh=(sum(kwh for kwh, _ in usage_blocks) or None if usage_blocks else None),
510
+ service_agreements=len(agreements) or max(1, _delivery_pages(pages)),
511
+ agreements=agreements,
512
+ gas_charges=_scalar(joined, GAS_TOTAL),
513
+ electric_adjustments=_summary_amount(summary, "Electric Adjustments"),
514
+ sections=sections,
515
+ rate_schedule=agreements[0].printed_schedule if agreements else "",
516
+ printed_schedules=tuple(dict.fromkeys(s.strip() for s in schedules if s.strip())),
517
+ cca_name=cca.group("cca").strip() if cca else "",
518
+ cca_rate_schedule=cca_schedules[0] if cca_schedules else "",
519
+ baseline_territory=territory.group(1) if territory else "",
520
+ pcia_vintage=int(vintage.group(1)) if vintage else None,
521
+ source=source,
522
+ recognised=recognised,
523
+ )
524
+
525
+
526
+ def _sections(pages: Sequence[str]) -> tuple[StatementSection, ...]:
527
+ """Collect each section's rows, sliced to the column its heading starts in.
528
+
529
+ A section runs from its heading to its total row. Closing on the total is
530
+ what keeps the delivery detail's monthly-history chart out of the charges:
531
+ those bars are money-shaped and sit directly beneath the total.
532
+ """
533
+ found: dict[Section, list[StatementLine]] = {}
534
+ text: dict[Section, list[str]] = {}
535
+ totals: dict[Section, float] = {}
536
+ current: Section | None = None
537
+ #: How many times each section's heading has opened. A statement covering
538
+ #: two service agreements prints the delivery detail and the generation page
539
+ #: once per agreement, with identical labels in each; without this they look
540
+ #: like one section whose boundaries overlap.
541
+ opened_count: dict[Section, int] = {}
542
+ agreement = 0
543
+ column = 0
544
+ pending_total: Section | None = None
545
+ pending_span = 0
546
+ awaiting_label: StatementLine | None = None
547
+
548
+ for index, page in enumerate(pages):
549
+ for raw in page.splitlines():
550
+ opened = _anchor(raw)
551
+ if opened is not None:
552
+ current, column = opened
553
+ pending_total = None
554
+ opened_count[current] = opened_count.get(current, 0) + 1
555
+ agreement = opened_count[current]
556
+ found.setdefault(current, [])
557
+ text.setdefault(current, [])
558
+ continue
559
+
560
+ line = raw[column:] if column else raw
561
+
562
+ # The total's label sometimes wraps, leaving the amount on the row
563
+ # below: "Total MCE Electric Generation" / "Charges $134.54".
564
+ #
565
+ # Not necessarily the *next* row, though. On the older layout the
566
+ # right-hand sidebar interleaves its own lines between the two, so
567
+ # giving up after one row loses the total and the statement then
568
+ # fails its own checks with a whole section apparently missing.
569
+ # Bounded, because scanning on indefinitely would eventually adopt
570
+ # some unrelated amount as the section total.
571
+ if pending_total is not None:
572
+ amount = _first_money(line)
573
+ if amount is not None:
574
+ totals[pending_total] = totals.get(pending_total, 0.0) + amount
575
+ pending_total = None
576
+ pending_span = 0
577
+ continue
578
+ pending_span += 1
579
+ if pending_span > TOTAL_WRAP_LINES:
580
+ pending_total = None
581
+ pending_span = 0
582
+ continue
583
+
584
+ if current is None:
585
+ continue
586
+
587
+ # A dot-marked row holds its amount but not its name; the next row
588
+ # supplies the label and carries nothing of its own.
589
+ if awaiting_label is not None:
590
+ fields = _fields(line)
591
+ label = fields[0].strip() if fields else ""
592
+ if label:
593
+ found[current].append(replace(awaiting_label, label=label, agreement=agreement))
594
+ awaiting_label = None
595
+ continue
596
+
597
+ if DEFERRED_LABEL.match(line):
598
+ text[current].append(line)
599
+ # Only a priced row is a charge. The same dot marker also
600
+ # introduces the baseline allowance -- "281.30 kWh (29 days)" --
601
+ # which states a quantity and no money; reading its kWh as
602
+ # dollars adds a few hundred to the section and looks exactly
603
+ # like a real overcharge.
604
+ if "@" in _fields(line):
605
+ deferred = _line(line, current, index, label="?")
606
+ if deferred is not None:
607
+ awaiting_label = deferred
608
+ continue
609
+
610
+ if TOTAL_ROW.match(line) or SBP_TOTAL.match(line):
611
+ amount = _first_money(line)
612
+ if amount is not None:
613
+ # Added, not replaced. Each service agreement prints its own
614
+ # total, and together they are the cycle's -- 3.59 + 21.89
615
+ # on 2026-07-07. Overwriting keeps only the last and reports
616
+ # the section as short by the whole of the first.
617
+ totals[current] = totals.get(current, 0.0) + amount
618
+ current = None
619
+ else:
620
+ pending_total, current = current, None
621
+ pending_span = 0
622
+ continue
623
+
624
+ text[current].append(line)
625
+ parsed = _line(line, current, index)
626
+ if parsed is not None:
627
+ found[current].append(replace(parsed, agreement=agreement))
628
+
629
+ return tuple(
630
+ StatementSection(
631
+ name=name,
632
+ lines=tuple(_with_blocks(_with_subperiods(found[name], text[name], name), text[name])),
633
+ printed_total=totals.get(name),
634
+ )
635
+ for name in Section
636
+ if name in found
637
+ )
638
+
639
+
640
+ def _first_money(line: str) -> float | None:
641
+ for candidate in _fields(line):
642
+ amount = _money(candidate)
643
+ if amount is not None:
644
+ return amount
645
+ return None
646
+
647
+
648
+ def _anchor(raw: str) -> tuple[Section, int] | None:
649
+ for name, pattern in ANCHORS:
650
+ match = pattern.search(raw)
651
+ if match:
652
+ # Slice a little left of the heading: rows in the same column are
653
+ # sometimes indented one or two characters further left than it.
654
+ return name, max(0, match.start() - 2)
655
+ return None
656
+
657
+
658
+ def _implied_at(rest: list[str]) -> int | None:
659
+ """Where the "@" would be on a metered row that lost it.
660
+
661
+ Recognised statements drop the symbol -- it is small, and it sits in a gap.
662
+ The shape is still unmistakable: a quantity, its unit, then two amounts,
663
+ the rate and the charge. Without this the rate is read as the charge, so a
664
+ $86.96 line prints as $0.63 and the section quietly comes up short.
665
+
666
+ Returns the index the rate starts at, or None when the row is not that
667
+ shape. Deliberately strict: guessing here invents a charge.
668
+ """
669
+ for index, field in enumerate(rest):
670
+ # The quantity and its unit may be one field or two, depending on how
671
+ # wide the gap between them came out.
672
+ metered = bool(METERED_FIELD.match(field)) or (
673
+ index and QUANTITY.match(rest[index - 1]) and field.lower() in {"kwh", "days"}
674
+ )
675
+ if not metered:
676
+ continue
677
+ after = rest[index + 1 :]
678
+ if len([value for value in after if _money(value) is not None]) >= 2:
679
+ return index + 1
680
+ return None
681
+
682
+
683
+ def _line(line: str, section: Section, page: int, *, label: str = "") -> StatementLine | None:
684
+ """One row, if it carries an amount.
685
+
686
+ A rate row reads "<quantity> <unit> @ <rate> <amount>". Anchoring on the "@"
687
+ rather than on the word "kWh" is what lets the Base Services Charge -- billed
688
+ "29 days @ $0.79343" -- come out as a charge instead of having its *rate*
689
+ read as its amount, which is the shape of error that looks like a $22
690
+ discrepancy.
691
+ """
692
+ fields = _fields(line)
693
+ if len(fields) < 2:
694
+ return None
695
+
696
+ derived = not label
697
+ label = label or fields[0].strip(" .")
698
+ # A section is sliced at the column its heading starts in, and on a
699
+ # recognised page that column is an estimate. One character out to the left
700
+ # and the tail of the neighbouring prose column arrives as the first field:
701
+ # "...income-qualified households" becomes a label of "s", and the row's
702
+ # real label sits in the next field. No charge on a statement is named by
703
+ # one or two characters, so a fragment that short is dropped rather than
704
+ # believed -- the amount is read correctly either way, and the cost of
705
+ # believing it is a charge that reconciles against nothing.
706
+ if derived and len(label) <= 2 and not label.isdigit() and len(fields) >= 3:
707
+ fields = fields[1:]
708
+ label = fields[0].strip(" .")
709
+ if not label or SKIP_LABELS.match(label):
710
+ return None
711
+
712
+ quantity = rate = None
713
+ unit = ""
714
+ rest = fields[1:]
715
+ at = rest.index("@") if "@" in rest else _implied_at(rest)
716
+ if at is not None:
717
+ priced = rest[:at]
718
+ # The quantity and its unit may be one field or two, the same way the
719
+ # "@" itself may or may not have survived. Reading only the two-field
720
+ # form leaves a recognised row with no quantity at all, which silently
721
+ # costs the reconciler its ability to re-price that row from the
722
+ # statement's own metered figures.
723
+ if priced and METERED_FIELD.match(priced[-1]):
724
+ # split(), not partition(" "): the pattern allows any whitespace, so
725
+ # a tab-separated "40.00\tkWh" matches and then partitions on a
726
+ # space that is not there, and float() raises out of the parse.
727
+ amount_text, unit = priced[-1].split(maxsplit=1)
728
+ quantity = float(amount_text.replace(",", ""))
729
+ unit = unit.strip()
730
+ elif len(priced) >= 2 and QUANTITY.match(priced[-2]):
731
+ quantity = float(priced[-2].replace(",", ""))
732
+ unit = priced[-1]
733
+ # The rate sits immediately after the "@", whether or not the symbol
734
+ # itself survived. Skipping past it is the whole point: read as the
735
+ # amount, $0.62569 becomes a 63-cent charge where $86.96 was due.
736
+ rest = rest[at:] if "@" not in rest else rest[at + 1 :]
737
+ if rest:
738
+ rate = _money(rest[0])
739
+ rest = rest[1:]
740
+
741
+ for candidate in rest:
742
+ amount = _money(candidate)
743
+ if amount is not None:
744
+ return StatementLine(
745
+ label=label,
746
+ amount=amount,
747
+ section=section,
748
+ page=page,
749
+ quantity=quantity,
750
+ unit=unit,
751
+ rate=rate,
752
+ raw=line.rstrip(),
753
+ )
754
+ return None
755
+
756
+
757
+ def _with_blocks(lines: Sequence[StatementLine], text: Sequence[str]) -> list[StatementLine]:
758
+ """Tag rows with the sub-heading they sit under.
759
+
760
+ Needed once solar is interconnected: the Solar Billing Plan prints the same
761
+ three time-of-use labels twice, under "Energy Produced" and under "Energy
762
+ Delivered". They are export and import, and telling them apart is the
763
+ difference between a credit and a charge.
764
+ """
765
+ heads: list[tuple[int, str]] = []
766
+ for index, raw in enumerate(text):
767
+ head = BLOCK_HEADING.match(raw)
768
+ if head and _first_money(raw) is None:
769
+ heads.append((index, head.group(1).strip()))
770
+ if not heads:
771
+ return list(lines)
772
+
773
+ tagged: list[StatementLine] = []
774
+ cursor = 0
775
+ for position, raw in enumerate(text):
776
+ if cursor < len(lines) and lines[cursor].raw == raw.rstrip():
777
+ under = next((h for p, h in reversed(heads) if p < position), "")
778
+ tagged.append(replace(lines[cursor], block=under))
779
+ cursor += 1
780
+ return tagged if len(tagged) == len(lines) else list(lines)
781
+
782
+
783
+ def _with_subperiods(
784
+ lines: Sequence[StatementLine], text: Sequence[str], section: Section
785
+ ) -> list[StatementLine]:
786
+ """Tag rows with the rate-change block they appeared under.
787
+
788
+ Only the delivery detail prints these. A cycle spanning a rate change gets
789
+ one block per side, which is the statement independently confirming that
790
+ effective-dated pricing is the right model rather than an over-engineering.
791
+ """
792
+ if section is not Section.PGE_DELIVERY:
793
+ return list(lines)
794
+
795
+ spans: list[tuple[int, tuple[date, date]]] = []
796
+ for position, raw in enumerate(text):
797
+ match = SUBPERIOD.match(raw)
798
+ # The cycle header has the same shape; its day count is what tells them
799
+ # apart, and treating it as a block would put every row in one span.
800
+ if match and "billing day" not in raw.lower():
801
+ spans.append((position, (_parse_date(match.group(1)), _parse_date(match.group(2)))))
802
+ if len(spans) < 2:
803
+ return list(lines)
804
+
805
+ # Walk rows and blocks together in document order.
806
+ tagged: list[StatementLine] = []
807
+ cursor = 0
808
+ for position, raw in enumerate(text):
809
+ if cursor < len(lines) and lines[cursor].raw == raw.rstrip():
810
+ span = next((s for p, s in reversed(spans) if p < position), None)
811
+ tagged.append(replace(lines[cursor], subperiod=span))
812
+ cursor += 1
813
+ return tagged if len(tagged) == len(lines) else list(lines)