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