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,26 @@
|
|
|
1
|
+
"""Local PG&E statement import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import StatementAmbiguityError, StatementError
|
|
6
|
+
from .model import (
|
|
7
|
+
Section,
|
|
8
|
+
Statement,
|
|
9
|
+
StatementAgreement,
|
|
10
|
+
StatementLine,
|
|
11
|
+
StatementSection,
|
|
12
|
+
)
|
|
13
|
+
from .parse import normalize_tariff, parse_statement, read_statement
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Section",
|
|
17
|
+
"Statement",
|
|
18
|
+
"StatementAgreement",
|
|
19
|
+
"StatementAmbiguityError",
|
|
20
|
+
"StatementError",
|
|
21
|
+
"StatementLine",
|
|
22
|
+
"StatementSection",
|
|
23
|
+
"normalize_tariff",
|
|
24
|
+
"parse_statement",
|
|
25
|
+
"read_statement",
|
|
26
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Errors raised while importing PG&E statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from tariffkit.errors import TariffKitError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StatementError(TariffKitError):
|
|
11
|
+
"""A statement could not be read or did not pass its self-check."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StatementAmbiguityError(StatementError):
|
|
15
|
+
"""The statement does not provide one-to-one agreement evidence."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, diagnostics: Iterable[str] = ()) -> None:
|
|
18
|
+
self.diagnostics = tuple(diagnostics)
|
|
19
|
+
detail = "; ".join(self.diagnostics)
|
|
20
|
+
super().__init__(f"{message}: {detail}" if detail else message)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""What a PG&E statement says, in a shape a computed bill can be compared against.
|
|
2
|
+
|
|
3
|
+
A statement prints the same money more than once, in different arrangements, and
|
|
4
|
+
that redundancy is the most useful thing about it. The December 2025 / January
|
|
5
|
+
2026 statement says ``$333.87`` twice: once as the sum of time-of-use lines split
|
|
6
|
+
across the rate change on 1 January, and once as an unbundled list of tariff
|
|
7
|
+
components. Those two views have to agree with each other before either is worth
|
|
8
|
+
comparing against anything this library computed, which is what
|
|
9
|
+
:meth:`Statement.self_check` is for.
|
|
10
|
+
|
|
11
|
+
The four sections are not four pages. They are four *presentations*:
|
|
12
|
+
|
|
13
|
+
``SUMMARY``
|
|
14
|
+
What is owed, and the split between the utility and the generation provider.
|
|
15
|
+
``PGE_DELIVERY``
|
|
16
|
+
Time-of-use lines, one block per sub-period. A cycle spanning a rate change
|
|
17
|
+
prints two blocks, which is how the statement itself confirms that
|
|
18
|
+
effective-dated pricing is the right model.
|
|
19
|
+
``PGE_BREAKDOWN``
|
|
20
|
+
The same total, unbundled into tariff components. This is the presentation
|
|
21
|
+
that maps onto ``Bill.import_components``.
|
|
22
|
+
``CCA_GENERATION``
|
|
23
|
+
The generation provider's own page. On a CCA account this is where the state
|
|
24
|
+
energy surcharge prints, which is how it stayed invisible for so long while
|
|
25
|
+
every line on the utility's pages reconciled.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import date
|
|
33
|
+
from enum import StrEnum
|
|
34
|
+
|
|
35
|
+
from tariffkit.billing import BillingPeriod
|
|
36
|
+
|
|
37
|
+
#: A printed figure is rounded to the cent, so agreement is judged at half a cent
|
|
38
|
+
#: plus a cent per line that had to be added together to get there.
|
|
39
|
+
CENT = 0.005
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Section(StrEnum):
|
|
43
|
+
SUMMARY = "summary"
|
|
44
|
+
PGE_DELIVERY = "pge_delivery"
|
|
45
|
+
PGE_BREAKDOWN = "pge_breakdown"
|
|
46
|
+
CCA_GENERATION = "cca_generation"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: Lines that restate other lines rather than adding to them. Counting these
|
|
50
|
+
#: would double the section's sum, and the failure is quiet: the section total
|
|
51
|
+
#: comes out plausibly wrong rather than obviously so.
|
|
52
|
+
SUBTOTAL_LABELS = frozenset({"net charges", "subtotal"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class StatementLine:
|
|
57
|
+
"""One printed row carrying an amount."""
|
|
58
|
+
|
|
59
|
+
label: str
|
|
60
|
+
amount: float
|
|
61
|
+
section: Section
|
|
62
|
+
page: int
|
|
63
|
+
#: Present on a metered row: "982.126000 kWh @ $0.13500", and also on a
|
|
64
|
+
#: per-day one: "29 days @ $0.79343", which is how the Base Services Charge
|
|
65
|
+
#: prints. Both are quantity-times-rate, so both are kept the same way.
|
|
66
|
+
quantity: float | None = None
|
|
67
|
+
unit: str = ""
|
|
68
|
+
rate: float | None = None
|
|
69
|
+
#: Which sub-period block this row appeared under, when the section has them.
|
|
70
|
+
subperiod: tuple[date, date] | None = None
|
|
71
|
+
#: The sub-heading this row sits under, where one distinguishes otherwise
|
|
72
|
+
#: identical labels. On the Solar Billing Plan the same three time-of-use
|
|
73
|
+
#: labels print twice, once under "Energy Produced" and once under "Energy
|
|
74
|
+
#: Delivered" -- export and import. Without this they look like the section
|
|
75
|
+
#: boundaries overlapping, and either the credit or the charge is dropped.
|
|
76
|
+
block: str = ""
|
|
77
|
+
#: Which service agreement this row belongs to, 1-based. A statement issued
|
|
78
|
+
#: in a month the account changed tariff carries two of everything, with the
|
|
79
|
+
#: same labels in each, and they are different charges under different
|
|
80
|
+
#: schedules rather than a section read twice.
|
|
81
|
+
agreement: int = 1
|
|
82
|
+
raw: str = ""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_subtotal(self) -> bool:
|
|
86
|
+
return self.label.strip().lower() in SUBTOTAL_LABELS
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def kwh(self) -> float | None:
|
|
90
|
+
"""The quantity, when it is energy rather than days."""
|
|
91
|
+
return self.quantity if self.unit.lower() == "kwh" else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class StatementSection:
|
|
96
|
+
name: Section
|
|
97
|
+
lines: tuple[StatementLine, ...] = ()
|
|
98
|
+
#: The section's own printed total, when it prints one.
|
|
99
|
+
printed_total: float | None = None
|
|
100
|
+
total_label: str = ""
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def charged(self) -> tuple[StatementLine, ...]:
|
|
104
|
+
"""The rows that add up, excluding restatements of other rows."""
|
|
105
|
+
return tuple(line for line in self.lines if not line.is_subtotal)
|
|
106
|
+
|
|
107
|
+
def total(self) -> float:
|
|
108
|
+
return sum(line.amount for line in self.charged)
|
|
109
|
+
|
|
110
|
+
def find(self, label: str) -> tuple[StatementLine, ...]:
|
|
111
|
+
wanted = label.strip().lower()
|
|
112
|
+
return tuple(line for line in self.lines if line.label.strip().lower() == wanted)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class StatementAgreement:
|
|
117
|
+
"""One service-agreement span and the tariff evidence printed with it."""
|
|
118
|
+
|
|
119
|
+
period: BillingPeriod
|
|
120
|
+
printed_schedule: str
|
|
121
|
+
tariff: str
|
|
122
|
+
page: int
|
|
123
|
+
account_masked: str = ""
|
|
124
|
+
baseline_territory: str = ""
|
|
125
|
+
pcia_vintage: int | None = None
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if not self.printed_schedule.strip():
|
|
129
|
+
raise ValueError("statement agreement needs a printed schedule")
|
|
130
|
+
if not self.tariff.strip():
|
|
131
|
+
raise ValueError("statement agreement needs a normalized tariff")
|
|
132
|
+
if self.page < 0:
|
|
133
|
+
raise ValueError("statement agreement page must be non-negative")
|
|
134
|
+
if self.account_masked and (
|
|
135
|
+
len(self.account_masked) != 4 or not self.account_masked.isdigit()
|
|
136
|
+
):
|
|
137
|
+
raise ValueError("statement agreement account suffix must be four digits")
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def start(self) -> date:
|
|
141
|
+
"""The inclusive first date printed for this agreement."""
|
|
142
|
+
return self.period.start
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def end(self) -> date:
|
|
146
|
+
"""The inclusive last date printed for this agreement."""
|
|
147
|
+
return self.period.end
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class Statement:
|
|
152
|
+
"""One issued statement, parsed."""
|
|
153
|
+
|
|
154
|
+
statement_date: date
|
|
155
|
+
period: BillingPeriod
|
|
156
|
+
amount_due: float
|
|
157
|
+
#: Last four digits only. The full number is read to find the masked form and
|
|
158
|
+
#: then discarded: it never reaches a fixture, a log line, or JSON output.
|
|
159
|
+
account_masked: str = ""
|
|
160
|
+
billed_days: int | None = None
|
|
161
|
+
billed_kwh: float | None = None
|
|
162
|
+
#: How many service agreements the statement covers. More than one means the
|
|
163
|
+
#: account changed tariff mid-cycle and the utility priced each part
|
|
164
|
+
#: separately -- a count, never the identifiers themselves. Priced, not
|
|
165
|
+
#: refused: ``compute_segments`` handles it.
|
|
166
|
+
service_agreements: int = 1
|
|
167
|
+
#: Exact service-agreement spans paired with tariff evidence from the same
|
|
168
|
+
#: delivery page. An empty tuple means the statement did not provide enough
|
|
169
|
+
#: evidence to establish that mapping.
|
|
170
|
+
agreements: tuple[StatementAgreement, ...] = ()
|
|
171
|
+
#: Gas, on a combined statement. This account burned no therms and was still
|
|
172
|
+
#: billed a minimum transportation charge, so a zero-usage service is not a
|
|
173
|
+
#: zero-money one. Recorded to make the amount due add up and then ignored:
|
|
174
|
+
#: nothing here prices gas, so no computed component may claim it.
|
|
175
|
+
gas_charges: float | None = None
|
|
176
|
+
#: Summary-level electric adjustments, e.g. the California Climate Credit,
|
|
177
|
+
#: which belong to no detail section and are not per-cycle charges.
|
|
178
|
+
electric_adjustments: float | None = None
|
|
179
|
+
sections: tuple[StatementSection, ...] = ()
|
|
180
|
+
#: What the statement says about itself, used to catch a stale account
|
|
181
|
+
#: configuration before it can produce a confident, fabricated finding.
|
|
182
|
+
rate_schedule: str = ""
|
|
183
|
+
#: Every rate schedule the statement prints, in order and deduplicated. More
|
|
184
|
+
#: than one means the cycle was billed under more than one tariff.
|
|
185
|
+
printed_schedules: tuple[str, ...] = ()
|
|
186
|
+
cca_name: str = ""
|
|
187
|
+
cca_rate_schedule: str = ""
|
|
188
|
+
baseline_territory: str = ""
|
|
189
|
+
pcia_vintage: int | None = None
|
|
190
|
+
source: str = ""
|
|
191
|
+
#: True when the text came from recognising rendered pages rather than from
|
|
192
|
+
#: the PDF's own text layer. Recorded because a misread digit is a plausible
|
|
193
|
+
#: amount rather than an obvious failure, so a reader deserves to know which
|
|
194
|
+
#: figures were read and which were recognised.
|
|
195
|
+
recognised: bool = False
|
|
196
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
197
|
+
|
|
198
|
+
def section(self, name: Section) -> StatementSection | None:
|
|
199
|
+
for candidate in self.sections:
|
|
200
|
+
if candidate.name is name:
|
|
201
|
+
return candidate
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
def lines(self) -> Iterator[StatementLine]:
|
|
205
|
+
for section in self.sections:
|
|
206
|
+
yield from section.lines
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def subperiods(self) -> tuple[tuple[date, date], ...]:
|
|
210
|
+
"""The rate-change blocks the utility itself split the cycle into."""
|
|
211
|
+
seen: list[tuple[date, date]] = []
|
|
212
|
+
for line in self.lines():
|
|
213
|
+
if line.subperiod and line.subperiod not in seen:
|
|
214
|
+
seen.append(line.subperiod)
|
|
215
|
+
return tuple(seen)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def electric_charges(self) -> float:
|
|
219
|
+
"""The part of the bill this library is actually pricing.
|
|
220
|
+
|
|
221
|
+
The amount due is not comparable to a computed total: it carries gas,
|
|
222
|
+
and it carries summary-level credits like the California Climate Credit
|
|
223
|
+
that are not per-cycle charges. On 2025-11 those two make the difference
|
|
224
|
+
between $213.89 and $268.13 -- a statement that reconciles line by line
|
|
225
|
+
while the headline figures look $54 apart, which reads as a failure and
|
|
226
|
+
is not one.
|
|
227
|
+
"""
|
|
228
|
+
return self.amount_due - (self.gas_charges or 0.0) - (self.electric_adjustments or 0.0)
|
|
229
|
+
|
|
230
|
+
def self_check(self) -> list[str]:
|
|
231
|
+
"""Problems with the parse itself, independent of any computed bill.
|
|
232
|
+
|
|
233
|
+
Run before reconciliation and treated as fatal, because a dropped row
|
|
234
|
+
produces a bill-shaped disagreement. Reporting that as a billing defect
|
|
235
|
+
would destroy the only thing this harness sells, which is that its
|
|
236
|
+
findings can be trusted.
|
|
237
|
+
"""
|
|
238
|
+
problems: list[str] = []
|
|
239
|
+
|
|
240
|
+
for section in self.sections:
|
|
241
|
+
# The summary is a running balance -- prior balance, payments
|
|
242
|
+
# received, this cycle's charges -- not a list of things that add up
|
|
243
|
+
# to the amount due. Summing it would fail on every statement, and a
|
|
244
|
+
# check that always fails is one nobody reads.
|
|
245
|
+
if section.name is Section.SUMMARY:
|
|
246
|
+
continue
|
|
247
|
+
if section.printed_total is None or not section.charged:
|
|
248
|
+
continue
|
|
249
|
+
summed = section.total()
|
|
250
|
+
slack = CENT + 0.01 * len(section.charged)
|
|
251
|
+
if abs(summed - section.printed_total) > slack:
|
|
252
|
+
problems.append(
|
|
253
|
+
f"{section.name}: rows sum to {summed:.2f} but the section prints "
|
|
254
|
+
f"{section.printed_total:.2f} (off by {summed - section.printed_total:+.2f})"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# The utility states one number twice, in two arrangements. They must
|
|
258
|
+
# agree, or one of the two presentations was mis-parsed.
|
|
259
|
+
delivery = self.section(Section.PGE_DELIVERY)
|
|
260
|
+
breakdown = self.section(Section.PGE_BREAKDOWN)
|
|
261
|
+
if delivery is None:
|
|
262
|
+
problems.append("the PG&E delivery section is missing")
|
|
263
|
+
elif not self.agreements:
|
|
264
|
+
problems.append("the statement has no service-agreement tariff evidence")
|
|
265
|
+
if delivery and breakdown and None not in (delivery.printed_total, breakdown.printed_total):
|
|
266
|
+
assert delivery.printed_total is not None and breakdown.printed_total is not None
|
|
267
|
+
if abs(delivery.printed_total - breakdown.printed_total) > CENT:
|
|
268
|
+
problems.append(
|
|
269
|
+
f"the two views of the utility's charges disagree: delivery detail prints "
|
|
270
|
+
f"{delivery.printed_total:.2f}, unbundled breakdown prints "
|
|
271
|
+
f"{breakdown.printed_total:.2f}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
parts = [
|
|
275
|
+
s.printed_total
|
|
276
|
+
for s in self.sections
|
|
277
|
+
if s.name in (Section.PGE_DELIVERY, Section.CCA_GENERATION)
|
|
278
|
+
and s.printed_total is not None
|
|
279
|
+
]
|
|
280
|
+
# The identity is not "the electric sections add up to the bill". A PG&E
|
|
281
|
+
# statement is a combined one: it can carry gas, and it carries
|
|
282
|
+
# summary-level adjustments that belong to no detail section. Both are
|
|
283
|
+
# named on the statement, so both are added here rather than absorbed
|
|
284
|
+
# into a tolerance -- an unexplained residue should stay visible.
|
|
285
|
+
expected = sum(parts) + (self.electric_adjustments or 0.0) + (self.gas_charges or 0.0)
|
|
286
|
+
if parts and abs(expected - self.amount_due) > CENT:
|
|
287
|
+
extra = []
|
|
288
|
+
if self.electric_adjustments:
|
|
289
|
+
extra.append(f"adjustments {self.electric_adjustments:+.2f}")
|
|
290
|
+
if self.gas_charges:
|
|
291
|
+
extra.append(f"gas {self.gas_charges:+.2f}")
|
|
292
|
+
detail = f" (sections {sum(parts):.2f}, {', '.join(extra)})" if extra else ""
|
|
293
|
+
problems.append(
|
|
294
|
+
f"the statement's own parts sum to {expected:.2f} but it is due "
|
|
295
|
+
f"{self.amount_due:.2f}{detail}; a whole section is probably missing"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if self.billed_days is not None and self.billed_days != self.period.days:
|
|
299
|
+
problems.append(
|
|
300
|
+
f"the statement prints {self.billed_days} billing days but its dates "
|
|
301
|
+
f"{self.period.start}..{self.period.end} span {self.period.days}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
seen: set[tuple[Section, str, tuple[date, date] | None, str, int]] = set()
|
|
305
|
+
for line in self.lines():
|
|
306
|
+
key = (
|
|
307
|
+
line.section,
|
|
308
|
+
line.label.strip().lower(),
|
|
309
|
+
line.subperiod,
|
|
310
|
+
line.block,
|
|
311
|
+
line.agreement,
|
|
312
|
+
)
|
|
313
|
+
if key in seen:
|
|
314
|
+
problems.append(
|
|
315
|
+
f"{line.section}: {line.label!r} appears twice in the same block, "
|
|
316
|
+
f"so the section boundaries probably overlap"
|
|
317
|
+
)
|
|
318
|
+
seen.add(key)
|
|
319
|
+
|
|
320
|
+
return problems
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Reading a statement that carries no usable text.
|
|
2
|
+
|
|
3
|
+
PG&E's statements before November 2025 are drawn with Type 3 fonts whose
|
|
4
|
+
``ToUnicode`` map declares most glyphs to be U+0020. Extraction is not failing on
|
|
5
|
+
them -- it is faithfully returning the spaces the document asks for -- so no
|
|
6
|
+
choice of PDF library helps, and pypdf and poppler agree. The pixels are the only
|
|
7
|
+
honest source, which means rendering and recognising them.
|
|
8
|
+
|
|
9
|
+
The parser downstream expects ``extraction_mode="layout"`` text, where a row is a
|
|
10
|
+
label, two or more spaces, and a right-aligned amount. So this does not just
|
|
11
|
+
return words: it rebuilds the page as a character grid from the word boxes
|
|
12
|
+
tesseract reports, which is what makes the same parser work on both kinds of
|
|
13
|
+
statement rather than needing a second one.
|
|
14
|
+
|
|
15
|
+
**Recognition can be wrong, and that matters more here than usual** -- a misread
|
|
16
|
+
digit is a plausible dollar amount rather than an obvious failure. Nothing here
|
|
17
|
+
tries to be clever about that. It relies instead on the check that already
|
|
18
|
+
exists: a statement prints its totals twice, and :meth:`Statement.self_check`
|
|
19
|
+
refuses to reconcile one whose sections do not sum to what it says is due. A
|
|
20
|
+
digit recognised wrongly almost always breaks that arithmetic.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import csv
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from .errors import StatementError
|
|
33
|
+
|
|
34
|
+
#: Rendering resolution. 300 is not a floor to be raised for better results:
|
|
35
|
+
#: 400 was measurably worse here, losing the statement date entirely, because
|
|
36
|
+
#: tesseract's model is trained around this scale and the reconstructed grid
|
|
37
|
+
#: depends on its word boxes being consistent. Change it only with a statement
|
|
38
|
+
#: in front of you.
|
|
39
|
+
DPI = 300
|
|
40
|
+
|
|
41
|
+
#: Tesseract's page segmentation mode. 11 is "sparse text": find words, attempt
|
|
42
|
+
#: no layout analysis at all.
|
|
43
|
+
#:
|
|
44
|
+
#: That sounds like the wrong choice and is the right one, because the rows are
|
|
45
|
+
#: reassembled here from word geometry anyway. Tesseract's own analysis fights
|
|
46
|
+
#: that -- on a statement laid out as a table beside a sidebar, mode 6 silently
|
|
47
|
+
#: drops whole rows. It lost the Peak charge from the 2025-09 delivery table,
|
|
48
|
+
#: $104.43 of a $178.64 section, while the chart legend two inches lower
|
|
49
|
+
#: recognised the same figure perfectly. Mode 11 reads that row completely.
|
|
50
|
+
PSM = "11"
|
|
51
|
+
|
|
52
|
+
#: Modes to try, in order. Recognition quality is not uniform across statements
|
|
53
|
+
#: -- 11 reads rows 6 drops, and 6 occasionally resolves an amount column 11
|
|
54
|
+
#: fragments -- and there is no way to tell in advance which will do better on a
|
|
55
|
+
#: given page. So more than one is attempted and the *statement decides*: a bill
|
|
56
|
+
#: prints its totals twice and its sections have to sum to them, which is an
|
|
57
|
+
#: objective test the document supplies itself rather than a preference for
|
|
58
|
+
#: whichever answer looks nicer.
|
|
59
|
+
PSM_CANDIDATES = ("11", "6", "4")
|
|
60
|
+
|
|
61
|
+
#: How much of a line's height two words may differ by and still be the same
|
|
62
|
+
#: row. Sparse mode does no line grouping, so rows are clustered by vertical
|
|
63
|
+
#: position here; a fraction rather than a constant because it has to hold at
|
|
64
|
+
#: whatever resolution the page was rendered at.
|
|
65
|
+
ROW_TOLERANCE = 0.6
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def available() -> bool:
|
|
69
|
+
"""Whether the external tools needed for recognition are installed."""
|
|
70
|
+
return bool(shutil.which("pdftoppm") and shutil.which("tesseract"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render(source: Path, into: Path) -> list[Path]:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["pdftoppm", "-r", str(DPI), "-png", str(source), str(into / "page")],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode != 0:
|
|
80
|
+
raise StatementError(
|
|
81
|
+
f"could not render {source.name} for recognition: "
|
|
82
|
+
f"{result.stderr.decode('utf-8', 'replace')[:200]}"
|
|
83
|
+
)
|
|
84
|
+
return sorted(into.glob("page*.png"))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _words(image: Path, psm: str = PSM) -> list[tuple[int, int, int, int, str]]:
|
|
88
|
+
"""Recognised words as (top, height, left, width, text)."""
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["tesseract", str(image), "stdout", "--psm", psm, "tsv"],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
if result.returncode != 0:
|
|
95
|
+
raise StatementError(
|
|
96
|
+
f"recognition failed on {image.name}: {result.stderr.decode('utf-8', 'replace')[:200]}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
rows: list[tuple[int, int, int, int, str]] = []
|
|
100
|
+
reader = csv.DictReader(result.stdout.decode("utf-8", "replace").splitlines(), delimiter="\t")
|
|
101
|
+
for row in reader:
|
|
102
|
+
text = (row.get("text") or "").strip()
|
|
103
|
+
if not text:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
# Geometry only. Sparse mode reports no meaningful block, paragraph
|
|
107
|
+
# or line numbering, and relying on any of it is what reordered rows
|
|
108
|
+
# across sections before.
|
|
109
|
+
rows.append(
|
|
110
|
+
(
|
|
111
|
+
int(row["top"]),
|
|
112
|
+
int(row["height"]),
|
|
113
|
+
int(row["left"]),
|
|
114
|
+
int(row["width"]),
|
|
115
|
+
text,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
except KeyError, ValueError:
|
|
119
|
+
continue
|
|
120
|
+
return rows
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _as_layout(words: list[tuple[int, int, int, int, str]]) -> str:
|
|
124
|
+
"""Rebuild a character grid from word boxes.
|
|
125
|
+
|
|
126
|
+
The parser reads column positions, not just words, so the horizontal
|
|
127
|
+
geometry has to survive. Each word is placed at the column its pixel offset
|
|
128
|
+
implies, using the median character width as the scale -- which is what makes
|
|
129
|
+
"Distribution" and its right-aligned amount stay in different fields.
|
|
130
|
+
"""
|
|
131
|
+
if not words:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
widths = sorted(width / len(text) for _, _, _, width, text in words if text)
|
|
135
|
+
char_width = widths[len(widths) // 2] or 1.0
|
|
136
|
+
heights = sorted(height for _, height, _, _, _ in words)
|
|
137
|
+
band = (heights[len(heights) // 2] or 1) * ROW_TOLERANCE
|
|
138
|
+
|
|
139
|
+
# Clustered down the page. Ordering by anything tesseract numbers rather
|
|
140
|
+
# than by position reorders rows across section boundaries, and the symptom
|
|
141
|
+
# is one section short by a charge while another is over by the same rows.
|
|
142
|
+
grouped: list[list[tuple[int, str]]] = []
|
|
143
|
+
anchor = None
|
|
144
|
+
for top, _, left, _, text in sorted(words, key=lambda w: (w[0], w[2])):
|
|
145
|
+
if anchor is None or top - anchor > band:
|
|
146
|
+
grouped.append([])
|
|
147
|
+
anchor = top
|
|
148
|
+
grouped[-1].append((round(left / char_width), text))
|
|
149
|
+
|
|
150
|
+
out: list[str] = []
|
|
151
|
+
for group in grouped:
|
|
152
|
+
row = ""
|
|
153
|
+
for column, text in sorted(group):
|
|
154
|
+
if column <= len(row):
|
|
155
|
+
# The estimate put this word at or inside the end of the last
|
|
156
|
+
# one. One space, never none: run together, "Off Peak" becomes
|
|
157
|
+
# "OffPeak" and matches no rule, and "Baseline Allowance" stops
|
|
158
|
+
# being recognised as the row to skip -- so its kWh quantity is
|
|
159
|
+
# read as a few hundred dollars of charges.
|
|
160
|
+
row += " " + text
|
|
161
|
+
else:
|
|
162
|
+
row = row.ljust(column) + text
|
|
163
|
+
out.append(row.rstrip())
|
|
164
|
+
return "\n".join(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def readings(path: str | Path) -> Iterator[list[str]]:
|
|
168
|
+
"""Each candidate recognition of a statement's pages, best mode first.
|
|
169
|
+
|
|
170
|
+
Yields rather than returns so a caller can stop at the first reading that
|
|
171
|
+
satisfies the statement's own arithmetic, and pay for the slower modes only
|
|
172
|
+
when the first one does not.
|
|
173
|
+
"""
|
|
174
|
+
source = Path(path)
|
|
175
|
+
if not available():
|
|
176
|
+
raise StatementError(
|
|
177
|
+
f"{source.name} carries no readable text and recognition tools are not "
|
|
178
|
+
f"installed; `brew install tesseract poppler` provides both"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
with tempfile.TemporaryDirectory(prefix="nem-ocr-") as scratch:
|
|
182
|
+
images = _render(source, Path(scratch))
|
|
183
|
+
if not images:
|
|
184
|
+
raise StatementError(f"{source.name} produced no pages to recognise")
|
|
185
|
+
for psm in PSM_CANDIDATES:
|
|
186
|
+
yield [_as_layout(_words(image, psm)) for image in images]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def pages_via_ocr(path: str | Path) -> list[str]:
|
|
190
|
+
"""The first recognition of a statement's pages."""
|
|
191
|
+
for pages in readings(path):
|
|
192
|
+
return pages
|
|
193
|
+
raise StatementError(f"{Path(path).name} produced no pages to recognise")
|