tariffkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tariffkit/__init__.py +52 -0
- tariffkit/account/__init__.py +41 -0
- tariffkit/account/cli.py +493 -0
- tariffkit/account/errors.py +25 -0
- tariffkit/account/model.py +641 -0
- tariffkit/account/rates.py +61 -0
- tariffkit/account/repository.py +329 -0
- tariffkit/billing/__init__.py +56 -0
- tariffkit/billing/engine.py +505 -0
- tariffkit/billing/ledger.py +365 -0
- tariffkit/billing/models.py +274 -0
- tariffkit/billing/netting.py +137 -0
- tariffkit/billing/trueup.py +498 -0
- tariffkit/cca.py +122 -0
- tariffkit/cli.py +809 -0
- tariffkit/config.py +296 -0
- tariffkit/data/__init__.py +40 -0
- tariffkit/data/cca/mce/2023-01-01.toml +61 -0
- tariffkit/data/cca/mce/2026-04-01.toml +152 -0
- tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
- tariffkit/data/export/pge/nbt00.json.gz +0 -0
- tariffkit/data/export/pge/nbt23.json.gz +0 -0
- tariffkit/data/export/pge/nbt24.json.gz +0 -0
- tariffkit/data/export/pge/nbt25.json.gz +0 -0
- tariffkit/data/export/pge/nbt26.json.gz +0 -0
- tariffkit/data/holidays.toml +36 -0
- tariffkit/data/manifest.json +56 -0
- tariffkit/data/nsc/pge.toml +57 -0
- tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
- tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
- tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
- tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
- tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
- tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
- tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
- tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
- tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
- tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
- tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
- tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
- tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
- tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
- tariffkit/data/versioned.py +118 -0
- tariffkit/engine.py +82 -0
- tariffkit/errors.py +19 -0
- tariffkit/export/__init__.py +5 -0
- tariffkit/export/nbt.py +207 -0
- tariffkit/interop/__init__.py +21 -0
- tariffkit/interop/emhass.py +79 -0
- tariffkit/interop/predbat.py +102 -0
- tariffkit/interop/slots.py +63 -0
- tariffkit/models.py +164 -0
- tariffkit/mqtt/__init__.py +6 -0
- tariffkit/mqtt/discovery.py +84 -0
- tariffkit/mqtt/publisher.py +305 -0
- tariffkit/providers/__init__.py +1 -0
- tariffkit/providers/pge/__init__.py +33 -0
- tariffkit/providers/pge/reconcile.py +828 -0
- tariffkit/providers/pge/statements/__init__.py +26 -0
- tariffkit/providers/pge/statements/errors.py +20 -0
- tariffkit/providers/pge/statements/model.py +320 -0
- tariffkit/providers/pge/statements/ocr.py +193 -0
- tariffkit/providers/pge/statements/parse.py +813 -0
- tariffkit/py.typed +0 -0
- tariffkit/secrets.py +164 -0
- tariffkit/sources/__init__.py +71 -0
- tariffkit/sources/greenbutton.py +318 -0
- tariffkit/sources/homeassistant.py +342 -0
- tariffkit/sources/influx.py +359 -0
- tariffkit/sources/pge.py +1153 -0
- tariffkit/tariff/__init__.py +5 -0
- tariffkit/tariff/retail.py +271 -0
- tariffkit/timeutil.py +120 -0
- tariffkit/web/__init__.py +5 -0
- tariffkit/web/app.py +220 -0
- tariffkit-0.2.0.dist-info/METADATA +260 -0
- tariffkit-0.2.0.dist-info/RECORD +83 -0
- tariffkit-0.2.0.dist-info/WHEEL +4 -0
- tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
- tariffkit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Netting rules and data-quality checks.
|
|
2
|
+
|
|
3
|
+
A bill computed over a lossy interval series is silently wrong -- it simply
|
|
4
|
+
looks like a month with less usage. So coverage problems are surfaced as
|
|
5
|
+
warnings on the bill rather than being papered over by interpolation.
|
|
6
|
+
|
|
7
|
+
They do not clear ``Bill.complete``, which is a claim about the rates rather
|
|
8
|
+
than the readings: a bill can reconcile against a real statement and still carry
|
|
9
|
+
coverage warnings. Callers wanting "trust this total" check both.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from itertools import pairwise
|
|
17
|
+
|
|
18
|
+
from ..timeutil import to_pacific
|
|
19
|
+
from .models import BillingPeriod, IntervalReading
|
|
20
|
+
|
|
21
|
+
#: Fraction of a period that may be unaccounted for before it is reported.
|
|
22
|
+
COVERAGE_TOLERANCE = 0.01
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_coverage(readings: Sequence[IntervalReading], period: BillingPeriod) -> Iterator[str]:
|
|
26
|
+
"""Report ways the readings fail to cleanly cover ``period``.
|
|
27
|
+
|
|
28
|
+
Yields human-readable problems. An empty result means the series is
|
|
29
|
+
contiguous, non-overlapping, and spans the whole cycle.
|
|
30
|
+
"""
|
|
31
|
+
if not readings:
|
|
32
|
+
yield f"no readings in {period.start}..{period.end}"
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
ordered = sorted(readings, key=lambda r: to_pacific(r.start))
|
|
36
|
+
|
|
37
|
+
covered = sum((r.duration for r in ordered), timedelta())
|
|
38
|
+
# Real elapsed time, not days x 24: a cycle spanning a DST transition is an
|
|
39
|
+
# hour longer or shorter, and on the autumn one that difference hides an
|
|
40
|
+
# hour of genuinely missing data.
|
|
41
|
+
expected = period.elapsed
|
|
42
|
+
shortfall = expected - covered
|
|
43
|
+
if shortfall > expected * COVERAGE_TOLERANCE:
|
|
44
|
+
yield (
|
|
45
|
+
f"readings cover {covered.total_seconds() / 3600:.1f}h of the "
|
|
46
|
+
f"{expected.total_seconds() / 3600:.0f}h period "
|
|
47
|
+
f"({shortfall.total_seconds() / 3600:.1f}h missing)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
gaps = list(find_gaps(ordered))
|
|
51
|
+
if gaps:
|
|
52
|
+
first = gaps[0]
|
|
53
|
+
yield (
|
|
54
|
+
f"{len(gaps)} gap(s) in the series; first from "
|
|
55
|
+
f"{first[0].isoformat()} to {first[1].isoformat()}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
overlaps = list(find_overlaps(ordered))
|
|
59
|
+
if overlaps:
|
|
60
|
+
yield f"{len(overlaps)} overlapping interval(s); first at {overlaps[0].isoformat()}"
|
|
61
|
+
|
|
62
|
+
guessed = [r for r in ordered if r.estimated]
|
|
63
|
+
if guessed:
|
|
64
|
+
energy = sum(r.imported + r.exported for r in guessed)
|
|
65
|
+
hours = sum((r.duration for r in guessed), timedelta()).total_seconds() / 3600
|
|
66
|
+
yield (
|
|
67
|
+
f"{len(guessed)} interval(s) covering {hours:.1f}h and {energy:.1f} kWh were "
|
|
68
|
+
f"reconstructed across gaps in the source, so their time-of-use split is a "
|
|
69
|
+
f"guess even though the cycle total is not. Spreading a long gap evenly gives "
|
|
70
|
+
f"peak hours their share of the clock rather than their share of the load"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
both = [r for r in ordered if r.imported and r.exported]
|
|
74
|
+
if both:
|
|
75
|
+
# Deliberately does not tell the reader to net. It used to, and the
|
|
76
|
+
# advice is wrong for the commonest source: a meter's own import and
|
|
77
|
+
# export registers are already netted at the meter's interval, and
|
|
78
|
+
# aggregating them to a coarser one legitimately leaves both non-zero.
|
|
79
|
+
# Netting again is double-netting, which the tariff does not do --
|
|
80
|
+
# measured against a real Solar Billing Plan statement it moved the
|
|
81
|
+
# cycle from three cents out to forty. Only independently metered gross
|
|
82
|
+
# sources, an inverter or a CT clamp, want `IntervalReading.from_gross`.
|
|
83
|
+
yield (
|
|
84
|
+
f"{len(both)} interval(s) report both import and export. Expected when "
|
|
85
|
+
f"already-netted meter registers are aggregated to a coarser interval; "
|
|
86
|
+
f"a sign of un-netted gross data only if these are inverter or CT "
|
|
87
|
+
f"readings, which want IntervalReading.from_gross"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _elapsed(moment: datetime) -> datetime:
|
|
92
|
+
"""The instant, so arithmetic measures real time rather than clock face.
|
|
93
|
+
|
|
94
|
+
Adding a duration to a zoned datetime advances the wall clock, which on a DST
|
|
95
|
+
transition is not the same as advancing time. Both transitions get it wrong
|
|
96
|
+
and in opposite directions: on the autumn day an hour missing from the data
|
|
97
|
+
is hidden, because 01:45 plus fifteen minutes reads as 02:00 and the clock
|
|
98
|
+
has meanwhile gone back; on the spring day a contiguous series looks
|
|
99
|
+
discontinuous, because the labels jump an hour that never existed.
|
|
100
|
+
"""
|
|
101
|
+
return to_pacific(moment).astimezone(UTC)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_gaps(readings: Sequence[IntervalReading]) -> Iterator[tuple[datetime, datetime]]:
|
|
105
|
+
"""Yield (gap_start, gap_end) for each discontinuity, in order."""
|
|
106
|
+
ordered = sorted(readings, key=lambda r: _elapsed(r.start))
|
|
107
|
+
for earlier, later in pairwise(ordered):
|
|
108
|
+
expected = _elapsed(earlier.start) + earlier.duration
|
|
109
|
+
actual = _elapsed(later.start)
|
|
110
|
+
if actual > expected:
|
|
111
|
+
yield (to_pacific(expected), to_pacific(actual))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_overlaps(readings: Sequence[IntervalReading]) -> Iterator[datetime]:
|
|
115
|
+
"""Yield the start of each interval that begins before its predecessor ends."""
|
|
116
|
+
ordered = sorted(readings, key=lambda r: _elapsed(r.start))
|
|
117
|
+
for earlier, later in pairwise(ordered):
|
|
118
|
+
if _elapsed(later.start) < _elapsed(earlier.start) + earlier.duration:
|
|
119
|
+
yield to_pacific(later.start)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def net_intervals(readings: Iterable[IntervalReading]) -> list[IntervalReading]:
|
|
123
|
+
"""Net import against export within each interval.
|
|
124
|
+
|
|
125
|
+
A no-op for real AMI data, which the meter has already netted. Meaningful
|
|
126
|
+
only for series assembled from separate consumption and production feeds,
|
|
127
|
+
where an interval can carry both.
|
|
128
|
+
|
|
129
|
+
Netting granularity is a real tariff question, not a formatting choice: the
|
|
130
|
+
finer the interval, the less self-consumption offsets, and the higher the
|
|
131
|
+
bill. This nets at whatever granularity the readings arrive in, which is the
|
|
132
|
+
honest default -- it does not invent a coarser or finer one.
|
|
133
|
+
"""
|
|
134
|
+
return [
|
|
135
|
+
IntervalReading.from_net(r.start, r.net, r.duration) if r.imported and r.exported else r
|
|
136
|
+
for r in readings
|
|
137
|
+
]
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""The annual true-up: what happens to a credit bank at the end of a year.
|
|
2
|
+
|
|
3
|
+
:mod:`tariffkit.billing.ledger` carries credits from cycle to cycle. This closes
|
|
4
|
+
the year on them. For a CCA account that is two separate events on two separate
|
|
5
|
+
calendars, which is the first thing to get right:
|
|
6
|
+
|
|
7
|
+
============ ============================ ============================
|
|
8
|
+
MCE Annual Cash-Out PG&E Relevant Period
|
|
9
|
+
============ ============================ ============================
|
|
10
|
+
ends after the March-April cycle on the PTO anniversary
|
|
11
|
+
fixed for every customer alike this account alone
|
|
12
|
+
covers generation credits delivery credits, ACC Plus
|
|
13
|
+
pays surplus yes, at MCE's NSC rate no -- a CCA account is barred
|
|
14
|
+
============ ============================ ============================
|
|
15
|
+
|
|
16
|
+
So an account with a June PTO date has its MCE cash-out in April and its PG&E
|
|
17
|
+
true-up in June, and neither one closes the other's bank. Modelling a single
|
|
18
|
+
annual event would be wrong for at least one of them.
|
|
19
|
+
|
|
20
|
+
**PG&E pays a CCA account nothing.** Schedule NBT, Special Condition 5.a:
|
|
21
|
+
|
|
22
|
+
Net Surplus Generators who receive Direct Access (DA) Service from an ESP or
|
|
23
|
+
who receive Community Choice Aggregation (CCA) Service from a CCA are not
|
|
24
|
+
eligible to receive NSC from PG&E but may contact their ESP or CCA Provider
|
|
25
|
+
to see if they provide NSC.
|
|
26
|
+
|
|
27
|
+
Applicability is limited to "all bundled Net Surplus Generators". PG&E's
|
|
28
|
+
published NSC series is therefore the wrong input for this account even though
|
|
29
|
+
it is the only published one; see :mod:`tariffkit.data`'s ``nsc/pge.toml``.
|
|
30
|
+
|
|
31
|
+
**Credits do not expire.** This is worth stating because the opposite is widely
|
|
32
|
+
repeated. Schedule NBT: excess generation and delivery credits "will be carried
|
|
33
|
+
forward to the customer's next Relevant Period", forfeited only "on the last
|
|
34
|
+
true-up on NBT" if the customer leaves the tariff. MCE's Solar Billing Plan
|
|
35
|
+
tariff: "Any remaining export credit balance will rollover to the next relevant
|
|
36
|
+
period, indefinitely." The annual reset to zero belongs to NEM 2.0.
|
|
37
|
+
|
|
38
|
+
Scope, and what is still unverified: no true-up statement exists for this
|
|
39
|
+
account yet -- the first MCE cash-out falls after the March-April 2027 cycle and
|
|
40
|
+
the first PG&E Relevant Period ends on the 2027 PTO anniversary. Everything here
|
|
41
|
+
is read off tariff text rather than reconciled against a bill, so a
|
|
42
|
+
:class:`TrueUp` carries ``verified=False``. Two specific things to check when
|
|
43
|
+
that statement arrives are recorded on :data:`OPEN_QUESTIONS`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import tomllib
|
|
49
|
+
from collections.abc import Iterable, Sequence
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from datetime import date
|
|
52
|
+
from enum import StrEnum
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from ..data import read_data_text
|
|
56
|
+
from ..errors import ConfigError, DataError
|
|
57
|
+
from .ledger import CreditBalances, CreditBucket, LedgerEntry
|
|
58
|
+
from .models import BillingPeriod
|
|
59
|
+
|
|
60
|
+
#: PG&E's published Net Surplus Compensation series, used only as a stand-in.
|
|
61
|
+
NSC_RATE_FILE = "nsc/pge.toml"
|
|
62
|
+
|
|
63
|
+
#: The billing cycle that closes MCE's cash-out year.
|
|
64
|
+
#:
|
|
65
|
+
#: The tariff says "following the conclusion of each customer's March-April
|
|
66
|
+
#: billing cycle" -- a fixed calendar for every customer, unlike PG&E's
|
|
67
|
+
#: per-account anniversary. Identified by the months a cycle spans rather than
|
|
68
|
+
#: by a date, because cycle boundaries drift by a few days from year to year.
|
|
69
|
+
CASH_OUT_START_MONTH = 3
|
|
70
|
+
CASH_OUT_END_MONTH = 4
|
|
71
|
+
|
|
72
|
+
#: Cash-out at or below this is credited on the bill; above it, paid by cheque.
|
|
73
|
+
CHECK_THRESHOLD = 200.0
|
|
74
|
+
|
|
75
|
+
#: What a real cash-out statement needs to settle. Both are places where the
|
|
76
|
+
#: tariff text supports more than one reading, and guessing would be worse than
|
|
77
|
+
#: recording the guess.
|
|
78
|
+
OPEN_QUESTIONS: tuple[str, ...] = (
|
|
79
|
+
"Whether the Export Credit Reversal and the NSC rate are two steps or one. "
|
|
80
|
+
"Section 2.a.ii reverses the initial export credit at the average Energy "
|
|
81
|
+
"Export Credit rate; section 2.a.iv.(1) then describes the NSC rate as "
|
|
82
|
+
"already 'reduced by the approximate value of export credits already "
|
|
83
|
+
"provided for the same surplus energy'. Applying both would deduct the same "
|
|
84
|
+
"credits twice. This module applies the explicit reversal in 2.a.ii and "
|
|
85
|
+
"treats the configured NSC rate as gross.",
|
|
86
|
+
"Whether MCE's $5,000 annual cap and the NEM-era 'NSC rate plus $0.02/kWh' "
|
|
87
|
+
"formula carry over to the Solar Billing Plan. Both appear on MCE's website "
|
|
88
|
+
"under the NEM 1.0/2.0 program; neither appears in the SBP tariff text.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TrueUpKind(StrEnum):
|
|
93
|
+
"""Which of the two annual events this is."""
|
|
94
|
+
|
|
95
|
+
#: MCE's Annual Cash-Out, after the March-April billing cycle.
|
|
96
|
+
MCE_CASH_OUT = "mce_cash_out"
|
|
97
|
+
#: PG&E's Relevant Period, ending on the PTO anniversary.
|
|
98
|
+
PGE_RELEVANT_PERIOD = "pge_relevant_period"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _nsc_series() -> tuple[dict[str, float], str]:
|
|
102
|
+
raw = tomllib.loads(read_data_text(NSC_RATE_FILE))
|
|
103
|
+
rates: dict[str, Any] = raw["rates"]
|
|
104
|
+
return {k: float(v) for k, v in rates.items()}, str(raw["source_url"])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def published_nsc_rate(month: date) -> float:
|
|
108
|
+
"""PG&E's published NSC rate for a true-up month, exactly.
|
|
109
|
+
|
|
110
|
+
This is PG&E's rate for *bundled* customers. For a CCA account it is a
|
|
111
|
+
stand-in and nothing more -- see the module docstring.
|
|
112
|
+
"""
|
|
113
|
+
rates, source = _nsc_series()
|
|
114
|
+
key = f"{month.year:04d}-{month.month:02d}"
|
|
115
|
+
if key not in rates:
|
|
116
|
+
known = sorted(rates)
|
|
117
|
+
raise DataError(
|
|
118
|
+
f"no published NSC rate for {key}; the vendored series covers "
|
|
119
|
+
f"{known[0]} to {known[-1]}. Re-vendor from {source} "
|
|
120
|
+
f"or set nsc_rate explicitly."
|
|
121
|
+
)
|
|
122
|
+
return rates[key]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def nsc_rate_estimate(month: date) -> tuple[float, str]:
|
|
126
|
+
"""``(rate, month_key)`` -- the published rate, or the latest one before it.
|
|
127
|
+
|
|
128
|
+
A true-up month in the future has no published rate yet, and that is the
|
|
129
|
+
normal case rather than an edge one: PG&E posts a month's rate in that
|
|
130
|
+
month, so the first cash-out for a 2027 period cannot be priced from a
|
|
131
|
+
series vendored in 2026. Falling back to the most recent published month is
|
|
132
|
+
the useful behaviour for an estimate, provided the substitution is visible
|
|
133
|
+
-- the returned key says which month was actually used.
|
|
134
|
+
|
|
135
|
+
The series has moved in a narrow band (0.02684 to 0.03396 over twenty
|
|
136
|
+
months), so a stale month is a defensible stand-in. That is an observation
|
|
137
|
+
about the data, not a guarantee about it.
|
|
138
|
+
"""
|
|
139
|
+
rates, _ = _nsc_series()
|
|
140
|
+
key = f"{month.year:04d}-{month.month:02d}"
|
|
141
|
+
if key in rates:
|
|
142
|
+
return rates[key], key
|
|
143
|
+
earlier = sorted(k for k in rates if k <= key)
|
|
144
|
+
if not earlier:
|
|
145
|
+
known = sorted(rates)
|
|
146
|
+
raise DataError(
|
|
147
|
+
f"no published NSC rate at or before {key}; the vendored series "
|
|
148
|
+
f"starts at {known[0]}. Set nsc_rate explicitly."
|
|
149
|
+
)
|
|
150
|
+
return rates[earlier[-1]], earlier[-1]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class TrueUp:
|
|
155
|
+
"""One annual close-out of a credit bank."""
|
|
156
|
+
|
|
157
|
+
kind: TrueUpKind
|
|
158
|
+
period: BillingPeriod
|
|
159
|
+
opening: CreditBalances
|
|
160
|
+
#: What rolls into the next period. Never zeroed; both tariffs carry forward.
|
|
161
|
+
closing: CreditBalances
|
|
162
|
+
imported_kwh: float
|
|
163
|
+
exported_kwh: float
|
|
164
|
+
#: Exported minus imported, floored at zero. Positive makes the customer a
|
|
165
|
+
#: Net Surplus Generator in both tariffs' sense.
|
|
166
|
+
surplus_kwh: float
|
|
167
|
+
#: Whether this provider pays NSC to this account at all.
|
|
168
|
+
eligible: bool
|
|
169
|
+
#: Export credit clawed back so the same energy is not paid for twice.
|
|
170
|
+
reversal: float = 0.0
|
|
171
|
+
#: The rate used, and whether it came from config or the PG&E stand-in.
|
|
172
|
+
nsc_rate: float | None = None
|
|
173
|
+
nsc_payment: float = 0.0
|
|
174
|
+
#: Cash actually leaving the provider: NSC net of any unabsorbed reversal.
|
|
175
|
+
cash_out: float = 0.0
|
|
176
|
+
#: True when the cash-out exceeds the cheque threshold.
|
|
177
|
+
paid_by_check: bool = False
|
|
178
|
+
#: True when ``nsc_rate`` is PG&E's published stand-in rather than a rate
|
|
179
|
+
#: this provider published for this account.
|
|
180
|
+
estimated: bool = False
|
|
181
|
+
#: Always False: no true-up statement has been reconciled yet.
|
|
182
|
+
verified: bool = False
|
|
183
|
+
notes: tuple[str, ...] = ()
|
|
184
|
+
|
|
185
|
+
def to_dict(self) -> dict[str, Any]:
|
|
186
|
+
return {
|
|
187
|
+
"kind": str(self.kind),
|
|
188
|
+
"period": self.period.to_dict(),
|
|
189
|
+
"opening": self.opening.to_dict(),
|
|
190
|
+
"closing": self.closing.to_dict(),
|
|
191
|
+
"imported_kwh": round(self.imported_kwh, 3),
|
|
192
|
+
"exported_kwh": round(self.exported_kwh, 3),
|
|
193
|
+
"surplus_kwh": round(self.surplus_kwh, 3),
|
|
194
|
+
"eligible": self.eligible,
|
|
195
|
+
"reversal": round(self.reversal, 2),
|
|
196
|
+
"nsc_rate": self.nsc_rate,
|
|
197
|
+
"nsc_payment": round(self.nsc_payment, 2),
|
|
198
|
+
"cash_out": round(self.cash_out, 2),
|
|
199
|
+
"paid_by_check": self.paid_by_check,
|
|
200
|
+
"estimated": self.estimated,
|
|
201
|
+
"verified": self.verified,
|
|
202
|
+
"notes": list(self.notes),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _span(entries: Sequence[LedgerEntry]) -> BillingPeriod:
|
|
207
|
+
return BillingPeriod(entries[0].period.start, entries[-1].period.end)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cash_out_periods(entries: Iterable[LedgerEntry]) -> list[list[LedgerEntry]]:
|
|
211
|
+
"""Group cycles into MCE cash-out years.
|
|
212
|
+
|
|
213
|
+
A year closes with the cycle that begins in March and ends in April, so the
|
|
214
|
+
grouping is driven by the cycles themselves rather than by assumed dates --
|
|
215
|
+
boundaries drift by a few days annually. A run that never reaches a
|
|
216
|
+
March-April cycle is one open period, which is the normal case partway
|
|
217
|
+
through a year.
|
|
218
|
+
"""
|
|
219
|
+
ordered = sorted(entries, key=lambda e: e.period.start)
|
|
220
|
+
groups: list[list[LedgerEntry]] = []
|
|
221
|
+
current: list[LedgerEntry] = []
|
|
222
|
+
for entry in ordered:
|
|
223
|
+
current.append(entry)
|
|
224
|
+
if (
|
|
225
|
+
entry.period.start.month == CASH_OUT_START_MONTH
|
|
226
|
+
and entry.period.end.month == CASH_OUT_END_MONTH
|
|
227
|
+
):
|
|
228
|
+
groups.append(current)
|
|
229
|
+
current = []
|
|
230
|
+
if current:
|
|
231
|
+
groups.append(current)
|
|
232
|
+
return groups
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def relevant_period_end(pto_date: date, after: date) -> date:
|
|
236
|
+
"""The first PTO anniversary strictly after ``after``.
|
|
237
|
+
|
|
238
|
+
PG&E's Relevant Period runs "from the customer's PTO date or anniversary
|
|
239
|
+
thereof", so this is per-account rather than a fixed calendar. A 29 February
|
|
240
|
+
PTO date falls back to the 28th in common years.
|
|
241
|
+
|
|
242
|
+
The PTO date itself is the start of the first period, not the end of one, so
|
|
243
|
+
it never closes a period however early ``after`` falls. Without that a ledger
|
|
244
|
+
beginning in the month before interconnection would close a Relevant Period
|
|
245
|
+
days after it opened.
|
|
246
|
+
"""
|
|
247
|
+
for year in range(min(after.year, pto_date.year), max(after.year, pto_date.year) + 2):
|
|
248
|
+
try:
|
|
249
|
+
anniversary = pto_date.replace(year=year)
|
|
250
|
+
except ValueError: # 29 February in a common year
|
|
251
|
+
anniversary = pto_date.replace(year=year, day=28)
|
|
252
|
+
if anniversary > after and anniversary > pto_date:
|
|
253
|
+
return anniversary
|
|
254
|
+
raise ConfigError(f"could not place a PTO anniversary after {after.isoformat()}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def average_export_rate(entries: Sequence[LedgerEntry], bucket: CreditBucket) -> float:
|
|
258
|
+
"""Dollars of export credit earned per kWh exported, over the period.
|
|
259
|
+
|
|
260
|
+
This is the rate MCE reverses at: "the initial export credit will be
|
|
261
|
+
reversed at the average Energy Export Credit (including Solar Bonus Credit)
|
|
262
|
+
rate". The solar bonus is inside ``earned`` for the generation bucket, so it
|
|
263
|
+
is included here without special handling.
|
|
264
|
+
"""
|
|
265
|
+
exported = sum(e.exported_kwh for e in entries)
|
|
266
|
+
if exported <= 0.0:
|
|
267
|
+
return 0.0
|
|
268
|
+
return sum(e.earned[bucket] for e in entries) / exported
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def mce_cash_out(
|
|
272
|
+
entries: Sequence[LedgerEntry],
|
|
273
|
+
nsc_rate: float | None = None,
|
|
274
|
+
*,
|
|
275
|
+
opening: CreditBalances | None = None,
|
|
276
|
+
) -> TrueUp:
|
|
277
|
+
"""Close one MCE cash-out year over ``entries``.
|
|
278
|
+
|
|
279
|
+
Follows the four steps the Solar Billing Plan tariff lays out, in its order.
|
|
280
|
+
Retroactive payment is not modelled as a separate step: this ledger applies
|
|
281
|
+
credits against charges as each cycle is folded, so credits owed against
|
|
282
|
+
earlier charges in the same year have already been applied rather than left
|
|
283
|
+
outstanding to be refunded.
|
|
284
|
+
"""
|
|
285
|
+
if not entries:
|
|
286
|
+
raise ConfigError("a cash-out period needs at least one cycle")
|
|
287
|
+
ordered = sorted(entries, key=lambda e: e.period.start)
|
|
288
|
+
period = _span(ordered)
|
|
289
|
+
opening = opening if opening is not None else ordered[0].opening
|
|
290
|
+
closing = ordered[-1].closing
|
|
291
|
+
imported = sum(e.imported_kwh for e in ordered)
|
|
292
|
+
exported = sum(e.exported_kwh for e in ordered)
|
|
293
|
+
surplus = max(exported - imported, 0.0)
|
|
294
|
+
|
|
295
|
+
notes: list[str] = []
|
|
296
|
+
if surplus <= 0.0:
|
|
297
|
+
# Not a Net Surplus Generator, so no reversal and no payment. The bank
|
|
298
|
+
# still rolls forward untouched.
|
|
299
|
+
notes.append(
|
|
300
|
+
f"imported {imported:.1f} kWh against {exported:.1f} exported, so no "
|
|
301
|
+
"Net Surplus Electricity and no cash-out; the balance rolls forward."
|
|
302
|
+
)
|
|
303
|
+
return TrueUp(
|
|
304
|
+
kind=TrueUpKind.MCE_CASH_OUT,
|
|
305
|
+
period=period,
|
|
306
|
+
opening=opening,
|
|
307
|
+
closing=closing,
|
|
308
|
+
imported_kwh=imported,
|
|
309
|
+
exported_kwh=exported,
|
|
310
|
+
surplus_kwh=0.0,
|
|
311
|
+
eligible=False,
|
|
312
|
+
notes=tuple(notes),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
estimated = nsc_rate is None
|
|
316
|
+
if nsc_rate is not None:
|
|
317
|
+
rate = nsc_rate
|
|
318
|
+
else:
|
|
319
|
+
rate, used = nsc_rate_estimate(period.end)
|
|
320
|
+
stale = "" if used == f"{period.end:%Y-%m}" else f" (posted for {used})"
|
|
321
|
+
notes.append(
|
|
322
|
+
f"NSC rate {rate}{stale} is PG&E's published rate, used as a stand-in: "
|
|
323
|
+
"MCE determines its Solar Billing Plan rate at cash-out and does not "
|
|
324
|
+
"publish it in advance. Set Config.nsc_rate once a statement says what "
|
|
325
|
+
"was actually paid."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Reverse the credit already given for the surplus energy, so the same
|
|
329
|
+
# kilowatt-hours are not paid for twice. Charged against the balance first,
|
|
330
|
+
# and against the payment for whatever the balance cannot absorb.
|
|
331
|
+
reversal = surplus * average_export_rate(ordered, CreditBucket.GENERATION)
|
|
332
|
+
absorbed = min(reversal, closing[CreditBucket.GENERATION])
|
|
333
|
+
closing = closing.with_bucket(
|
|
334
|
+
CreditBucket.GENERATION, closing[CreditBucket.GENERATION] - absorbed
|
|
335
|
+
)
|
|
336
|
+
gross_payment = surplus * rate
|
|
337
|
+
cash_out = gross_payment - (reversal - absorbed)
|
|
338
|
+
if cash_out < 0.0:
|
|
339
|
+
# The tariff charges the shortfall against the NSC payment; it does not
|
|
340
|
+
# say the customer then owes the difference. Floor at zero and say so.
|
|
341
|
+
notes.append(
|
|
342
|
+
f"the reversal exceeded the NSC payment by {abs(cash_out):.2f}; floored "
|
|
343
|
+
"at zero rather than billed, which the tariff does not provide for."
|
|
344
|
+
)
|
|
345
|
+
cash_out = 0.0
|
|
346
|
+
|
|
347
|
+
notes.append(OPEN_QUESTIONS[0])
|
|
348
|
+
return TrueUp(
|
|
349
|
+
kind=TrueUpKind.MCE_CASH_OUT,
|
|
350
|
+
period=period,
|
|
351
|
+
opening=opening,
|
|
352
|
+
closing=closing,
|
|
353
|
+
imported_kwh=imported,
|
|
354
|
+
exported_kwh=exported,
|
|
355
|
+
surplus_kwh=surplus,
|
|
356
|
+
eligible=True,
|
|
357
|
+
reversal=reversal,
|
|
358
|
+
nsc_rate=rate,
|
|
359
|
+
nsc_payment=gross_payment,
|
|
360
|
+
cash_out=cash_out,
|
|
361
|
+
paid_by_check=cash_out > CHECK_THRESHOLD,
|
|
362
|
+
estimated=estimated,
|
|
363
|
+
notes=tuple(notes),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def pge_true_up(entries: Sequence[LedgerEntry], pto_date: date, *, is_cca: bool) -> TrueUp:
|
|
368
|
+
"""Close one PG&E Relevant Period over ``entries``.
|
|
369
|
+
|
|
370
|
+
For a CCA account this settles nothing in cash: the bank carries forward and
|
|
371
|
+
PG&E pays no Net Surplus Compensation, per Special Condition 5.a. For a
|
|
372
|
+
bundled account the surplus test applies and PG&E's published rate is the
|
|
373
|
+
real rate rather than a stand-in.
|
|
374
|
+
"""
|
|
375
|
+
if not entries:
|
|
376
|
+
raise ConfigError("a relevant period needs at least one cycle")
|
|
377
|
+
ordered = sorted(entries, key=lambda e: e.period.start)
|
|
378
|
+
period = _span(ordered)
|
|
379
|
+
imported = sum(e.imported_kwh for e in ordered)
|
|
380
|
+
exported = sum(e.exported_kwh for e in ordered)
|
|
381
|
+
surplus = max(exported - imported, 0.0)
|
|
382
|
+
closing = ordered[-1].closing
|
|
383
|
+
anniversary = relevant_period_end(pto_date, ordered[-1].period.start)
|
|
384
|
+
|
|
385
|
+
notes = [f"Relevant Period measured against the PTO anniversary {anniversary.isoformat()}."]
|
|
386
|
+
if is_cca:
|
|
387
|
+
notes.append(
|
|
388
|
+
"PG&E pays no NSC on a CCA account (Schedule NBT, Special Condition "
|
|
389
|
+
"5.a); the generation and delivery credits carry forward instead, "
|
|
390
|
+
"and are forfeited only on leaving the NBT."
|
|
391
|
+
)
|
|
392
|
+
return TrueUp(
|
|
393
|
+
kind=TrueUpKind.PGE_RELEVANT_PERIOD,
|
|
394
|
+
period=period,
|
|
395
|
+
opening=ordered[0].opening,
|
|
396
|
+
closing=closing,
|
|
397
|
+
imported_kwh=imported,
|
|
398
|
+
exported_kwh=exported,
|
|
399
|
+
surplus_kwh=surplus,
|
|
400
|
+
eligible=False,
|
|
401
|
+
notes=tuple(notes),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if surplus <= 0.0:
|
|
405
|
+
notes.append("no Net Surplus Electricity; the balance rolls forward.")
|
|
406
|
+
return TrueUp(
|
|
407
|
+
kind=TrueUpKind.PGE_RELEVANT_PERIOD,
|
|
408
|
+
period=period,
|
|
409
|
+
opening=ordered[0].opening,
|
|
410
|
+
closing=closing,
|
|
411
|
+
imported_kwh=imported,
|
|
412
|
+
exported_kwh=exported,
|
|
413
|
+
surplus_kwh=0.0,
|
|
414
|
+
eligible=False,
|
|
415
|
+
notes=tuple(notes),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
rate, used = nsc_rate_estimate(period.end)
|
|
419
|
+
stale = used != f"{period.end:%Y-%m}"
|
|
420
|
+
if stale:
|
|
421
|
+
notes.append(
|
|
422
|
+
f"NSC rate {rate} is PG&E's rate for {used}, the latest published; the "
|
|
423
|
+
f"rate for {period.end:%Y-%m} is posted in that month."
|
|
424
|
+
)
|
|
425
|
+
# D.22-12-056: debit the surplus kWh at the average real-world retail export
|
|
426
|
+
# compensation rate, then credit the same kWh at the NSC rate. ACC Plus paid
|
|
427
|
+
# on surplus energy is explicitly not debited, so the bonus bucket is left
|
|
428
|
+
# alone here.
|
|
429
|
+
reversal = surplus * average_export_rate(ordered, CreditBucket.GENERATION)
|
|
430
|
+
absorbed = min(reversal, closing[CreditBucket.GENERATION])
|
|
431
|
+
closing = closing.with_bucket(
|
|
432
|
+
CreditBucket.GENERATION, closing[CreditBucket.GENERATION] - absorbed
|
|
433
|
+
)
|
|
434
|
+
gross_payment = surplus * rate
|
|
435
|
+
cash_out = max(gross_payment - (reversal - absorbed), 0.0)
|
|
436
|
+
notes.append("ACC Plus paid on surplus energy is not debited (Schedule NBT, SC 5.d).")
|
|
437
|
+
return TrueUp(
|
|
438
|
+
kind=TrueUpKind.PGE_RELEVANT_PERIOD,
|
|
439
|
+
period=period,
|
|
440
|
+
opening=ordered[0].opening,
|
|
441
|
+
closing=closing,
|
|
442
|
+
imported_kwh=imported,
|
|
443
|
+
exported_kwh=exported,
|
|
444
|
+
surplus_kwh=surplus,
|
|
445
|
+
eligible=True,
|
|
446
|
+
reversal=reversal,
|
|
447
|
+
nsc_rate=rate,
|
|
448
|
+
nsc_payment=gross_payment,
|
|
449
|
+
cash_out=cash_out,
|
|
450
|
+
paid_by_check=cash_out > CHECK_THRESHOLD,
|
|
451
|
+
estimated=stale,
|
|
452
|
+
notes=tuple(notes),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def run_true_ups(
|
|
457
|
+
entries: Iterable[LedgerEntry],
|
|
458
|
+
*,
|
|
459
|
+
pto_date: date | None = None,
|
|
460
|
+
is_cca: bool = True,
|
|
461
|
+
nsc_rate: float | None = None,
|
|
462
|
+
) -> list[TrueUp]:
|
|
463
|
+
"""Every annual event a run of cycles crosses, in date order.
|
|
464
|
+
|
|
465
|
+
Emits one MCE cash-out per completed March-April year and one PG&E true-up
|
|
466
|
+
per completed PTO anniversary. An incomplete trailing period is not emitted:
|
|
467
|
+
a year that has not closed has not been trued up, and reporting it as though
|
|
468
|
+
it had would invite reading a partial surplus as a settled one.
|
|
469
|
+
"""
|
|
470
|
+
ordered = sorted(entries, key=lambda e: e.period.start)
|
|
471
|
+
if not ordered:
|
|
472
|
+
return []
|
|
473
|
+
|
|
474
|
+
out: list[TrueUp] = []
|
|
475
|
+
for group in cash_out_periods(ordered):
|
|
476
|
+
last = group[-1]
|
|
477
|
+
if (
|
|
478
|
+
last.period.start.month == CASH_OUT_START_MONTH
|
|
479
|
+
and last.period.end.month == CASH_OUT_END_MONTH
|
|
480
|
+
):
|
|
481
|
+
out.append(mce_cash_out(group, nsc_rate))
|
|
482
|
+
|
|
483
|
+
if pto_date is not None:
|
|
484
|
+
# Close the period *including* the cycle that reaches the anniversary,
|
|
485
|
+
# the way the March-April cycle closes an MCE year rather than opening
|
|
486
|
+
# the next one. An anniversary falls mid-cycle, and the true-up lands on
|
|
487
|
+
# the statement for the cycle containing it; ending the period at the
|
|
488
|
+
# cycle before would drop a month of energy and credits out of it.
|
|
489
|
+
window: list[LedgerEntry] = []
|
|
490
|
+
boundary = relevant_period_end(pto_date, ordered[0].period.start)
|
|
491
|
+
for entry in ordered:
|
|
492
|
+
window.append(entry)
|
|
493
|
+
if entry.period.end >= boundary:
|
|
494
|
+
out.append(pge_true_up(window, pto_date, is_cca=is_cca))
|
|
495
|
+
window = []
|
|
496
|
+
boundary = relevant_period_end(pto_date, entry.period.end)
|
|
497
|
+
|
|
498
|
+
return sorted(out, key=lambda t: (t.period.end, str(t.kind)))
|