tariffkit 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. tariffkit/__init__.py +52 -0
  2. tariffkit/account/__init__.py +41 -0
  3. tariffkit/account/cli.py +493 -0
  4. tariffkit/account/errors.py +25 -0
  5. tariffkit/account/model.py +641 -0
  6. tariffkit/account/rates.py +61 -0
  7. tariffkit/account/repository.py +329 -0
  8. tariffkit/billing/__init__.py +56 -0
  9. tariffkit/billing/engine.py +505 -0
  10. tariffkit/billing/ledger.py +365 -0
  11. tariffkit/billing/models.py +274 -0
  12. tariffkit/billing/netting.py +137 -0
  13. tariffkit/billing/trueup.py +498 -0
  14. tariffkit/cca.py +122 -0
  15. tariffkit/cli.py +809 -0
  16. tariffkit/config.py +296 -0
  17. tariffkit/data/__init__.py +40 -0
  18. tariffkit/data/cca/mce/2023-01-01.toml +61 -0
  19. tariffkit/data/cca/mce/2026-04-01.toml +152 -0
  20. tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
  21. tariffkit/data/export/pge/nbt00.json.gz +0 -0
  22. tariffkit/data/export/pge/nbt23.json.gz +0 -0
  23. tariffkit/data/export/pge/nbt24.json.gz +0 -0
  24. tariffkit/data/export/pge/nbt25.json.gz +0 -0
  25. tariffkit/data/export/pge/nbt26.json.gz +0 -0
  26. tariffkit/data/holidays.toml +36 -0
  27. tariffkit/data/manifest.json +56 -0
  28. tariffkit/data/nsc/pge.toml +57 -0
  29. tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
  30. tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
  31. tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
  32. tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
  33. tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
  34. tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
  35. tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
  36. tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
  37. tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
  38. tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
  39. tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
  40. tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
  41. tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
  42. tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
  43. tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
  44. tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
  45. tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
  46. tariffkit/data/versioned.py +118 -0
  47. tariffkit/engine.py +82 -0
  48. tariffkit/errors.py +19 -0
  49. tariffkit/export/__init__.py +5 -0
  50. tariffkit/export/nbt.py +207 -0
  51. tariffkit/interop/__init__.py +21 -0
  52. tariffkit/interop/emhass.py +79 -0
  53. tariffkit/interop/predbat.py +102 -0
  54. tariffkit/interop/slots.py +63 -0
  55. tariffkit/models.py +164 -0
  56. tariffkit/mqtt/__init__.py +6 -0
  57. tariffkit/mqtt/discovery.py +84 -0
  58. tariffkit/mqtt/publisher.py +305 -0
  59. tariffkit/providers/__init__.py +1 -0
  60. tariffkit/providers/pge/__init__.py +33 -0
  61. tariffkit/providers/pge/reconcile.py +828 -0
  62. tariffkit/providers/pge/statements/__init__.py +26 -0
  63. tariffkit/providers/pge/statements/errors.py +20 -0
  64. tariffkit/providers/pge/statements/model.py +320 -0
  65. tariffkit/providers/pge/statements/ocr.py +193 -0
  66. tariffkit/providers/pge/statements/parse.py +813 -0
  67. tariffkit/py.typed +0 -0
  68. tariffkit/secrets.py +164 -0
  69. tariffkit/sources/__init__.py +71 -0
  70. tariffkit/sources/greenbutton.py +318 -0
  71. tariffkit/sources/homeassistant.py +342 -0
  72. tariffkit/sources/influx.py +359 -0
  73. tariffkit/sources/pge.py +1153 -0
  74. tariffkit/tariff/__init__.py +5 -0
  75. tariffkit/tariff/retail.py +271 -0
  76. tariffkit/timeutil.py +120 -0
  77. tariffkit/web/__init__.py +5 -0
  78. tariffkit/web/app.py +220 -0
  79. tariffkit-0.2.0.dist-info/METADATA +260 -0
  80. tariffkit-0.2.0.dist-info/RECORD +83 -0
  81. tariffkit-0.2.0.dist-info/WHEEL +4 -0
  82. tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
  83. tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Retail import tariffs."""
2
+
3
+ from .retail import RetailTariff, TariffSnapshot, load_snapshot
4
+
5
+ __all__ = ["RetailTariff", "TariffSnapshot", "load_snapshot"]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Optional FastAPI service."""
2
+
3
+ from .app import create_app
4
+
5
+ __all__ = ["create_app"]
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__