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,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)
|