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,271 @@
|
|
|
1
|
+
"""PG&E residential retail import pricing.
|
|
2
|
+
|
|
3
|
+
Schedule-agnostic: everything that differs between schedules lives in the
|
|
4
|
+
vendored snapshot, so adding one is a data change. Currently vendored are
|
|
5
|
+
E-ELEC, E-TOU-C, and EV2-A.
|
|
6
|
+
|
|
7
|
+
They share a shape that keeps pricing cheap: period boundaries are identical
|
|
8
|
+
every day of the week including holidays, and do not shift by season. So a
|
|
9
|
+
price is determined by (season, hour) alone.
|
|
10
|
+
|
|
11
|
+
The one exception is E-TOU-C's baseline credit, which applies to the first N
|
|
12
|
+
kWh of a cycle. That is a quantity rather than a time, so no marginal price can
|
|
13
|
+
express it. ``price_at`` returns the over-baseline price -- the right answer for
|
|
14
|
+
a dispatch decision, since an allowance is normally spent early in the cycle --
|
|
15
|
+
and reports the available credit as ``ImportPrice.baseline_credit`` for the
|
|
16
|
+
billing engine, which sees a whole cycle, to apply.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import date, datetime
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from importlib.resources import files
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..cca import load_rate_card
|
|
29
|
+
from ..config import Config
|
|
30
|
+
from ..errors import ConfigError, DataError
|
|
31
|
+
from ..models import ImportPrice, Season, Supplier, TouPeriod
|
|
32
|
+
|
|
33
|
+
TARIFF_DATA_ROOT = "tariff"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class TariffSnapshot:
|
|
38
|
+
"""One effective-dated version of a tariff sheet."""
|
|
39
|
+
|
|
40
|
+
effective: date
|
|
41
|
+
raw: dict[str, Any]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def advice_letter(self) -> str:
|
|
45
|
+
return str(self.raw.get("advice_letter", ""))
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def source_url(self) -> str:
|
|
49
|
+
return str(self.raw.get("source_url", ""))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@lru_cache(maxsize=8)
|
|
53
|
+
def _snapshots(utility: str, tariff: str) -> tuple[TariffSnapshot, ...]:
|
|
54
|
+
directory = (
|
|
55
|
+
files("tariffkit.data")
|
|
56
|
+
/ TARIFF_DATA_ROOT
|
|
57
|
+
/ utility.lower()
|
|
58
|
+
/ tariff.lower().replace("-", "")
|
|
59
|
+
)
|
|
60
|
+
if not directory.is_dir():
|
|
61
|
+
raise DataError(f"no vendored tariff data for {utility}/{tariff}")
|
|
62
|
+
found = []
|
|
63
|
+
for entry in directory.iterdir():
|
|
64
|
+
if not entry.name.endswith(".toml"):
|
|
65
|
+
continue
|
|
66
|
+
raw = tomllib.loads(entry.read_text(encoding="utf-8"))
|
|
67
|
+
found.append(TariffSnapshot(date.fromisoformat(raw["effective"]), raw))
|
|
68
|
+
if not found:
|
|
69
|
+
raise DataError(f"no tariff snapshots for {utility}/{tariff}")
|
|
70
|
+
return tuple(sorted(found, key=lambda s: s.effective))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_snapshot(utility: str, tariff: str, on: date) -> TariffSnapshot:
|
|
74
|
+
"""The snapshot in force on ``on`` -- the latest one not in the future."""
|
|
75
|
+
snapshots = _snapshots(utility, tariff)
|
|
76
|
+
applicable = [s for s in snapshots if s.effective <= on]
|
|
77
|
+
if not applicable:
|
|
78
|
+
raise DataError(
|
|
79
|
+
f"{utility}/{tariff}: no snapshot effective on or before {on}; "
|
|
80
|
+
f"earliest vendored is {snapshots[0].effective}"
|
|
81
|
+
)
|
|
82
|
+
return applicable[-1]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RetailTariff:
|
|
86
|
+
"""Prices a kWh of grid import under the configured schedule."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, config: Config) -> None:
|
|
89
|
+
self.config = config
|
|
90
|
+
|
|
91
|
+
def snapshot_for(self, moment: datetime) -> TariffSnapshot:
|
|
92
|
+
return load_snapshot(self.config.utility, self.config.tariff, moment.date())
|
|
93
|
+
|
|
94
|
+
def season(self, moment: datetime, snapshot: TariffSnapshot | None = None) -> Season:
|
|
95
|
+
snapshot = snapshot or self.snapshot_for(moment)
|
|
96
|
+
seasons = snapshot.raw["seasons"]
|
|
97
|
+
start_month, start_day = (int(p) for p in seasons["summer_start"].split("-"))
|
|
98
|
+
end_month, end_day = (int(p) for p in seasons["summer_end"].split("-"))
|
|
99
|
+
marker = (moment.month, moment.day)
|
|
100
|
+
if (start_month, start_day) <= marker <= (end_month, end_day):
|
|
101
|
+
return Season.SUMMER
|
|
102
|
+
return Season.WINTER
|
|
103
|
+
|
|
104
|
+
def period(self, moment: datetime, snapshot: TariffSnapshot | None = None) -> TouPeriod:
|
|
105
|
+
"""Resolve the TOU period. Identical every day of the week."""
|
|
106
|
+
snapshot = snapshot or self.snapshot_for(moment)
|
|
107
|
+
periods = snapshot.raw["periods"]
|
|
108
|
+
hour = moment.hour
|
|
109
|
+
for start, end in periods["peak"]:
|
|
110
|
+
if start <= hour < end:
|
|
111
|
+
return TouPeriod.PEAK
|
|
112
|
+
# Absent rather than empty on a schedule with no part-peak, which is how
|
|
113
|
+
# E-TOU-C is written: peak or off-peak, nothing between.
|
|
114
|
+
for start, end in periods.get("part_peak", []):
|
|
115
|
+
if start <= hour < end:
|
|
116
|
+
return TouPeriod.PART_PEAK
|
|
117
|
+
return TouPeriod.OFF_PEAK
|
|
118
|
+
|
|
119
|
+
def price_at(self, moment: datetime) -> ImportPrice:
|
|
120
|
+
snapshot = self.snapshot_for(moment)
|
|
121
|
+
season = self.season(moment, snapshot)
|
|
122
|
+
period = self.period(moment, snapshot)
|
|
123
|
+
components, complete = self._components(snapshot, season, period, moment)
|
|
124
|
+
total = self._apply_discount(snapshot, components)
|
|
125
|
+
return ImportPrice(
|
|
126
|
+
total=round(total, 6),
|
|
127
|
+
season=season,
|
|
128
|
+
period=period,
|
|
129
|
+
components={k: round(v, 6) for k, v in components.items()},
|
|
130
|
+
complete=complete,
|
|
131
|
+
baseline_credit=float(snapshot.raw.get("baseline", {}).get("credit", 0.0)),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def baseline_allowance(self, moment: datetime) -> float:
|
|
135
|
+
"""Baseline kWh allowed for the day containing ``moment``.
|
|
136
|
+
|
|
137
|
+
Zero on a schedule without a baseline, and zero when no territory is
|
|
138
|
+
configured -- the quantities vary several-fold between territories, so
|
|
139
|
+
guessing one would be worse than reporting no allowance.
|
|
140
|
+
"""
|
|
141
|
+
snapshot = self.snapshot_for(moment)
|
|
142
|
+
quantities = snapshot.raw.get("baseline", {}).get("quantities")
|
|
143
|
+
if not quantities or self.config.baseline_territory is None:
|
|
144
|
+
return 0.0
|
|
145
|
+
table = quantities.get(self.config.baseline_code)
|
|
146
|
+
if table is None:
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
f"unknown baseline_code {self.config.baseline_code!r}; "
|
|
149
|
+
f"vendored: {sorted(quantities)}"
|
|
150
|
+
)
|
|
151
|
+
territory = table.get(self.config.baseline_territory.upper())
|
|
152
|
+
if territory is None:
|
|
153
|
+
raise ConfigError(
|
|
154
|
+
f"no baseline quantity for territory "
|
|
155
|
+
f"{self.config.baseline_territory!r}; vendored: {sorted(table)}"
|
|
156
|
+
)
|
|
157
|
+
return float(territory[str(self.season(moment, snapshot))])
|
|
158
|
+
|
|
159
|
+
def _components(
|
|
160
|
+
self,
|
|
161
|
+
snapshot: TariffSnapshot,
|
|
162
|
+
season: Season,
|
|
163
|
+
period: TouPeriod,
|
|
164
|
+
moment: datetime,
|
|
165
|
+
) -> tuple[dict[str, float], bool]:
|
|
166
|
+
energy = snapshot.raw["energy"][str(season)][str(period)]
|
|
167
|
+
components: dict[str, float] = dict(energy)
|
|
168
|
+
components.update(snapshot.raw["adders"])
|
|
169
|
+
complete = True
|
|
170
|
+
|
|
171
|
+
if self.config.supplier is Supplier.CCA:
|
|
172
|
+
cca = self.config.cca
|
|
173
|
+
assert cca is not None
|
|
174
|
+
for name in snapshot.raw["cca"]["drop_components"]:
|
|
175
|
+
components.pop(name, None)
|
|
176
|
+
|
|
177
|
+
# A rate taken straight from a bill wins over the vintage table,
|
|
178
|
+
# which only carries the vintages printed on the E-ELEC sheet.
|
|
179
|
+
if cca.pcia_rate is not None:
|
|
180
|
+
components["pcia"] = cca.pcia_rate
|
|
181
|
+
elif cca.pcia_vintage is not None:
|
|
182
|
+
table = snapshot.raw["cca"]["pcia_vintages"]
|
|
183
|
+
key = str(cca.pcia_vintage)
|
|
184
|
+
if key not in table:
|
|
185
|
+
raise ConfigError(
|
|
186
|
+
f"no PCIA rate vendored for vintage {cca.pcia_vintage}; "
|
|
187
|
+
f"available: {sorted(table)}"
|
|
188
|
+
)
|
|
189
|
+
components["pcia"] = float(table[key])
|
|
190
|
+
|
|
191
|
+
# Same precedence as the PCIA above: an explicit rate wins, then the
|
|
192
|
+
# vendored Schedule E-FFS table, which is vintaged off the same year.
|
|
193
|
+
if cca.franchise_fee_surcharge is not None:
|
|
194
|
+
components["franchise_fee_surcharge"] = cca.franchise_fee_surcharge
|
|
195
|
+
elif cca.pcia_vintage is not None:
|
|
196
|
+
ffs_table = snapshot.raw["cca"]["franchise_fee_vintages"]
|
|
197
|
+
key = str(cca.pcia_vintage)
|
|
198
|
+
if key not in ffs_table:
|
|
199
|
+
# Reaching here means the two vintaged tables disagree, since
|
|
200
|
+
# the PCIA lookup above already accepted this year. That is a
|
|
201
|
+
# vendoring bug, not a gap in the user's config, so it raises
|
|
202
|
+
# rather than degrading to complete=False -- which means
|
|
203
|
+
# something quite different and much larger.
|
|
204
|
+
raise ConfigError(
|
|
205
|
+
f"vintage {cca.pcia_vintage} has a PCIA rate but no franchise fee "
|
|
206
|
+
f"surcharge; vendored franchise fee vintages: {sorted(ffs_table)}"
|
|
207
|
+
)
|
|
208
|
+
components["franchise_fee_surcharge"] = float(ffs_table[key])
|
|
209
|
+
else:
|
|
210
|
+
complete = False
|
|
211
|
+
|
|
212
|
+
rates = cca.generation_rates.get(str(season), {})
|
|
213
|
+
if str(period) in rates:
|
|
214
|
+
components["cca_generation"] = float(rates[str(period)])
|
|
215
|
+
elif cca.rate_card is not None:
|
|
216
|
+
card = load_rate_card(cca.rate_card, moment.date())
|
|
217
|
+
components["cca_generation"] = card.generation(
|
|
218
|
+
self.config.tariff, str(season), str(period), cca.option
|
|
219
|
+
)
|
|
220
|
+
credit = card.cost_relief_credit(moment.date())
|
|
221
|
+
if credit:
|
|
222
|
+
components["cca_cost_relief_credit"] = credit
|
|
223
|
+
else:
|
|
224
|
+
# Delivery-only price. Flagged rather than silently understated.
|
|
225
|
+
complete = False
|
|
226
|
+
|
|
227
|
+
return components, complete
|
|
228
|
+
|
|
229
|
+
def _apply_discount(self, snapshot: TariffSnapshot, components: dict[str, float]) -> float:
|
|
230
|
+
total = sum(components.values())
|
|
231
|
+
if self.config.discount == "none":
|
|
232
|
+
return total
|
|
233
|
+
discounts = snapshot.raw.get("discounts")
|
|
234
|
+
if not discounts or self.config.discount not in discounts:
|
|
235
|
+
# The percentage is schedule-specific and not every sheet prints it.
|
|
236
|
+
# Better to refuse than to borrow another schedule's figure.
|
|
237
|
+
raise ConfigError(
|
|
238
|
+
f"{snapshot.raw['tariff']} does not vendor a {self.config.discount.upper()} "
|
|
239
|
+
"discount; it is not published on that schedule's tariff sheet"
|
|
240
|
+
)
|
|
241
|
+
rate = float(discounts[self.config.discount])
|
|
242
|
+
if self.config.discount == "care":
|
|
243
|
+
# The Wildfire Fund Charge is not levied on CARE sales at all, so it
|
|
244
|
+
# comes off before the percentage discount rather than being
|
|
245
|
+
# discounted alongside everything else.
|
|
246
|
+
for name in discounts.get("care_excludes", []):
|
|
247
|
+
if name in components:
|
|
248
|
+
total -= components.pop(name)
|
|
249
|
+
return total * (1.0 - rate)
|
|
250
|
+
|
|
251
|
+
def daily_fixed_charge(self, moment: datetime) -> float:
|
|
252
|
+
"""Base Services Charge in $/day.
|
|
253
|
+
|
|
254
|
+
Kept out of the per-kWh price on purpose: it does not vary with
|
|
255
|
+
consumption, so folding it in would corrupt any marginal decision about
|
|
256
|
+
whether to import, export, or self-consume.
|
|
257
|
+
|
|
258
|
+
The CARE/FERA reduction is already baked into the tier 1 and tier 2
|
|
259
|
+
amounts, so the percentage discount is not applied again here.
|
|
260
|
+
|
|
261
|
+
Zero when the vintage being priced has no such charge. AB 205's charge
|
|
262
|
+
began on 2026-03-01; before it, E-TOU-C and EV2-A had no daily fixed
|
|
263
|
+
charge at all, and E-ELEC had a single flat per-meter rate. A snapshot
|
|
264
|
+
from then carries no table, and that absence is the correct answer
|
|
265
|
+
rather than a missing-data error.
|
|
266
|
+
"""
|
|
267
|
+
snapshot = self.snapshot_for(moment)
|
|
268
|
+
table = snapshot.raw.get("base_services_charge")
|
|
269
|
+
if not table:
|
|
270
|
+
return 0.0
|
|
271
|
+
return float(table[f"tier_{self.config.base_services_charge_tier}"])
|
tariffkit/timeutil.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Pacific-time primitives shared by the import and export engines.
|
|
2
|
+
|
|
3
|
+
Everything PG&E publishes is anchored to Pacific Prevailing Time, so the whole
|
|
4
|
+
package normalizes to it exactly once, here, and works in wall-clock terms
|
|
5
|
+
downstream.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from datetime import UTC, date, datetime, timedelta
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from zoneinfo import ZoneInfo
|
|
15
|
+
|
|
16
|
+
from .errors import DataError
|
|
17
|
+
|
|
18
|
+
PACIFIC = ZoneInfo("America/Los_Angeles")
|
|
19
|
+
|
|
20
|
+
MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DayType(StrEnum):
|
|
24
|
+
"""Export day type. E-ELEC import pricing does not use this at all."""
|
|
25
|
+
|
|
26
|
+
WEEKDAY = "Weekday"
|
|
27
|
+
WEEKEND = "Weekend"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_pacific(moment: datetime) -> datetime:
|
|
31
|
+
"""Convert an aware datetime to Pacific time.
|
|
32
|
+
|
|
33
|
+
Naive datetimes are rejected rather than assumed: silently guessing a zone
|
|
34
|
+
is how an export lookup ends up eight hours off.
|
|
35
|
+
"""
|
|
36
|
+
if moment.tzinfo is None:
|
|
37
|
+
raise ValueError("naive datetime; pass an aware one, e.g. datetime.now(tariffkit.PACIFIC)")
|
|
38
|
+
return moment.astimezone(PACIFIC)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def now_pacific() -> datetime:
|
|
42
|
+
return datetime.now(PACIFIC)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def hour_floor(moment: datetime) -> datetime:
|
|
46
|
+
"""Truncate to the start of the containing hour, preserving the offset."""
|
|
47
|
+
return moment.replace(minute=0, second=0, microsecond=0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def next_hour(moment: datetime) -> datetime:
|
|
51
|
+
"""The start of the next hour.
|
|
52
|
+
|
|
53
|
+
Steps in absolute time so it stays correct across DST transitions: on the
|
|
54
|
+
fall-back day 01:00 PDT + 1h is 01:00 PST, a real distinct hour. Adding to
|
|
55
|
+
the zoned value instead would skip straight to 02:00, two hours later, and
|
|
56
|
+
an hourly publisher driven off that would never emit the second 01:00.
|
|
57
|
+
"""
|
|
58
|
+
floor = hour_floor(moment)
|
|
59
|
+
return (floor.astimezone(UTC) + timedelta(hours=1)).astimezone(floor.tzinfo)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@lru_cache(maxsize=1)
|
|
63
|
+
def _holiday_table() -> dict[int, frozenset[date]]:
|
|
64
|
+
from .data import read_data_text
|
|
65
|
+
|
|
66
|
+
raw = tomllib.loads(read_data_text("holidays.toml"))["holidays"]
|
|
67
|
+
return {
|
|
68
|
+
int(year): frozenset(date.fromisoformat(d) for d in dates) for year, dates in raw.items()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def holidays(year: int) -> frozenset[date]:
|
|
73
|
+
"""The eight tariff holidays observed in ``year``.
|
|
74
|
+
|
|
75
|
+
Extracted from PG&E's own data rather than recomputed, so the observed-date
|
|
76
|
+
rules (Saturday -> preceding Friday, Sunday -> following Monday) and the
|
|
77
|
+
year-spanning New Year's case come from the source of truth.
|
|
78
|
+
"""
|
|
79
|
+
table = _holiday_table()
|
|
80
|
+
if year not in table:
|
|
81
|
+
raise DataError(
|
|
82
|
+
f"no holiday calendar for {year}; vendored data covers {min(table)}-{max(table)}"
|
|
83
|
+
)
|
|
84
|
+
return table[year]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_holiday(day: date) -> bool:
|
|
88
|
+
return day in holidays(day.year)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def export_hour(moment: datetime) -> int:
|
|
92
|
+
"""The hour index (``HS0``-``HS23``) upstream assigns to this instant.
|
|
93
|
+
|
|
94
|
+
Almost always the wall-clock hour, but the fall-back day has 25 real hours
|
|
95
|
+
and only 24 labels. PG&E resolves that by giving the repeated 01:00 PST the
|
|
96
|
+
``HS2`` label, so the second 1am is priced as 2am and the rest of the day
|
|
97
|
+
stays aligned. ``fold`` is 1 only during that ambiguous hour, so adding it
|
|
98
|
+
reproduces the published labelling exactly.
|
|
99
|
+
|
|
100
|
+
The spring-forward day needs no special case: 02:00 simply does not exist,
|
|
101
|
+
and upstream correspondingly publishes no ``HS2`` row for it.
|
|
102
|
+
"""
|
|
103
|
+
return moment.hour + moment.fold
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def day_type(moment: datetime, calendar: frozenset[date] | None = None) -> DayType:
|
|
107
|
+
"""Classify a Pacific-time moment for export-rate lookup.
|
|
108
|
+
|
|
109
|
+
Saturdays, Sundays, and the eight tariff holidays all share the "Weekend"
|
|
110
|
+
matrix column.
|
|
111
|
+
|
|
112
|
+
``calendar`` overrides the shared holiday table. Export lookups pass the
|
|
113
|
+
calendar embedded in their own vintage file: the vintages disagree about
|
|
114
|
+
far-future holidays, and using the wrong one reads the wrong matrix column.
|
|
115
|
+
"""
|
|
116
|
+
if moment.weekday() >= 5:
|
|
117
|
+
return DayType.WEEKEND
|
|
118
|
+
day = moment.date()
|
|
119
|
+
holiday = day in calendar if calendar is not None else is_holiday(day)
|
|
120
|
+
return DayType.WEEKEND if holiday else DayType.WEEKDAY
|
tariffkit/web/app.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""A small read-only REST API over the rate engine.
|
|
2
|
+
|
|
3
|
+
Every response is pure computation over vendored data, so there is nothing to
|
|
4
|
+
cache invalidate and no upstream to rate-limit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from ..account import (
|
|
14
|
+
AccountError,
|
|
15
|
+
AccountRateEngine,
|
|
16
|
+
NamedProfileRepository,
|
|
17
|
+
ProfileNotFoundError,
|
|
18
|
+
configured_profile_name,
|
|
19
|
+
)
|
|
20
|
+
from ..config import Config
|
|
21
|
+
from ..engine import RateEngine
|
|
22
|
+
from ..errors import ConfigError, DataError, OutOfRangeError
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
|
|
27
|
+
MAX_FORECAST_HOURS = 24 * 365
|
|
28
|
+
_PROFILE_UNAVAILABLE = "profile unavailable"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_app(
|
|
32
|
+
config: Config | None = None,
|
|
33
|
+
*,
|
|
34
|
+
profile_name: str | None = None,
|
|
35
|
+
profile_repository: NamedProfileRepository | None = None,
|
|
36
|
+
config_path: str | Path | None = None,
|
|
37
|
+
) -> FastAPI:
|
|
38
|
+
try:
|
|
39
|
+
from fastapi import Body, FastAPI, HTTPException, Query
|
|
40
|
+
except ImportError as exc: # pragma: no cover - exercised by packaging
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"web support requires the 'web' extra: pip install 'tariffkit[web]'"
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
def load_profile(name: str) -> AccountRateEngine:
|
|
46
|
+
repository = profile_repository or NamedProfileRepository()
|
|
47
|
+
try:
|
|
48
|
+
return AccountRateEngine(repository.load(name))
|
|
49
|
+
except ProfileNotFoundError as exc:
|
|
50
|
+
# Do not reveal whether a profile exists, nor details from its
|
|
51
|
+
# managed file, through a request-facing error.
|
|
52
|
+
raise HTTPException(404, _PROFILE_UNAVAILABLE) from exc
|
|
53
|
+
except AccountError as exc:
|
|
54
|
+
raise HTTPException(404, _PROFILE_UNAVAILABLE) from exc
|
|
55
|
+
|
|
56
|
+
selected_profile = profile_name
|
|
57
|
+
if selected_profile is None and config is None:
|
|
58
|
+
selected_profile = configured_profile_name(config_path)
|
|
59
|
+
engine: RateEngine | AccountRateEngine
|
|
60
|
+
if selected_profile is not None:
|
|
61
|
+
engine = load_profile(selected_profile)
|
|
62
|
+
else:
|
|
63
|
+
engine = RateEngine(config or Config.load(config_path))
|
|
64
|
+
|
|
65
|
+
def request_engine(
|
|
66
|
+
payload: dict[str, Any], *, allowed: set[str]
|
|
67
|
+
) -> RateEngine | AccountRateEngine:
|
|
68
|
+
unknown = set(payload) - allowed
|
|
69
|
+
if unknown:
|
|
70
|
+
raise HTTPException(422, f"unknown request keys: {sorted(unknown)}")
|
|
71
|
+
raw = payload.get("config")
|
|
72
|
+
requested_profile = payload.get("profile")
|
|
73
|
+
requested_account = payload.get("account")
|
|
74
|
+
if requested_profile is not None and requested_account is not None:
|
|
75
|
+
if requested_profile != requested_account:
|
|
76
|
+
raise HTTPException(422, "profile and account selections disagree")
|
|
77
|
+
elif requested_profile is None:
|
|
78
|
+
requested_profile = requested_account
|
|
79
|
+
if raw is not None and requested_profile is not None:
|
|
80
|
+
raise HTTPException(422, "choose either config or profile")
|
|
81
|
+
if requested_profile is not None:
|
|
82
|
+
if not isinstance(requested_profile, str):
|
|
83
|
+
raise HTTPException(404, _PROFILE_UNAVAILABLE)
|
|
84
|
+
return load_profile(requested_profile)
|
|
85
|
+
if raw is None:
|
|
86
|
+
if selected_profile is not None:
|
|
87
|
+
return engine
|
|
88
|
+
raise HTTPException(422, "config must be a JSON object or profile must be selected")
|
|
89
|
+
if not isinstance(raw, dict):
|
|
90
|
+
raise HTTPException(422, "config must be a JSON object")
|
|
91
|
+
try:
|
|
92
|
+
return RateEngine(Config.from_dict(raw))
|
|
93
|
+
except (ConfigError, DataError) as exc:
|
|
94
|
+
raise HTTPException(422, str(exc)) from exc
|
|
95
|
+
|
|
96
|
+
def request_timestamp(raw: object, name: str) -> datetime:
|
|
97
|
+
if not isinstance(raw, str):
|
|
98
|
+
raise HTTPException(422, f"{name} must be an ISO 8601 string")
|
|
99
|
+
try:
|
|
100
|
+
parsed = datetime.fromisoformat(raw)
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise HTTPException(422, f"{name} must be an ISO 8601 string") from exc
|
|
103
|
+
if parsed.tzinfo is None:
|
|
104
|
+
raise HTTPException(422, f"{name} must include a UTC offset")
|
|
105
|
+
return parsed
|
|
106
|
+
|
|
107
|
+
app = FastAPI(
|
|
108
|
+
title="tariffkit",
|
|
109
|
+
summary="PG&E E-ELEC import/export prices under NEM 3.0",
|
|
110
|
+
version=_version(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@app.get("/v1/healthz")
|
|
114
|
+
def healthz() -> dict[str, str]:
|
|
115
|
+
return {"status": "ok"}
|
|
116
|
+
|
|
117
|
+
@app.get("/v1/meta")
|
|
118
|
+
def meta() -> dict[str, Any]:
|
|
119
|
+
try:
|
|
120
|
+
return engine.describe()
|
|
121
|
+
except AccountError as exc:
|
|
122
|
+
raise HTTPException(404, str(exc)) from exc
|
|
123
|
+
|
|
124
|
+
@app.post("/v1/meta")
|
|
125
|
+
def configured_meta(payload: dict[str, Any] = Body()) -> dict[str, Any]:
|
|
126
|
+
try:
|
|
127
|
+
return request_engine(payload, allowed={"config", "profile", "account"}).describe()
|
|
128
|
+
except AccountError as exc:
|
|
129
|
+
raise HTTPException(404, str(exc)) from exc
|
|
130
|
+
|
|
131
|
+
@app.get("/v1/price/now")
|
|
132
|
+
def price_now() -> dict[str, Any]:
|
|
133
|
+
try:
|
|
134
|
+
return engine.price_now().to_dict()
|
|
135
|
+
except AccountError as exc:
|
|
136
|
+
raise HTTPException(404, str(exc)) from exc
|
|
137
|
+
|
|
138
|
+
@app.post("/v1/price/now")
|
|
139
|
+
def configured_price_now(payload: dict[str, Any] = Body()) -> dict[str, Any]:
|
|
140
|
+
try:
|
|
141
|
+
return (
|
|
142
|
+
request_engine(payload, allowed={"config", "profile", "account"})
|
|
143
|
+
.price_now()
|
|
144
|
+
.to_dict()
|
|
145
|
+
)
|
|
146
|
+
except AccountError as exc:
|
|
147
|
+
raise HTTPException(404, str(exc)) from exc
|
|
148
|
+
|
|
149
|
+
@app.get("/v1/price/at")
|
|
150
|
+
def price_at(
|
|
151
|
+
ts: datetime = Query(description="ISO 8601 timestamp with offset"),
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
if ts.tzinfo is None:
|
|
154
|
+
raise HTTPException(422, "ts must include a UTC offset, e.g. 2026-09-15T19:00-07:00")
|
|
155
|
+
try:
|
|
156
|
+
return engine.price_at(ts).to_dict()
|
|
157
|
+
except AccountError as exc:
|
|
158
|
+
raise HTTPException(404, str(exc)) from exc
|
|
159
|
+
except OutOfRangeError as exc:
|
|
160
|
+
raise HTTPException(404, str(exc)) from exc
|
|
161
|
+
except DataError as exc:
|
|
162
|
+
raise HTTPException(422, str(exc)) from exc
|
|
163
|
+
|
|
164
|
+
@app.post("/v1/price/at")
|
|
165
|
+
def configured_price_at(payload: dict[str, Any] = Body()) -> dict[str, Any]:
|
|
166
|
+
request = request_engine(payload, allowed={"config", "profile", "account", "ts"})
|
|
167
|
+
ts = request_timestamp(payload.get("ts"), "ts")
|
|
168
|
+
try:
|
|
169
|
+
return request.price_at(ts).to_dict()
|
|
170
|
+
except AccountError as exc:
|
|
171
|
+
raise HTTPException(404, str(exc)) from exc
|
|
172
|
+
except OutOfRangeError as exc:
|
|
173
|
+
raise HTTPException(404, str(exc)) from exc
|
|
174
|
+
except DataError as exc:
|
|
175
|
+
raise HTTPException(422, str(exc)) from exc
|
|
176
|
+
|
|
177
|
+
@app.get("/v1/forecast")
|
|
178
|
+
def forecast(
|
|
179
|
+
hours: int = Query(24, ge=1, le=MAX_FORECAST_HOURS),
|
|
180
|
+
start: datetime | None = Query(None, description="defaults to the current hour"),
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
if start is not None and start.tzinfo is None:
|
|
183
|
+
raise HTTPException(422, "start must include a UTC offset")
|
|
184
|
+
try:
|
|
185
|
+
return engine.forecast(hours=hours, start=start).to_dict()
|
|
186
|
+
except AccountError as exc:
|
|
187
|
+
raise HTTPException(404, str(exc)) from exc
|
|
188
|
+
except OutOfRangeError as exc:
|
|
189
|
+
raise HTTPException(404, str(exc)) from exc
|
|
190
|
+
except DataError as exc:
|
|
191
|
+
raise HTTPException(422, str(exc)) from exc
|
|
192
|
+
|
|
193
|
+
@app.post("/v1/forecast")
|
|
194
|
+
def configured_forecast(payload: dict[str, Any] = Body()) -> dict[str, Any]:
|
|
195
|
+
request = request_engine(
|
|
196
|
+
payload, allowed={"config", "profile", "account", "hours", "start"}
|
|
197
|
+
)
|
|
198
|
+
hours = payload.get("hours", 24)
|
|
199
|
+
if not isinstance(hours, int) or isinstance(hours, bool):
|
|
200
|
+
raise HTTPException(422, "hours must be an integer")
|
|
201
|
+
if not 1 <= hours <= MAX_FORECAST_HOURS:
|
|
202
|
+
raise HTTPException(422, f"hours must be between 1 and {MAX_FORECAST_HOURS}")
|
|
203
|
+
raw_start = payload.get("start")
|
|
204
|
+
start = request_timestamp(raw_start, "start") if raw_start is not None else None
|
|
205
|
+
try:
|
|
206
|
+
return request.forecast(hours=hours, start=start).to_dict()
|
|
207
|
+
except AccountError as exc:
|
|
208
|
+
raise HTTPException(404, str(exc)) from exc
|
|
209
|
+
except OutOfRangeError as exc:
|
|
210
|
+
raise HTTPException(404, str(exc)) from exc
|
|
211
|
+
except DataError as exc:
|
|
212
|
+
raise HTTPException(422, str(exc)) from exc
|
|
213
|
+
|
|
214
|
+
return app
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _version() -> str:
|
|
218
|
+
from .. import __version__
|
|
219
|
+
|
|
220
|
+
return __version__
|