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.
Files changed (83) hide show
  1. tariffkit/__init__.py +52 -0
  2. tariffkit/account/__init__.py +41 -0
  3. tariffkit/account/cli.py +493 -0
  4. tariffkit/account/errors.py +25 -0
  5. tariffkit/account/model.py +641 -0
  6. tariffkit/account/rates.py +61 -0
  7. tariffkit/account/repository.py +329 -0
  8. tariffkit/billing/__init__.py +56 -0
  9. tariffkit/billing/engine.py +505 -0
  10. tariffkit/billing/ledger.py +365 -0
  11. tariffkit/billing/models.py +274 -0
  12. tariffkit/billing/netting.py +137 -0
  13. tariffkit/billing/trueup.py +498 -0
  14. tariffkit/cca.py +122 -0
  15. tariffkit/cli.py +809 -0
  16. tariffkit/config.py +296 -0
  17. tariffkit/data/__init__.py +40 -0
  18. tariffkit/data/cca/mce/2023-01-01.toml +61 -0
  19. tariffkit/data/cca/mce/2026-04-01.toml +152 -0
  20. tariffkit/data/export/pge/acc_plus/2023-04-15.toml +43 -0
  21. tariffkit/data/export/pge/nbt00.json.gz +0 -0
  22. tariffkit/data/export/pge/nbt23.json.gz +0 -0
  23. tariffkit/data/export/pge/nbt24.json.gz +0 -0
  24. tariffkit/data/export/pge/nbt25.json.gz +0 -0
  25. tariffkit/data/export/pge/nbt26.json.gz +0 -0
  26. tariffkit/data/holidays.toml +36 -0
  27. tariffkit/data/manifest.json +56 -0
  28. tariffkit/data/nsc/pge.toml +57 -0
  29. tariffkit/data/tariff/pge/eelec/2025-01-01.toml +151 -0
  30. tariffkit/data/tariff/pge/eelec/2025-03-01.toml +150 -0
  31. tariffkit/data/tariff/pge/eelec/2025-09-01.toml +150 -0
  32. tariffkit/data/tariff/pge/eelec/2026-01-01.toml +153 -0
  33. tariffkit/data/tariff/pge/eelec/2026-03-01.toml +157 -0
  34. tariffkit/data/tariff/pge/etouc/2025-01-01.toml +222 -0
  35. tariffkit/data/tariff/pge/etouc/2025-03-01.toml +221 -0
  36. tariffkit/data/tariff/pge/etouc/2025-09-01.toml +221 -0
  37. tariffkit/data/tariff/pge/etouc/2026-01-01.toml +224 -0
  38. tariffkit/data/tariff/pge/etouc/2026-03-01.toml +231 -0
  39. tariffkit/data/tariff/pge/ev2a/2025-01-01.toml +144 -0
  40. tariffkit/data/tariff/pge/ev2a/2025-03-01.toml +143 -0
  41. tariffkit/data/tariff/pge/ev2a/2025-09-01.toml +143 -0
  42. tariffkit/data/tariff/pge/ev2a/2026-01-01.toml +146 -0
  43. tariffkit/data/tariff/pge/ev2a/2026-03-01.toml +153 -0
  44. tariffkit/data/tax/ca_energy_resources/2025-01-01.toml +27 -0
  45. tariffkit/data/tax/ca_energy_resources/2026-01-01.toml +27 -0
  46. tariffkit/data/versioned.py +118 -0
  47. tariffkit/engine.py +82 -0
  48. tariffkit/errors.py +19 -0
  49. tariffkit/export/__init__.py +5 -0
  50. tariffkit/export/nbt.py +207 -0
  51. tariffkit/interop/__init__.py +21 -0
  52. tariffkit/interop/emhass.py +79 -0
  53. tariffkit/interop/predbat.py +102 -0
  54. tariffkit/interop/slots.py +63 -0
  55. tariffkit/models.py +164 -0
  56. tariffkit/mqtt/__init__.py +6 -0
  57. tariffkit/mqtt/discovery.py +84 -0
  58. tariffkit/mqtt/publisher.py +305 -0
  59. tariffkit/providers/__init__.py +1 -0
  60. tariffkit/providers/pge/__init__.py +33 -0
  61. tariffkit/providers/pge/reconcile.py +828 -0
  62. tariffkit/providers/pge/statements/__init__.py +26 -0
  63. tariffkit/providers/pge/statements/errors.py +20 -0
  64. tariffkit/providers/pge/statements/model.py +320 -0
  65. tariffkit/providers/pge/statements/ocr.py +193 -0
  66. tariffkit/providers/pge/statements/parse.py +813 -0
  67. tariffkit/py.typed +0 -0
  68. tariffkit/secrets.py +164 -0
  69. tariffkit/sources/__init__.py +71 -0
  70. tariffkit/sources/greenbutton.py +318 -0
  71. tariffkit/sources/homeassistant.py +342 -0
  72. tariffkit/sources/influx.py +359 -0
  73. tariffkit/sources/pge.py +1153 -0
  74. tariffkit/tariff/__init__.py +5 -0
  75. tariffkit/tariff/retail.py +271 -0
  76. tariffkit/timeutil.py +120 -0
  77. tariffkit/web/__init__.py +5 -0
  78. tariffkit/web/app.py +220 -0
  79. tariffkit-0.2.0.dist-info/METADATA +260 -0
  80. tariffkit-0.2.0.dist-info/RECORD +83 -0
  81. tariffkit-0.2.0.dist-info/WHEEL +4 -0
  82. tariffkit-0.2.0.dist-info/entry_points.txt +2 -0
  83. 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)