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,365 @@
1
+ """Export credit balances carried across billing cycles.
2
+
3
+ A single cycle's charges come from :mod:`tariffkit.billing.engine`, which is
4
+ pure. This is the stateful layer above it: credits earned but not spent in a
5
+ cycle bank, and offset later charges.
6
+
7
+ Credits are not fungible. PG&E's statement states the rule directly:
8
+
9
+ 1. Energy Produced credits can only offset Energy Produced charges
10
+ 2. Energy Delivered credits can only offset Energy Delivered charges
11
+ 3. Energy Export Bonus Credit can offset any and all electric charges
12
+
13
+ So a balance is three buckets, not a number, and applying it needs each charge
14
+ classified by which bucket may offset it.
15
+
16
+ **One bank at a time.** A CCA customer has two, kept separately: PG&E's and the
17
+ CCA's, each with its own balance and its own charges to offset. A :class:`Bill`
18
+ merges both providers, so applying this to one straight off the billing engine
19
+ gives an approximation -- the two banks spend in an order a merged view cannot
20
+ reproduce. Feed one provider's charges and credits to get an exact answer; this
21
+ must be done for each half of a real statement.
22
+
23
+ Scope: this models carryover within a year. Closing a year -- the annual
24
+ true-up, and Net Surplus Compensation -- lives in
25
+ :mod:`tariffkit.billing.trueup`, which is built from tariff text rather than
26
+ reconciled against a statement, because no true-up has happened on this account
27
+ yet.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Iterable, Sequence
33
+ from dataclasses import dataclass, field, replace
34
+ from enum import StrEnum
35
+ from typing import Any
36
+
37
+ from .models import Bill, BillingPeriod
38
+
39
+
40
+ class CreditBucket(StrEnum):
41
+ """Which charges a credit may offset."""
42
+
43
+ #: PG&E's "Energy Produced" credits, and a CCA's Energy Export Credit.
44
+ GENERATION = "generation"
45
+ #: PG&E's "Energy Delivered" credits.
46
+ DELIVERY = "delivery"
47
+ #: Offsets anything not explicitly non-bypassable.
48
+ BONUS = "bonus"
49
+
50
+
51
+ #: Export credit component -> the bucket it banks into.
52
+ #:
53
+ #: Verified against the 2026-08-04 statement, whose credit bank splits into
54
+ #: "Energy Delivered Credits" $6.25 (the ``delivery`` component) and "Bonus
55
+ #: Credits" $1.71 (``acc_plus``), and whose CCA page reports an Energy Export
56
+ #: Credit of $9.63 (``cca_generation``).
57
+ #:
58
+ CREDIT_BUCKETS: dict[str, CreditBucket] = {
59
+ "delivery": CreditBucket.DELIVERY,
60
+ "generation": CreditBucket.GENERATION,
61
+ "cca_generation": CreditBucket.GENERATION,
62
+ "acc_plus": CreditBucket.BONUS,
63
+ }
64
+
65
+ #: Export-side components a statement spends inside the cycle instead of banking.
66
+ #:
67
+ #: MCE's 10% Solar Bonus Credit is the one vendored. The library computes it as
68
+ #: part of the export credit, but the statement prints it on the *charges* side,
69
+ #: between the cost relief credit and "Net Charges" -- so it reduces that
70
+ #: cycle's generation charges and never reaches the bank. Banking it instead
71
+ #: overstates both credits earned and credits applied, by $0.96 on the
72
+ #: 2026-08-04 statement, while coincidentally still landing on the right closing
73
+ #: balance because it was spent the same cycle.
74
+ CHARGE_OFFSETS: dict[str, CreditBucket] = {
75
+ "cca_solar_bonus": CreditBucket.GENERATION,
76
+ }
77
+
78
+ #: Charge component -> the bucket whose credits may offset it.
79
+ #:
80
+ #: Anything absent is treated as non-offsettable. That is deliberate: a new
81
+ #: component defaults to being payable in cash rather than silently becoming
82
+ #: creditable.
83
+ CHARGE_BUCKETS: dict[str, CreditBucket] = {
84
+ # Generation, whoever supplies it.
85
+ "generation": CreditBucket.GENERATION,
86
+ "cca_generation": CreditBucket.GENERATION,
87
+ "cca_cost_relief_credit": CreditBucket.GENERATION,
88
+ # Delivery. The statement bills these as its "Energy Delivered" block.
89
+ "distribution": CreditBucket.DELIVERY,
90
+ "transmission": CreditBucket.DELIVERY,
91
+ "transmission_rate_adjustments": CreditBucket.DELIVERY,
92
+ "reliability_services": CreditBucket.DELIVERY,
93
+ "wildfire_hardening": CreditBucket.DELIVERY,
94
+ "recovery_bond_charge": CreditBucket.DELIVERY,
95
+ "recovery_bond_credit": CreditBucket.DELIVERY,
96
+ "new_system_generation": CreditBucket.DELIVERY,
97
+ "bundled_pcia": CreditBucket.DELIVERY,
98
+ }
99
+
100
+ #: Charges no export credit may offset, so they are payable in cash.
101
+ #:
102
+ #: The five non-bypassable charges are non-bypassable in the tariff's own sense
103
+ #: -- that is what the term means -- so not even a bonus credit reaches them.
104
+ #: PCIA, the franchise fee surcharge and the Base Services Charge are listed
105
+ #: here as the conservative reading; see ``SCOPING_VERIFIED``.
106
+ NON_OFFSETTABLE = frozenset(
107
+ {
108
+ "public_purpose_programs",
109
+ "wildfire_fund_charge",
110
+ "competition_transition_charges",
111
+ "nuclear_decommissioning",
112
+ "energy_cost_recovery",
113
+ "pcia",
114
+ "franchise_fee_surcharge",
115
+ "baseline_credit",
116
+ }
117
+ )
118
+
119
+ #: False while the classification above is only partly reconciled.
120
+ #:
121
+ #: What a statement has confirmed: which components bank into which bucket, and
122
+ #: that unspent credit carries forward capped by the charges available to offset.
123
+ #:
124
+ #: What it has not: where the boundary of an "Energy Delivered charge" actually
125
+ #: falls. Confirming that needs a cycle whose credits *exceed* the charges they
126
+ #: may offset, so the cap binds and the leftover is visible. On the statements
127
+ #: reconciled so far the PG&E credits were smaller than the delivery charges, so
128
+ #: the cap never bound and any classification would have produced the same bill.
129
+ SCOPING_VERIFIED = False
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class CreditBalances:
134
+ """A credit bank, by bucket. Never negative."""
135
+
136
+ generation: float = 0.0
137
+ delivery: float = 0.0
138
+ bonus: float = 0.0
139
+
140
+ def __post_init__(self) -> None:
141
+ for bucket in CreditBucket:
142
+ if self[bucket] < -1e-9:
143
+ raise ValueError(f"{bucket} balance is negative: {self[bucket]}")
144
+
145
+ def __getitem__(self, bucket: CreditBucket) -> float:
146
+ return float(getattr(self, str(bucket)))
147
+
148
+ def with_bucket(self, bucket: CreditBucket, value: float) -> CreditBalances:
149
+ return replace(self, **{str(bucket): value})
150
+
151
+ @property
152
+ def total(self) -> float:
153
+ return self.generation + self.delivery + self.bonus
154
+
155
+ def to_dict(self) -> dict[str, float]:
156
+ return {
157
+ "generation": round(self.generation, 4),
158
+ "delivery": round(self.delivery, 4),
159
+ "bonus": round(self.bonus, 4),
160
+ "total": round(self.total, 4),
161
+ }
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class LedgerEntry:
166
+ """One cycle, after credits are applied."""
167
+
168
+ period: BillingPeriod
169
+ opening: CreditBalances
170
+ earned: CreditBalances
171
+ applied: CreditBalances
172
+ closing: CreditBalances
173
+ #: Charges left after credits, i.e. what is actually owed.
174
+ cash_due: float
175
+ #: Charges before any credit was applied.
176
+ gross_charges: float
177
+ #: The part of ``gross_charges`` no credit could reach.
178
+ non_offsettable: float
179
+ #: Metered energy for the cycle. Carried here because the annual true-up
180
+ #: tests surplus in kilowatt-hours rather than dollars -- both PG&E and MCE
181
+ #: define a Net Surplus Generator as one whose exported energy exceeds its
182
+ #: imported energy over the period.
183
+ imported_kwh: float = 0.0
184
+ exported_kwh: float = 0.0
185
+ #: False while the charge classification is only partly reconciled against a
186
+ #: statement; see ``SCOPING_VERIFIED``.
187
+ complete: bool = SCOPING_VERIFIED
188
+
189
+ def to_dict(self) -> dict[str, Any]:
190
+ return {
191
+ "period": self.period.to_dict(),
192
+ "opening": self.opening.to_dict(),
193
+ "earned": self.earned.to_dict(),
194
+ "applied": self.applied.to_dict(),
195
+ "closing": self.closing.to_dict(),
196
+ "cash_due": round(self.cash_due, 2),
197
+ "gross_charges": round(self.gross_charges, 2),
198
+ "non_offsettable": round(self.non_offsettable, 2),
199
+ "imported_kwh": round(self.imported_kwh, 3),
200
+ "exported_kwh": round(self.exported_kwh, 3),
201
+ "complete": self.complete,
202
+ }
203
+
204
+
205
+ def credits_earned(bill: Bill) -> CreditBalances:
206
+ """Split a cycle's export credits into buckets.
207
+
208
+ ``Bill.export_components`` holds credits as negative numbers so a bill sums
209
+ directly; a bank holds them positive.
210
+ """
211
+ totals: dict[CreditBucket, float] = dict.fromkeys(CreditBucket, 0.0)
212
+ for name, value in bill.export_components.items():
213
+ bucket = CREDIT_BUCKETS.get(name)
214
+ if bucket is not None:
215
+ totals[bucket] += abs(value)
216
+ return CreditBalances(
217
+ generation=totals[CreditBucket.GENERATION],
218
+ delivery=totals[CreditBucket.DELIVERY],
219
+ bonus=totals[CreditBucket.BONUS],
220
+ )
221
+
222
+
223
+ def charges_by_bucket(
224
+ bill: Bill,
225
+ ) -> tuple[dict[CreditBucket, float], float, dict[CreditBucket, float]]:
226
+ """``({bucket: offsettable charges}, non-offsettable charges, {bucket: unspent})``.
227
+
228
+ A component that nets out negative -- ``cca_cost_relief_credit``, or the
229
+ recovery bond credit -- reduces its bucket rather than creating charge to
230
+ offset elsewhere, which is how the statement prints it.
231
+
232
+ Fixed charges are offsettable, but only by the bonus bucket. This was the
233
+ other way round on the evidence then available -- "no reconciled statement
234
+ has shown a credit reaching it" -- and the 2026-07-07 statement falsified
235
+ it: it applies $1.59 of bonus credit where the energy charges alone leave
236
+ room for $0.92, and PG&E's own wording is that the bonus offsets anything
237
+ not explicitly non-bypassable. The Base Services Charge is not
238
+ non-bypassable; the charges printed as Non-Bypassable Charges are, and they
239
+ stay out of reach.
240
+ """
241
+ offsettable: dict[CreditBucket, float] = dict.fromkeys(CreditBucket, 0.0)
242
+ non_offsettable = 0.0
243
+ offsettable[CreditBucket.BONUS] += sum(bill.fixed_components.values())
244
+
245
+ for name, value in bill.import_components.items():
246
+ bucket = CHARGE_BUCKETS.get(name)
247
+ if bucket is None or name in NON_OFFSETTABLE:
248
+ non_offsettable += value
249
+ else:
250
+ offsettable[bucket] += value
251
+
252
+ # Spent in-cycle rather than banked; held negative on the export side.
253
+ for name, value in bill.export_components.items():
254
+ bucket = CHARGE_OFFSETS.get(name)
255
+ if bucket is not None:
256
+ offsettable[bucket] -= abs(value)
257
+
258
+ # An in-cycle offset can wipe out its bucket's charges but no more. The
259
+ # excess must not leak into non_offsettable: those are the non-bypassable
260
+ # charges, the ones nothing is allowed to reduce, and a generation-scoped
261
+ # offset reaching them would be exactly backwards. It banks instead, which
262
+ # is the rule the statement gives for any credit it cannot spend --
263
+ # "saved to help offset future bill charges".
264
+ unspent: dict[CreditBucket, float] = dict.fromkeys(CreditBucket, 0.0)
265
+ for bucket, value in offsettable.items():
266
+ if value < 0.0:
267
+ unspent[bucket] = -value
268
+ offsettable[bucket] = 0.0
269
+ return offsettable, non_offsettable, unspent
270
+
271
+
272
+ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEntry:
273
+ """Offset one cycle's charges with banked and newly earned credits.
274
+
275
+ Scoped buckets are spent first, then the bonus bucket against whatever
276
+ remains, because the bonus is the only one that can offset anything. Doing
277
+ it the other way round would burn the flexible credit on charges a scoped
278
+ one could have covered, and strand the scoped credit.
279
+ """
280
+ opening = opening or CreditBalances()
281
+ offsettable, non_offsettable, unspent = charges_by_bucket(bill)
282
+
283
+ # An in-cycle offset larger than the charges it was meant to cover banks the
284
+ # remainder rather than being lost or turned into cash owed.
285
+ earned = credits_earned(bill)
286
+ for bucket, value in unspent.items():
287
+ if value:
288
+ earned = earned.with_bucket(bucket, earned[bucket] + value)
289
+
290
+ available = CreditBalances(
291
+ generation=opening.generation + earned.generation,
292
+ delivery=opening.delivery + earned.delivery,
293
+ bonus=opening.bonus + earned.bonus,
294
+ )
295
+ applied = CreditBalances()
296
+ remaining = dict(offsettable)
297
+
298
+ for bucket in (CreditBucket.GENERATION, CreditBucket.DELIVERY):
299
+ spend = min(available[bucket], remaining[bucket])
300
+ applied = applied.with_bucket(bucket, spend)
301
+ remaining[bucket] -= spend
302
+
303
+ # The bonus reaches anything still standing, except charges the tariff makes
304
+ # non-bypassable -- that is what "non-bypassable" means.
305
+ bonus_spend = min(available.bonus, sum(remaining.values()))
306
+ applied = applied.with_bucket(CreditBucket.BONUS, bonus_spend)
307
+
308
+ closing = CreditBalances(
309
+ generation=available.generation - applied.generation,
310
+ delivery=available.delivery - applied.delivery,
311
+ bonus=available.bonus - applied.bonus,
312
+ )
313
+ gross = sum(offsettable.values()) + non_offsettable
314
+ return LedgerEntry(
315
+ period=bill.period,
316
+ opening=opening,
317
+ earned=earned,
318
+ applied=applied,
319
+ closing=closing,
320
+ cash_due=gross - applied.total,
321
+ gross_charges=gross,
322
+ non_offsettable=non_offsettable,
323
+ imported_kwh=sum(b.imported for b in bill.buckets),
324
+ exported_kwh=sum(b.exported for b in bill.buckets),
325
+ )
326
+
327
+
328
+ @dataclass(frozen=True, slots=True)
329
+ class Ledger:
330
+ """A run of cycles with the bank carried between them."""
331
+
332
+ entries: tuple[LedgerEntry, ...] = field(default_factory=tuple)
333
+
334
+ @property
335
+ def closing(self) -> CreditBalances:
336
+ return self.entries[-1].closing if self.entries else CreditBalances()
337
+
338
+ @property
339
+ def cash_due(self) -> float:
340
+ return sum(e.cash_due for e in self.entries)
341
+
342
+ def to_dict(self) -> dict[str, Any]:
343
+ return {
344
+ "entries": [e.to_dict() for e in self.entries],
345
+ "closing": self.closing.to_dict(),
346
+ "cash_due": round(self.cash_due, 2),
347
+ }
348
+
349
+
350
+ def run_ledger(bills: Iterable[Bill], opening: CreditBalances | None = None) -> Ledger:
351
+ """Fold ``bills`` in order, carrying the bank between cycles.
352
+
353
+ Bills are sorted by period start, so a caller need not pre-order them. They
354
+ are not checked for gaps or overlaps: a ledger over a discontinuous run is
355
+ the caller's business, and a mid-year starting balance is a legitimate way
356
+ to begin partway through a program year.
357
+ """
358
+ balances = opening or CreditBalances()
359
+ entries: list[LedgerEntry] = []
360
+ ordered: Sequence[Bill] = sorted(bills, key=lambda b: b.period.start)
361
+ for bill in ordered:
362
+ entry = apply_credits(bill, balances)
363
+ entries.append(entry)
364
+ balances = entry.closing
365
+ return Ledger(tuple(entries))
@@ -0,0 +1,274 @@
1
+ """Value types for bill computation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, date, datetime, timedelta
8
+ from typing import Any
9
+
10
+ from ..models import Season, TouPeriod
11
+ from ..timeutil import PACIFIC, to_pacific
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class IntervalReading:
16
+ """Metered energy over one interval.
17
+
18
+ ``imported`` and ``exported`` are what crossed the meter, in kWh. Under the
19
+ Net Billing Tariff the meter has already netted within the interval, so in
20
+ real AMI data at most one of them is non-zero. Both are kept because the
21
+ distinction is what the tariff prices differently, and because gross
22
+ inverter data has to be netted before it can be billed.
23
+ """
24
+
25
+ start: datetime
26
+ imported: float = 0.0
27
+ exported: float = 0.0
28
+ duration: timedelta = timedelta(hours=1)
29
+ #: True when this interval's energy was reconstructed across a gap in the
30
+ #: source rather than measured over the interval itself. The total stays
31
+ #: right -- a cumulative counter only depends on its endpoints -- but the
32
+ #: shape does not, and the shape is what a time-of-use tariff prices. A
33
+ #: three-day outage spread evenly gives peak hours 5/24 of the energy where
34
+ #: the real day gives them nearly a third, which is a real dollar on a
35
+ #: cycle whose total is exact to 0.05 kWh.
36
+ estimated: bool = False
37
+
38
+ def __post_init__(self) -> None:
39
+ if self.imported < 0 or self.exported < 0:
40
+ raise ValueError(
41
+ f"readings must be non-negative; got imported={self.imported}, "
42
+ f"exported={self.exported}. Net readings should go through "
43
+ f"IntervalReading.from_net()."
44
+ )
45
+ if self.duration <= timedelta(0):
46
+ raise ValueError(f"duration must be positive, got {self.duration}")
47
+
48
+ @property
49
+ def end(self) -> datetime:
50
+ return self.start + self.duration
51
+
52
+ @property
53
+ def net(self) -> float:
54
+ """Positive when importing, negative when exporting."""
55
+ return self.imported - self.exported
56
+
57
+ @classmethod
58
+ def from_net(
59
+ cls, start: datetime, net_kwh: float, duration: timedelta = timedelta(hours=1)
60
+ ) -> IntervalReading:
61
+ """Build from a single signed value, positive meaning import."""
62
+ if net_kwh >= 0:
63
+ return cls(start, imported=net_kwh, duration=duration)
64
+ return cls(start, exported=-net_kwh, duration=duration)
65
+
66
+ @classmethod
67
+ def from_gross(
68
+ cls,
69
+ start: datetime,
70
+ consumption_kwh: float,
71
+ production_kwh: float,
72
+ duration: timedelta = timedelta(hours=1),
73
+ ) -> IntervalReading:
74
+ """Net gross site load against gross generation.
75
+
76
+ Use for inverter or CT-clamp data, which reports both sides
77
+ independently. Real AMI data is already netted -- do not double-net it.
78
+ """
79
+ return cls.from_net(start, consumption_kwh - production_kwh, duration)
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class BillingPeriod:
84
+ """A meter-read-to-meter-read cycle.
85
+
86
+ Both ends are inclusive dates, matching how a statement prints them. The
87
+ Base Services Charge is billed per day over ``days``, which is why this is
88
+ not a calendar month -- a real cycle is 27 to 33 days.
89
+ """
90
+
91
+ start: date
92
+ end: date
93
+
94
+ def __post_init__(self) -> None:
95
+ if self.end < self.start:
96
+ raise ValueError(f"period ends before it starts: {self.start} to {self.end}")
97
+
98
+ @property
99
+ def days(self) -> int:
100
+ return (self.end - self.start).days + 1
101
+
102
+ def contains(self, moment: datetime) -> bool:
103
+ return self.start <= to_pacific(moment).date() <= self.end
104
+
105
+ @property
106
+ def elapsed(self) -> timedelta:
107
+ """Real time the cycle spans, which is not ``days`` times 24 hours.
108
+
109
+ A cycle containing a DST transition is an hour longer or shorter. Use
110
+ this to ask how much metered data *should* be there; use ``days`` for
111
+ anything billed per calendar day, like the Base Services Charge.
112
+ """
113
+ opens = datetime(self.start.year, self.start.month, self.start.day, tzinfo=PACIFIC)
114
+ # Wall-clock arithmetic is right here: the cycle closes at the next local
115
+ # midnight, however many real hours away that falls.
116
+ closes = datetime(self.end.year, self.end.month, self.end.day, tzinfo=PACIFIC) + timedelta(
117
+ days=1
118
+ )
119
+ return closes.astimezone(UTC) - opens.astimezone(UTC)
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ return {
123
+ "start": self.start.isoformat(),
124
+ "end": self.end.isoformat(),
125
+ "days": self.days,
126
+ }
127
+
128
+ @classmethod
129
+ def from_readings(cls, readings: Sequence[IntervalReading]) -> BillingPeriod:
130
+ """Infer the cycle from the data's own span."""
131
+ if not readings:
132
+ raise ValueError("cannot infer a billing period from no readings")
133
+ starts = [to_pacific(r.start) for r in readings]
134
+ return cls(min(starts).date(), max(starts).date())
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class UsageBucket:
139
+ """Energy in one season and TOU period, at one rate.
140
+
141
+ Mirrors a printed bill line -- "Off Peak 22.903000 kWh @ $0.11878" -- so a
142
+ computed bill can be compared against a statement line by line.
143
+ """
144
+
145
+ season: Season
146
+ period: TouPeriod
147
+ imported: float = 0.0
148
+ exported: float = 0.0
149
+ import_charge: float = 0.0
150
+ export_credit: float = 0.0
151
+
152
+ @property
153
+ def import_rate(self) -> float | None:
154
+ """Effective $/kWh, or None when nothing was imported."""
155
+ return self.import_charge / self.imported if self.imported else None
156
+
157
+ @property
158
+ def export_rate(self) -> float | None:
159
+ return self.export_credit / self.exported if self.exported else None
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ return {
163
+ "season": str(self.season),
164
+ "period": str(self.period),
165
+ "imported_kwh": round(self.imported, 6),
166
+ "exported_kwh": round(self.exported, 6),
167
+ "import_charge": round(self.import_charge, 4),
168
+ "export_credit": round(self.export_credit, 4),
169
+ "import_rate": None if self.import_rate is None else round(self.import_rate, 5),
170
+ "export_rate": None if self.export_rate is None else round(self.export_rate, 5),
171
+ }
172
+
173
+
174
+ #: Per-kWh charges a statement prints as taxes rather than as energy charges.
175
+ #: In ``import_components`` and in ``total``, but outside ``energy_charges``.
176
+ TAX_COMPONENTS = frozenset({"energy_commission_tax"})
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class Bill:
181
+ """A computed statement.
182
+
183
+ Charges are positive, credits negative, so every collection here sums
184
+ directly into ``total``.
185
+ """
186
+
187
+ period: BillingPeriod
188
+ buckets: tuple[UsageBucket, ...] = ()
189
+ #: Import charges by rate component, e.g. distribution, cca_generation.
190
+ import_components: dict[str, float] = field(default_factory=dict)
191
+ #: Export credits by component, e.g. delivery, cca_generation, acc_plus.
192
+ export_components: dict[str, float] = field(default_factory=dict)
193
+ #: Charges that do not scale with energy, e.g. the Base Services Charge.
194
+ fixed_components: dict[str, float] = field(default_factory=dict)
195
+ #: Set when the readings did not cover the period cleanly. Independent of
196
+ #: ``complete``: patchy meter data does not make the rates uncertain.
197
+ warnings: tuple[str, ...] = ()
198
+ #: False when any priced hour was itself incomplete or inexact -- a statement
199
+ #: about the *rates*, not the readings. A bill can be fully priced and still
200
+ #: carry coverage warnings, or cover the period perfectly and still be priced
201
+ #: from an unverified CCA export credit. Check both before trusting a total.
202
+ complete: bool = True
203
+
204
+ @property
205
+ def imported_kwh(self) -> float:
206
+ return sum(b.imported for b in self.buckets)
207
+
208
+ @property
209
+ def exported_kwh(self) -> float:
210
+ return sum(b.exported for b in self.buckets)
211
+
212
+ @property
213
+ def energy_charges(self) -> float:
214
+ """The per-kWh charges for energy, as a statement's own lines total.
215
+
216
+ Excludes statutory taxes, which are per-kWh but not energy charges and
217
+ print on their own line: the July 2026 statement's six energy lines come
218
+ to $8.90 and its Energy Commission Tax is separate. They are still in
219
+ ``import_components`` and still in ``total`` -- they are simply not what
220
+ this figure means.
221
+ """
222
+ return sum(
223
+ value for name, value in self.import_components.items() if name not in TAX_COMPONENTS
224
+ )
225
+
226
+ @property
227
+ def export_credits(self) -> float:
228
+ """Negative: credits reduce the bill."""
229
+ return sum(self.export_components.values())
230
+
231
+ @property
232
+ def fixed_charges(self) -> float:
233
+ return sum(self.fixed_components.values())
234
+
235
+ @property
236
+ def total(self) -> float:
237
+ return self.energy_charges + self.taxes + self.export_credits + self.fixed_charges
238
+
239
+ @property
240
+ def taxes(self) -> float:
241
+ """Statutory per-kWh charges, which a statement prints on their own line.
242
+
243
+ Separate from ``energy_charges`` because they are not charges for energy
244
+ and a statement does not total them with the energy lines -- but they are
245
+ owed, so they are in ``total``.
246
+ """
247
+ return sum(
248
+ value for name, value in self.import_components.items() if name in TAX_COMPONENTS
249
+ )
250
+
251
+ @property
252
+ def effective_import_rate(self) -> float | None:
253
+ """Blended $/kWh actually paid for energy, credits included.
254
+
255
+ Not a marginal rate -- do not dispatch on it.
256
+ """
257
+ return self.energy_charges / self.imported_kwh if self.imported_kwh else None
258
+
259
+ def to_dict(self) -> dict[str, Any]:
260
+ return {
261
+ "period": self.period.to_dict(),
262
+ "imported_kwh": round(self.imported_kwh, 4),
263
+ "exported_kwh": round(self.exported_kwh, 4),
264
+ "buckets": [b.to_dict() for b in self.buckets],
265
+ "import_components": {k: round(v, 4) for k, v in self.import_components.items()},
266
+ "export_components": {k: round(v, 4) for k, v in self.export_components.items()},
267
+ "fixed_components": {k: round(v, 4) for k, v in self.fixed_components.items()},
268
+ "energy_charges": round(self.energy_charges, 2),
269
+ "export_credits": round(self.export_credits, 2),
270
+ "fixed_charges": round(self.fixed_charges, 2),
271
+ "total": round(self.total, 2),
272
+ "complete": self.complete,
273
+ "warnings": list(self.warnings),
274
+ }