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
tariffkit/config.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""User-facing configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from datetime import date, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError
|
|
14
|
+
from .models import Supplier
|
|
15
|
+
|
|
16
|
+
AccPlusSegment = Literal["residential", "residential_low_income", "none"]
|
|
17
|
+
Discount = Literal["none", "care", "fera"]
|
|
18
|
+
#: PG&E's Code B (basic) and Code H (all-electric) baseline quantity columns.
|
|
19
|
+
BaselineCode = Literal["basic", "all_electric"]
|
|
20
|
+
|
|
21
|
+
#: Interconnection-application year -> NBT vintage. Systems that do not qualify
|
|
22
|
+
#: for a nine-year lock use the floating vintage, NBT00.
|
|
23
|
+
VINTAGE_BY_YEAR = {
|
|
24
|
+
2023: "NBT23",
|
|
25
|
+
2024: "NBT24",
|
|
26
|
+
2025: "NBT25",
|
|
27
|
+
2026: "NBT26",
|
|
28
|
+
}
|
|
29
|
+
FLOATING_VINTAGE = "NBT00"
|
|
30
|
+
|
|
31
|
+
LOCK_YEARS = 9
|
|
32
|
+
_ONE_DAY = timedelta(days=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class CcaConfig:
|
|
37
|
+
"""Settings for customers whose generation comes from a CCA or ESP.
|
|
38
|
+
|
|
39
|
+
PG&E still delivers, so the delivery half of both the import price and the
|
|
40
|
+
export credit continues to come from PG&E's tariff. Generation comes from
|
|
41
|
+
the CCA, and this package ships no CCA rate data -- supply it here.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str = ""
|
|
45
|
+
#: Vendored rate card to price generation from, e.g. ``"mce"``. Without one,
|
|
46
|
+
#: supply ``generation_rates`` directly.
|
|
47
|
+
rate_card: str | None = None
|
|
48
|
+
#: Product tier on that rate card, e.g. MCE's light_green / deep_green.
|
|
49
|
+
option: str = "light_green"
|
|
50
|
+
#: Vintage year for the PCIA the customer pays in place of the bundled PCIA.
|
|
51
|
+
pcia_vintage: int | None = None
|
|
52
|
+
#: PCIA in $/kWh, taken straight from a bill. Overrides ``pcia_vintage``,
|
|
53
|
+
#: which only covers the vintages published on the E-ELEC sheet.
|
|
54
|
+
pcia_rate: float | None = None
|
|
55
|
+
#: Schedule E-FFS franchise fee surcharge, $/kWh. Not published on the
|
|
56
|
+
#: E-ELEC sheet, so it must be supplied rather than guessed.
|
|
57
|
+
franchise_fee_surcharge: float | None = None
|
|
58
|
+
#: CCA generation charge by season and TOU period, e.g.
|
|
59
|
+
#: ``{"summer": {"peak": 0.123, ...}, "winter": {...}}``.
|
|
60
|
+
generation_rates: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
61
|
+
#: Per-kWh export compensation the CCA pays, if any. Under NBT the PG&E
|
|
62
|
+
#: file's generation component does not apply to CCA customers.
|
|
63
|
+
export_generation_rate: float | None = None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def complete(self) -> bool:
|
|
67
|
+
has_generation = bool(self.generation_rates) or self.rate_card is not None
|
|
68
|
+
# A PCIA vintage supplies both the PCIA and the matching E-FFS value
|
|
69
|
+
# from the vendored tariff sheet. Requiring the surcharge to be copied
|
|
70
|
+
# into every profile made the otherwise valid ``rate_card + pcia_vintage``
|
|
71
|
+
# form look incomplete, even though RetailTariff can price it exactly.
|
|
72
|
+
has_pcia = self.pcia_rate is not None or self.pcia_vintage is not None
|
|
73
|
+
has_franchise_fee = (
|
|
74
|
+
self.franchise_fee_surcharge is not None or self.pcia_vintage is not None
|
|
75
|
+
)
|
|
76
|
+
return has_generation and has_pcia and has_franchise_fee
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Config:
|
|
81
|
+
"""Everything needed to price a kWh for one service agreement."""
|
|
82
|
+
|
|
83
|
+
utility: str = "PGE"
|
|
84
|
+
tariff: str = "E-ELEC"
|
|
85
|
+
supplier: Supplier = Supplier.BUNDLED
|
|
86
|
+
|
|
87
|
+
#: Calendar year of the completed interconnection application. Selects both
|
|
88
|
+
#: the NBT vintage and the ACC Plus row.
|
|
89
|
+
interconnection_year: int | None = 2026
|
|
90
|
+
#: Permission-To-Operate date. Starts the nine-year rate lock.
|
|
91
|
+
pto_date: date | None = date(2026, 6, 3)
|
|
92
|
+
#: Overrides the vintage derived from ``interconnection_year``.
|
|
93
|
+
vintage: str | None = None
|
|
94
|
+
|
|
95
|
+
acc_plus_segment: AccPlusSegment = "residential"
|
|
96
|
+
discount: Discount = "none"
|
|
97
|
+
base_services_charge_tier: Literal[1, 2, 3] = 3
|
|
98
|
+
|
|
99
|
+
#: Baseline territory letter, printed on the bill as e.g. "Baseline
|
|
100
|
+
#: Territory X". Only schedules with a baseline allowance use it -- E-TOU-C
|
|
101
|
+
#: among those vendored -- and only when computing a whole bill.
|
|
102
|
+
baseline_territory: str | None = None
|
|
103
|
+
#: "basic" for a gas-heated home, "all_electric" where space heating is
|
|
104
|
+
#: electric. PG&E prints this as Code B or Code H; the bill's "Heat Source"
|
|
105
|
+
#: line says which.
|
|
106
|
+
baseline_code: BaselineCode = "basic"
|
|
107
|
+
|
|
108
|
+
cca: CcaConfig | None = None
|
|
109
|
+
|
|
110
|
+
#: Net Surplus Compensation rate, $/kWh, for the annual true-up.
|
|
111
|
+
#:
|
|
112
|
+
#: Left unset because for a CCA account nobody publishes one in advance: MCE
|
|
113
|
+
#: determines its Solar Billing Plan rate at cash-out. When this is ``None``
|
|
114
|
+
#: the true-up falls back to PG&E's published series as a stand-in and marks
|
|
115
|
+
#: the result estimated. Set it once a real cash-out statement says what was
|
|
116
|
+
#: actually paid.
|
|
117
|
+
nsc_rate: float | None = None
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
# Coerce the string forms of the enums. Supplier is a StrEnum, so a
|
|
121
|
+
# plain "cca" compares equal to Supplier.CCA but is not it -- and every
|
|
122
|
+
# branch that matters tests identity. Constructing Config directly with
|
|
123
|
+
# a string therefore priced a CCA customer as bundled, silently and with
|
|
124
|
+
# entirely plausible numbers. from_dict already coerced; direct
|
|
125
|
+
# construction did not, which is the path library callers take.
|
|
126
|
+
# Written through the value rather than as an isinstance guard: the
|
|
127
|
+
# annotation promises a Supplier, so mypy reads any such guard as dead
|
|
128
|
+
# code. That promise is exactly what is not enforced at runtime -- a
|
|
129
|
+
# caller passing "cca" got a str, which compares equal to Supplier.CCA
|
|
130
|
+
# but is not it, and every branch that matters tests identity.
|
|
131
|
+
# Supplier(Supplier.CCA) is Supplier.CCA, so this is a no-op when the
|
|
132
|
+
# annotation was honoured and a coercion when it was not.
|
|
133
|
+
object.__setattr__(self, "supplier", Supplier(self.supplier))
|
|
134
|
+
if self.supplier is Supplier.CCA and self.cca is None:
|
|
135
|
+
raise ConfigError("supplier='cca' requires a CcaConfig")
|
|
136
|
+
if self.vintage is None and self.interconnection_year is None:
|
|
137
|
+
raise ConfigError("set either interconnection_year or vintage")
|
|
138
|
+
if self.discount != "none" and self.acc_plus_segment == "residential":
|
|
139
|
+
# CARE/FERA customers qualify for the much larger low-income ACC
|
|
140
|
+
# Plus adder; silently applying the standard one would understate
|
|
141
|
+
# the export credit by several cents per kWh.
|
|
142
|
+
raise ConfigError(
|
|
143
|
+
f"discount={self.discount!r} implies acc_plus_segment="
|
|
144
|
+
"'residential_low_income'; set it explicitly"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def resolved_vintage(self) -> str:
|
|
149
|
+
"""The NBT vintage whose matrix applies to this customer."""
|
|
150
|
+
if self.vintage is not None:
|
|
151
|
+
return self.vintage
|
|
152
|
+
assert self.interconnection_year is not None
|
|
153
|
+
return VINTAGE_BY_YEAR.get(self.interconnection_year, FLOATING_VINTAGE)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def lock_end(self) -> date | None:
|
|
157
|
+
"""Last date covered by the nine-year rate lock, inclusive.
|
|
158
|
+
|
|
159
|
+
``None`` for a floating (NBT00) customer, who has no lock at all.
|
|
160
|
+
"""
|
|
161
|
+
if self.pto_date is None or self.resolved_vintage == FLOATING_VINTAGE:
|
|
162
|
+
return None
|
|
163
|
+
return self.pto_date.replace(year=self.pto_date.year + LOCK_YEARS) - _ONE_DAY
|
|
164
|
+
|
|
165
|
+
def with_(self, **changes: Any) -> Config:
|
|
166
|
+
return replace(self, **changes)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_dict(cls, raw: dict[str, Any]) -> Config:
|
|
170
|
+
data = dict(raw)
|
|
171
|
+
unknown = set(data) - {f.name for f in cls.__dataclass_fields__.values()}
|
|
172
|
+
if unknown:
|
|
173
|
+
raise ConfigError(f"unknown config keys: {sorted(unknown)}")
|
|
174
|
+
try:
|
|
175
|
+
cca_value = data.get("cca")
|
|
176
|
+
if isinstance(cca_value, dict):
|
|
177
|
+
cca_raw = cca_value
|
|
178
|
+
unknown_cca = set(cca_raw) - {
|
|
179
|
+
f.name for f in CcaConfig.__dataclass_fields__.values()
|
|
180
|
+
}
|
|
181
|
+
if unknown_cca:
|
|
182
|
+
raise ConfigError(f"unknown CCA config keys: {sorted(unknown_cca)}")
|
|
183
|
+
data["cca"] = CcaConfig(**cca_raw)
|
|
184
|
+
elif cca_value is not None and not isinstance(cca_value, CcaConfig):
|
|
185
|
+
raise ConfigError("cca must be an object")
|
|
186
|
+
if "supplier" in data:
|
|
187
|
+
data["supplier"] = Supplier(data["supplier"])
|
|
188
|
+
value = data.get("pto_date")
|
|
189
|
+
if isinstance(value, str):
|
|
190
|
+
data["pto_date"] = date.fromisoformat(value)
|
|
191
|
+
return cls(**data)
|
|
192
|
+
except (TypeError, ValueError) as exc:
|
|
193
|
+
raise ConfigError(f"invalid config: {exc}") from exc
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict[str, Any]:
|
|
196
|
+
"""A JSON-compatible representation suitable for API request bodies."""
|
|
197
|
+
data: dict[str, Any] = {
|
|
198
|
+
"utility": self.utility,
|
|
199
|
+
"tariff": self.tariff,
|
|
200
|
+
"supplier": self.supplier.value,
|
|
201
|
+
"interconnection_year": self.interconnection_year,
|
|
202
|
+
"pto_date": self.pto_date.isoformat() if self.pto_date else None,
|
|
203
|
+
"vintage": self.vintage,
|
|
204
|
+
"acc_plus_segment": self.acc_plus_segment,
|
|
205
|
+
"discount": self.discount,
|
|
206
|
+
"base_services_charge_tier": self.base_services_charge_tier,
|
|
207
|
+
"baseline_territory": self.baseline_territory,
|
|
208
|
+
"baseline_code": self.baseline_code,
|
|
209
|
+
"nsc_rate": self.nsc_rate,
|
|
210
|
+
}
|
|
211
|
+
if self.cca is not None:
|
|
212
|
+
data["cca"] = {
|
|
213
|
+
"name": self.cca.name,
|
|
214
|
+
"rate_card": self.cca.rate_card,
|
|
215
|
+
"option": self.cca.option,
|
|
216
|
+
"pcia_vintage": self.cca.pcia_vintage,
|
|
217
|
+
"pcia_rate": self.cca.pcia_rate,
|
|
218
|
+
"franchise_fee_surcharge": self.cca.franchise_fee_surcharge,
|
|
219
|
+
"generation_rates": self.cca.generation_rates,
|
|
220
|
+
"export_generation_rate": self.cca.export_generation_rate,
|
|
221
|
+
}
|
|
222
|
+
return data
|
|
223
|
+
|
|
224
|
+
@classmethod
|
|
225
|
+
def from_toml(cls, path: str | Path) -> Config:
|
|
226
|
+
with Path(path).open("rb") as handle:
|
|
227
|
+
table = tomllib.load(handle)
|
|
228
|
+
# The shared user config also carries integration settings such as the
|
|
229
|
+
# default account profile and MQTT broker. They are not pricing fields
|
|
230
|
+
# and must not make a stateless Config unusable.
|
|
231
|
+
for section in ("account", "mqtt", "home_assistant", "influxdb"):
|
|
232
|
+
table.pop(section, None)
|
|
233
|
+
for key in ("profile", "default_profile", "account_profile"):
|
|
234
|
+
table.pop(key, None)
|
|
235
|
+
return cls.from_dict(table)
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_env(cls, base: Config | None = None) -> Config:
|
|
239
|
+
"""Overlay ``TARIFFKIT_*`` environment variables onto ``base``."""
|
|
240
|
+
config = base or cls()
|
|
241
|
+
overrides: dict[str, Any] = {}
|
|
242
|
+
if value := os.environ.get("TARIFFKIT_UTILITY"):
|
|
243
|
+
overrides["utility"] = value
|
|
244
|
+
if value := os.environ.get("TARIFFKIT_TARIFF"):
|
|
245
|
+
overrides["tariff"] = value
|
|
246
|
+
if value := os.environ.get("TARIFFKIT_SUPPLIER"):
|
|
247
|
+
overrides["supplier"] = Supplier(value)
|
|
248
|
+
if value := os.environ.get("TARIFFKIT_VINTAGE"):
|
|
249
|
+
overrides["vintage"] = value
|
|
250
|
+
if value := os.environ.get("TARIFFKIT_INTERCONNECTION_YEAR"):
|
|
251
|
+
overrides["interconnection_year"] = int(value)
|
|
252
|
+
if value := os.environ.get("TARIFFKIT_PTO_DATE"):
|
|
253
|
+
overrides["pto_date"] = date.fromisoformat(value)
|
|
254
|
+
if value := os.environ.get("TARIFFKIT_ACC_PLUS_SEGMENT"):
|
|
255
|
+
overrides["acc_plus_segment"] = value
|
|
256
|
+
if value := os.environ.get("TARIFFKIT_DISCOUNT"):
|
|
257
|
+
overrides["discount"] = value
|
|
258
|
+
if value := os.environ.get("TARIFFKIT_BSC_TIER"):
|
|
259
|
+
overrides["base_services_charge_tier"] = int(value)
|
|
260
|
+
if value := os.environ.get("TARIFFKIT_BASELINE_TERRITORY"):
|
|
261
|
+
overrides["baseline_territory"] = value
|
|
262
|
+
if value := os.environ.get("TARIFFKIT_BASELINE_CODE"):
|
|
263
|
+
overrides["baseline_code"] = value
|
|
264
|
+
if value := os.environ.get("TARIFFKIT_NSC_RATE"):
|
|
265
|
+
overrides["nsc_rate"] = float(value)
|
|
266
|
+
if value := os.environ.get("TARIFFKIT_CCA_JSON"):
|
|
267
|
+
try:
|
|
268
|
+
cca_raw = json.loads(value)
|
|
269
|
+
except json.JSONDecodeError as exc:
|
|
270
|
+
raise ConfigError("TARIFFKIT_CCA_JSON is not valid JSON") from exc
|
|
271
|
+
if not isinstance(cca_raw, dict):
|
|
272
|
+
raise ConfigError("TARIFFKIT_CCA_JSON must be a JSON object")
|
|
273
|
+
unknown_cca = set(cca_raw) - {f.name for f in CcaConfig.__dataclass_fields__.values()}
|
|
274
|
+
if unknown_cca:
|
|
275
|
+
raise ConfigError(f"unknown CCA config keys: {sorted(unknown_cca)}")
|
|
276
|
+
overrides["cca"] = CcaConfig(**cca_raw)
|
|
277
|
+
return replace(config, **overrides) if overrides else config
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def load(cls, path: str | Path | None = None) -> Config:
|
|
281
|
+
"""Load from ``path``, else the default config file, else defaults.
|
|
282
|
+
|
|
283
|
+
Environment variables overlay whichever source is used.
|
|
284
|
+
"""
|
|
285
|
+
if path is not None:
|
|
286
|
+
return cls.from_env(cls.from_toml(path))
|
|
287
|
+
default = default_config_path()
|
|
288
|
+
if default.is_file():
|
|
289
|
+
return cls.from_env(cls.from_toml(default))
|
|
290
|
+
return cls.from_env()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def default_config_path() -> Path:
|
|
294
|
+
root = os.environ.get("XDG_CONFIG_HOME")
|
|
295
|
+
base = Path(root) if root else Path.home() / ".config"
|
|
296
|
+
return base / "tariffkit" / "config.toml"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Access to the vendored rate data shipped inside the package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import json
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..errors import DataError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resource(relative: str) -> Any:
|
|
14
|
+
resource = files(__package__)
|
|
15
|
+
for part in relative.split("/"):
|
|
16
|
+
resource = resource / part
|
|
17
|
+
return resource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def read_data_text(relative: str) -> str:
|
|
21
|
+
"""Read a text data file, e.g. ``holidays.toml``."""
|
|
22
|
+
try:
|
|
23
|
+
text: str = _resource(relative).read_text(encoding="utf-8")
|
|
24
|
+
return text
|
|
25
|
+
except FileNotFoundError as exc:
|
|
26
|
+
raise DataError(f"vendored data file missing: {relative}") from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_data_json_gz(relative: str) -> dict[str, Any]:
|
|
30
|
+
"""Read a gzipped JSON data file, e.g. ``export/pge/nbt26.json.gz``."""
|
|
31
|
+
try:
|
|
32
|
+
raw = _resource(relative).read_bytes()
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
raise DataError(f"vendored data file missing: {relative}") from exc
|
|
35
|
+
payload: dict[str, Any] = json.loads(gzip.decompress(raw))
|
|
36
|
+
return payload
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def data_exists(relative: str) -> bool:
|
|
40
|
+
return bool(_resource(relative).is_file())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Marin Clean Energy (MCE) residential generation rates.
|
|
2
|
+
#
|
|
3
|
+
# Source: https://mcecleanenergy.org/wp-content/uploads/2025/09/MCE-website-rate-table_RES_as-of-4.1.26.pdf
|
|
4
|
+
# Solar Billing Plan tariff: https://mcecleanenergy.org/wp-content/uploads/2024/12/MCE-Solar-Billing-Plan-Tariff_120424.pdf
|
|
5
|
+
#
|
|
6
|
+
# GENERATED by `python -m tools.regen cca` -- do not hand-edit the rate tables.
|
|
7
|
+
#
|
|
8
|
+
# A CCA supplies generation only; delivery stays with the utility, so these
|
|
9
|
+
# are generation rates alone and pair with that utility's tariff sheet.
|
|
10
|
+
#
|
|
11
|
+
# These may currently equal the utility's own generation component exactly.
|
|
12
|
+
# That is parity today, not an alias: they are vendored separately so a
|
|
13
|
+
# divergence shows up rather than being silently inherited. Regeneration
|
|
14
|
+
# reports the parity status of every rate it writes.
|
|
15
|
+
|
|
16
|
+
schema = 1
|
|
17
|
+
provider = "MCE"
|
|
18
|
+
name = "Marin Clean Energy"
|
|
19
|
+
utility = "PGE"
|
|
20
|
+
schedules = ["E-ELEC", "E-TOU-C", "EV2-A"]
|
|
21
|
+
effective = "2023-01-01"
|
|
22
|
+
currency = "USD/kWh"
|
|
23
|
+
source_url = "https://mcecleanenergy.org/wp-content/uploads/2025/09/MCE-website-rate-table_RES_as-of-4.1.26.pdf"
|
|
24
|
+
|
|
25
|
+
# Checksum of the document these values were read from. A scheduled check
|
|
26
|
+
# compares it so a republished card is noticed even when it cannot be
|
|
27
|
+
# parsed -- detection does not need a text layer, only bytes.
|
|
28
|
+
source_sha256 = "db92362d02205c24ccaed4c42e5508d5f4fd7077226b88cea3900acec9c55270"
|
|
29
|
+
source_read_on = "2026-08-12"
|
|
30
|
+
|
|
31
|
+
[options]
|
|
32
|
+
light_green = 0.0
|
|
33
|
+
deep_green = 0.01
|
|
34
|
+
|
|
35
|
+
[generation.eelec.summer]
|
|
36
|
+
peak = 0.301
|
|
37
|
+
part_peak = 0.199
|
|
38
|
+
off_peak = 0.152
|
|
39
|
+
|
|
40
|
+
[generation.eelec.winter]
|
|
41
|
+
peak = 0.134
|
|
42
|
+
part_peak = 0.113
|
|
43
|
+
off_peak = 0.099
|
|
44
|
+
|
|
45
|
+
[generation.etouc.summer]
|
|
46
|
+
peak = 0.195
|
|
47
|
+
off_peak = 0.144
|
|
48
|
+
|
|
49
|
+
[generation.etouc.winter]
|
|
50
|
+
peak = 0.149
|
|
51
|
+
off_peak = 0.135
|
|
52
|
+
|
|
53
|
+
[generation.ev2a.summer]
|
|
54
|
+
peak = 0.21
|
|
55
|
+
part_peak = 0.167
|
|
56
|
+
off_peak = 0.128
|
|
57
|
+
|
|
58
|
+
[generation.ev2a.winter]
|
|
59
|
+
peak = 0.155
|
|
60
|
+
part_peak = 0.144
|
|
61
|
+
off_peak = 0.121
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# MCE (Marin Clean Energy) residential generation rates.
|
|
2
|
+
#
|
|
3
|
+
# Source: MCE Light Green Residential Rates, effective April 1, 2026
|
|
4
|
+
# https://mcecleanenergy.org/wp-content/uploads/2025/09/MCE-website-rate-table_RES_as-of-4.1.26.pdf
|
|
5
|
+
# Solar Billing Plan tariff (effective 2023-12-01, rev. 2024-12-04):
|
|
6
|
+
# https://mcecleanenergy.org/wp-content/uploads/2024/12/MCE-Solar-Billing-Plan-Tariff_120424.pdf
|
|
7
|
+
#
|
|
8
|
+
# HOW THESE WERE READ.
|
|
9
|
+
#
|
|
10
|
+
# The rate card is a "Microsoft: Print To PDF" export of a spreadsheet with no
|
|
11
|
+
# text layer -- pdftotext, pdffonts and pypdf all come back empty, and its font
|
|
12
|
+
# maps six characters to Unicode, enough to spell "Page 1". The page renders
|
|
13
|
+
# perfectly, so the table is READ FROM THE RENDERED PAGE rather than parsed.
|
|
14
|
+
# That is a supported way to maintain this file, not a failure: what is lost is
|
|
15
|
+
# the automated extraction, not the source.
|
|
16
|
+
#
|
|
17
|
+
# `python -m tools.regen cca` therefore cannot rebuild this file inside a CI runner,
|
|
18
|
+
# which has no reader -- but an agent session renders the page and reads it, so
|
|
19
|
+
# the file is still maintainable without anyone transcribing by hand. CI detects
|
|
20
|
+
# a republished card by checksum and leaves the reading to such a session. The
|
|
21
|
+
# extractor exists and reads MCE's 2023 card exactly, and takes over the day
|
|
22
|
+
# they publish text again.
|
|
23
|
+
#
|
|
24
|
+
# VERIFICATION. Every one of the 16 generation rates below was checked against
|
|
25
|
+
# the rendered card on 2026-08-12 and matched. So were the cost relief credit
|
|
26
|
+
# (-0.0062, through 2026-12-31) and the Deep Green premium (+0.0125).
|
|
27
|
+
#
|
|
28
|
+
# Three of them are independently confirmed a second way, against the July 2026
|
|
29
|
+
# statement -- summer E-ELEC peak 0.26299, part-peak 0.16388, off-peak 0.11878
|
|
30
|
+
# (tests/test_mce.py::test_generation_matches_the_billed_rate). The remaining
|
|
31
|
+
# thirteen rest on the card alone, since no bill has exercised them yet; winter
|
|
32
|
+
# first applies in October 2026.
|
|
33
|
+
#
|
|
34
|
+
# What this file does NOT have, unlike a PG&E snapshot, is an arithmetic check:
|
|
35
|
+
# there is no published total for its components to sum to. Re-read the card
|
|
36
|
+
# against these values when it changes.
|
|
37
|
+
#
|
|
38
|
+
# MCE calls the schedule "ELEC - Residential Time-of-Use for Qualified Electric
|
|
39
|
+
# Technologies"; it pairs with PG&E's E-ELEC delivery service and appears on
|
|
40
|
+
# bills as "SBP EELEC" for Solar Billing Plan customers.
|
|
41
|
+
#
|
|
42
|
+
# NOTE: every one of these values is currently IDENTICAL to PG&E's bundled
|
|
43
|
+
# generation component. That is an OBSERVATION from the rendered card, not an
|
|
44
|
+
# alias and not an assumption -- MCE prices at parity today. They are vendored
|
|
45
|
+
# separately because nothing guarantees that continues, and a future divergence
|
|
46
|
+
# must not go unnoticed. tests/test_mce.py asserts the parity so it fails loudly
|
|
47
|
+
# if MCE moves.
|
|
48
|
+
|
|
49
|
+
schema = 1
|
|
50
|
+
provider = "MCE"
|
|
51
|
+
name = "Marin Clean Energy"
|
|
52
|
+
utility = "PGE"
|
|
53
|
+
#: MCE prices each PG&E schedule separately, so a rate card is keyed by schedule
|
|
54
|
+
#: rather than tied to one. Slugs match the tariff data directories.
|
|
55
|
+
schedules = ["E-ELEC", "E-TOU-C", "EV2-A"]
|
|
56
|
+
effective = "2026-04-01"
|
|
57
|
+
currency = "USD/kWh"
|
|
58
|
+
source_url = "https://mcecleanenergy.org/compare-rates-and-options/"
|
|
59
|
+
|
|
60
|
+
# Checksum of the rate card these values were read from. This card has no text
|
|
61
|
+
# layer, so it cannot be re-parsed -- but a scheduled check compares these bytes
|
|
62
|
+
# and says whether MCE has republished, which is the half that matters. Silence
|
|
63
|
+
# means the publisher has not moved, not that nobody looked.
|
|
64
|
+
source_sha256 = "0c0ff13aa015eeb27fb8067e434e669a4ecfe4a2995af6f4bc2146f7465b1036"
|
|
65
|
+
source_read_on = "2026-08-12"
|
|
66
|
+
|
|
67
|
+
# TOU periods and season boundaries follow each PG&E schedule exactly, including
|
|
68
|
+
# E-TOU-C having no part-peak at all.
|
|
69
|
+
#
|
|
70
|
+
# Every rate below is IDENTICAL to PG&E's generation component for the same
|
|
71
|
+
# schedule, season and period. That is not an alias and not an error -- MCE
|
|
72
|
+
# prices at parity today. They are vendored separately because nothing
|
|
73
|
+
# guarantees that continues, and tests/test_mce.py asserts the parity so a
|
|
74
|
+
# divergence fails loudly rather than going unnoticed.
|
|
75
|
+
#
|
|
76
|
+
# Keyed by schedule because the rates differ substantially between them: MCE
|
|
77
|
+
# winter off-peak is 0.06754 on ELEC and 0.11042 on ETOUC. Applying one
|
|
78
|
+
# schedule's card to another understates or overstates generation by ~2x, so an
|
|
79
|
+
# uncovered schedule raises rather than falling back.
|
|
80
|
+
|
|
81
|
+
[generation.eelec.summer]
|
|
82
|
+
peak = 0.26299
|
|
83
|
+
part_peak = 0.16388
|
|
84
|
+
off_peak = 0.11878
|
|
85
|
+
|
|
86
|
+
[generation.eelec.winter]
|
|
87
|
+
peak = 0.10086
|
|
88
|
+
part_peak = 0.08089
|
|
89
|
+
off_peak = 0.06754
|
|
90
|
+
|
|
91
|
+
# ETOUC/EMTOUC on MCE's table. No part-peak, matching PG&E's E-TOU-C.
|
|
92
|
+
[generation.etouc.summer]
|
|
93
|
+
peak = 0.20782
|
|
94
|
+
off_peak = 0.10482
|
|
95
|
+
|
|
96
|
+
[generation.etouc.winter]
|
|
97
|
+
peak = 0.13710
|
|
98
|
+
off_peak = 0.11042
|
|
99
|
+
|
|
100
|
+
# EV2 on MCE's table, for PG&E's EV2-A.
|
|
101
|
+
[generation.ev2a.summer]
|
|
102
|
+
peak = 0.18830
|
|
103
|
+
part_peak = 0.14359
|
|
104
|
+
off_peak = 0.10245
|
|
105
|
+
|
|
106
|
+
[generation.ev2a.winter]
|
|
107
|
+
peak = 0.13143
|
|
108
|
+
part_peak = 0.11894
|
|
109
|
+
off_peak = 0.09546
|
|
110
|
+
|
|
111
|
+
# Applied to all generation kWh. Explicitly time-limited -- the rate card says
|
|
112
|
+
# "Runs through December 31, 2026" -- so it is dated rather than folded into the
|
|
113
|
+
# rates above, and stops applying on its own afterwards.
|
|
114
|
+
[cost_relief_credit]
|
|
115
|
+
rate = -0.00620
|
|
116
|
+
through = "2026-12-31"
|
|
117
|
+
|
|
118
|
+
# Deep Green (100% renewable) costs the Light Green rate plus this adder.
|
|
119
|
+
[options]
|
|
120
|
+
light_green = 0.0
|
|
121
|
+
deep_green = 0.0125
|
|
122
|
+
|
|
123
|
+
[export]
|
|
124
|
+
# "All customers will also receive a Solar Bonus Credit which provides an
|
|
125
|
+
# additional 10% of the base export credit." -- SBP tariff section 1.
|
|
126
|
+
solar_bonus_fraction = 0.10
|
|
127
|
+
|
|
128
|
+
# CARE/FERA customers only, on all exports, additional to the above.
|
|
129
|
+
care_fera_bonus_rate = 0.05
|
|
130
|
+
care_fera_bonus_through = "2028-12-31"
|
|
131
|
+
|
|
132
|
+
# MCE does not publish a machine-readable Energy Export Credit matrix; the
|
|
133
|
+
# tariff says only that exports are credited at "the applicable Energy Export
|
|
134
|
+
# Credit Value, which reflects the value of power to the grid at any given
|
|
135
|
+
# time". Whether that equals the CPUC Avoided Cost Calculator generation
|
|
136
|
+
# component (PG&E's USCA-XXPG series) was long unverified.
|
|
137
|
+
#
|
|
138
|
+
# It is now measured. Priced against 2,784 quarter-hourly meter intervals for the
|
|
139
|
+
# 2026-06-30..2026-07-28 cycle and compared with the statement for that cycle,
|
|
140
|
+
# every export component lands within 0.3% -- inside the rounding of the bill's
|
|
141
|
+
# own displayed dollars:
|
|
142
|
+
#
|
|
143
|
+
# MCE generation EEC library $9.618 billed $9.63 -0.1%
|
|
144
|
+
# PG&E delivery EEC $6.236 $6.25 -0.2%
|
|
145
|
+
# ACC Plus bonus $1.705 $1.71 -0.3%
|
|
146
|
+
# MCE 10% solar bonus $0.962 $0.96 +0.2%
|
|
147
|
+
#
|
|
148
|
+
# One summer cycle on one account, so the seasonal spread is untested; the claim
|
|
149
|
+
# it supports is structural (MCE credits exports at the ACC generation component)
|
|
150
|
+
# and 193 kWh spread across all three TOU periods is a fair test of that.
|
|
151
|
+
export_credit_basis = "acc_generation"
|
|
152
|
+
export_credit_verified = true
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ACC Plus adder, Pacific Gas and Electric Schedule NBT.
|
|
2
|
+
#
|
|
3
|
+
# Source: https://www.pge.com/tariffs/assets/pdf/tariffbook/ELEC_SCHEDS_NBT.pdf
|
|
4
|
+
#
|
|
5
|
+
# GENERATED by `python -m tools.regen accplus` -- do not hand-edit.
|
|
6
|
+
#
|
|
7
|
+
# This is NOT present in the export-rate data files -- every row there
|
|
8
|
+
# carries Sector=ALL with no customer-class differentiation -- so it is
|
|
9
|
+
# read from the tariff and added on top.
|
|
10
|
+
#
|
|
11
|
+
# Keyed by the calendar year of the completed interconnection application,
|
|
12
|
+
# then held CONSTANT for nine years from the Permission-To-Operate date.
|
|
13
|
+
# The year-over-year decline below is a step-down for later applicants, not
|
|
14
|
+
# a decay applied to an existing customer.
|
|
15
|
+
#
|
|
16
|
+
# Unlike ordinary export credits, ACC Plus applies to all charges including
|
|
17
|
+
# non-bypassable charges, does not expire, and is cashed out on account
|
|
18
|
+
# closure.
|
|
19
|
+
|
|
20
|
+
schema = 1
|
|
21
|
+
# When this version of the adder table took force. The Net Billing Tariff opened
|
|
22
|
+
# on 2023-04-15 and the five-year step-down has been published unchanged since,
|
|
23
|
+
# so there is one vintage -- but it is dated, because a revision must be able to
|
|
24
|
+
# sit beside it rather than overwrite it.
|
|
25
|
+
effective = "2023-04-15"
|
|
26
|
+
unit = "USD/kWh"
|
|
27
|
+
source = "Pacific Gas and Electric Schedule NBT"
|
|
28
|
+
|
|
29
|
+
[residential]
|
|
30
|
+
2023 = 0.02200
|
|
31
|
+
2024 = 0.01760
|
|
32
|
+
2025 = 0.01320
|
|
33
|
+
2026 = 0.00880
|
|
34
|
+
2027 = 0.00440
|
|
35
|
+
|
|
36
|
+
[residential_low_income]
|
|
37
|
+
2023 = 0.09000
|
|
38
|
+
2024 = 0.07200
|
|
39
|
+
2025 = 0.05400
|
|
40
|
+
2026 = 0.03600
|
|
41
|
+
2027 = 0.01800
|
|
42
|
+
|
|
43
|
+
# Non-residential customers are not eligible for ACC Plus.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Generated by `python -m tools.regen.export` -- do not edit by hand.
|
|
2
|
+
#
|
|
3
|
+
# Extracted from the DayStart==8 rows of PG&E's NBT export-rate files, so
|
|
4
|
+
# the observed-date rules (Saturday -> preceding Friday, Sunday -> following
|
|
5
|
+
# Monday) come from the source rather than being reimplemented here.
|
|
6
|
+
# These eight holidays affect the EXPORT day type only; E-ELEC import
|
|
7
|
+
# pricing makes no weekday/weekend/holiday distinction at all.
|
|
8
|
+
#
|
|
9
|
+
# This is the INTERSECTION across the vintage files covering each year.
|
|
10
|
+
# They agree through 2036; beyond that NBT25/26/00 duplicate some holidays
|
|
11
|
+
# onto the following day, and intersecting drops those artifacts. Export
|
|
12
|
+
# lookups use the per-vintage calendar embedded in each matrix instead.
|
|
13
|
+
|
|
14
|
+
[holidays]
|
|
15
|
+
2024 = ["2024-01-01", "2024-02-19", "2024-05-27", "2024-07-04", "2024-09-02", "2024-11-11", "2024-11-28", "2024-12-25"]
|
|
16
|
+
2025 = ["2025-01-01", "2025-02-17", "2025-05-26", "2025-07-04", "2025-09-01", "2025-11-11", "2025-11-27", "2025-12-25"]
|
|
17
|
+
2026 = ["2026-01-01", "2026-02-16", "2026-05-25", "2026-07-03", "2026-09-07", "2026-11-11", "2026-11-26", "2026-12-25"]
|
|
18
|
+
2027 = ["2027-01-01", "2027-02-15", "2027-05-31", "2027-07-05", "2027-09-06", "2027-11-11", "2027-11-25", "2027-12-24", "2027-12-31"]
|
|
19
|
+
2028 = ["2028-02-21", "2028-05-29", "2028-07-04", "2028-09-04", "2028-11-10", "2028-11-23", "2028-12-25"]
|
|
20
|
+
2029 = ["2029-01-01", "2029-02-19", "2029-05-28", "2029-07-04", "2029-09-03", "2029-11-12", "2029-11-22", "2029-12-25"]
|
|
21
|
+
2030 = ["2030-01-01", "2030-02-18", "2030-05-27", "2030-07-04", "2030-09-02", "2030-11-11", "2030-11-28", "2030-12-25"]
|
|
22
|
+
2031 = ["2031-01-01", "2031-02-17", "2031-05-26", "2031-07-04", "2031-09-01", "2031-11-11", "2031-11-27", "2031-12-25"]
|
|
23
|
+
2032 = ["2032-01-01", "2032-02-16", "2032-05-31", "2032-07-05", "2032-09-06", "2032-11-11", "2032-11-25", "2032-12-24", "2032-12-31"]
|
|
24
|
+
2033 = ["2033-02-21", "2033-05-30", "2033-07-04", "2033-09-05", "2033-11-11", "2033-11-24", "2033-12-26"]
|
|
25
|
+
2034 = ["2034-01-02", "2034-02-20", "2034-05-29", "2034-07-04", "2034-09-04", "2034-11-10", "2034-11-23", "2034-12-25"]
|
|
26
|
+
2035 = ["2035-01-01", "2035-02-19", "2035-05-28", "2035-07-04", "2035-09-03", "2035-11-12", "2035-11-22", "2035-12-25"]
|
|
27
|
+
2036 = ["2036-01-01", "2036-02-18", "2036-05-26", "2036-07-04", "2036-09-01", "2036-11-11", "2036-11-27", "2036-12-25"]
|
|
28
|
+
2037 = ["2037-01-01", "2037-02-16", "2037-05-25", "2037-07-03", "2037-09-07", "2037-11-11", "2037-11-26", "2037-12-25"]
|
|
29
|
+
2038 = ["2038-01-01", "2038-02-15", "2038-05-31", "2038-07-05", "2038-09-06", "2038-11-11", "2038-11-25", "2038-12-24", "2038-12-31"]
|
|
30
|
+
2039 = ["2039-02-21", "2039-05-30", "2039-07-04", "2039-09-05", "2039-11-11", "2039-11-24", "2039-12-26"]
|
|
31
|
+
2040 = ["2040-01-02", "2040-02-20", "2040-05-28", "2040-07-04", "2040-09-03", "2040-11-12", "2040-11-22", "2040-12-25"]
|
|
32
|
+
2041 = ["2041-01-01", "2041-02-18", "2041-05-27", "2041-07-04", "2041-09-02", "2041-11-11", "2041-11-28", "2041-12-25"]
|
|
33
|
+
2042 = ["2042-01-01", "2042-02-17", "2042-05-26", "2042-07-04", "2042-09-01", "2042-11-11", "2042-11-27", "2042-12-25"]
|
|
34
|
+
2043 = ["2043-01-01", "2043-02-16", "2043-05-25", "2043-07-03", "2043-09-07", "2043-11-11", "2043-11-26", "2043-12-25"]
|
|
35
|
+
2044 = ["2044-01-01", "2044-02-15", "2044-05-30", "2044-05-31", "2044-07-04", "2044-07-05", "2044-09-05", "2044-09-06", "2044-11-11", "2044-11-24", "2044-12-26"]
|
|
36
|
+
2045 = ["2045-01-02", "2045-02-20", "2045-05-29", "2045-05-30", "2045-07-04", "2045-07-05", "2045-09-04", "2045-09-05", "2045-11-10", "2045-11-23", "2045-12-25"]
|