tariffkit 0.4.0__tar.gz → 0.5.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 (120) hide show
  1. {tariffkit-0.4.0 → tariffkit-0.5.0}/CHANGELOG.md +80 -1
  2. {tariffkit-0.4.0 → tariffkit-0.5.0}/PKG-INFO +1 -1
  3. {tariffkit-0.4.0 → tariffkit-0.5.0}/pyproject.toml +1 -1
  4. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/ledger.py +62 -14
  5. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/cca.py +12 -0
  6. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/components.py +1 -0
  7. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/cca/mce/2026-04-01.toml +13 -0
  8. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/export/nbt.py +42 -18
  9. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_export_golden.py +35 -0
  10. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_backfill.py +78 -0
  11. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_bank.py +5 -4
  12. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_energy.py +72 -1
  13. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ledger.py +113 -6
  14. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_mce.py +6 -3
  15. {tariffkit-0.4.0 → tariffkit-0.5.0}/.gitignore +0 -0
  16. {tariffkit-0.4.0 → tariffkit-0.5.0}/LICENSE +0 -0
  17. {tariffkit-0.4.0 → tariffkit-0.5.0}/README.md +0 -0
  18. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/__init__.py +0 -0
  19. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/__init__.py +0 -0
  20. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/cli.py +0 -0
  21. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/errors.py +0 -0
  22. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/model.py +0 -0
  23. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/rates.py +0 -0
  24. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/repository.py +0 -0
  25. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/__init__.py +0 -0
  26. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/engine.py +0 -0
  27. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/models.py +0 -0
  28. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/netting.py +0 -0
  29. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/trueup.py +0 -0
  30. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/cli.py +0 -0
  31. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/config.py +0 -0
  32. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/__init__.py +0 -0
  33. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/cca/mce/2023-01-01.toml +0 -0
  34. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/acc_plus/2023-04-15.toml +0 -0
  35. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt00.json.gz +0 -0
  36. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt23.json.gz +0 -0
  37. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt24.json.gz +0 -0
  38. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt25.json.gz +0 -0
  39. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt26.json.gz +0 -0
  40. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/holidays.toml +0 -0
  41. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/nsc/pge.toml +0 -0
  42. /tariffkit-0.4.0/src/tariffkit/data/manifest.json → /tariffkit-0.5.0/src/tariffkit/data/sources.json +0 -0
  43. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/e1/2026-03-01.toml +0 -0
  44. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-01-01.toml +0 -0
  45. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-03-01.toml +0 -0
  46. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-09-01.toml +0 -0
  47. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2026-01-01.toml +0 -0
  48. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2026-03-01.toml +0 -0
  49. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-01-01.toml +0 -0
  50. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-03-01.toml +0 -0
  51. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-09-01.toml +0 -0
  52. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2026-01-01.toml +0 -0
  53. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2026-03-01.toml +0 -0
  54. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etoud/2026-03-01.toml +0 -0
  55. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +0 -0
  56. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +0 -0
  57. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +0 -0
  58. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +0 -0
  59. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +0 -0
  60. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/dcare/2025-01-01.toml +0 -0
  61. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/dmedical/2026-03-01.toml +0 -0
  62. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/ersmart/2022-05-01.toml +0 -0
  63. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/medicalbaseline/2023-03-23.toml +0 -0
  64. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +0 -0
  65. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +0 -0
  66. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/versioned.py +0 -0
  67. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/engine.py +0 -0
  68. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/errors.py +0 -0
  69. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/export/__init__.py +0 -0
  70. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/__init__.py +0 -0
  71. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/emhass.py +0 -0
  72. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/predbat.py +0 -0
  73. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/slots.py +0 -0
  74. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/models.py +0 -0
  75. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/__init__.py +0 -0
  76. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/discovery.py +0 -0
  77. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/publisher.py +0 -0
  78. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/__init__.py +0 -0
  79. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/__init__.py +0 -0
  80. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/reconcile.py +0 -0
  81. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/__init__.py +0 -0
  82. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/errors.py +0 -0
  83. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/model.py +0 -0
  84. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/ocr.py +0 -0
  85. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/parse.py +0 -0
  86. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/py.typed +0 -0
  87. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/secrets.py +0 -0
  88. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/__init__.py +0 -0
  89. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/greenbutton.py +0 -0
  90. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/homeassistant.py +0 -0
  91. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/influx.py +0 -0
  92. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/pge.py +0 -0
  93. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/tariff/__init__.py +0 -0
  94. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/tariff/retail.py +0 -0
  95. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/timeutil.py +0 -0
  96. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/web/__init__.py +0 -0
  97. {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/web/app.py +0 -0
  98. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/conftest.py +0 -0
  99. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/fixtures/nbt_golden_rows.jsonl +0 -0
  100. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/fixtures/statements/synthetic_cca_ratechange.txt +0 -0
  101. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_account.py +0 -0
  102. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_account_cli.py +0 -0
  103. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_billing.py +0 -0
  104. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_components.py +0 -0
  105. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_eelec.py +0 -0
  106. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_engine.py +0 -0
  107. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ev2a.py +0 -0
  108. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_integrations.py +0 -0
  109. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_interop.py +0 -0
  110. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_mqtt_publisher.py +0 -0
  111. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_pge_reconcile.py +0 -0
  112. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_pge_statements.py +0 -0
  113. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_private_config.py +0 -0
  114. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_schedules.py +0 -0
  115. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_greenbutton.py +0 -0
  116. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_ha.py +0 -0
  117. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_influx.py +0 -0
  118. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_pge.py +0 -0
  119. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_trueup.py +0 -0
  120. {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_versioned.py +0 -0
@@ -5,6 +5,83 @@ All notable changes to this project are documented here. This project follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-08-29
9
+
10
+ ### Added
11
+ - The Amount Due entities and the backfill response publish the terms their
12
+ breakdown rests on. `gross_charges` is the ledger's own charge total,
13
+ `non_offsettable` the part of it no credit may reach, and `not_paid_out` the
14
+ clamp a statement applies rather than refunding. Together
15
+ `gross_charges - credit_applied + not_paid_out` is the state exactly, for a
16
+ day and for a cycle.
17
+
18
+ The published components could not be added into the state before this. A
19
+ component the statement spends inside the cycle rather than banking -- MCE's
20
+ Solar Bonus Credit is the one vendored -- is subtracted from the charges
21
+ before credits are applied and appeared in no attribute: not in
22
+ `export_credits`, not in `credit_applied`. A consumer summing what was there
23
+ landed short by exactly that, with no way to tell a missing term from a
24
+ rounding error.
25
+
26
+ ### Fixed
27
+ - The backfill coverage check no longer reports an hour it refused as an hour
28
+ the recorder lost. An hour falls outside the priced set for three reasons and
29
+ only one of them is absence: no statistics row, no usable total on the row,
30
+ or a change refused as a counter's catch-up across an outage. All three
31
+ printed as "is missing N of M hour(s)", which reads as data loss -- on a
32
+ window whose 1392 hours were all on disk it claimed 299 were gone. What the
33
+ recorder held and what could be priced are now counted apart and reported in
34
+ their own words.
35
+
36
+ ## [0.4.1] - 2026-08-27
37
+
38
+ ### Fixed
39
+ - A CCA that credits an ACC Plus adder of its own is now paid for it. The adder
40
+ is credited **twice** on such an account, once by each party at the same
41
+ $/kWh, and only the utility's half was modelled -- so `export_price.total`
42
+ understated a CCA export by the adder, and the CCA's credit bank was short by
43
+ its entire balance.
44
+
45
+ The two halves do not behave alike, which is why they are now separate
46
+ buckets rather than one. The utility earns its half and spends it against
47
+ delivery charges in the same cycle: on the 2026-08-04 statement PG&E's bank
48
+ prints Bonus Credits earned $1.71, applied -$1.71, remaining $0.00. MCE earns
49
+ its own $1.70 in that cycle and applies none of it -- its "Energy Export Bonus
50
+ Credits Applied" line prints $0.00 -- so its EEBC balance reaches $3.29, being
51
+ June's $1.59 plus July's $1.70 with nothing taken out. Folding both into one
52
+ bonus bucket let the utility's spending drain a balance the statement shows
53
+ growing, and reported the CCA's bank $3.29 light on a $12.63 balance.
54
+
55
+ A new `cca_acc_plus` export component carries it, `CreditBucket.CCA_BONUS`
56
+ banks it, and it is spent only after the CCA's export credit is exhausted --
57
+ the order the statement's own applied figures imply. Providers declare it
58
+ with `credits_acc_plus` in their rate card, defaulting to false, so no
59
+ provider is credited an adder no statement has shown them paying. Only MCE's
60
+ card sets it.
61
+
62
+ No reconciled bill caught this and none could: the audit compares printed
63
+ charges against computed ones, and a credit that is never applied never
64
+ reaches a charge. It was found by folding a real meter series against the
65
+ statement's printed balances instead of its charges.
66
+
67
+ The audit map keeps up with it: the earned component is declared as one the
68
+ statement does not print separately, and the CCA's grouped "Energy Export
69
+ Credits Applied" rule now sums the bonus applied alongside the export credit
70
+ applied, as the two printed lines it already reads do. Both were needed for
71
+ `audit reconcile` to keep passing -- without the first it reported an unmapped
72
+ component on every MCE cycle, and without the second it would have reported a
73
+ mismatch on the first correct bill that spends the bonus.
74
+
75
+ `month_curve` plots the export price again. It had been the delivery
76
+ component plus the ACC Plus adders on a CCA account, omitting the CCA's
77
+ generation credit and its bonuses -- a figure neither party pays. It and
78
+ `price_at` are now built from one component rule instead of two copies of it.
79
+
80
+ **Export prices change for CCA accounts**, by the ACC Plus rate --
81
+ $0.00880/kWh for a 2026 residential interconnection. Forecasts, the MQTT
82
+ payloads, and the EMHASS and Predbat attributes all carry the higher figure,
83
+ because it is what the two statements between them actually credit.
84
+
8
85
  ## [0.4.0] - 2026-08-26
9
86
 
10
87
  ### Added
@@ -477,7 +554,9 @@ Initial release.
477
554
  - Holiday calendars are extracted per vintage from the source data rather than
478
555
  recomputed, because the vintage files disagree in far-future years.
479
556
 
480
- [Unreleased]: https://github.com/eman/tariffkit/compare/v0.4.0...HEAD
557
+ [Unreleased]: https://github.com/eman/tariffkit/compare/v0.5.0...HEAD
558
+ [0.5.0]: https://github.com/eman/tariffkit/releases/tag/v0.5.0
559
+ [0.4.1]: https://github.com/eman/tariffkit/releases/tag/v0.4.1
481
560
  [0.4.0]: https://github.com/eman/tariffkit/releases/tag/v0.4.0
482
561
  [0.3.0]: https://github.com/eman/tariffkit/releases/tag/v0.3.0
483
562
  [0.2.3]: https://github.com/eman/tariffkit/releases/tag/v0.2.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tariffkit
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tariffkit"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Offline electricity tariff pricing, billing, and integration tools"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -10,8 +10,10 @@ Credits are not fungible. PG&E's statement states the rule directly:
10
10
  2. Energy Delivered credits can only offset Energy Delivered charges
11
11
  3. Energy Export Bonus Credit can offset any and all electric charges
12
12
 
13
- So a balance is three buckets, not a number, and applying it needs each charge
14
- classified by which bucket may offset it.
13
+ So a balance is four buckets, not a number, and applying it needs each charge
14
+ classified by which bucket may offset it. Four rather than three because a CCA
15
+ account is credited the ACC Plus adder twice -- the utility's half is spent
16
+ where it is earned, the CCA's half banks on the CCA's own page.
15
17
 
16
18
  **One bank at a time.** A CCA customer has two, kept separately: PG&E's and the
17
19
  CCA's, each with its own balance and its own charges to offset. A :class:`Bill`
@@ -46,20 +48,40 @@ class CreditBucket(StrEnum):
46
48
  DELIVERY = "delivery"
47
49
  #: Offsets anything not explicitly non-bypassable.
48
50
  BONUS = "bonus"
51
+ #: The generation supplier's own bonus credit, banked on its page and
52
+ #: settled on its calendar. The ACC Plus adder is credited twice on a CCA
53
+ #: account -- see ``CREDIT_BUCKETS`` -- and the two halves are not one
54
+ #: bucket: the utility spends its half against delivery charges in the
55
+ #: cycle that earns it, while this one accumulates untouched.
56
+ CCA_BONUS = "cca_bonus"
49
57
 
50
58
 
51
59
  #: Export credit component -> the bucket it banks into.
52
60
  #:
53
- #: Verified against the 2026-08-04 statement, whose credit bank splits into
54
- #: "Energy Delivered Credits" $6.25 (the ``delivery`` component) and "Bonus
55
- #: Credits" $1.71 (``acc_plus``), and whose CCA page reports an Energy Export
56
- #: Credit of $9.63 (``cca_generation``).
61
+ #: Verified against the 2026-08-04 statement. Its two credit banks print:
57
62
  #:
63
+ #: PG&E "YOUR ENERGY EXPORT CREDIT BANK"
64
+ #: Energy Delivered Credits earned $6.25, applied -$6.25, remaining $0.00
65
+ #: Bonus Credits earned $1.71, applied -$1.71, remaining $0.00
66
+ #: MCE
67
+ #: Solar Export Credits (EEC) earned this cycle $9.63
68
+ #: Solar Export Bonus Credits (EEBC) earned this cycle $1.70
69
+ #: Current EEC Balance $9.34, Current EEBC Balance $3.29, SBP total $12.63
70
+ #:
71
+ #: The ACC Plus adder is therefore credited TWICE, once by each party at the
72
+ #: same $/kWh: the utility earns and spends its half inside the cycle, and the
73
+ #: CCA banks its own half without ever applying it -- MCE's "Energy Export
74
+ #: Bonus Credits Applied" line prints $0.00 while its EEBC balance grows by the
75
+ #: full amount every cycle ($1.59 in June plus $1.70 in July gives the printed
76
+ #: $3.29). Modelling one adder understated the CCA's bank by its whole balance
77
+ #: and no reconciled bill noticed, because a credit that is never applied never
78
+ #: reaches a charge the audit compares.
58
79
  CREDIT_BUCKETS: dict[str, CreditBucket] = {
59
80
  "delivery": CreditBucket.DELIVERY,
60
81
  "generation": CreditBucket.GENERATION,
61
82
  "cca_generation": CreditBucket.GENERATION,
62
83
  "acc_plus": CreditBucket.BONUS,
84
+ "cca_acc_plus": CreditBucket.CCA_BONUS,
63
85
  }
64
86
 
65
87
  #: Export-side components a statement spends inside the cycle instead of banking.
@@ -132,10 +154,12 @@ SCOPING_VERIFIED = False
132
154
  #: Which buckets each party's bank holds when a CCA supplies generation.
133
155
  #:
134
156
  #: PG&E's "Energy Delivered Credits" and "Bonus Credits" against the CCA's
135
- #: "Energy Export Credit". They settle on unrelated calendars -- PG&E at the
136
- #: Permission To Operate anniversary, the CCA on its own cash-out year.
157
+ #: "Energy Export Credit" and its own bonus credit -- MCE prints the latter two
158
+ #: as the EEC and EEBC balances that sum to the SBP balance. They settle on
159
+ #: unrelated calendars -- PG&E at the Permission To Operate anniversary, the CCA
160
+ #: on its own cash-out year.
137
161
  UTILITY_BUCKETS = (CreditBucket.DELIVERY, CreditBucket.BONUS)
138
- GENERATION_BUCKETS = (CreditBucket.GENERATION,)
162
+ GENERATION_BUCKETS = (CreditBucket.GENERATION, CreditBucket.CCA_BONUS)
139
163
 
140
164
 
141
165
  @dataclass(frozen=True, slots=True)
@@ -145,6 +169,7 @@ class CreditBalances:
145
169
  generation: float = 0.0
146
170
  delivery: float = 0.0
147
171
  bonus: float = 0.0
172
+ cca_bonus: float = 0.0
148
173
 
149
174
  def __post_init__(self) -> None:
150
175
  for bucket in CreditBucket:
@@ -159,7 +184,7 @@ class CreditBalances:
159
184
 
160
185
  @property
161
186
  def total(self) -> float:
162
- return self.generation + self.delivery + self.bonus
187
+ return self.generation + self.delivery + self.bonus + self.cca_bonus
163
188
 
164
189
  def held_by(self, party: str, *, split: bool) -> float:
165
190
  """The balance one party holds.
@@ -188,6 +213,7 @@ class CreditBalances:
188
213
  "generation": round(self.generation, 4),
189
214
  "delivery": round(self.delivery, 4),
190
215
  "bonus": round(self.bonus, 4),
216
+ "cca_bonus": round(self.cca_bonus, 4),
191
217
  "total": round(self.total, 4),
192
218
  }
193
219
 
@@ -248,6 +274,7 @@ def credits_earned(bill: Bill) -> CreditBalances:
248
274
  generation=totals[CreditBucket.GENERATION],
249
275
  delivery=totals[CreditBucket.DELIVERY],
250
276
  bonus=totals[CreditBucket.BONUS],
277
+ cca_bonus=totals[CreditBucket.CCA_BONUS],
251
278
  )
252
279
 
253
280
 
@@ -322,6 +349,7 @@ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEn
322
349
  generation=opening.generation + earned.generation,
323
350
  delivery=opening.delivery + earned.delivery,
324
351
  bonus=opening.bonus + earned.bonus,
352
+ cca_bonus=opening.cca_bonus + earned.cca_bonus,
325
353
  )
326
354
  applied = CreditBalances()
327
355
  remaining = dict(offsettable)
@@ -331,6 +359,15 @@ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEn
331
359
  applied = applied.with_bucket(bucket, spend)
332
360
  remaining[bucket] -= spend
333
361
 
362
+ # The CCA's bonus reaches its own generation charges, and only after its
363
+ # export credit has. That order is the statement's, not a preference: on
364
+ # 2026-08-04 MCE applied $3.63 of Energy Export Credit and $0.00 of Energy
365
+ # Export Bonus Credit against the same $3.63 of net generation charges.
366
+ # Spending it first would drain the bank that the statement shows growing.
367
+ cca_bonus_spend = min(available.cca_bonus, remaining[CreditBucket.GENERATION])
368
+ applied = applied.with_bucket(CreditBucket.CCA_BONUS, cca_bonus_spend)
369
+ remaining[CreditBucket.GENERATION] -= cca_bonus_spend
370
+
334
371
  # The bonus reaches anything still standing, except charges the tariff makes
335
372
  # non-bypassable -- that is what "non-bypassable" means.
336
373
  bonus_spend = min(available.bonus, sum(remaining.values()))
@@ -340,6 +377,7 @@ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEn
340
377
  generation=available.generation - applied.generation,
341
378
  delivery=available.delivery - applied.delivery,
342
379
  bonus=available.bonus - applied.bonus,
380
+ cca_bonus=available.cca_bonus - applied.cca_bonus,
343
381
  )
344
382
  gross = sum(offsettable.values()) + non_offsettable
345
383
  # A statement charges nothing rather than paying out. `non_offsettable` can
@@ -355,6 +393,7 @@ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEn
355
393
  generation=available.generation - applied.generation,
356
394
  delivery=available.delivery - applied.delivery,
357
395
  bonus=available.bonus - applied.bonus,
396
+ cca_bonus=available.cca_bonus - applied.cca_bonus,
358
397
  )
359
398
  return LedgerEntry(
360
399
  period=bill.period,
@@ -373,12 +412,21 @@ def apply_credits(bill: Bill, opening: CreditBalances | None = None) -> LedgerEn
373
412
  def _reduce(applied: CreditBalances, by: float) -> CreditBalances:
374
413
  """Un-spend ``by`` dollars of credit, bonus first.
375
414
 
376
- Reverse of the order it was spent in: the bonus is the most widely usable
377
- bucket, so it is the one to hand back when the charges turn out not to have
378
- needed it.
415
+ Reverse of the order it was spent in, exactly: the bonus is the most widely
416
+ usable bucket, so it is the one to hand back when the charges turn out not
417
+ to have needed it, and the CCA's bonus is next because it is spent last of
418
+ the scoped buckets. Handing back a scoped bucket ahead of one spent after it
419
+ moves credit between the two banks -- returning delivery credit to the
420
+ utility while leaving the CCA's bonus spent is the misattribution
421
+ ``CCA_BONUS`` exists to prevent.
379
422
  """
380
423
  left = by
381
- for bucket in (CreditBucket.BONUS, CreditBucket.DELIVERY, CreditBucket.GENERATION):
424
+ for bucket in (
425
+ CreditBucket.BONUS,
426
+ CreditBucket.CCA_BONUS,
427
+ CreditBucket.DELIVERY,
428
+ CreditBucket.GENERATION,
429
+ ):
382
430
  give = min(applied[bucket], left)
383
431
  applied = applied.with_bucket(bucket, applied[bucket] - give)
384
432
  left -= give
@@ -93,6 +93,18 @@ class CcaRateCard:
93
93
  """Whether this provider's export credit basis has been confirmed."""
94
94
  return bool(self.raw.get("export", {}).get("export_credit_verified", False))
95
95
 
96
+ @property
97
+ def credits_acc_plus(self) -> bool:
98
+ """Whether this provider credits the ACC Plus adder in its own right.
99
+
100
+ The utility credits it too, on its own page and at the same rate, so a
101
+ provider that does this pays the customer a second adder rather than a
102
+ share of one. Default false: crediting an adder nobody pays is an
103
+ invented credit, which is the class of defect the audit exists to
104
+ catch, so a provider claims this only once a statement has shown it.
105
+ """
106
+ return bool(self.raw.get("export", {}).get("credits_acc_plus", False))
107
+
96
108
 
97
109
  @lru_cache(maxsize=8)
98
110
  def load_rate_card(provider: str, on: date) -> CcaRateCard:
@@ -139,6 +139,7 @@ COMPONENT_GROUPS: dict[str, ComponentGroup] = {
139
139
  # Discounts and incentives.
140
140
  "medical_discount": ComponentGroup.CREDITS,
141
141
  "acc_plus": ComponentGroup.CREDITS,
142
+ "cca_acc_plus": ComponentGroup.CREDITS,
142
143
  # Bill-level components. A marginal price never carries these -- a baseline
143
144
  # credit depends on cumulative usage, and a tax on a whole cycle's charges
144
145
  # -- but ``group_of`` is public, so a caller grouping a ``Bill``'s
@@ -150,3 +150,16 @@ care_fera_bonus_through = "2028-12-31"
150
150
  # and 193 kWh spread across all three TOU periods is a fair test of that.
151
151
  export_credit_basis = "acc_generation"
152
152
  export_credit_verified = true
153
+
154
+ # MCE credits an ACC Plus adder of its OWN, additional to PG&E's, at the same
155
+ # $/kWh -- it banks as the Energy Export Bonus Credit (EEBC) and is settled on
156
+ # MCE's Solar Billing Plan year rather than PG&E's True-Up.
157
+ #
158
+ # Read from the 2026-08-04 statement, which prints both in the same cycle:
159
+ # PG&E's page applies -$1.71 of "Energy Export Bonus Credits" and closes its
160
+ # Bonus Credits bank at $0.00, while MCE's page reports "Solar Export Bonus
161
+ # Credits (EEBC) earned this cycle $1.70" and a "Current EEBC Balance" of
162
+ # $3.29 -- June's $1.59 plus July's $1.70, nothing applied. MCE's own "Energy
163
+ # Export Bonus Credits Applied" line prints $0.00 in the same cycle, so the
164
+ # balance is not PG&E's adder shown twice; it is a second one.
165
+ credits_acc_plus = true
@@ -140,8 +140,30 @@ class NbtExportRates:
140
140
  day_index = 0 if kind is DayType.WEEKDAY else 1
141
141
  hour = export_hour(moment)
142
142
 
143
- delivery = float(data["delivery"][month_index][day_index][hour])
144
- components: dict[str, float] = {"delivery": delivery}
143
+ components, complete = self._components(data, month_index, day_index, hour, moment.date())
144
+ return ExportPrice(
145
+ total=round(sum(components.values()), 6),
146
+ vintage=self.vintage,
147
+ day_type=kind,
148
+ components={k: round(v, 6) for k, v in components.items()},
149
+ locked=self.is_locked(moment),
150
+ complete=complete,
151
+ exact=moment.year <= self.exact_through,
152
+ )
153
+
154
+ def _components(
155
+ self, data: dict[str, Any], month_index: int, day_index: int, hour: int, on: date
156
+ ) -> tuple[dict[str, float], bool]:
157
+ """One hour's export credit components, and whether they are verified.
158
+
159
+ Shared with ``month_curve`` rather than duplicated into it. The two had
160
+ drifted -- the curve was the delivery component plus adders on a CCA
161
+ account, a figure neither party pays -- and a second copy of a rule this
162
+ fiddly is a second place for it to go wrong.
163
+ """
164
+ components: dict[str, float] = {
165
+ "delivery": float(data["delivery"][month_index][day_index][hour])
166
+ }
145
167
  complete = True
146
168
 
147
169
  if self.config.supplier is Supplier.BUNDLED:
@@ -163,7 +185,7 @@ class NbtExportRates:
163
185
  # MCE's is reconciled against a real cycle, others are estimates.
164
186
  from ..cca import load_rate_card
165
187
 
166
- card = load_rate_card(cca.rate_card, moment.date())
188
+ card = load_rate_card(cca.rate_card, on)
167
189
  components["cca_generation"] = float(
168
190
  data["generation"][month_index][day_index][hour]
169
191
  )
@@ -171,36 +193,38 @@ class NbtExportRates:
171
193
  components["cca_solar_bonus"] = (
172
194
  components["cca_generation"] * card.solar_bonus_fraction
173
195
  )
196
+ if card.credits_acc_plus and self._acc_plus:
197
+ # A second ACC Plus adder, not a share of the utility's.
198
+ # MCE credits its own at the same $/kWh and banks it as the
199
+ # Energy Export Bonus Credit; the 2026-08-04 statement
200
+ # prints both, $1.71 applied on PG&E's page and $1.70 added
201
+ # to MCE's EEBC balance in the same cycle.
202
+ components["cca_acc_plus"] = self._acc_plus
174
203
  complete = card.export_credit_verified
175
204
  else:
176
205
  complete = False
177
206
 
178
207
  if self._acc_plus:
179
208
  components["acc_plus"] = self._acc_plus
180
-
181
- return ExportPrice(
182
- total=round(sum(components.values()), 6),
183
- vintage=self.vintage,
184
- day_type=kind,
185
- components={k: round(v, 6) for k, v in components.items()},
186
- locked=self.is_locked(moment),
187
- complete=complete,
188
- exact=moment.year <= self.exact_through,
189
- )
209
+ return components, complete
190
210
 
191
211
  def month_curve(self, year: int, month: str, kind: DayType) -> tuple[float, ...]:
192
- """The 24 hourly totals for one month and day type -- handy for plots."""
212
+ """The 24 hourly totals for one month and day type -- handy for plots.
213
+
214
+ Each hour is what ``price_at`` totals for it, CCA generation and both
215
+ bonus credits included, so a plotted curve is the export price rather
216
+ than a subset of it. The card is read for the first of the month, which
217
+ is the same card ``price_at`` reads for any hour it contains.
218
+ """
193
219
  data = self._payload["data"].get(str(year))
194
220
  if data is None:
195
221
  low, high = self.covered_years
196
222
  raise OutOfRangeError(f"{self.vintage}: {year} outside {low}-{high}")
197
223
  month_index = MONTHS.index(month)
198
224
  day_index = 0 if kind is DayType.WEEKDAY else 1
199
- generation = data["generation"][month_index][day_index]
200
- delivery = data["delivery"][month_index][day_index]
201
- include_generation = self.config.supplier is Supplier.BUNDLED
225
+ on = date(year, month_index + 1, 1)
202
226
  return tuple(
203
- round((generation[h] if include_generation else 0.0) + delivery[h] + self._acc_plus, 6)
227
+ round(sum(self._components(data, month_index, day_index, h, on)[0].values()), 6)
204
228
  for h in range(24)
205
229
  )
206
230
 
@@ -99,3 +99,38 @@ def test_lock_window_falls_inside_the_exactly_verified_range() -> None:
99
99
  lock_end = config.lock_end
100
100
  assert lock_end is not None
101
101
  assert lock_end.year <= rates.exact_through
102
+
103
+
104
+ @pytest.mark.parametrize("supplier", ["bundled", "cca"])
105
+ def test_the_month_curve_is_the_price_it_plots(supplier: str) -> None:
106
+ """Each hour of the curve equals what ``price_at`` totals for that hour.
107
+
108
+ Untested until now, and it had drifted: on a CCA account the curve was the
109
+ delivery component plus the ACC Plus adders, omitting the CCA's generation
110
+ credit and its bonuses -- a figure neither party pays, plotted as though it
111
+ were the export price. Both are built from one component rule now, and this
112
+ is what says they still are.
113
+ """
114
+ from datetime import date
115
+
116
+ from tariffkit.config import CcaConfig
117
+ from tariffkit.models import Supplier
118
+
119
+ config = (
120
+ Config(
121
+ tariff="E-ELEC",
122
+ supplier=Supplier.CCA,
123
+ cca=CcaConfig(name="MCE", rate_card="mce"),
124
+ interconnection_year=2026,
125
+ pto_date=date(2026, 1, 1),
126
+ )
127
+ if supplier == "cca"
128
+ else Config()
129
+ )
130
+ rates = NbtExportRates(config)
131
+ curve = rates.month_curve(2026, "Jul", DayType.WEEKDAY)
132
+ # 2026-07-15 is a Wednesday, so every hour of it is a weekday hour.
133
+ hourly = [
134
+ rates.price_at(datetime(2026, 7, 15, hour, tzinfo=PACIFIC)).total for hour in range(24)
135
+ ]
136
+ assert list(curve) == pytest.approx(hourly)
@@ -888,3 +888,81 @@ def test_a_settlement_lowers_what_the_cycles_after_it_can_offset() -> None:
888
888
  after = [e for e in result.lifetime.entries if e.period.start > settled]
889
889
  assert before and after
890
890
  assert after[0].opening.total < before[-1].closing.total, "the clawback reached the next cycle"
891
+
892
+
893
+ def test_a_recorded_hour_this_refuses_is_not_reported_as_missing() -> None:
894
+ """An hour the recorder wrote is not an hour the recorder lost.
895
+
896
+ The coverage warning counted every hour absent from `covered`, and an hour
897
+ whose counter jumped further than a house can draw is refused rather than
898
+ absent. Reporting it as missing told an owner their meter history had holes
899
+ in it when every hour was on disk.
900
+ """
901
+ import asyncio
902
+ from datetime import datetime, timedelta
903
+
904
+ from custom_components.tariffkit.energy import MeterSettings, UsageReader
905
+
906
+ from tariffkit.timeutil import PACIFIC
907
+
908
+ def warnings_for(*, refuse: bool) -> tuple[str, ...]:
909
+ reader = UsageReader(None, MeterSettings(import_entity="sensor.x")) # type: ignore[arg-type]
910
+ opens = datetime(2026, 6, 1, tzinfo=PACIFIC)
911
+ rows: list[dict[str, float]] = []
912
+ total = 0.0
913
+ for index in range(73):
914
+ hole = bool(index) and index % 6 == 0
915
+ if hole and not refuse:
916
+ continue # the recorder never wrote this hour
917
+ total += 500.0 if hole else 0.5
918
+ rows.append(
919
+ {"start": (opens - timedelta(hours=1)).timestamp() + index * 3600, "sum": total}
920
+ | {"change": 500.0 if hole else 0.5, "state": total}
921
+ )
922
+
923
+ async def query(window: datetime, until: datetime) -> dict[str, list[dict[str, float]]]:
924
+ del window, until
925
+ return {"sensor.x": rows}
926
+
927
+ reader._async_query = query # type: ignore[method-assign]
928
+ asyncio.run(reader.async_readings(date(2026, 6, 1), date(2026, 6, 3)))
929
+ return reader.absent
930
+
931
+ refused = warnings_for(refuse=True)
932
+ assert len(refused) == 1
933
+ assert "could not use" in refused[0]
934
+ assert "is missing" not in refused[0], "every hour was on disk"
935
+
936
+ # The genuine hole still reads as one, in the same words as before.
937
+ absent = warnings_for(refuse=False)
938
+ assert absent == ("sensor.x is missing 12 of 72 hour(s) in this window",)
939
+
940
+
941
+ def test_the_summary_cycles_carry_the_terms_that_reconcile_them() -> None:
942
+ """The payload has to add up for the same reason the entity attributes do.
943
+
944
+ `cash_due` is `max(0, gross_charges - credit_applied)`, so a consumer given
945
+ only the charge components lands short of it by whatever the statement spent
946
+ in-cycle instead of banking. Both terms are published; this is what notices
947
+ if either stops being.
948
+ """
949
+ profile = _profile()
950
+ readings = _hours(date(2026, 6, 1), date(2026, 7, 31), imported=0.2, exported=2.0)
951
+ result = backfill.build(profile, readings, date(2026, 6, 1), date(2026, 7, 31), 1)
952
+ from tariffkit.billing import run_ledger
953
+
954
+ entries = {entry.period: entry for entry in run_ledger(result.bills).entries}
955
+
956
+ cycles = result.summary("probe")["cycles"]
957
+ assert len(cycles) == 2
958
+ for cycle in cycles:
959
+ entry = entries[
960
+ next(b.period for b in result.bills if b.period.start.isoformat() == cycle["start"])
961
+ ]
962
+ assert cycle["gross_charges"] == pytest.approx(round(entry.gross_charges, 2))
963
+ assert cycle["non_offsettable"] == pytest.approx(round(entry.non_offsettable, 2))
964
+ # Each term is rounded to cents independently, so the identity over the
965
+ # payload cannot hold tighter than the three roundings it carries.
966
+ assert cycle["cash_due"] == pytest.approx(
967
+ max(0.0, cycle["gross_charges"] - cycle["credit_applied"]), abs=0.02
968
+ )
@@ -139,9 +139,10 @@ def test_a_cca_account_holds_two_banks_that_never_settle_together() -> None:
139
139
  """A statement prints them on separate pages, on unrelated calendars.
140
140
 
141
141
  PG&E keeps "Energy Delivered Credits" and "Bonus Credits"; the CCA keeps its
142
- "Energy Export Credit". PG&E settles at the Permission To Operate
143
- anniversary and the CCA on its own cash-out year, so a single total is a
144
- figure no statement shows and that never settles as a whole.
142
+ "Energy Export Credit" and its own bonus credit. PG&E settles at the
143
+ Permission To Operate anniversary and the CCA on its own cash-out year, so a
144
+ single total is a figure no statement shows and that never settles as a
145
+ whole.
145
146
  """
146
147
  profile = _cca_profile()
147
148
  bills = [
@@ -154,7 +155,7 @@ def test_a_cca_account_holds_two_banks_that_never_settle_together() -> None:
154
155
  utility = state.held_by("utility")
155
156
  generation = state.held_by("generation")
156
157
  assert utility == pytest.approx(state.balance.delivery + state.balance.bonus)
157
- assert generation == pytest.approx(state.balance.generation)
158
+ assert generation == pytest.approx(state.balance.generation + state.balance.cca_bonus)
158
159
  assert utility + generation == pytest.approx(state.balance.total)
159
160
  # Neither alone is the whole bank, which is the point.
160
161
  assert utility != pytest.approx(state.balance.total)