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.
Files changed (117) hide show
  1. {tariffkit-0.2.2 → tariffkit-0.3.0}/CHANGELOG.md +65 -1
  2. {tariffkit-0.2.2 → tariffkit-0.3.0}/PKG-INFO +12 -8
  3. {tariffkit-0.2.2 → tariffkit-0.3.0}/README.md +11 -7
  4. {tariffkit-0.2.2 → tariffkit-0.3.0}/pyproject.toml +1 -1
  5. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/__init__.py +4 -0
  6. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/engine.py +26 -0
  7. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/cca.py +12 -1
  8. tariffkit-0.3.0/src/tariffkit/components.py +200 -0
  9. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/config.py +66 -3
  10. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/cca/mce/2023-01-01.toml +1 -1
  11. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/cca/mce/2026-04-01.toml +1 -1
  12. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/acc_plus/2023-04-15.toml +2 -6
  13. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/nsc/pge.toml +2 -2
  14. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/e1/2026-03-01.toml +224 -0
  15. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-01-01.toml +1 -1
  16. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-03-01.toml +1 -1
  17. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2025-09-01.toml +1 -1
  18. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2026-01-01.toml +1 -1
  19. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/eelec/2026-03-01.toml +2 -2
  20. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-01-01.toml +6 -2
  21. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-03-01.toml +6 -2
  22. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2025-09-01.toml +6 -2
  23. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2026-01-01.toml +6 -2
  24. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/etouc/2026-03-01.toml +10 -4
  25. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/etoud/2026-03-01.toml +143 -0
  26. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +1 -1
  27. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +1 -1
  28. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +1 -1
  29. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +1 -1
  30. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +2 -2
  31. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/dcare/2025-01-01.toml +14 -0
  32. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/dmedical/2026-03-01.toml +14 -0
  33. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/ersmart/2022-05-01.toml +19 -0
  34. tariffkit-0.3.0/src/tariffkit/data/tariff/pge/program/medicalbaseline/2023-03-23.toml +13 -0
  35. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/engine.py +15 -2
  36. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/export/nbt.py +5 -4
  37. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/predbat.py +17 -11
  38. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/models.py +54 -0
  39. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/discovery.py +58 -3
  40. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/publisher.py +40 -0
  41. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/reconcile.py +2 -1
  42. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/parse.py +3 -0
  43. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/tariff/retail.py +95 -37
  44. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_billing.py +24 -0
  45. tariffkit-0.3.0/tests/test_components.py +164 -0
  46. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_eelec.py +19 -10
  47. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_engine.py +74 -2
  48. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_integrations.py +29 -2
  49. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_interop.py +39 -14
  50. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_mce.py +6 -2
  51. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_mqtt_publisher.py +54 -5
  52. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_schedules.py +165 -21
  53. {tariffkit-0.2.2 → tariffkit-0.3.0}/.gitignore +0 -0
  54. {tariffkit-0.2.2 → tariffkit-0.3.0}/LICENSE +0 -0
  55. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/__init__.py +0 -0
  56. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/cli.py +0 -0
  57. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/errors.py +0 -0
  58. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/model.py +0 -0
  59. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/rates.py +0 -0
  60. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/account/repository.py +0 -0
  61. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/__init__.py +0 -0
  62. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/ledger.py +0 -0
  63. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/models.py +0 -0
  64. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/netting.py +0 -0
  65. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/billing/trueup.py +0 -0
  66. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/cli.py +0 -0
  67. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/__init__.py +0 -0
  68. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt00.json.gz +0 -0
  69. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt23.json.gz +0 -0
  70. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt24.json.gz +0 -0
  71. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt25.json.gz +0 -0
  72. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/export/pge/nbt26.json.gz +0 -0
  73. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/holidays.toml +0 -0
  74. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/manifest.json +0 -0
  75. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +0 -0
  76. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +0 -0
  77. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/data/versioned.py +0 -0
  78. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/errors.py +0 -0
  79. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/export/__init__.py +0 -0
  80. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/__init__.py +0 -0
  81. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/emhass.py +0 -0
  82. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/interop/slots.py +0 -0
  83. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/mqtt/__init__.py +0 -0
  84. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/__init__.py +0 -0
  85. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/__init__.py +0 -0
  86. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/__init__.py +0 -0
  87. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/errors.py +0 -0
  88. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/model.py +0 -0
  89. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/providers/pge/statements/ocr.py +0 -0
  90. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/py.typed +0 -0
  91. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/secrets.py +0 -0
  92. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/__init__.py +0 -0
  93. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/greenbutton.py +0 -0
  94. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/homeassistant.py +0 -0
  95. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/influx.py +0 -0
  96. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/sources/pge.py +0 -0
  97. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/tariff/__init__.py +0 -0
  98. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/timeutil.py +0 -0
  99. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/web/__init__.py +0 -0
  100. {tariffkit-0.2.2 → tariffkit-0.3.0}/src/tariffkit/web/app.py +0 -0
  101. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/conftest.py +0 -0
  102. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/fixtures/nbt_golden_rows.jsonl +0 -0
  103. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/fixtures/statements/synthetic_cca_ratechange.txt +0 -0
  104. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_account.py +0 -0
  105. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_account_cli.py +0 -0
  106. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_ev2a.py +0 -0
  107. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_export_golden.py +0 -0
  108. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_ledger.py +0 -0
  109. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_pge_reconcile.py +0 -0
  110. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_pge_statements.py +0 -0
  111. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_private_config.py +0 -0
  112. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_greenbutton.py +0 -0
  113. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_ha.py +0 -0
  114. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_influx.py +0 -0
  115. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_sources_pge.py +0 -0
  116. {tariffkit-0.2.2 → tariffkit-0.3.0}/tests/test_trueup.py +0 -0
  117. {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.2.2...HEAD
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.2.2
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
- Three schedules are vendored: **E-ELEC** (Electric Home), **E-TOU-C**
63
- (Time-of-Use, peak 4–9 p.m. every day), and **EV2-A** (Home Charging).
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: these schedules' period boundaries are identical every day
96
- of the week including holidays and do not shift by season, so an import price is
97
- fully determined by `(season, hour)`.
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
- Three schedules are vendored: **E-ELEC** (Electric Home), **E-TOU-C**
18
- (Time-of-Use, peak 4–9 p.m. every day), and **EV2-A** (Home Charging).
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: these schedules' period boundaries are identical every day
51
- of the week including holidays and do not shift by season, so an import price is
52
- fully determined by `(season, hour)`.
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 |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tariffkit"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Offline electricity tariff pricing, billing, and integration tools"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- return CcaRateCard(provider=str(version.raw["provider"]), raw=version.raw)
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: str = "PGE"
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"):
@@ -16,7 +16,7 @@
16
16
  schema = 1
17
17
  provider = "MCE"
18
18
  name = "Marin Clean Energy"
19
- utility = "PGE"
19
+ utility = "pacific_gas_and_electric"
20
20
  schedules = ["E-ELEC", "E-TOU-C", "EV2-A"]
21
21
  effective = "2023-01-01"
22
22
  currency = "USD/kWh"
@@ -49,7 +49,7 @@
49
49
  schema = 1
50
50
  provider = "MCE"
51
51
  name = "Marin Clean Energy"
52
- utility = "PGE"
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"]