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
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"]