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,505 @@
|
|
|
1
|
+
"""Compute a bill from interval readings.
|
|
2
|
+
|
|
3
|
+
Pure: takes metered energy and a rate engine, returns charges. Nothing here
|
|
4
|
+
knows where the readings came from.
|
|
5
|
+
|
|
6
|
+
Scope note -- this computes a single period's charges. It deliberately does not
|
|
7
|
+
model export-credit *balances*: carryover between months, the annual true-up, or
|
|
8
|
+
Net Surplus Compensation. Those are stateful across a whole program year and
|
|
9
|
+
belong in a ledger built on top of this.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date, datetime, timedelta
|
|
17
|
+
from itertools import pairwise
|
|
18
|
+
|
|
19
|
+
from ..cca import load_rate_card
|
|
20
|
+
from ..config import Config
|
|
21
|
+
from ..engine import RateEngine
|
|
22
|
+
from ..errors import DataError
|
|
23
|
+
from ..models import ImportPrice, Season, TouPeriod
|
|
24
|
+
from ..timeutil import PACIFIC, hour_floor, to_pacific
|
|
25
|
+
from .models import Bill, BillingPeriod, IntervalReading, UsageBucket
|
|
26
|
+
from .netting import check_coverage
|
|
27
|
+
|
|
28
|
+
#: Components billed on gross import regardless of what a site exported. Kept
|
|
29
|
+
#: for reference: with per-interval netting these already apply to every
|
|
30
|
+
#: imported kWh, so no separate handling is needed. It becomes load-bearing only
|
|
31
|
+
#: if a monthly-netting mode is ever added.
|
|
32
|
+
NON_BYPASSABLE = (
|
|
33
|
+
"public_purpose_programs",
|
|
34
|
+
"wildfire_fund_charge",
|
|
35
|
+
"competition_transition_charges",
|
|
36
|
+
"nuclear_decommissioning",
|
|
37
|
+
"energy_cost_recovery",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BillEngine:
|
|
42
|
+
"""Turns interval readings into a decomposed bill."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, rates: RateEngine | None = None) -> None:
|
|
45
|
+
self.rates = rates or RateEngine()
|
|
46
|
+
|
|
47
|
+
def _compensated(self, moment: datetime) -> bool:
|
|
48
|
+
"""Whether an export at ``moment`` earns anything.
|
|
49
|
+
|
|
50
|
+
Net Billing compensation runs from Permission To Operate. Energy leaving
|
|
51
|
+
the house before then is real -- the meter records it -- but the tariff
|
|
52
|
+
grants nothing for it, so crediting it would invent money.
|
|
53
|
+
"""
|
|
54
|
+
pto = self.rates.config.pto_date
|
|
55
|
+
return pto is None or moment.date() >= pto
|
|
56
|
+
|
|
57
|
+
def compute(
|
|
58
|
+
self,
|
|
59
|
+
readings: Iterable[IntervalReading],
|
|
60
|
+
period: BillingPeriod | None = None,
|
|
61
|
+
*,
|
|
62
|
+
check: bool = True,
|
|
63
|
+
) -> Bill:
|
|
64
|
+
"""Price ``readings`` over ``period``.
|
|
65
|
+
|
|
66
|
+
``period`` defaults to the span of the readings themselves. Readings
|
|
67
|
+
outside it are ignored, so a year of data can be billed one cycle at a
|
|
68
|
+
time without slicing it first.
|
|
69
|
+
"""
|
|
70
|
+
readings = list(readings)
|
|
71
|
+
if period is None:
|
|
72
|
+
period = BillingPeriod.from_readings(readings)
|
|
73
|
+
|
|
74
|
+
in_period = [r for r in readings if period.contains(r.start)]
|
|
75
|
+
warnings = list(check_coverage(in_period, period)) if check else []
|
|
76
|
+
|
|
77
|
+
buckets: dict[tuple[Season, TouPeriod], _Accumulator] = {}
|
|
78
|
+
uncompensated = 0.0
|
|
79
|
+
import_components: dict[str, float] = {}
|
|
80
|
+
export_components: dict[str, float] = {}
|
|
81
|
+
complete = True
|
|
82
|
+
|
|
83
|
+
for reading in in_period:
|
|
84
|
+
# Price at the hour containing the interval: rates change hourly,
|
|
85
|
+
# data may be finer.
|
|
86
|
+
moment = hour_floor(to_pacific(reading.start))
|
|
87
|
+
import_price = self.rates.tariff.price_at(moment)
|
|
88
|
+
|
|
89
|
+
# Only ask what an export was worth when it could earn anything.
|
|
90
|
+
#
|
|
91
|
+
# Export compensation starts at Permission To Operate: before it
|
|
92
|
+
# there is no Net Billing arrangement, whatever the meter saw. The
|
|
93
|
+
# two data sources disagree about that on purpose -- PG&E's own
|
|
94
|
+
# export reports zero exported kWh for the December 2025 cycle
|
|
95
|
+
# because there was no export channel to meter, while the Rainforest
|
|
96
|
+
# counter behind it recorded real energy leaving the house. Pricing
|
|
97
|
+
# the counter's view would invent credits the tariff does not grant.
|
|
98
|
+
export_price = None
|
|
99
|
+
if reading.exported and self._compensated(moment):
|
|
100
|
+
export_price = self.rates.export_rates.price_at(moment)
|
|
101
|
+
elif reading.exported:
|
|
102
|
+
uncompensated += reading.exported
|
|
103
|
+
|
|
104
|
+
if not import_price.complete:
|
|
105
|
+
complete = False
|
|
106
|
+
if export_price is not None and not (export_price.complete and export_price.exact):
|
|
107
|
+
complete = False
|
|
108
|
+
|
|
109
|
+
key = (import_price.season, import_price.period)
|
|
110
|
+
bucket = buckets.setdefault(key, _Accumulator(*key))
|
|
111
|
+
|
|
112
|
+
if reading.imported:
|
|
113
|
+
bucket.imported += reading.imported
|
|
114
|
+
bucket.import_charge += reading.imported * import_price.total
|
|
115
|
+
_add_scaled(import_components, import_price.components, reading.imported)
|
|
116
|
+
|
|
117
|
+
if reading.exported and export_price is not None:
|
|
118
|
+
bucket.exported += reading.exported
|
|
119
|
+
# Credits are negative so the bill sums directly.
|
|
120
|
+
bucket.export_credit -= reading.exported * export_price.total
|
|
121
|
+
_add_scaled(export_components, export_price.components, -reading.exported)
|
|
122
|
+
|
|
123
|
+
fixed_components = self._fixed_charges(period)
|
|
124
|
+
tax, untaxed_days = self._energy_surcharge(in_period, period)
|
|
125
|
+
if tax:
|
|
126
|
+
import_components["energy_commission_tax"] = tax
|
|
127
|
+
if untaxed_days:
|
|
128
|
+
complete = False
|
|
129
|
+
warnings.append(
|
|
130
|
+
f"no energy surcharge vintage covers {len(untaxed_days)} day(s) "
|
|
131
|
+
f"({untaxed_days[0]} to {untaxed_days[-1]}); those days carry no tax, "
|
|
132
|
+
f"so the total is understated. Run `python -m tools.regen tax`."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
stale = self._stale_rate_card(period)
|
|
136
|
+
if stale:
|
|
137
|
+
warnings.append(stale)
|
|
138
|
+
|
|
139
|
+
credit = self._baseline_credit(in_period, period)
|
|
140
|
+
if credit:
|
|
141
|
+
import_components["baseline_credit"] = credit
|
|
142
|
+
|
|
143
|
+
if uncompensated:
|
|
144
|
+
warnings.append(
|
|
145
|
+
f"{uncompensated:.1f} kWh exported before the Permission To Operate date "
|
|
146
|
+
f"({self.rates.config.pto_date}); Net Billing compensation starts at PTO, "
|
|
147
|
+
f"so it earns nothing and is not credited here"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return Bill(
|
|
151
|
+
period=period,
|
|
152
|
+
buckets=tuple(
|
|
153
|
+
b.finish() for b in sorted(buckets.values(), key=lambda a: (a.season, a.period))
|
|
154
|
+
),
|
|
155
|
+
import_components=import_components,
|
|
156
|
+
export_components=export_components,
|
|
157
|
+
fixed_components=fixed_components,
|
|
158
|
+
warnings=tuple(warnings),
|
|
159
|
+
# Pricing confidence only. Coverage problems travel separately in
|
|
160
|
+
# `warnings`: they say the meter data is patchy, not that the rates
|
|
161
|
+
# applied to it are uncertain, and folding them together made a bill
|
|
162
|
+
# that reconciles to a statement still describe itself as an
|
|
163
|
+
# estimate. Callers wanting "trust this total" should check both.
|
|
164
|
+
complete=complete,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _energy_surcharge(
|
|
168
|
+
self, readings: Sequence[IntervalReading], period: BillingPeriod
|
|
169
|
+
) -> tuple[float, list[date]]:
|
|
170
|
+
r"""California's Energy Resources Surcharge on energy consumed.
|
|
171
|
+
|
|
172
|
+
A state tax rather than a utility tariff, so it is charged whoever
|
|
173
|
+
supplies the generation. A statement prints it as "Energy Commission
|
|
174
|
+
Tax", and when a CCA supplies generation it prints on *their* page --
|
|
175
|
+
which is how it went unmodelled while every line on the utility's pages
|
|
176
|
+
reconciled.
|
|
177
|
+
|
|
178
|
+
Rated per kilowatt-hour *consumed*, by the vintage in force on each day,
|
|
179
|
+
so a cycle spanning a January rate change is charged correctly.
|
|
180
|
+
|
|
181
|
+
Consumed, not imported, and the difference only appears once exports
|
|
182
|
+
earn something. Two statements pin it down. On 2026-03-10 the site
|
|
183
|
+
exported 36 kWh before Permission To Operate and was taxed on the full
|
|
184
|
+
731 kWh imported -- \$0.22, to the cent. On 2026-08-04 it exported after
|
|
185
|
+
PTO and was taxed on nothing at all despite importing 39 kWh. So an
|
|
186
|
+
export offsets the tax base exactly when the tariff compensates it,
|
|
187
|
+
which is the same test that decides whether it earns a credit. Floored
|
|
188
|
+
per day: a day that exports more than it imports owes no tax and does
|
|
189
|
+
not bank a negative against the next one.
|
|
190
|
+
|
|
191
|
+
Returns the charge and the days no vintage covered. Those days are not
|
|
192
|
+
charged, and the caller says so and marks the bill incomplete: a bill
|
|
193
|
+
that quietly omits a tax is the plausible-but-wrong kind, which is worse
|
|
194
|
+
than one that refuses to claim it is finished.
|
|
195
|
+
"""
|
|
196
|
+
from ..data import versioned
|
|
197
|
+
|
|
198
|
+
total = 0.0
|
|
199
|
+
remaining: dict[date, float] = {}
|
|
200
|
+
for reading in readings:
|
|
201
|
+
day = to_pacific(reading.start).date()
|
|
202
|
+
consumed = reading.imported
|
|
203
|
+
if reading.exported and self._compensated(hour_floor(to_pacific(reading.start))):
|
|
204
|
+
consumed -= reading.exported
|
|
205
|
+
remaining[day] = remaining.get(day, 0.0) + consumed
|
|
206
|
+
uncovered: list[date] = []
|
|
207
|
+
for day, net in sorted(remaining.items()):
|
|
208
|
+
imported = max(net, 0.0)
|
|
209
|
+
if not imported:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
rate = float(versioned.load("tax/ca_energy_resources", day).raw["rate"])
|
|
213
|
+
except DataError:
|
|
214
|
+
# The rest of the bill is still worth producing, so this is not
|
|
215
|
+
# fatal -- but it is not silent either.
|
|
216
|
+
uncovered.append(day)
|
|
217
|
+
continue
|
|
218
|
+
total += imported * rate
|
|
219
|
+
return total, uncovered
|
|
220
|
+
|
|
221
|
+
#: How far a CCA rate card may predate a cycle before it is worth saying so.
|
|
222
|
+
#: A CCA reprices at least annually, so a card more than a year older than
|
|
223
|
+
#: the energy it is pricing is being *borrowed*, not merely still in force.
|
|
224
|
+
STALE_CARD = timedelta(days=400)
|
|
225
|
+
|
|
226
|
+
def _stale_rate_card(self, period: BillingPeriod) -> str:
|
|
227
|
+
"""Whether the CCA generation was priced from a much older rate card.
|
|
228
|
+
|
|
229
|
+
`versioned.load` takes the latest vintage on or before the date, which
|
|
230
|
+
is right for a tariff -- a rate stays in force until superseded. It is
|
|
231
|
+
indistinguishable, though, from "nobody vendored the vintage that was
|
|
232
|
+
actually in force", and the two are worlds apart: the first is correct,
|
|
233
|
+
the second silently prices 2025 energy at 2023 rates.
|
|
234
|
+
|
|
235
|
+
This cannot tell them apart either. It can say how old the card is and
|
|
236
|
+
let the reader judge, which is the whole difference between a number
|
|
237
|
+
that is wrong and a number that is wrong and says nothing.
|
|
238
|
+
"""
|
|
239
|
+
cca = self.rates.config.cca
|
|
240
|
+
if cca is None or cca.rate_card is None or cca.generation_rates:
|
|
241
|
+
return ""
|
|
242
|
+
try:
|
|
243
|
+
card = load_rate_card(cca.rate_card, period.end)
|
|
244
|
+
except DataError:
|
|
245
|
+
return ""
|
|
246
|
+
age = period.end - card.effective
|
|
247
|
+
if age <= self.STALE_CARD:
|
|
248
|
+
return ""
|
|
249
|
+
return (
|
|
250
|
+
f"{cca.rate_card.upper()} generation priced from the rate card effective "
|
|
251
|
+
f"{card.effective}, {age.days} days before this cycle ended. Either the "
|
|
252
|
+
f"provider did not reprice in between, or the vintage that applied was "
|
|
253
|
+
f"never vendored -- and nothing here can tell those apart"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _baseline_credit(self, readings: Sequence[IntervalReading], period: BillingPeriod) -> float:
|
|
257
|
+
"""Credit on imports falling within the cycle's baseline allowance.
|
|
258
|
+
|
|
259
|
+
Only schedules with a baseline produce one, and only when a territory is
|
|
260
|
+
configured. It lands here rather than in the marginal price because
|
|
261
|
+
eligibility depends on cumulative usage over the cycle, which
|
|
262
|
+
``price_at`` cannot see.
|
|
263
|
+
|
|
264
|
+
Both the allowance and the credit rate are daily quantities, and both
|
|
265
|
+
can change inside one cycle -- the allowance at the season boundary, the
|
|
266
|
+
rate whenever a new tariff vintage takes force. So this walks the days
|
|
267
|
+
and credits each one at its own rate rather than reading a rate once.
|
|
268
|
+
|
|
269
|
+
A statement spanning a rate change prints exactly that: the December
|
|
270
|
+
2025 cycle shows 19.40 kWh at $0.10084 for its two December days and
|
|
271
|
+
281.30 kWh at $0.09566 for its twenty-nine January ones. Reading one
|
|
272
|
+
rate for the cycle applied December's to all 300.70 kWh and overstated
|
|
273
|
+
the credit by $1.45.
|
|
274
|
+
|
|
275
|
+
The credit is identical in every TOU period, so how PG&E allocates
|
|
276
|
+
baseline usage across periods moves the printed lines but not this total.
|
|
277
|
+
"""
|
|
278
|
+
tariff = self.rates.tariff
|
|
279
|
+
remaining = sum(r.imported for r in readings)
|
|
280
|
+
if remaining <= 0:
|
|
281
|
+
return 0.0
|
|
282
|
+
|
|
283
|
+
credit = 0.0
|
|
284
|
+
for offset in range(period.days):
|
|
285
|
+
day = period.start + timedelta(days=offset)
|
|
286
|
+
noon = datetime(day.year, day.month, day.day, 12, tzinfo=PACIFIC)
|
|
287
|
+
allowance = tariff.baseline_allowance(noon)
|
|
288
|
+
if not allowance:
|
|
289
|
+
continue
|
|
290
|
+
rate = tariff.price_at(noon).baseline_credit
|
|
291
|
+
if not rate:
|
|
292
|
+
continue
|
|
293
|
+
# Imports are credited against the allowance in day order, so a
|
|
294
|
+
# cycle that used less than its allowance is capped rather than
|
|
295
|
+
# credited for energy it never took.
|
|
296
|
+
within = min(allowance, remaining)
|
|
297
|
+
credit -= within * rate
|
|
298
|
+
remaining -= within
|
|
299
|
+
if remaining <= 0:
|
|
300
|
+
break
|
|
301
|
+
return credit
|
|
302
|
+
|
|
303
|
+
def _fixed_charges(self, period: BillingPeriod) -> dict[str, float]:
|
|
304
|
+
"""Charges billed per day rather than per kWh.
|
|
305
|
+
|
|
306
|
+
Priced day by day, because the utility prorates and a daily charge can
|
|
307
|
+
begin mid-cycle. AB 205's Base Services Charge began on 2026-03-01, and
|
|
308
|
+
the January-to-March cycle that spans it is billed 30 days at nothing
|
|
309
|
+
and 2 days at the new rate. Pricing the whole cycle from the tariff in
|
|
310
|
+
force on its first day charges nothing at all for those two days, which
|
|
311
|
+
is a real dollar and change on a statement that otherwise reconciles to
|
|
312
|
+
the cent -- small enough to look like rounding, which is what makes it
|
|
313
|
+
worth getting right rather than tolerating.
|
|
314
|
+
"""
|
|
315
|
+
total = 0.0
|
|
316
|
+
for offset in range(period.days):
|
|
317
|
+
day = period.start + timedelta(days=offset)
|
|
318
|
+
moment = datetime(day.year, day.month, day.day, 12, tzinfo=PACIFIC)
|
|
319
|
+
total += self.rates.tariff.daily_fixed_charge(moment)
|
|
320
|
+
return {"base_services_charge": total}
|
|
321
|
+
|
|
322
|
+
def marginal_rates(
|
|
323
|
+
self, readings: Sequence[IntervalReading]
|
|
324
|
+
) -> dict[tuple[Season, TouPeriod], ImportPrice]:
|
|
325
|
+
"""The distinct import prices that applied across ``readings``.
|
|
326
|
+
|
|
327
|
+
Useful for showing which rate produced a bucket without re-deriving it.
|
|
328
|
+
"""
|
|
329
|
+
seen: dict[tuple[Season, TouPeriod], ImportPrice] = {}
|
|
330
|
+
for reading in readings:
|
|
331
|
+
price = self.rates.price_at(reading.start).import_price
|
|
332
|
+
seen.setdefault((price.season, price.period), price)
|
|
333
|
+
return seen
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _add_scaled(target: dict[str, float], source: dict[str, float], kwh: float) -> None:
|
|
337
|
+
for name, rate in source.items():
|
|
338
|
+
target[name] = target.get(name, 0.0) + rate * kwh
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class _Accumulator:
|
|
342
|
+
__slots__ = ("export_credit", "exported", "import_charge", "imported", "period", "season")
|
|
343
|
+
|
|
344
|
+
def __init__(self, season: Season, period: TouPeriod) -> None:
|
|
345
|
+
self.season = season
|
|
346
|
+
self.period = period
|
|
347
|
+
self.imported = 0.0
|
|
348
|
+
self.exported = 0.0
|
|
349
|
+
self.import_charge = 0.0
|
|
350
|
+
self.export_credit = 0.0
|
|
351
|
+
|
|
352
|
+
def finish(self) -> UsageBucket:
|
|
353
|
+
return UsageBucket(
|
|
354
|
+
season=self.season,
|
|
355
|
+
period=self.period,
|
|
356
|
+
imported=self.imported,
|
|
357
|
+
exported=self.exported,
|
|
358
|
+
import_charge=self.import_charge,
|
|
359
|
+
export_credit=self.export_credit,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def hourly(readings: Iterable[IntervalReading]) -> list[IntervalReading]:
|
|
364
|
+
"""Collapse sub-hourly readings into whole hours.
|
|
365
|
+
|
|
366
|
+
Netting granularity matters: summing 15-minute imports and exports into an
|
|
367
|
+
hour before netting gives a different answer than netting each quarter hour.
|
|
368
|
+
This helper preserves the finer netting by summing each side separately, so
|
|
369
|
+
it only changes how charges are grouped, never what they total.
|
|
370
|
+
"""
|
|
371
|
+
merged: dict[datetime, list[float]] = {}
|
|
372
|
+
for reading in readings:
|
|
373
|
+
key = hour_floor(to_pacific(reading.start))
|
|
374
|
+
slot = merged.setdefault(key, [0.0, 0.0])
|
|
375
|
+
slot[0] += reading.imported
|
|
376
|
+
slot[1] += reading.exported
|
|
377
|
+
return [
|
|
378
|
+
IntervalReading(start, imported=imp, exported=exp, duration=timedelta(hours=1))
|
|
379
|
+
for start, (imp, exp) in sorted(merged.items())
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _ordered_segments(segments: Sequence[Segment]) -> list[Segment]:
|
|
384
|
+
if not segments:
|
|
385
|
+
raise DataError("a bill needs at least one segment")
|
|
386
|
+
ordered = sorted(segments, key=lambda s: s.period.start)
|
|
387
|
+
for earlier, later in pairwise(ordered):
|
|
388
|
+
if later.period.start <= earlier.period.end:
|
|
389
|
+
raise DataError(
|
|
390
|
+
f"segments overlap: {earlier.period.start}..{earlier.period.end} and "
|
|
391
|
+
f"{later.period.start}..{later.period.end}. Overlapping segments would "
|
|
392
|
+
f"price the same day twice"
|
|
393
|
+
)
|
|
394
|
+
if later.period.start > earlier.period.end + timedelta(days=1):
|
|
395
|
+
raise DataError(
|
|
396
|
+
f"segments have a gap: {earlier.period.end}..{later.period.start}. "
|
|
397
|
+
"A segmented bill must cover every day exactly once"
|
|
398
|
+
)
|
|
399
|
+
return ordered
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass(frozen=True, slots=True)
|
|
403
|
+
class Segment:
|
|
404
|
+
"""One stretch of a cycle, priced under its own configuration.
|
|
405
|
+
|
|
406
|
+
A cycle is not always billed under a single tariff. When an account changes
|
|
407
|
+
schedule mid-cycle -- or interconnects solar, which closes one service
|
|
408
|
+
agreement and opens another -- the utility prices each stretch separately
|
|
409
|
+
and prints them as separate blocks on one statement.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
config: Config
|
|
413
|
+
period: BillingPeriod
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def price_segments(
|
|
417
|
+
segments: Sequence[Segment],
|
|
418
|
+
readings: Iterable[IntervalReading],
|
|
419
|
+
*,
|
|
420
|
+
check: bool = True,
|
|
421
|
+
) -> list[Bill]:
|
|
422
|
+
"""One bill per segment, unmerged.
|
|
423
|
+
|
|
424
|
+
Kept separate from :func:`compute_segments` because export credits do not
|
|
425
|
+
cross a service agreement. A cycle where solar was interconnected carries a
|
|
426
|
+
closed agreement and a new one, and the utility applies the new agreement's
|
|
427
|
+
export credits only against its own charges -- on 2026-07-07 it spends 2.18
|
|
428
|
+
against the Solar Billing Plan's charges and nothing against the closed
|
|
429
|
+
agreement's 0.94, which predates Permission To Operate and has no export
|
|
430
|
+
arrangement at all. A ledger run over the merged bill spends them against
|
|
431
|
+
both and overstates what was applied.
|
|
432
|
+
"""
|
|
433
|
+
ordered = _ordered_segments(segments)
|
|
434
|
+
readings = list(readings)
|
|
435
|
+
return [
|
|
436
|
+
BillEngine(RateEngine(segment.config)).compute(readings, segment.period, check=check)
|
|
437
|
+
for segment in ordered
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def compute_segments(
|
|
442
|
+
segments: Sequence[Segment],
|
|
443
|
+
readings: Iterable[IntervalReading],
|
|
444
|
+
*,
|
|
445
|
+
check: bool = True,
|
|
446
|
+
) -> Bill:
|
|
447
|
+
"""Price one cycle that more than one configuration governs.
|
|
448
|
+
|
|
449
|
+
Each segment is priced by its own engine over its own dates and the results
|
|
450
|
+
are added, because that is what the utility does: a mid-cycle schedule
|
|
451
|
+
change produces two blocks on one statement, not a blended rate.
|
|
452
|
+
|
|
453
|
+
Refusing this case and demanding a single ``Config`` was the wrong shape.
|
|
454
|
+
The months worth checking most are exactly the ones where something changed,
|
|
455
|
+
and a harness that skips them checks only the quiet months.
|
|
456
|
+
"""
|
|
457
|
+
ordered = _ordered_segments(segments)
|
|
458
|
+
readings = list(readings)
|
|
459
|
+
whole = BillingPeriod(ordered[0].period.start, ordered[-1].period.end)
|
|
460
|
+
|
|
461
|
+
imports: dict[str, float] = {}
|
|
462
|
+
exports: dict[str, float] = {}
|
|
463
|
+
fixed: dict[str, float] = {}
|
|
464
|
+
buckets: dict[tuple[Season, TouPeriod], UsageBucket] = {}
|
|
465
|
+
warnings: list[str] = []
|
|
466
|
+
complete = True
|
|
467
|
+
|
|
468
|
+
for segment, part in zip(ordered, price_segments(ordered, readings, check=check), strict=True):
|
|
469
|
+
for target, source in (
|
|
470
|
+
(imports, part.import_components),
|
|
471
|
+
(exports, part.export_components),
|
|
472
|
+
(fixed, part.fixed_components),
|
|
473
|
+
):
|
|
474
|
+
for key, value in source.items():
|
|
475
|
+
target[key] = target.get(key, 0.0) + value
|
|
476
|
+
|
|
477
|
+
for bucket in part.buckets:
|
|
478
|
+
slot = (bucket.season, bucket.period)
|
|
479
|
+
running = buckets.get(slot)
|
|
480
|
+
buckets[slot] = UsageBucket(
|
|
481
|
+
season=bucket.season,
|
|
482
|
+
period=bucket.period,
|
|
483
|
+
imported=(running.imported if running else 0.0) + bucket.imported,
|
|
484
|
+
exported=(running.exported if running else 0.0) + bucket.exported,
|
|
485
|
+
import_charge=(running.import_charge if running else 0.0) + bucket.import_charge,
|
|
486
|
+
export_credit=(running.export_credit if running else 0.0) + bucket.export_credit,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Attributed, because "no tax vintage covers 3 days" is a different
|
|
490
|
+
# problem depending on which tariff was in force when it happened.
|
|
491
|
+
warnings.extend(
|
|
492
|
+
f"{segment.period.start}..{segment.period.end} ({segment.config.tariff}): {warning}"
|
|
493
|
+
for warning in part.warnings
|
|
494
|
+
)
|
|
495
|
+
complete = complete and part.complete
|
|
496
|
+
|
|
497
|
+
return Bill(
|
|
498
|
+
period=whole,
|
|
499
|
+
buckets=tuple(buckets.values()),
|
|
500
|
+
import_components=imports,
|
|
501
|
+
export_components=exports,
|
|
502
|
+
fixed_components=fixed,
|
|
503
|
+
warnings=tuple(warnings),
|
|
504
|
+
complete=complete,
|
|
505
|
+
)
|