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
tariffkit/py.typed
ADDED
|
File without changes
|
tariffkit/secrets.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Credentials stored in the operating system's keyring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import Final, Protocol, cast
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigError
|
|
11
|
+
|
|
12
|
+
SERVICE: Final = "tariffkit"
|
|
13
|
+
_CREDENTIAL_SET = re.compile(r"^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$")
|
|
14
|
+
SECRET_NAMES: Final = (
|
|
15
|
+
"home_assistant.token",
|
|
16
|
+
"influxdb.token",
|
|
17
|
+
"mqtt.password",
|
|
18
|
+
"mqtt.username",
|
|
19
|
+
"pge.account_urn",
|
|
20
|
+
"pge.browser_cookie",
|
|
21
|
+
"pge.password",
|
|
22
|
+
"pge.username",
|
|
23
|
+
"pge.validation_cookie",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _KeyringErrors(Protocol):
|
|
28
|
+
KeyringError: type[Exception]
|
|
29
|
+
NoKeyringError: type[Exception]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Keyring(Protocol):
|
|
33
|
+
errors: _KeyringErrors
|
|
34
|
+
|
|
35
|
+
def get_password(self, service: str, name: str) -> str | None: ...
|
|
36
|
+
|
|
37
|
+
def set_password(self, service: str, name: str, value: str) -> None: ...
|
|
38
|
+
|
|
39
|
+
def delete_password(self, service: str, name: str) -> None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _keyring() -> _Keyring | None:
|
|
43
|
+
try:
|
|
44
|
+
module = importlib.import_module("keyring")
|
|
45
|
+
except ImportError:
|
|
46
|
+
return None
|
|
47
|
+
return cast(_Keyring, module)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_secret(name: str) -> str | None:
|
|
51
|
+
"""Return a named secret, or ``None`` when keyring support is not installed."""
|
|
52
|
+
_validate_name(name)
|
|
53
|
+
if os.environ.get("TARIFFKIT_DISABLE_KEYRING") == "1":
|
|
54
|
+
return None
|
|
55
|
+
keyring = _keyring()
|
|
56
|
+
if keyring is None:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return keyring.get_password(SERVICE, name)
|
|
60
|
+
except keyring.errors.NoKeyringError:
|
|
61
|
+
# Headless containers commonly have the package but no OS secret
|
|
62
|
+
# service. That means "no keyring source", not a failed credential read;
|
|
63
|
+
# environment injection remains available there.
|
|
64
|
+
return None
|
|
65
|
+
except keyring.errors.KeyringError as exc:
|
|
66
|
+
raise ConfigError(f"could not read {name!r} from the operating-system keyring") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_secret(name: str, value: str) -> None:
|
|
70
|
+
"""Store a non-empty secret without exposing it in process arguments."""
|
|
71
|
+
_validate_name(name)
|
|
72
|
+
if not value:
|
|
73
|
+
raise ConfigError("secret value must not be empty")
|
|
74
|
+
keyring = _require_keyring()
|
|
75
|
+
try:
|
|
76
|
+
keyring.set_password(SERVICE, name, value)
|
|
77
|
+
except keyring.errors.KeyringError as exc:
|
|
78
|
+
raise ConfigError(f"could not store {name!r} in the operating-system keyring") from exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def delete_secret(name: str) -> None:
|
|
82
|
+
"""Delete a named secret, raising when the keyring operation fails."""
|
|
83
|
+
_validate_name(name)
|
|
84
|
+
keyring = _require_keyring()
|
|
85
|
+
try:
|
|
86
|
+
keyring.delete_password(SERVICE, name)
|
|
87
|
+
except keyring.errors.KeyringError as exc:
|
|
88
|
+
raise ConfigError(f"could not delete {name!r} from the operating-system keyring") from exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def configured_secrets() -> tuple[str, ...]:
|
|
92
|
+
"""Names that are present, without ever returning their values."""
|
|
93
|
+
return tuple(name for name in SECRET_NAMES if get_secret(name) is not None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_named_secret(credential_set: str, name: str) -> str | None:
|
|
97
|
+
"""Read a secret from a named keyring set without exposing its value."""
|
|
98
|
+
_validate_credential_set(credential_set)
|
|
99
|
+
_validate_name(name)
|
|
100
|
+
if os.environ.get("TARIFFKIT_DISABLE_KEYRING") == "1":
|
|
101
|
+
return None
|
|
102
|
+
keyring = _keyring()
|
|
103
|
+
if keyring is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return keyring.get_password(f"{SERVICE}:credential:{credential_set}", name)
|
|
107
|
+
except keyring.errors.NoKeyringError:
|
|
108
|
+
return None
|
|
109
|
+
except keyring.errors.KeyringError as exc:
|
|
110
|
+
raise ConfigError(
|
|
111
|
+
f"could not read {name!r} from credential set {credential_set!r}"
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_named_secret(credential_set: str, name: str, value: str) -> None:
|
|
116
|
+
"""Store a secret in a named keyring set."""
|
|
117
|
+
_validate_credential_set(credential_set)
|
|
118
|
+
_validate_name(name)
|
|
119
|
+
if not value:
|
|
120
|
+
raise ConfigError("secret value must not be empty")
|
|
121
|
+
keyring = _require_keyring()
|
|
122
|
+
try:
|
|
123
|
+
keyring.set_password(f"{SERVICE}:credential:{credential_set}", name, value)
|
|
124
|
+
except keyring.errors.KeyringError as exc:
|
|
125
|
+
raise ConfigError(f"could not store {name!r} in credential set {credential_set!r}") from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete_named_secret(credential_set: str, name: str) -> None:
|
|
129
|
+
"""Delete a secret from a named keyring set."""
|
|
130
|
+
_validate_credential_set(credential_set)
|
|
131
|
+
_validate_name(name)
|
|
132
|
+
keyring = _require_keyring()
|
|
133
|
+
try:
|
|
134
|
+
keyring.delete_password(f"{SERVICE}:credential:{credential_set}", name)
|
|
135
|
+
except keyring.errors.KeyringError as exc:
|
|
136
|
+
raise ConfigError(
|
|
137
|
+
f"could not delete {name!r} from credential set {credential_set!r}"
|
|
138
|
+
) from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def configured_named_secrets(credential_set: str) -> tuple[str, ...]:
|
|
142
|
+
"""List names present in a named credential set, without values."""
|
|
143
|
+
return tuple(
|
|
144
|
+
name for name in SECRET_NAMES if get_named_secret(credential_set, name) is not None
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _require_keyring() -> _Keyring:
|
|
149
|
+
keyring = _keyring()
|
|
150
|
+
if keyring is None:
|
|
151
|
+
raise ConfigError(
|
|
152
|
+
"credential storage requires the 'secrets' extra: pip install 'tariffkit[secrets]'"
|
|
153
|
+
)
|
|
154
|
+
return keyring
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_name(name: str) -> None:
|
|
158
|
+
if name not in SECRET_NAMES:
|
|
159
|
+
raise ConfigError(f"unknown secret {name!r}; choose one of {', '.join(SECRET_NAMES)}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validate_credential_set(name: str) -> None:
|
|
163
|
+
if _CREDENTIAL_SET.fullmatch(name) is None:
|
|
164
|
+
raise ConfigError("credential set must be a lowercase safe name")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Interval-data sources.
|
|
2
|
+
|
|
3
|
+
Everything here turns some external record of metered energy into
|
|
4
|
+
``IntervalReading`` objects for :mod:`tariffkit.billing`. Sources may pull in
|
|
5
|
+
dependencies -- a WebSocket client, an HTTP client -- which is why they live
|
|
6
|
+
outside the billing package, which stays stdlib-only.
|
|
7
|
+
|
|
8
|
+
Three sources, with different strengths:
|
|
9
|
+
|
|
10
|
+
============= ========================= ============================ ===========
|
|
11
|
+
source reads totals history
|
|
12
|
+
============= ========================= ============================ ===========
|
|
13
|
+
greenbutton PG&E's Green Button CSV 2 dp per interval, ~2% low as supplied
|
|
14
|
+
homeassistant long-term statistics pre-aggregated buckets ~5 months
|
|
15
|
+
influx raw counter samples exact (endpoint difference) ~14 months
|
|
16
|
+
============= ========================= ============================ ===========
|
|
17
|
+
|
|
18
|
+
Totals are not the whole story. Both live sources see the same counter, so they
|
|
19
|
+
agree on how much energy moved; they disagree on *when*, and time-of-use pricing
|
|
20
|
+
cares. Home Assistant credits an advance to the bucket holding the later sample,
|
|
21
|
+
which biases energy forward over every boundary it spans; ``influx`` works from
|
|
22
|
+
the raw samples and can spread it pro rata instead. Measured against a real
|
|
23
|
+
statement that is worth more than the totals are -- see
|
|
24
|
+
:mod:`tariffkit.sources.influx`. Green Button is the opposite trade: its totals
|
|
25
|
+
are the worst of the three and its timing the best, because it is the utility's
|
|
26
|
+
own record at true fifteen-minute metering.
|
|
27
|
+
|
|
28
|
+
``greenbutton`` is stdlib-only and so needs no extra, unlike its neighbours. It
|
|
29
|
+
lives here anyway: what a module reads is a better organising principle than
|
|
30
|
+
what it happens to import, and putting the one file source somewhere else made
|
|
31
|
+
it hard to find.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from .greenbutton import GreenButtonLayout, read_green_button
|
|
35
|
+
from .homeassistant import (
|
|
36
|
+
DEFAULT_EXPORT_ENTITY,
|
|
37
|
+
DEFAULT_IMPORT_ENTITY,
|
|
38
|
+
HaSettings,
|
|
39
|
+
describe_resolution,
|
|
40
|
+
load_dotenv,
|
|
41
|
+
read_statistics,
|
|
42
|
+
read_statistics_async,
|
|
43
|
+
)
|
|
44
|
+
from .influx import InfluxSettings, monotonic, read_counters
|
|
45
|
+
from .pge import (
|
|
46
|
+
PgeSession,
|
|
47
|
+
PgeSettings,
|
|
48
|
+
PortalError,
|
|
49
|
+
parse_green_button,
|
|
50
|
+
read_green_button_download,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"DEFAULT_EXPORT_ENTITY",
|
|
55
|
+
"DEFAULT_IMPORT_ENTITY",
|
|
56
|
+
"GreenButtonLayout",
|
|
57
|
+
"HaSettings",
|
|
58
|
+
"InfluxSettings",
|
|
59
|
+
"PgeSession",
|
|
60
|
+
"PgeSettings",
|
|
61
|
+
"PortalError",
|
|
62
|
+
"describe_resolution",
|
|
63
|
+
"load_dotenv",
|
|
64
|
+
"monotonic",
|
|
65
|
+
"parse_green_button",
|
|
66
|
+
"read_counters",
|
|
67
|
+
"read_green_button",
|
|
68
|
+
"read_green_button_download",
|
|
69
|
+
"read_statistics",
|
|
70
|
+
"read_statistics_async",
|
|
71
|
+
]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Interval readings from a Green Button CSV export.
|
|
2
|
+
|
|
3
|
+
Green Button is the industry format for handing a customer their own meter data,
|
|
4
|
+
and PG&E exposes it as "Download my data" on the usage page. What comes back is
|
|
5
|
+
a CSV named like ``pge_electric_usage_interval_data_<account>_<dates>.csv``,
|
|
6
|
+
generally at fifteen-minute resolution.
|
|
7
|
+
|
|
8
|
+
**This reads the CSV form, not the XML one.** Green Button also has an ESPI/XML
|
|
9
|
+
serialisation, which is a different parser and is not implemented here.
|
|
10
|
+
|
|
11
|
+
It is Green Button first rather than Green Button only. The preamble skipping in
|
|
12
|
+
:func:`_skip_preamble` exists because PG&E's export opens with account metadata,
|
|
13
|
+
and the default column names are the ones PG&E emits -- but every column is
|
|
14
|
+
configurable through :class:`GreenButtonLayout`, so an inverter log or a Home
|
|
15
|
+
Assistant statistics dump with different headers works too.
|
|
16
|
+
|
|
17
|
+
The one thing to know before trusting it: **PG&E rounds every interval to two
|
|
18
|
+
decimals before exporting**, and on a low-import month that loses about 2% of the
|
|
19
|
+
total. Reading the meter directly through :mod:`tariffkit.sources.influx` or
|
|
20
|
+
:mod:`tariffkit.sources.homeassistant` avoids it. The Green Button file's
|
|
21
|
+
advantage is that it is the utility's own record of what it billed, at true
|
|
22
|
+
fifteen-minute metering, which makes it the better source for how energy divided
|
|
23
|
+
across time-of-use periods even where its totals are worse.
|
|
24
|
+
|
|
25
|
+
Stdlib-only, unlike its neighbours in this package: there is no download step to
|
|
26
|
+
authenticate and no client library involved, just a file the user already has.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import csv
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Iterator, Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import UTC, datetime, timedelta, tzinfo
|
|
36
|
+
from itertools import pairwise
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import IO, NamedTuple
|
|
39
|
+
|
|
40
|
+
from ..billing.models import IntervalReading
|
|
41
|
+
from ..errors import DataError
|
|
42
|
+
from ..timeutil import PACIFIC
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Row(NamedTuple):
|
|
46
|
+
"""One parsed CSV row, before interval length is known."""
|
|
47
|
+
|
|
48
|
+
start: datetime
|
|
49
|
+
imported: float | None
|
|
50
|
+
exported: float | None
|
|
51
|
+
net: float | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: Column names seen in the wild, tried in order when none is configured.
|
|
55
|
+
START_CANDIDATES = (
|
|
56
|
+
"start",
|
|
57
|
+
"start_time",
|
|
58
|
+
"timestamp",
|
|
59
|
+
"datetime",
|
|
60
|
+
"date_time",
|
|
61
|
+
"time",
|
|
62
|
+
"last_reset",
|
|
63
|
+
)
|
|
64
|
+
IMPORT_CANDIDATES = ("imported", "import", "import_kwh", "delivered", "consumption", "usage", "kwh")
|
|
65
|
+
EXPORT_CANDIDATES = ("exported", "export", "export_kwh", "received", "surplus", "production")
|
|
66
|
+
NET_CANDIDATES = ("net", "net_kwh", "net_usage")
|
|
67
|
+
#: Split date/time pairs, as PG&E's interval export uses. A matching pair takes
|
|
68
|
+
#: precedence over a single timestamp column, because PG&E's time column is named
|
|
69
|
+
#: "START TIME" and would otherwise be mistaken for a whole timestamp. A date
|
|
70
|
+
#: column with no time column beside it falls back to being read as one.
|
|
71
|
+
DATE_CANDIDATES = ("date", "usage_date", "read_date", "interval_date")
|
|
72
|
+
TIME_CANDIDATES = ("start_time", "time", "interval_start_time", "hour")
|
|
73
|
+
#: Trailing unit spellings stripped when matching a header against the candidate
|
|
74
|
+
#: lists, so ``"USAGE (kWh)"`` matches the ``usage`` candidate.
|
|
75
|
+
UNIT_SUFFIXES = ("_kwh", "_kw_h", "_wh", "_kilowatt_hours")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(name: str) -> str:
|
|
79
|
+
"""Fold case, punctuation, and units so header spellings converge.
|
|
80
|
+
|
|
81
|
+
``"IMPORT (kWh)"`` and ``"import_kwh"`` are the same column; so are
|
|
82
|
+
``"START TIME"`` and ``"start_time"``. Matching on the raw string means every
|
|
83
|
+
new export format needs its own candidate entry.
|
|
84
|
+
"""
|
|
85
|
+
return re.sub(r"[^0-9a-z]+", "_", name.strip().lower()).strip("_")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class GreenButtonLayout:
|
|
90
|
+
"""Which columns hold what.
|
|
91
|
+
|
|
92
|
+
Leave fields as ``None`` to auto-detect from the header.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
start: str | None = None
|
|
96
|
+
#: Split date/time pair. Setting both takes precedence over ``start``; set
|
|
97
|
+
#: neither and a pair is still auto-detected ahead of a single column.
|
|
98
|
+
date: str | None = None
|
|
99
|
+
time: str | None = None
|
|
100
|
+
imported: str | None = None
|
|
101
|
+
exported: str | None = None
|
|
102
|
+
#: Signed column, positive meaning import. Alternative to imported/exported.
|
|
103
|
+
net: str | None = None
|
|
104
|
+
#: Interval length. Inferred from the first two rows when omitted.
|
|
105
|
+
duration: timedelta | None = None
|
|
106
|
+
#: Applied to timestamps that carry no UTC offset.
|
|
107
|
+
assume_tz: tzinfo = PACIFIC
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pick(header: list[str], configured: str | None, candidates: tuple[str, ...]) -> str | None:
|
|
111
|
+
if configured is not None:
|
|
112
|
+
if configured not in header:
|
|
113
|
+
raise DataError(f"column {configured!r} not in header: {header}")
|
|
114
|
+
return configured
|
|
115
|
+
# Exact header names first, then unit-stripped aliases, so an alias can never
|
|
116
|
+
# shadow a column that genuinely carries the candidate's name. Registering
|
|
117
|
+
# both in one pass also made the answer depend on column order: a file with
|
|
118
|
+
# "IMPORT (kWh)" before "import" resolved differently from the same two the
|
|
119
|
+
# other way round.
|
|
120
|
+
normalized: dict[str, str] = {}
|
|
121
|
+
for name in header:
|
|
122
|
+
if name:
|
|
123
|
+
normalized.setdefault(_normalize(name), name)
|
|
124
|
+
# A candidate list then does not need an entry per unit spelling. PG&E alone
|
|
125
|
+
# ships "IMPORT (kWh)" on an exporting account and "USAGE (kWh)" on one
|
|
126
|
+
# without.
|
|
127
|
+
for name in header:
|
|
128
|
+
if not name:
|
|
129
|
+
continue
|
|
130
|
+
key = _normalize(name)
|
|
131
|
+
for suffix in UNIT_SUFFIXES:
|
|
132
|
+
if key.endswith(suffix) and key != suffix.lstrip("_"):
|
|
133
|
+
normalized.setdefault(key[: -len(suffix)], name)
|
|
134
|
+
for candidate in candidates:
|
|
135
|
+
if candidate in normalized:
|
|
136
|
+
return normalized[candidate]
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _skip_preamble(handle: IO[str], layout: GreenButtonLayout) -> Iterator[str]:
|
|
141
|
+
"""Yield lines from the real header row onward.
|
|
142
|
+
|
|
143
|
+
PG&E's interval export opens with account metadata (name, address, account
|
|
144
|
+
and service numbers) and a blank line before the column header, so feeding
|
|
145
|
+
the file straight to ``DictReader`` picks up ``"Name,EMMANUEL ..."`` as the
|
|
146
|
+
header. Scan for the first row carrying a recognisable timestamp column
|
|
147
|
+
instead of assuming a fixed offset, since the preamble's length is not
|
|
148
|
+
documented and has changed before.
|
|
149
|
+
|
|
150
|
+
Configured column names count as recognisable too, so a preamble does not
|
|
151
|
+
stop ``GreenButtonLayout(start="when")`` from working on a file whose header this
|
|
152
|
+
module would not otherwise know.
|
|
153
|
+
"""
|
|
154
|
+
wanted = set(START_CANDIDATES) | set(DATE_CANDIDATES)
|
|
155
|
+
wanted |= {_normalize(name) for name in (layout.start, layout.date) if name}
|
|
156
|
+
lines = handle.read().splitlines()
|
|
157
|
+
for index, row in enumerate(csv.reader(lines)):
|
|
158
|
+
if any(_normalize(cell) in wanted for cell in row):
|
|
159
|
+
yield from lines[index:]
|
|
160
|
+
return
|
|
161
|
+
# No recognisable header anywhere. Hand back the file unchanged rather than
|
|
162
|
+
# guessing which row was meant: the caller gets the "no timestamp column"
|
|
163
|
+
# error naming whatever the first row held, which for a file with a preamble
|
|
164
|
+
# is the preamble. Nothing here can do better without inventing a rule for
|
|
165
|
+
# where the header starts, and a wrong guess would mis-parse silently.
|
|
166
|
+
yield from lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_timestamp(raw: str, assume_tz: tzinfo) -> datetime:
|
|
170
|
+
text = raw.strip().replace("Z", "+00:00")
|
|
171
|
+
try:
|
|
172
|
+
moment = datetime.fromisoformat(text)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise DataError(f"could not parse timestamp {raw!r} as ISO 8601") from exc
|
|
175
|
+
if moment.tzinfo is None:
|
|
176
|
+
# Naive timestamps are ambiguous across DST. Assume the configured zone
|
|
177
|
+
# and say so, rather than silently treating them as UTC.
|
|
178
|
+
return moment.replace(tzinfo=assume_tz)
|
|
179
|
+
return moment
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_float(raw: str | None) -> float:
|
|
183
|
+
if raw is None:
|
|
184
|
+
return 0.0
|
|
185
|
+
text = raw.strip().replace("$", "").replace(",", "")
|
|
186
|
+
if not text:
|
|
187
|
+
return 0.0
|
|
188
|
+
try:
|
|
189
|
+
return float(text)
|
|
190
|
+
except ValueError as exc:
|
|
191
|
+
raise DataError(f"could not parse {raw!r} as a number") from exc
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def read_green_button(
|
|
195
|
+
source: str | Path | IO[str], layout: GreenButtonLayout | None = None
|
|
196
|
+
) -> list[IntervalReading]:
|
|
197
|
+
"""Read interval readings from a CSV file or open stream."""
|
|
198
|
+
layout = layout or GreenButtonLayout()
|
|
199
|
+
if isinstance(source, str | Path):
|
|
200
|
+
with Path(source).open(encoding="utf-8-sig", newline="") as handle:
|
|
201
|
+
return list(_read(handle, layout))
|
|
202
|
+
return list(_read(source, layout))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _read(handle: IO[str], layout: GreenButtonLayout) -> Iterator[IntervalReading]:
|
|
206
|
+
reader = csv.DictReader(_skip_preamble(handle, layout))
|
|
207
|
+
header = reader.fieldnames
|
|
208
|
+
if not header:
|
|
209
|
+
raise DataError("CSV has no header row")
|
|
210
|
+
header = list(header)
|
|
211
|
+
|
|
212
|
+
date_col = _pick(header, layout.date, DATE_CANDIDATES)
|
|
213
|
+
time_col = _pick(header, layout.time, TIME_CANDIDATES)
|
|
214
|
+
start_col = _pick(header, layout.start, START_CANDIDATES)
|
|
215
|
+
|
|
216
|
+
# A split date/time pair wins over a single column, because PG&E names its
|
|
217
|
+
# time column "START TIME" -- which looks like a full timestamp column but
|
|
218
|
+
# holds "00:15". Falling back to a lone date column keeps files that put a
|
|
219
|
+
# complete ISO value under "date" working.
|
|
220
|
+
paired = date_col is not None and time_col is not None and date_col != time_col
|
|
221
|
+
if not paired:
|
|
222
|
+
start_col = start_col or date_col
|
|
223
|
+
date_col = time_col = None
|
|
224
|
+
if start_col is None:
|
|
225
|
+
raise DataError(
|
|
226
|
+
f"no timestamp column found in {header}; set GreenButtonLayout(start=...)"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
import_col = _pick(header, layout.imported, IMPORT_CANDIDATES)
|
|
230
|
+
export_col = _pick(header, layout.exported, EXPORT_CANDIDATES)
|
|
231
|
+
net_col = _pick(header, layout.net, NET_CANDIDATES)
|
|
232
|
+
|
|
233
|
+
if import_col is None and export_col is None and net_col is None:
|
|
234
|
+
raise DataError(
|
|
235
|
+
f"no energy column found in {header}; set GreenButtonLayout(imported=...), "
|
|
236
|
+
f"(exported=...), or (net=...)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
rows: list[_Row] = []
|
|
240
|
+
for row in reader:
|
|
241
|
+
if date_col is not None and time_col is not None:
|
|
242
|
+
day = (row.get(date_col) or "").strip()
|
|
243
|
+
clock = (row.get(time_col) or "").strip()
|
|
244
|
+
if not day or not clock:
|
|
245
|
+
continue
|
|
246
|
+
start = _parse_timestamp(f"{day}T{clock}", layout.assume_tz)
|
|
247
|
+
else:
|
|
248
|
+
assert start_col is not None
|
|
249
|
+
if not (row.get(start_col) or "").strip():
|
|
250
|
+
continue
|
|
251
|
+
start = _parse_timestamp(row[start_col], layout.assume_tz)
|
|
252
|
+
if net_col is not None and import_col is None and export_col is None:
|
|
253
|
+
rows.append(_Row(start, None, None, _parse_float(row.get(net_col))))
|
|
254
|
+
else:
|
|
255
|
+
rows.append(
|
|
256
|
+
_Row(
|
|
257
|
+
start,
|
|
258
|
+
_parse_float(row.get(import_col) if import_col else None),
|
|
259
|
+
_parse_float(row.get(export_col) if export_col else None),
|
|
260
|
+
None,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not rows:
|
|
265
|
+
raise DataError("CSV contained no data rows")
|
|
266
|
+
|
|
267
|
+
rows = _resolve_repeated_hour(rows)
|
|
268
|
+
duration = layout.duration or _infer_duration([r.start for r in rows])
|
|
269
|
+
|
|
270
|
+
for start, imported, exported, net in rows:
|
|
271
|
+
if net is not None:
|
|
272
|
+
yield IntervalReading.from_net(start, net, duration)
|
|
273
|
+
else:
|
|
274
|
+
yield IntervalReading(
|
|
275
|
+
start, imported=imported or 0.0, exported=exported or 0.0, duration=duration
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _resolve_repeated_hour(rows: list[_Row]) -> list[_Row]:
|
|
280
|
+
"""Mark the second pass through a repeated local hour as ``fold=1``.
|
|
281
|
+
|
|
282
|
+
A naive timestamp on the autumn transition is genuinely ambiguous: 01:00
|
|
283
|
+
happens twice. ``zoneinfo`` resolves both to ``fold=0``, the earlier PDT
|
|
284
|
+
hour, so an hour of readings prices at the wrong export rate -- PG&E labels
|
|
285
|
+
the repeat HS2 -- and coverage reports the file as overlapping itself.
|
|
286
|
+
|
|
287
|
+
Meter exports are chronological, which supplies the missing bit: if a row's
|
|
288
|
+
instant does not advance, and setting ``fold=1`` makes it advance, then it
|
|
289
|
+
belongs to the second pass. Rows carrying an explicit offset are already
|
|
290
|
+
unambiguous, and for them ``fold`` does not move the instant, so this is a
|
|
291
|
+
no-op rather than a special case.
|
|
292
|
+
"""
|
|
293
|
+
resolved: list[_Row] = []
|
|
294
|
+
previous: datetime | None = None
|
|
295
|
+
for row in rows:
|
|
296
|
+
start = row.start
|
|
297
|
+
if previous is not None and start.astimezone(UTC) <= previous.astimezone(UTC):
|
|
298
|
+
shifted = start.replace(fold=1)
|
|
299
|
+
if shifted.astimezone(UTC) > previous.astimezone(UTC):
|
|
300
|
+
start = shifted
|
|
301
|
+
resolved.append(row._replace(start=start))
|
|
302
|
+
previous = start
|
|
303
|
+
return resolved
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _infer_duration(timestamps: Sequence[datetime]) -> timedelta:
|
|
307
|
+
"""Infer interval length from the two closest consecutive timestamps.
|
|
308
|
+
|
|
309
|
+
The minimum gap rather than the first gap, so a leading DST jump or a single
|
|
310
|
+
missing row does not stretch every interval in the file.
|
|
311
|
+
"""
|
|
312
|
+
if len(timestamps) < 2:
|
|
313
|
+
return timedelta(hours=1)
|
|
314
|
+
starts = sorted(timestamps)
|
|
315
|
+
gaps = [later - earlier for earlier, later in pairwise(starts) if later > earlier]
|
|
316
|
+
if not gaps:
|
|
317
|
+
return timedelta(hours=1)
|
|
318
|
+
return min(gaps)
|