tariffkit 0.2.2__tar.gz → 0.3.0__tar.gz
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-0.2.2 → tariffkit-0.3.0}/CHANGELOG.md +65 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/PKG-INFO +12 -8
- {tariffkit-0.2.2 → tariffkit-0.3.0}/README.md +11 -7
- {tariffkit-0.2.2 → tariffkit-0.3.0}/pyproject.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/__init__.py +4 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/engine.py +26 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/cca.py +12 -1
- tariffkit-0.3.0/src/tariffkit/components.py +200 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/config.py +66 -3
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/cca/mce/2023-01-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/cca/mce/2026-04-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/acc_plus/2023-04-15.toml +2 -6
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/nsc/pge.toml +2 -2
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/e1/2026-03-01.toml +224 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-01-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-03-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-09-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2026-01-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2026-03-01.toml +2 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-01-01.toml +6 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-03-01.toml +6 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-09-01.toml +6 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2026-01-01.toml +6 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2026-03-01.toml +10 -4
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/etoud/2026-03-01.toml +143 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +1 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +2 -2
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/dcare/2025-01-01.toml +14 -0
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/dmedical/2026-03-01.toml +14 -0
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/ersmart/2022-05-01.toml +19 -0
- tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/medicalbaseline/2023-03-23.toml +13 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/engine.py +15 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/export/nbt.py +5 -4
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/predbat.py +17 -11
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/models.py +54 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/discovery.py +58 -3
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/publisher.py +40 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/reconcile.py +2 -1
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/parse.py +3 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/tariff/retail.py +95 -37
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_billing.py +24 -0
- tariffkit-0.3.0/tests/test_components.py +164 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_eelec.py +19 -10
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_engine.py +74 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_integrations.py +29 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_interop.py +39 -14
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_mce.py +6 -2
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_mqtt_publisher.py +54 -5
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_schedules.py +165 -21
- {tariffkit-0.2.2 → tariffkit-0.3.0}/.gitignore +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/LICENSE +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/cli.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/errors.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/model.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/rates.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/repository.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/ledger.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/models.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/netting.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/trueup.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/cli.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt00.json.gz +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt23.json.gz +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt24.json.gz +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt25.json.gz +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt26.json.gz +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/holidays.toml +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/manifest.json +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/versioned.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/errors.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/export/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/emhass.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/slots.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/errors.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/model.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/ocr.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/py.typed +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/secrets.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/greenbutton.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/homeassistant.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/influx.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/pge.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/tariff/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/timeutil.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/web/__init__.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/web/app.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/conftest.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/fixtures/nbt_golden_rows.jsonl +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/fixtures/statements/synthetic_cca_ratechange.txt +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_account.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_account_cli.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_ev2a.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_export_golden.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_ledger.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_pge_reconcile.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_pge_statements.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_private_config.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_greenbutton.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_ha.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_influx.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_pge.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_trueup.py +0 -0
- {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_versioned.py +0 -0
|
@@ -5,6 +5,68 @@ All notable changes to this project are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-08-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- PG&E's complete active single-family residential lineup is now covered:
|
|
12
|
+
generated E-1 and E-TOU-D snapshots join E-ELEC, E-TOU-C, and EV2-A.
|
|
13
|
+
E-1 preserves tiered baseline billing while exposing the over-baseline
|
|
14
|
+
marginal price, and E-TOU-D observes its weekday-only 5–8 p.m. peak and
|
|
15
|
+
tariff holiday calendar.
|
|
16
|
+
- Generated, effective-dated D-CARE, D-MEDICAL, Rule 19 Medical Baseline, and
|
|
17
|
+
E-RSMART data now drive residential program adjustments. SmartRate accepts
|
|
18
|
+
explicit announced event dates and marks prices beyond the authoritative
|
|
19
|
+
event horizon incomplete rather than guessing future events.
|
|
20
|
+
- Prices now decompose into a fixed set of chartable component groups —
|
|
21
|
+
generation, distribution, transmission, surcharges, credits, and a catch-all
|
|
22
|
+
other on the import side; generation, delivery, credits, and other on the
|
|
23
|
+
export side. The groups sum back to the price they came from and do not vary
|
|
24
|
+
with the tariff, supplier, or discount, so a chart built against them
|
|
25
|
+
survives an account change. `ImportPrice.grouped()`, `ExportPrice.grouped()`,
|
|
26
|
+
a `groups` key in every `to_dict` payload, and `tariffkit.components` expose
|
|
27
|
+
them to library and REST callers.
|
|
28
|
+
- Home Assistant gains a sensor per component group in each direction, and the
|
|
29
|
+
forecast's `rates` attribute carries the same roll-up per hour, so both the
|
|
30
|
+
recorded past and the next 48 hours can be drawn as stacked charts -- one
|
|
31
|
+
card each, because stacking recorded and forecast points together would
|
|
32
|
+
double-count the current hour. The MQTT publisher publishes the same series
|
|
33
|
+
with matching discovery payloads, each band carrying its price's quality
|
|
34
|
+
flags.
|
|
35
|
+
- Home Assistant now exposes the AB 205 Base Services Charge as **Daily Fixed
|
|
36
|
+
Charge** in `USD/day`. The unit keeps it out of the Energy dashboard's price
|
|
37
|
+
pickers and out of any `USD/kWh` stack, which is why it can be published at
|
|
38
|
+
all: it is a fixed daily amount, not a marginal price.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- Home Assistant now offers every active PG&E residential schedule and
|
|
42
|
+
Medical Baseline configuration while preserving existing profile and entity
|
|
43
|
+
identities.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- Predbat attributes now use its `from` / `to` / `rate` contract for values
|
|
47
|
+
already expressed in cents. Predbat no longer interprets TariffKit's cents as
|
|
48
|
+
currency units and multiplies them by 100, while Pacific-midnight anchoring
|
|
49
|
+
and complete 46-, 48-, and 50-slot tariff days remain unchanged.
|
|
50
|
+
|
|
51
|
+
## [0.2.3] - 2026-08-17
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- Pacific Gas and Electric now has the unambiguous machine identifier
|
|
55
|
+
`pacific_gas_and_electric`, while Home Assistant and MQTT present `PG&E` or
|
|
56
|
+
the full company name. The separate `pge` identifier correctly means Portland
|
|
57
|
+
General Electric and is recognized but explicitly unsupported for pricing,
|
|
58
|
+
preventing it from ever selecting California tariff data.
|
|
59
|
+
- Home Assistant now labels the export-minus-import calculation explicitly,
|
|
60
|
+
translates time-of-use states for display, and presents the forecast horizon
|
|
61
|
+
as **Rates Available Through**. Forecast metadata and a new **Rate Data
|
|
62
|
+
Status** entity are grouped under diagnostics, where PTO date, export lock
|
|
63
|
+
end, NBT vintage, tariff provenance, source, and quality flags explain the
|
|
64
|
+
active rates without crowding the primary price controls.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
- The PyPI project page now loads the TariffKit banner from an absolute URL
|
|
68
|
+
instead of an unresolved repository-relative path.
|
|
69
|
+
|
|
8
70
|
## [0.2.2] - 2026-08-16
|
|
9
71
|
|
|
10
72
|
### Added
|
|
@@ -221,7 +283,9 @@ Initial release.
|
|
|
221
283
|
- Holiday calendars are extracted per vintage from the source data rather than
|
|
222
284
|
recomputed, because the vintage files disagree in far-future years.
|
|
223
285
|
|
|
224
|
-
[Unreleased]: https://github.com/eman/tariffkit/compare/v0.
|
|
286
|
+
[Unreleased]: https://github.com/eman/tariffkit/compare/v0.3.0...HEAD
|
|
287
|
+
[0.3.0]: https://github.com/eman/tariffkit/releases/tag/v0.3.0
|
|
288
|
+
[0.2.3]: https://github.com/eman/tariffkit/releases/tag/v0.2.3
|
|
225
289
|
[0.2.2]: https://github.com/eman/tariffkit/releases/tag/v0.2.2
|
|
226
290
|
[0.2.1]: https://github.com/eman/tariffkit/releases/tag/v0.2.1
|
|
227
291
|
[0.2.0]: https://github.com/eman/tariffkit/releases/tag/v0.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tariffkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Offline electricity tariff pricing, billing, and integration tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/eman/tariffkit
|
|
6
6
|
Project-URL: Documentation, https://github.com/eman/tariffkit#documentation
|
|
@@ -44,7 +44,7 @@ Requires-Dist: uvicorn[standard]>=0.29; extra == 'web'
|
|
|
44
44
|
Description-Content-Type: text/markdown
|
|
45
45
|
|
|
46
46
|
<p align="center">
|
|
47
|
-
<img src="images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
|
|
47
|
+
<img src="https://raw.githubusercontent.com/eman/tariffkit/main/images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
# TariffKit
|
|
@@ -59,8 +59,10 @@ integrations. The first data provider supports PG&E residential rate plans under
|
|
|
59
59
|
**NEM 3.0 / the Net Billing Tariff (NBT)**; the package identity is deliberately
|
|
60
60
|
not tied to one utility or tariff program.
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
The complete active single-family residential portfolio is vendored:
|
|
63
|
+
**E-1**, **E-ELEC**, **E-TOU-C**, **E-TOU-D**, and **EV2-A**. CARE, FERA,
|
|
64
|
+
Medical Baseline/D-MEDICAL, and event-injected SmartRate adjustments are
|
|
65
|
+
modeled separately from their underlying plans.
|
|
64
66
|
|
|
65
67
|
Under NBT your export credit is not a time-of-use schedule: it is an hourly
|
|
66
68
|
Avoided Cost Calculator value that swings from about $0.06/kWh at midday to
|
|
@@ -92,9 +94,9 @@ component. `tariffkit` collapses it at build time, verifying losslessness cell b
|
|
|
92
94
|
cell, so the entire five-vintage dataset ships inside the wheel at **268 KiB**
|
|
93
95
|
and every lookup is a few list indexes.
|
|
94
96
|
|
|
95
|
-
The retail side is similar:
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
The retail side is similar: generated effective-dated tables hold every active
|
|
98
|
+
schedule. E-TOU-D additionally selects weekday versus weekend/observed-holiday
|
|
99
|
+
periods from the vendored tariff calendar.
|
|
98
100
|
|
|
99
101
|
Nothing here touches the network at runtime.
|
|
100
102
|
|
|
@@ -108,7 +110,9 @@ Nothing here touches the network at runtime.
|
|
|
108
110
|
| [Bill calculator](docs/billing.md) | Computing a cycle from interval meter data |
|
|
109
111
|
| [MQTT](docs/mqtt.md) | Publishing, with Home Assistant discovery |
|
|
110
112
|
| [REST API](docs/web.md) | HTTP service |
|
|
111
|
-
| [Home Assistant](docs/home-assistant.md) | Custom component, Energy dashboard, account history, response actions, opt-in Predbat |
|
|
113
|
+
| [Home Assistant](docs/home-assistant.md) | Custom component, Energy dashboard, stacked component charts, account history, response actions, opt-in Predbat |
|
|
114
|
+
| [Predbat](docs/predbat.md) | Installing TariffKit and Predbat together, end to end |
|
|
115
|
+
| [Predbat on Sigenergy](docs/predbat-sigenergy.md) | Sigenergy SigenStor specifics: entity mapping, sign and unit conversions, control |
|
|
112
116
|
| [Home Assistant quality checklist](docs/home-assistant-quality.md) | Self-assessment against the Integration Quality Scale, with exemptions |
|
|
113
117
|
| [Containers](docs/containers.md) | Local Home Assistant development stack and API/MQTT deployment proposal |
|
|
114
118
|
| [Maintaining rate data](docs/data.md) | Regenerating export rates, updating the retail tariff and CCA cards |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/eman/tariffkit/main/images/banner.svg" alt="TariffKit — electricity pricing, billing, and energy integrations" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# TariffKit
|
|
@@ -14,8 +14,10 @@ integrations. The first data provider supports PG&E residential rate plans under
|
|
|
14
14
|
**NEM 3.0 / the Net Billing Tariff (NBT)**; the package identity is deliberately
|
|
15
15
|
not tied to one utility or tariff program.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
The complete active single-family residential portfolio is vendored:
|
|
18
|
+
**E-1**, **E-ELEC**, **E-TOU-C**, **E-TOU-D**, and **EV2-A**. CARE, FERA,
|
|
19
|
+
Medical Baseline/D-MEDICAL, and event-injected SmartRate adjustments are
|
|
20
|
+
modeled separately from their underlying plans.
|
|
19
21
|
|
|
20
22
|
Under NBT your export credit is not a time-of-use schedule: it is an hourly
|
|
21
23
|
Avoided Cost Calculator value that swings from about $0.06/kWh at midday to
|
|
@@ -47,9 +49,9 @@ component. `tariffkit` collapses it at build time, verifying losslessness cell b
|
|
|
47
49
|
cell, so the entire five-vintage dataset ships inside the wheel at **268 KiB**
|
|
48
50
|
and every lookup is a few list indexes.
|
|
49
51
|
|
|
50
|
-
The retail side is similar:
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
The retail side is similar: generated effective-dated tables hold every active
|
|
53
|
+
schedule. E-TOU-D additionally selects weekday versus weekend/observed-holiday
|
|
54
|
+
periods from the vendored tariff calendar.
|
|
53
55
|
|
|
54
56
|
Nothing here touches the network at runtime.
|
|
55
57
|
|
|
@@ -63,7 +65,9 @@ Nothing here touches the network at runtime.
|
|
|
63
65
|
| [Bill calculator](docs/billing.md) | Computing a cycle from interval meter data |
|
|
64
66
|
| [MQTT](docs/mqtt.md) | Publishing, with Home Assistant discovery |
|
|
65
67
|
| [REST API](docs/web.md) | HTTP service |
|
|
66
|
-
| [Home Assistant](docs/home-assistant.md) | Custom component, Energy dashboard, account history, response actions, opt-in Predbat |
|
|
68
|
+
| [Home Assistant](docs/home-assistant.md) | Custom component, Energy dashboard, stacked component charts, account history, response actions, opt-in Predbat |
|
|
69
|
+
| [Predbat](docs/predbat.md) | Installing TariffKit and Predbat together, end to end |
|
|
70
|
+
| [Predbat on Sigenergy](docs/predbat-sigenergy.md) | Sigenergy SigenStor specifics: entity mapping, sign and unit conversions, control |
|
|
67
71
|
| [Home Assistant quality checklist](docs/home-assistant-quality.md) | Self-assessment against the Integration Quality Scale, with exemptions |
|
|
68
72
|
| [Containers](docs/containers.md) | Local Home Assistant development stack and API/MQTT deployment proposal |
|
|
69
73
|
| [Maintaining rate data](docs/data.md) | Regenerating export rates, updating the retail tariff and CCA cards |
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
from importlib.metadata import version
|
|
16
16
|
|
|
17
|
+
from .components import ComponentGroup
|
|
17
18
|
from .config import CcaConfig, Config
|
|
18
19
|
from .engine import RateEngine
|
|
19
20
|
from .errors import ConfigError, DataError, OutOfRangeError, TariffKitError
|
|
@@ -25,6 +26,7 @@ from .models import (
|
|
|
25
26
|
Season,
|
|
26
27
|
Supplier,
|
|
27
28
|
TouPeriod,
|
|
29
|
+
Utility,
|
|
28
30
|
)
|
|
29
31
|
from .timeutil import PACIFIC, DayType, now_pacific
|
|
30
32
|
|
|
@@ -33,6 +35,7 @@ __version__ = version("tariffkit")
|
|
|
33
35
|
__all__ = [
|
|
34
36
|
"PACIFIC",
|
|
35
37
|
"CcaConfig",
|
|
38
|
+
"ComponentGroup",
|
|
36
39
|
"Config",
|
|
37
40
|
"ConfigError",
|
|
38
41
|
"DataError",
|
|
@@ -47,6 +50,7 @@ __all__ = [
|
|
|
47
50
|
"Supplier",
|
|
48
51
|
"TariffKitError",
|
|
49
52
|
"TouPeriod",
|
|
53
|
+
"Utility",
|
|
50
54
|
"__version__",
|
|
51
55
|
"now_pacific",
|
|
52
56
|
]
|
|
@@ -21,6 +21,7 @@ from ..config import Config
|
|
|
21
21
|
from ..engine import RateEngine
|
|
22
22
|
from ..errors import DataError
|
|
23
23
|
from ..models import ImportPrice, Season, TouPeriod
|
|
24
|
+
from ..tariff.retail import load_program
|
|
24
25
|
from ..timeutil import PACIFIC, hour_floor, to_pacific
|
|
25
26
|
from .models import Bill, BillingPeriod, IntervalReading, UsageBucket
|
|
26
27
|
from .netting import check_coverage
|
|
@@ -139,6 +140,9 @@ class BillEngine:
|
|
|
139
140
|
credit = self._baseline_credit(in_period, period)
|
|
140
141
|
if credit:
|
|
141
142
|
import_components["baseline_credit"] = credit
|
|
143
|
+
smart_rate_credit = self._smartrate_credit(in_period, period)
|
|
144
|
+
if smart_rate_credit:
|
|
145
|
+
import_components["smartrate_credit"] = smart_rate_credit
|
|
142
146
|
|
|
143
147
|
if uncompensated:
|
|
144
148
|
warnings.append(
|
|
@@ -300,6 +304,28 @@ class BillEngine:
|
|
|
300
304
|
break
|
|
301
305
|
return credit
|
|
302
306
|
|
|
307
|
+
def _smartrate_credit(
|
|
308
|
+
self, readings: Sequence[IntervalReading], period: BillingPeriod
|
|
309
|
+
) -> float:
|
|
310
|
+
"""Credits non-high-price imports once for each SmartDay in the cycle."""
|
|
311
|
+
config = self.rates.config
|
|
312
|
+
if not config.smartrate:
|
|
313
|
+
return 0.0
|
|
314
|
+
events = {event for event in config.smartrate_events if period.start <= event <= period.end}
|
|
315
|
+
if not events:
|
|
316
|
+
return 0.0
|
|
317
|
+
terms = load_program("ersmart", period.end)
|
|
318
|
+
start = int(terms["high_price_start"])
|
|
319
|
+
end = int(terms["high_price_end"])
|
|
320
|
+
eligible = 0.0
|
|
321
|
+
for reading in readings:
|
|
322
|
+
moment = to_pacific(reading.start)
|
|
323
|
+
if moment.date() in events and start <= moment.hour < end:
|
|
324
|
+
continue
|
|
325
|
+
eligible += reading.imported
|
|
326
|
+
credit = float(terms["program_credit"]) + float(terms["participation_credit"])
|
|
327
|
+
return -eligible * credit * len(events)
|
|
328
|
+
|
|
303
329
|
def _fixed_charges(self, period: BillingPeriod) -> dict[str, float]:
|
|
304
330
|
"""Charges billed per day rather than per kWh.
|
|
305
331
|
|
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from .data import versioned
|
|
17
17
|
from .errors import ConfigError, DataError
|
|
18
|
+
from .models import Utility
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@dataclass(frozen=True, slots=True)
|
|
@@ -32,6 +33,11 @@ class CcaRateCard:
|
|
|
32
33
|
def source_url(self) -> str:
|
|
33
34
|
return str(self.raw.get("source_url", ""))
|
|
34
35
|
|
|
36
|
+
@property
|
|
37
|
+
def supported_schedules(self) -> frozenset[str]:
|
|
38
|
+
"""PG&E schedule slugs for which this card publishes generation rates."""
|
|
39
|
+
return frozenset(self.raw["generation"])
|
|
40
|
+
|
|
35
41
|
def generation(
|
|
36
42
|
self, schedule: str, season: str, period: str, option: str = "light_green"
|
|
37
43
|
) -> float:
|
|
@@ -115,7 +121,12 @@ def load_rate_card(provider: str, on: date) -> CcaRateCard:
|
|
|
115
121
|
) from exc
|
|
116
122
|
if version.raw.get("schema") != 1:
|
|
117
123
|
raise DataError(f"{provider}: unsupported rate card schema {version.raw.get('schema')}")
|
|
118
|
-
|
|
124
|
+
raw = dict(version.raw)
|
|
125
|
+
try:
|
|
126
|
+
raw["utility"] = Utility(raw["utility"]).value
|
|
127
|
+
except (KeyError, ValueError) as exc:
|
|
128
|
+
raise DataError(f"{provider}: invalid utility identifier") from exc
|
|
129
|
+
return CcaRateCard(provider=str(raw["provider"]), raw=raw)
|
|
119
130
|
|
|
120
131
|
|
|
121
132
|
def available_rate_cards() -> tuple[str, ...]:
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Rolling unbundled tariff components up into a fixed, stackable vocabulary.
|
|
2
|
+
|
|
3
|
+
A price's ``components`` mapping speaks the tariff sheet's own language: fifteen
|
|
4
|
+
or so import lines on a bundled residential schedule, a different set once a CCA
|
|
5
|
+
supplies generation, and another line again when a CARE discount or a SmartRate
|
|
6
|
+
event applies. That is exactly the right level for reconciling a statement, and
|
|
7
|
+
exactly the wrong level for a chart -- a fifteen-band stack is unreadable, and
|
|
8
|
+
a consumer that made one series per line would watch series appear and vanish as
|
|
9
|
+
the account's configuration changed.
|
|
10
|
+
|
|
11
|
+
The groups below are a small closed vocabulary that every component maps into.
|
|
12
|
+
Two properties make them safe to draw as a stacked graph against the price
|
|
13
|
+
itself:
|
|
14
|
+
|
|
15
|
+
* the set of groups for a direction is fixed, so the series exist whether or not
|
|
16
|
+
the account happens to pay that kind of charge this hour; and
|
|
17
|
+
* every component lands in exactly one group -- anything this table has not seen
|
|
18
|
+
falls into :attr:`ComponentGroup.OTHER` rather than being dropped -- so the
|
|
19
|
+
groups sum back to the price, within per-component rounding.
|
|
20
|
+
|
|
21
|
+
Grouping is a presentation, not a billing rule. ``tariffkit.billing.ledger``
|
|
22
|
+
classifies the same components again, differently and for a different purpose:
|
|
23
|
+
which export credits may offset which charges. Neither is derived from the
|
|
24
|
+
other, because being drawn in the same band of a chart and being offsettable by
|
|
25
|
+
the same credit bucket are unrelated questions.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Iterable, Mapping
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ComponentGroup(StrEnum):
|
|
35
|
+
"""A band in a stacked price chart."""
|
|
36
|
+
|
|
37
|
+
#: Generation supply, whoever supplies it, plus the charges that follow the
|
|
38
|
+
#: supply decision: the PCIA a departed customer pays, the bundled PCIA
|
|
39
|
+
#: credit a PG&E-supplied one receives, a CCA's own cost relief credit, and
|
|
40
|
+
#: a SmartRate event's high-price energy charge.
|
|
41
|
+
GENERATION = "generation"
|
|
42
|
+
#: Distribution, and the Conservation Incentive Adjustment that implements
|
|
43
|
+
#: the baseline credit as a distribution-side rate spread.
|
|
44
|
+
DISTRIBUTION = "distribution"
|
|
45
|
+
#: Transmission, its rate adjustments, and reliability services.
|
|
46
|
+
TRANSMISSION = "transmission"
|
|
47
|
+
#: The export side's delivery component. PG&E publishes the export rate as
|
|
48
|
+
#: exactly two components -- delivery, which every Solar Billing Plan
|
|
49
|
+
#: customer earns, and generation, which only a bundled one does. Delivery
|
|
50
|
+
#: is not published split into distribution and transmission, so it gets its
|
|
51
|
+
#: own band rather than being apportioned across two on a guess.
|
|
52
|
+
DELIVERY = "delivery"
|
|
53
|
+
#: Non-bypassable charges and riders: public purpose programs, nuclear
|
|
54
|
+
#: decommissioning, competition transition, energy cost recovery, the
|
|
55
|
+
#: wildfire fund and hardening charges, new system generation, the recovery
|
|
56
|
+
#: bond charge and its offsetting credit, and the CCA franchise fee.
|
|
57
|
+
SURCHARGES = "surcharges"
|
|
58
|
+
#: Discounts and incentives: CARE, FERA, Medical Baseline, and the ACC Plus
|
|
59
|
+
#: adder on the export side.
|
|
60
|
+
CREDITS = "credits"
|
|
61
|
+
#: Anything this module has not classified. Normally zero; non-zero means a
|
|
62
|
+
#: schedule grew a line and this table has not caught up.
|
|
63
|
+
OTHER = "other"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def label(self) -> str:
|
|
67
|
+
"""Human-readable name, for interfaces without their own translations."""
|
|
68
|
+
match self:
|
|
69
|
+
case ComponentGroup.GENERATION:
|
|
70
|
+
return "Generation"
|
|
71
|
+
case ComponentGroup.DISTRIBUTION:
|
|
72
|
+
return "Distribution"
|
|
73
|
+
case ComponentGroup.TRANSMISSION:
|
|
74
|
+
return "Transmission"
|
|
75
|
+
case ComponentGroup.DELIVERY:
|
|
76
|
+
return "Delivery"
|
|
77
|
+
case ComponentGroup.SURCHARGES:
|
|
78
|
+
return "Surcharges"
|
|
79
|
+
case ComponentGroup.CREDITS:
|
|
80
|
+
return "Credits"
|
|
81
|
+
case ComponentGroup.OTHER:
|
|
82
|
+
return "Other"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: The groups an import price is drawn from, in stack order: supply at the
|
|
86
|
+
#: bottom, then wires, then policy charges, then whatever reduces the bill.
|
|
87
|
+
IMPORT_GROUPS: tuple[ComponentGroup, ...] = (
|
|
88
|
+
ComponentGroup.GENERATION,
|
|
89
|
+
ComponentGroup.DISTRIBUTION,
|
|
90
|
+
ComponentGroup.TRANSMISSION,
|
|
91
|
+
ComponentGroup.SURCHARGES,
|
|
92
|
+
ComponentGroup.CREDITS,
|
|
93
|
+
ComponentGroup.OTHER,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
#: The groups an export credit is drawn from. The ACC has no retail wires or
|
|
97
|
+
#: policy charges in it -- there is nothing to bill a customer who is exporting.
|
|
98
|
+
EXPORT_GROUPS: tuple[ComponentGroup, ...] = (
|
|
99
|
+
ComponentGroup.GENERATION,
|
|
100
|
+
ComponentGroup.DELIVERY,
|
|
101
|
+
ComponentGroup.CREDITS,
|
|
102
|
+
ComponentGroup.OTHER,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
#: Component name -> group. Import and export names share one table because the
|
|
106
|
+
#: names that appear on both sides mean the same thing on both sides.
|
|
107
|
+
COMPONENT_GROUPS: dict[str, ComponentGroup] = {
|
|
108
|
+
# Generation supply and the charges attached to who supplies it.
|
|
109
|
+
"generation": ComponentGroup.GENERATION,
|
|
110
|
+
"cca_generation": ComponentGroup.GENERATION,
|
|
111
|
+
"cca_cost_relief_credit": ComponentGroup.GENERATION,
|
|
112
|
+
"cca_solar_bonus": ComponentGroup.GENERATION,
|
|
113
|
+
"bundled_pcia": ComponentGroup.GENERATION,
|
|
114
|
+
"pcia": ComponentGroup.GENERATION,
|
|
115
|
+
"smartrate_high_price": ComponentGroup.GENERATION,
|
|
116
|
+
# Distribution.
|
|
117
|
+
"distribution": ComponentGroup.DISTRIBUTION,
|
|
118
|
+
"conservation_incentive_adjustment": ComponentGroup.DISTRIBUTION,
|
|
119
|
+
# Transmission.
|
|
120
|
+
"transmission": ComponentGroup.TRANSMISSION,
|
|
121
|
+
"transmission_rate_adjustments": ComponentGroup.TRANSMISSION,
|
|
122
|
+
"reliability_services": ComponentGroup.TRANSMISSION,
|
|
123
|
+
# Export-side avoided delivery.
|
|
124
|
+
"delivery": ComponentGroup.DELIVERY,
|
|
125
|
+
# Non-bypassable charges and riders.
|
|
126
|
+
"public_purpose_programs": ComponentGroup.SURCHARGES,
|
|
127
|
+
"nuclear_decommissioning": ComponentGroup.SURCHARGES,
|
|
128
|
+
"competition_transition_charges": ComponentGroup.SURCHARGES,
|
|
129
|
+
"energy_cost_recovery": ComponentGroup.SURCHARGES,
|
|
130
|
+
"wildfire_fund_charge": ComponentGroup.SURCHARGES,
|
|
131
|
+
"wildfire_hardening": ComponentGroup.SURCHARGES,
|
|
132
|
+
"new_system_generation": ComponentGroup.SURCHARGES,
|
|
133
|
+
"recovery_bond_charge": ComponentGroup.SURCHARGES,
|
|
134
|
+
# Paired with the charge above, which it currently offsets exactly. Keeping
|
|
135
|
+
# the pair in one group means the band shows the net, rather than inflating
|
|
136
|
+
# surcharges and inventing an equal and opposite credit.
|
|
137
|
+
"recovery_bond_credit": ComponentGroup.SURCHARGES,
|
|
138
|
+
"franchise_fee_surcharge": ComponentGroup.SURCHARGES,
|
|
139
|
+
# Discounts and incentives.
|
|
140
|
+
"medical_discount": ComponentGroup.CREDITS,
|
|
141
|
+
"acc_plus": ComponentGroup.CREDITS,
|
|
142
|
+
# Bill-level components. A marginal price never carries these -- a baseline
|
|
143
|
+
# credit depends on cumulative usage, and a tax on a whole cycle's charges
|
|
144
|
+
# -- but ``group_of`` is public, so a caller grouping a ``Bill``'s
|
|
145
|
+
# breakdown gets the same answers rather than a pile of ``OTHER``. The Base
|
|
146
|
+
# Services Charge is deliberately absent: it is a $/day amount, and there
|
|
147
|
+
# is no band of a per-kWh chart it honestly belongs in.
|
|
148
|
+
"energy_commission_tax": ComponentGroup.SURCHARGES,
|
|
149
|
+
"baseline_credit": ComponentGroup.DISTRIBUTION,
|
|
150
|
+
"smartrate_credit": ComponentGroup.GENERATION,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#: The discount component is named for the program applied (``care_discount``,
|
|
154
|
+
#: ``fera_discount``), so the suffix is matched rather than every program being
|
|
155
|
+
#: enumerated -- a new discount program should not silently land in ``OTHER``.
|
|
156
|
+
_DISCOUNT_SUFFIX = "_discount"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def group_of(name: str) -> ComponentGroup:
|
|
160
|
+
"""The group ``name`` belongs to, or ``OTHER`` if it is unrecognized."""
|
|
161
|
+
group = COMPONENT_GROUPS.get(name)
|
|
162
|
+
if group is not None:
|
|
163
|
+
return group
|
|
164
|
+
if name.endswith(_DISCOUNT_SUFFIX):
|
|
165
|
+
return ComponentGroup.CREDITS
|
|
166
|
+
return ComponentGroup.OTHER
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def split_components(
|
|
170
|
+
components: Mapping[str, float],
|
|
171
|
+
groups: Iterable[ComponentGroup],
|
|
172
|
+
) -> dict[ComponentGroup, dict[str, float]]:
|
|
173
|
+
"""Bucket ``components`` by group, keeping each group's underlying lines.
|
|
174
|
+
|
|
175
|
+
Every group in ``groups`` is present, empty if nothing landed in it, so a
|
|
176
|
+
consumer builds the same series every time. A component whose group is not
|
|
177
|
+
among ``groups`` is folded into ``OTHER`` instead of being dropped, which
|
|
178
|
+
keeps the sum identity true even if a direction ever grows a line from a
|
|
179
|
+
band it was not expected to have.
|
|
180
|
+
"""
|
|
181
|
+
ordered = tuple(groups)
|
|
182
|
+
buckets: dict[ComponentGroup, dict[str, float]] = {group: {} for group in ordered}
|
|
183
|
+
for name, value in components.items():
|
|
184
|
+
group = group_of(name)
|
|
185
|
+
if group not in buckets:
|
|
186
|
+
group = ComponentGroup.OTHER
|
|
187
|
+
buckets.setdefault(group, {})
|
|
188
|
+
buckets[group][name] = value
|
|
189
|
+
return buckets
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def group_components(
|
|
193
|
+
components: Mapping[str, float],
|
|
194
|
+
groups: Iterable[ComponentGroup],
|
|
195
|
+
) -> dict[ComponentGroup, float]:
|
|
196
|
+
"""``components`` summed per group, one entry per group in ``groups``."""
|
|
197
|
+
return {
|
|
198
|
+
group: round(float(sum(lines.values())), 6)
|
|
199
|
+
for group, lines in split_components(components, groups).items()
|
|
200
|
+
}
|
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any, Literal
|
|
12
12
|
|
|
13
13
|
from .errors import ConfigError
|
|
14
|
-
from .models import Supplier
|
|
14
|
+
from .models import Supplier, Utility
|
|
15
15
|
|
|
16
16
|
AccPlusSegment = Literal["residential", "residential_low_income", "none"]
|
|
17
17
|
Discount = Literal["none", "care", "fera"]
|
|
@@ -32,6 +32,15 @@ LOCK_YEARS = 9
|
|
|
32
32
|
_ONE_DAY = timedelta(days=1)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _env_bool(name: str, value: str) -> bool:
|
|
36
|
+
normalized = value.strip().lower()
|
|
37
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
38
|
+
return True
|
|
39
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
40
|
+
return False
|
|
41
|
+
raise ConfigError(f"{name} must be true or false")
|
|
42
|
+
|
|
43
|
+
|
|
35
44
|
@dataclass(frozen=True, slots=True)
|
|
36
45
|
class CcaConfig:
|
|
37
46
|
"""Settings for customers whose generation comes from a CCA or ESP.
|
|
@@ -80,7 +89,7 @@ class CcaConfig:
|
|
|
80
89
|
class Config:
|
|
81
90
|
"""Everything needed to price a kWh for one service agreement."""
|
|
82
91
|
|
|
83
|
-
utility:
|
|
92
|
+
utility: Utility = Utility.PACIFIC_GAS_AND_ELECTRIC
|
|
84
93
|
tariff: str = "E-ELEC"
|
|
85
94
|
supplier: Supplier = Supplier.BUNDLED
|
|
86
95
|
|
|
@@ -104,6 +113,17 @@ class Config:
|
|
|
104
113
|
#: electric. PG&E prints this as Code B or Code H; the bill's "Heat Source"
|
|
105
114
|
#: line says which.
|
|
106
115
|
baseline_code: BaselineCode = "basic"
|
|
116
|
+
#: Medical Baseline enrollment. Tiered schedules receive additional
|
|
117
|
+
#: baseline quantity; E-ELEC, E-TOU-D, and EV2-A receive D-MEDICAL.
|
|
118
|
+
medical_baseline: bool = False
|
|
119
|
+
medical_kwh_per_day: float | None = None
|
|
120
|
+
|
|
121
|
+
#: SmartRate is event-driven. Event dates and the date through which the
|
|
122
|
+
#: supplied calendar is authoritative are explicit so a missing future
|
|
123
|
+
#: event cannot be silently priced as an ordinary day.
|
|
124
|
+
smartrate: bool = False
|
|
125
|
+
smartrate_events: tuple[date, ...] = ()
|
|
126
|
+
smartrate_known_through: date | None = None
|
|
107
127
|
|
|
108
128
|
cca: CcaConfig | None = None
|
|
109
129
|
|
|
@@ -117,6 +137,7 @@ class Config:
|
|
|
117
137
|
nsc_rate: float | None = None
|
|
118
138
|
|
|
119
139
|
def __post_init__(self) -> None:
|
|
140
|
+
object.__setattr__(self, "utility", Utility(self.utility))
|
|
120
141
|
# Coerce the string forms of the enums. Supplier is a StrEnum, so a
|
|
121
142
|
# plain "cca" compares equal to Supplier.CCA but is not it -- and every
|
|
122
143
|
# branch that matters tests identity. Constructing Config directly with
|
|
@@ -133,6 +154,18 @@ class Config:
|
|
|
133
154
|
object.__setattr__(self, "supplier", Supplier(self.supplier))
|
|
134
155
|
if self.supplier is Supplier.CCA and self.cca is None:
|
|
135
156
|
raise ConfigError("supplier='cca' requires a CcaConfig")
|
|
157
|
+
if self.smartrate and self.supplier is not Supplier.BUNDLED:
|
|
158
|
+
raise ConfigError("SmartRate is available only with bundled PG&E generation")
|
|
159
|
+
if self.smartrate_events and not self.smartrate:
|
|
160
|
+
raise ConfigError("smartrate_events requires smartrate=true")
|
|
161
|
+
if self.smartrate and self.smartrate_known_through is None:
|
|
162
|
+
raise ConfigError("smartrate=true requires smartrate_known_through")
|
|
163
|
+
if self.smartrate_known_through is not None and any(
|
|
164
|
+
event > self.smartrate_known_through for event in self.smartrate_events
|
|
165
|
+
):
|
|
166
|
+
raise ConfigError("a SmartRate event falls after smartrate_known_through")
|
|
167
|
+
if self.medical_kwh_per_day is not None and self.medical_kwh_per_day <= 0:
|
|
168
|
+
raise ConfigError("medical_kwh_per_day must be greater than zero")
|
|
136
169
|
if self.vintage is None and self.interconnection_year is None:
|
|
137
170
|
raise ConfigError("set either interconnection_year or vintage")
|
|
138
171
|
if self.discount != "none" and self.acc_plus_segment == "residential":
|
|
@@ -188,6 +221,15 @@ class Config:
|
|
|
188
221
|
value = data.get("pto_date")
|
|
189
222
|
if isinstance(value, str):
|
|
190
223
|
data["pto_date"] = date.fromisoformat(value)
|
|
224
|
+
value = data.get("smartrate_known_through")
|
|
225
|
+
if isinstance(value, str):
|
|
226
|
+
data["smartrate_known_through"] = date.fromisoformat(value)
|
|
227
|
+
events = data.get("smartrate_events")
|
|
228
|
+
if isinstance(events, list | tuple):
|
|
229
|
+
data["smartrate_events"] = tuple(
|
|
230
|
+
date.fromisoformat(value) if isinstance(value, str) else value
|
|
231
|
+
for value in events
|
|
232
|
+
)
|
|
191
233
|
return cls(**data)
|
|
192
234
|
except (TypeError, ValueError) as exc:
|
|
193
235
|
raise ConfigError(f"invalid config: {exc}") from exc
|
|
@@ -195,7 +237,7 @@ class Config:
|
|
|
195
237
|
def to_dict(self) -> dict[str, Any]:
|
|
196
238
|
"""A JSON-compatible representation suitable for API request bodies."""
|
|
197
239
|
data: dict[str, Any] = {
|
|
198
|
-
"utility": self.utility,
|
|
240
|
+
"utility": self.utility.value,
|
|
199
241
|
"tariff": self.tariff,
|
|
200
242
|
"supplier": self.supplier.value,
|
|
201
243
|
"interconnection_year": self.interconnection_year,
|
|
@@ -206,6 +248,15 @@ class Config:
|
|
|
206
248
|
"base_services_charge_tier": self.base_services_charge_tier,
|
|
207
249
|
"baseline_territory": self.baseline_territory,
|
|
208
250
|
"baseline_code": self.baseline_code,
|
|
251
|
+
"medical_baseline": self.medical_baseline,
|
|
252
|
+
"medical_kwh_per_day": self.medical_kwh_per_day,
|
|
253
|
+
"smartrate": self.smartrate,
|
|
254
|
+
"smartrate_events": [event.isoformat() for event in self.smartrate_events],
|
|
255
|
+
"smartrate_known_through": (
|
|
256
|
+
self.smartrate_known_through.isoformat()
|
|
257
|
+
if self.smartrate_known_through is not None
|
|
258
|
+
else None
|
|
259
|
+
),
|
|
209
260
|
"nsc_rate": self.nsc_rate,
|
|
210
261
|
}
|
|
211
262
|
if self.cca is not None:
|
|
@@ -261,6 +312,18 @@ class Config:
|
|
|
261
312
|
overrides["baseline_territory"] = value
|
|
262
313
|
if value := os.environ.get("TARIFFKIT_BASELINE_CODE"):
|
|
263
314
|
overrides["baseline_code"] = value
|
|
315
|
+
if value := os.environ.get("TARIFFKIT_MEDICAL_BASELINE"):
|
|
316
|
+
overrides["medical_baseline"] = _env_bool("TARIFFKIT_MEDICAL_BASELINE", value)
|
|
317
|
+
if value := os.environ.get("TARIFFKIT_MEDICAL_KWH_PER_DAY"):
|
|
318
|
+
overrides["medical_kwh_per_day"] = float(value)
|
|
319
|
+
if value := os.environ.get("TARIFFKIT_SMARTRATE"):
|
|
320
|
+
overrides["smartrate"] = _env_bool("TARIFFKIT_SMARTRATE", value)
|
|
321
|
+
if value := os.environ.get("TARIFFKIT_SMARTRATE_EVENTS"):
|
|
322
|
+
overrides["smartrate_events"] = tuple(
|
|
323
|
+
date.fromisoformat(item.strip()) for item in value.split(",") if item.strip()
|
|
324
|
+
)
|
|
325
|
+
if value := os.environ.get("TARIFFKIT_SMARTRATE_KNOWN_THROUGH"):
|
|
326
|
+
overrides["smartrate_known_through"] = date.fromisoformat(value)
|
|
264
327
|
if value := os.environ.get("TARIFFKIT_NSC_RATE"):
|
|
265
328
|
overrides["nsc_rate"] = float(value)
|
|
266
329
|
if value := os.environ.get("TARIFFKIT_CCA_JSON"):
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
schema = 1
|
|
50
50
|
provider = "MCE"
|
|
51
51
|
name = "Marin Clean Energy"
|
|
52
|
-
utility = "
|
|
52
|
+
utility = "pacific_gas_and_electric"
|
|
53
53
|
#: MCE prices each PG&E schedule separately, so a rate card is keyed by schedule
|
|
54
54
|
#: rather than tied to one. Slugs match the tariff data directories.
|
|
55
55
|
schedules = ["E-ELEC", "E-TOU-C", "EV2-A"]
|