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.
- {tariffkit-0.4.0 → tariffkit-0.5.0}/CHANGELOG.md +80 -1
- {tariffkit-0.4.0 → tariffkit-0.5.0}/PKG-INFO +1 -1
- {tariffkit-0.4.0 → tariffkit-0.5.0}/pyproject.toml +1 -1
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/ledger.py +62 -14
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/cca.py +12 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/components.py +1 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/cca/mce/2026-04-01.toml +13 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/export/nbt.py +42 -18
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_export_golden.py +35 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_backfill.py +78 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_bank.py +5 -4
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ha_energy.py +72 -1
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ledger.py +113 -6
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_mce.py +6 -3
- {tariffkit-0.4.0 → tariffkit-0.5.0}/.gitignore +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/LICENSE +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/README.md +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/cli.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/errors.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/model.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/rates.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/account/repository.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/engine.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/models.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/netting.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/billing/trueup.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/cli.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/config.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/cca/mce/2023-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/acc_plus/2023-04-15.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt00.json.gz +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt23.json.gz +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt24.json.gz +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt25.json.gz +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/export/pge/nbt26.json.gz +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/holidays.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/nsc/pge.toml +0 -0
- /tariffkit-0.4.0/src/tariffkit/data/manifest.json → /tariffkit-0.5.0/src/tariffkit/data/sources.json +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/e1/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2025-09-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2026-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/eelec/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2025-09-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2026-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etouc/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/etoud/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/dcare/2025-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/dmedical/2026-03-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/ersmart/2022-05-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tariff/pge/program/medicalbaseline/2023-03-23.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/data/versioned.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/engine.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/errors.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/export/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/emhass.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/predbat.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/interop/slots.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/models.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/discovery.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/mqtt/publisher.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/reconcile.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/errors.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/model.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/ocr.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/providers/pge/statements/parse.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/py.typed +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/secrets.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/greenbutton.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/homeassistant.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/influx.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/sources/pge.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/tariff/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/tariff/retail.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/timeutil.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/web/__init__.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/src/tariffkit/web/app.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/conftest.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/fixtures/nbt_golden_rows.jsonl +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/fixtures/statements/synthetic_cca_ratechange.txt +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_account.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_account_cli.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_billing.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_components.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_eelec.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_engine.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_ev2a.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_integrations.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_interop.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_mqtt_publisher.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_pge_reconcile.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_pge_statements.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_private_config.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_schedules.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_greenbutton.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_ha.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_influx.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_sources_pge.py +0 -0
- {tariffkit-0.4.0 → tariffkit-0.5.0}/tests/test_trueup.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
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"
|
|
136
|
-
#:
|
|
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
|
|
377
|
-
bucket, so it is the one to hand back when the charges turn out not
|
|
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 (
|
|
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
|
-
|
|
144
|
-
|
|
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,
|
|
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
|
-
|
|
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((
|
|
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
|
|
143
|
-
anniversary and the CCA on its own cash-out year, so a
|
|
144
|
-
figure no statement shows and that never settles as a
|
|
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)
|