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,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
|
+
}
|