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
@@ -0,0 +1,505 @@
1
+ """Compute a bill from interval readings.
2
+
3
+ Pure: takes metered energy and a rate engine, returns charges. Nothing here
4
+ knows where the readings came from.
5
+
6
+ Scope note -- this computes a single period's charges. It deliberately does not
7
+ model export-credit *balances*: carryover between months, the annual true-up, or
8
+ Net Surplus Compensation. Those are stateful across a whole program year and
9
+ belong in a ledger built on top of this.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable, Sequence
15
+ from dataclasses import dataclass
16
+ from datetime import date, datetime, timedelta
17
+ from itertools import pairwise
18
+
19
+ from ..cca import load_rate_card
20
+ from ..config import Config
21
+ from ..engine import RateEngine
22
+ from ..errors import DataError
23
+ from ..models import ImportPrice, Season, TouPeriod
24
+ from ..timeutil import PACIFIC, hour_floor, to_pacific
25
+ from .models import Bill, BillingPeriod, IntervalReading, UsageBucket
26
+ from .netting import check_coverage
27
+
28
+ #: Components billed on gross import regardless of what a site exported. Kept
29
+ #: for reference: with per-interval netting these already apply to every
30
+ #: imported kWh, so no separate handling is needed. It becomes load-bearing only
31
+ #: if a monthly-netting mode is ever added.
32
+ NON_BYPASSABLE = (
33
+ "public_purpose_programs",
34
+ "wildfire_fund_charge",
35
+ "competition_transition_charges",
36
+ "nuclear_decommissioning",
37
+ "energy_cost_recovery",
38
+ )
39
+
40
+
41
+ class BillEngine:
42
+ """Turns interval readings into a decomposed bill."""
43
+
44
+ def __init__(self, rates: RateEngine | None = None) -> None:
45
+ self.rates = rates or RateEngine()
46
+
47
+ def _compensated(self, moment: datetime) -> bool:
48
+ """Whether an export at ``moment`` earns anything.
49
+
50
+ Net Billing compensation runs from Permission To Operate. Energy leaving
51
+ the house before then is real -- the meter records it -- but the tariff
52
+ grants nothing for it, so crediting it would invent money.
53
+ """
54
+ pto = self.rates.config.pto_date
55
+ return pto is None or moment.date() >= pto
56
+
57
+ def compute(
58
+ self,
59
+ readings: Iterable[IntervalReading],
60
+ period: BillingPeriod | None = None,
61
+ *,
62
+ check: bool = True,
63
+ ) -> Bill:
64
+ """Price ``readings`` over ``period``.
65
+
66
+ ``period`` defaults to the span of the readings themselves. Readings
67
+ outside it are ignored, so a year of data can be billed one cycle at a
68
+ time without slicing it first.
69
+ """
70
+ readings = list(readings)
71
+ if period is None:
72
+ period = BillingPeriod.from_readings(readings)
73
+
74
+ in_period = [r for r in readings if period.contains(r.start)]
75
+ warnings = list(check_coverage(in_period, period)) if check else []
76
+
77
+ buckets: dict[tuple[Season, TouPeriod], _Accumulator] = {}
78
+ uncompensated = 0.0
79
+ import_components: dict[str, float] = {}
80
+ export_components: dict[str, float] = {}
81
+ complete = True
82
+
83
+ for reading in in_period:
84
+ # Price at the hour containing the interval: rates change hourly,
85
+ # data may be finer.
86
+ moment = hour_floor(to_pacific(reading.start))
87
+ import_price = self.rates.tariff.price_at(moment)
88
+
89
+ # Only ask what an export was worth when it could earn anything.
90
+ #
91
+ # Export compensation starts at Permission To Operate: before it
92
+ # there is no Net Billing arrangement, whatever the meter saw. The
93
+ # two data sources disagree about that on purpose -- PG&E's own
94
+ # export reports zero exported kWh for the December 2025 cycle
95
+ # because there was no export channel to meter, while the Rainforest
96
+ # counter behind it recorded real energy leaving the house. Pricing
97
+ # the counter's view would invent credits the tariff does not grant.
98
+ export_price = None
99
+ if reading.exported and self._compensated(moment):
100
+ export_price = self.rates.export_rates.price_at(moment)
101
+ elif reading.exported:
102
+ uncompensated += reading.exported
103
+
104
+ if not import_price.complete:
105
+ complete = False
106
+ if export_price is not None and not (export_price.complete and export_price.exact):
107
+ complete = False
108
+
109
+ key = (import_price.season, import_price.period)
110
+ bucket = buckets.setdefault(key, _Accumulator(*key))
111
+
112
+ if reading.imported:
113
+ bucket.imported += reading.imported
114
+ bucket.import_charge += reading.imported * import_price.total
115
+ _add_scaled(import_components, import_price.components, reading.imported)
116
+
117
+ if reading.exported and export_price is not None:
118
+ bucket.exported += reading.exported
119
+ # Credits are negative so the bill sums directly.
120
+ bucket.export_credit -= reading.exported * export_price.total
121
+ _add_scaled(export_components, export_price.components, -reading.exported)
122
+
123
+ fixed_components = self._fixed_charges(period)
124
+ tax, untaxed_days = self._energy_surcharge(in_period, period)
125
+ if tax:
126
+ import_components["energy_commission_tax"] = tax
127
+ if untaxed_days:
128
+ complete = False
129
+ warnings.append(
130
+ f"no energy surcharge vintage covers {len(untaxed_days)} day(s) "
131
+ f"({untaxed_days[0]} to {untaxed_days[-1]}); those days carry no tax, "
132
+ f"so the total is understated. Run `python -m tools.regen tax`."
133
+ )
134
+
135
+ stale = self._stale_rate_card(period)
136
+ if stale:
137
+ warnings.append(stale)
138
+
139
+ credit = self._baseline_credit(in_period, period)
140
+ if credit:
141
+ import_components["baseline_credit"] = credit
142
+
143
+ if uncompensated:
144
+ warnings.append(
145
+ f"{uncompensated:.1f} kWh exported before the Permission To Operate date "
146
+ f"({self.rates.config.pto_date}); Net Billing compensation starts at PTO, "
147
+ f"so it earns nothing and is not credited here"
148
+ )
149
+
150
+ return Bill(
151
+ period=period,
152
+ buckets=tuple(
153
+ b.finish() for b in sorted(buckets.values(), key=lambda a: (a.season, a.period))
154
+ ),
155
+ import_components=import_components,
156
+ export_components=export_components,
157
+ fixed_components=fixed_components,
158
+ warnings=tuple(warnings),
159
+ # Pricing confidence only. Coverage problems travel separately in
160
+ # `warnings`: they say the meter data is patchy, not that the rates
161
+ # applied to it are uncertain, and folding them together made a bill
162
+ # that reconciles to a statement still describe itself as an
163
+ # estimate. Callers wanting "trust this total" should check both.
164
+ complete=complete,
165
+ )
166
+
167
+ def _energy_surcharge(
168
+ self, readings: Sequence[IntervalReading], period: BillingPeriod
169
+ ) -> tuple[float, list[date]]:
170
+ r"""California's Energy Resources Surcharge on energy consumed.
171
+
172
+ A state tax rather than a utility tariff, so it is charged whoever
173
+ supplies the generation. A statement prints it as "Energy Commission
174
+ Tax", and when a CCA supplies generation it prints on *their* page --
175
+ which is how it went unmodelled while every line on the utility's pages
176
+ reconciled.
177
+
178
+ Rated per kilowatt-hour *consumed*, by the vintage in force on each day,
179
+ so a cycle spanning a January rate change is charged correctly.
180
+
181
+ Consumed, not imported, and the difference only appears once exports
182
+ earn something. Two statements pin it down. On 2026-03-10 the site
183
+ exported 36 kWh before Permission To Operate and was taxed on the full
184
+ 731 kWh imported -- \$0.22, to the cent. On 2026-08-04 it exported after
185
+ PTO and was taxed on nothing at all despite importing 39 kWh. So an
186
+ export offsets the tax base exactly when the tariff compensates it,
187
+ which is the same test that decides whether it earns a credit. Floored
188
+ per day: a day that exports more than it imports owes no tax and does
189
+ not bank a negative against the next one.
190
+
191
+ Returns the charge and the days no vintage covered. Those days are not
192
+ charged, and the caller says so and marks the bill incomplete: a bill
193
+ that quietly omits a tax is the plausible-but-wrong kind, which is worse
194
+ than one that refuses to claim it is finished.
195
+ """
196
+ from ..data import versioned
197
+
198
+ total = 0.0
199
+ remaining: dict[date, float] = {}
200
+ for reading in readings:
201
+ day = to_pacific(reading.start).date()
202
+ consumed = reading.imported
203
+ if reading.exported and self._compensated(hour_floor(to_pacific(reading.start))):
204
+ consumed -= reading.exported
205
+ remaining[day] = remaining.get(day, 0.0) + consumed
206
+ uncovered: list[date] = []
207
+ for day, net in sorted(remaining.items()):
208
+ imported = max(net, 0.0)
209
+ if not imported:
210
+ continue
211
+ try:
212
+ rate = float(versioned.load("tax/ca_energy_resources", day).raw["rate"])
213
+ except DataError:
214
+ # The rest of the bill is still worth producing, so this is not
215
+ # fatal -- but it is not silent either.
216
+ uncovered.append(day)
217
+ continue
218
+ total += imported * rate
219
+ return total, uncovered
220
+
221
+ #: How far a CCA rate card may predate a cycle before it is worth saying so.
222
+ #: A CCA reprices at least annually, so a card more than a year older than
223
+ #: the energy it is pricing is being *borrowed*, not merely still in force.
224
+ STALE_CARD = timedelta(days=400)
225
+
226
+ def _stale_rate_card(self, period: BillingPeriod) -> str:
227
+ """Whether the CCA generation was priced from a much older rate card.
228
+
229
+ `versioned.load` takes the latest vintage on or before the date, which
230
+ is right for a tariff -- a rate stays in force until superseded. It is
231
+ indistinguishable, though, from "nobody vendored the vintage that was
232
+ actually in force", and the two are worlds apart: the first is correct,
233
+ the second silently prices 2025 energy at 2023 rates.
234
+
235
+ This cannot tell them apart either. It can say how old the card is and
236
+ let the reader judge, which is the whole difference between a number
237
+ that is wrong and a number that is wrong and says nothing.
238
+ """
239
+ cca = self.rates.config.cca
240
+ if cca is None or cca.rate_card is None or cca.generation_rates:
241
+ return ""
242
+ try:
243
+ card = load_rate_card(cca.rate_card, period.end)
244
+ except DataError:
245
+ return ""
246
+ age = period.end - card.effective
247
+ if age <= self.STALE_CARD:
248
+ return ""
249
+ return (
250
+ f"{cca.rate_card.upper()} generation priced from the rate card effective "
251
+ f"{card.effective}, {age.days} days before this cycle ended. Either the "
252
+ f"provider did not reprice in between, or the vintage that applied was "
253
+ f"never vendored -- and nothing here can tell those apart"
254
+ )
255
+
256
+ def _baseline_credit(self, readings: Sequence[IntervalReading], period: BillingPeriod) -> float:
257
+ """Credit on imports falling within the cycle's baseline allowance.
258
+
259
+ Only schedules with a baseline produce one, and only when a territory is
260
+ configured. It lands here rather than in the marginal price because
261
+ eligibility depends on cumulative usage over the cycle, which
262
+ ``price_at`` cannot see.
263
+
264
+ Both the allowance and the credit rate are daily quantities, and both
265
+ can change inside one cycle -- the allowance at the season boundary, the
266
+ rate whenever a new tariff vintage takes force. So this walks the days
267
+ and credits each one at its own rate rather than reading a rate once.
268
+
269
+ A statement spanning a rate change prints exactly that: the December
270
+ 2025 cycle shows 19.40 kWh at $0.10084 for its two December days and
271
+ 281.30 kWh at $0.09566 for its twenty-nine January ones. Reading one
272
+ rate for the cycle applied December's to all 300.70 kWh and overstated
273
+ the credit by $1.45.
274
+
275
+ The credit is identical in every TOU period, so how PG&E allocates
276
+ baseline usage across periods moves the printed lines but not this total.
277
+ """
278
+ tariff = self.rates.tariff
279
+ remaining = sum(r.imported for r in readings)
280
+ if remaining <= 0:
281
+ return 0.0
282
+
283
+ credit = 0.0
284
+ for offset in range(period.days):
285
+ day = period.start + timedelta(days=offset)
286
+ noon = datetime(day.year, day.month, day.day, 12, tzinfo=PACIFIC)
287
+ allowance = tariff.baseline_allowance(noon)
288
+ if not allowance:
289
+ continue
290
+ rate = tariff.price_at(noon).baseline_credit
291
+ if not rate:
292
+ continue
293
+ # Imports are credited against the allowance in day order, so a
294
+ # cycle that used less than its allowance is capped rather than
295
+ # credited for energy it never took.
296
+ within = min(allowance, remaining)
297
+ credit -= within * rate
298
+ remaining -= within
299
+ if remaining <= 0:
300
+ break
301
+ return credit
302
+
303
+ def _fixed_charges(self, period: BillingPeriod) -> dict[str, float]:
304
+ """Charges billed per day rather than per kWh.
305
+
306
+ Priced day by day, because the utility prorates and a daily charge can
307
+ begin mid-cycle. AB 205's Base Services Charge began on 2026-03-01, and
308
+ the January-to-March cycle that spans it is billed 30 days at nothing
309
+ and 2 days at the new rate. Pricing the whole cycle from the tariff in
310
+ force on its first day charges nothing at all for those two days, which
311
+ is a real dollar and change on a statement that otherwise reconciles to
312
+ the cent -- small enough to look like rounding, which is what makes it
313
+ worth getting right rather than tolerating.
314
+ """
315
+ total = 0.0
316
+ for offset in range(period.days):
317
+ day = period.start + timedelta(days=offset)
318
+ moment = datetime(day.year, day.month, day.day, 12, tzinfo=PACIFIC)
319
+ total += self.rates.tariff.daily_fixed_charge(moment)
320
+ return {"base_services_charge": total}
321
+
322
+ def marginal_rates(
323
+ self, readings: Sequence[IntervalReading]
324
+ ) -> dict[tuple[Season, TouPeriod], ImportPrice]:
325
+ """The distinct import prices that applied across ``readings``.
326
+
327
+ Useful for showing which rate produced a bucket without re-deriving it.
328
+ """
329
+ seen: dict[tuple[Season, TouPeriod], ImportPrice] = {}
330
+ for reading in readings:
331
+ price = self.rates.price_at(reading.start).import_price
332
+ seen.setdefault((price.season, price.period), price)
333
+ return seen
334
+
335
+
336
+ def _add_scaled(target: dict[str, float], source: dict[str, float], kwh: float) -> None:
337
+ for name, rate in source.items():
338
+ target[name] = target.get(name, 0.0) + rate * kwh
339
+
340
+
341
+ class _Accumulator:
342
+ __slots__ = ("export_credit", "exported", "import_charge", "imported", "period", "season")
343
+
344
+ def __init__(self, season: Season, period: TouPeriod) -> None:
345
+ self.season = season
346
+ self.period = period
347
+ self.imported = 0.0
348
+ self.exported = 0.0
349
+ self.import_charge = 0.0
350
+ self.export_credit = 0.0
351
+
352
+ def finish(self) -> UsageBucket:
353
+ return UsageBucket(
354
+ season=self.season,
355
+ period=self.period,
356
+ imported=self.imported,
357
+ exported=self.exported,
358
+ import_charge=self.import_charge,
359
+ export_credit=self.export_credit,
360
+ )
361
+
362
+
363
+ def hourly(readings: Iterable[IntervalReading]) -> list[IntervalReading]:
364
+ """Collapse sub-hourly readings into whole hours.
365
+
366
+ Netting granularity matters: summing 15-minute imports and exports into an
367
+ hour before netting gives a different answer than netting each quarter hour.
368
+ This helper preserves the finer netting by summing each side separately, so
369
+ it only changes how charges are grouped, never what they total.
370
+ """
371
+ merged: dict[datetime, list[float]] = {}
372
+ for reading in readings:
373
+ key = hour_floor(to_pacific(reading.start))
374
+ slot = merged.setdefault(key, [0.0, 0.0])
375
+ slot[0] += reading.imported
376
+ slot[1] += reading.exported
377
+ return [
378
+ IntervalReading(start, imported=imp, exported=exp, duration=timedelta(hours=1))
379
+ for start, (imp, exp) in sorted(merged.items())
380
+ ]
381
+
382
+
383
+ def _ordered_segments(segments: Sequence[Segment]) -> list[Segment]:
384
+ if not segments:
385
+ raise DataError("a bill needs at least one segment")
386
+ ordered = sorted(segments, key=lambda s: s.period.start)
387
+ for earlier, later in pairwise(ordered):
388
+ if later.period.start <= earlier.period.end:
389
+ raise DataError(
390
+ f"segments overlap: {earlier.period.start}..{earlier.period.end} and "
391
+ f"{later.period.start}..{later.period.end}. Overlapping segments would "
392
+ f"price the same day twice"
393
+ )
394
+ if later.period.start > earlier.period.end + timedelta(days=1):
395
+ raise DataError(
396
+ f"segments have a gap: {earlier.period.end}..{later.period.start}. "
397
+ "A segmented bill must cover every day exactly once"
398
+ )
399
+ return ordered
400
+
401
+
402
+ @dataclass(frozen=True, slots=True)
403
+ class Segment:
404
+ """One stretch of a cycle, priced under its own configuration.
405
+
406
+ A cycle is not always billed under a single tariff. When an account changes
407
+ schedule mid-cycle -- or interconnects solar, which closes one service
408
+ agreement and opens another -- the utility prices each stretch separately
409
+ and prints them as separate blocks on one statement.
410
+ """
411
+
412
+ config: Config
413
+ period: BillingPeriod
414
+
415
+
416
+ def price_segments(
417
+ segments: Sequence[Segment],
418
+ readings: Iterable[IntervalReading],
419
+ *,
420
+ check: bool = True,
421
+ ) -> list[Bill]:
422
+ """One bill per segment, unmerged.
423
+
424
+ Kept separate from :func:`compute_segments` because export credits do not
425
+ cross a service agreement. A cycle where solar was interconnected carries a
426
+ closed agreement and a new one, and the utility applies the new agreement's
427
+ export credits only against its own charges -- on 2026-07-07 it spends 2.18
428
+ against the Solar Billing Plan's charges and nothing against the closed
429
+ agreement's 0.94, which predates Permission To Operate and has no export
430
+ arrangement at all. A ledger run over the merged bill spends them against
431
+ both and overstates what was applied.
432
+ """
433
+ ordered = _ordered_segments(segments)
434
+ readings = list(readings)
435
+ return [
436
+ BillEngine(RateEngine(segment.config)).compute(readings, segment.period, check=check)
437
+ for segment in ordered
438
+ ]
439
+
440
+
441
+ def compute_segments(
442
+ segments: Sequence[Segment],
443
+ readings: Iterable[IntervalReading],
444
+ *,
445
+ check: bool = True,
446
+ ) -> Bill:
447
+ """Price one cycle that more than one configuration governs.
448
+
449
+ Each segment is priced by its own engine over its own dates and the results
450
+ are added, because that is what the utility does: a mid-cycle schedule
451
+ change produces two blocks on one statement, not a blended rate.
452
+
453
+ Refusing this case and demanding a single ``Config`` was the wrong shape.
454
+ The months worth checking most are exactly the ones where something changed,
455
+ and a harness that skips them checks only the quiet months.
456
+ """
457
+ ordered = _ordered_segments(segments)
458
+ readings = list(readings)
459
+ whole = BillingPeriod(ordered[0].period.start, ordered[-1].period.end)
460
+
461
+ imports: dict[str, float] = {}
462
+ exports: dict[str, float] = {}
463
+ fixed: dict[str, float] = {}
464
+ buckets: dict[tuple[Season, TouPeriod], UsageBucket] = {}
465
+ warnings: list[str] = []
466
+ complete = True
467
+
468
+ for segment, part in zip(ordered, price_segments(ordered, readings, check=check), strict=True):
469
+ for target, source in (
470
+ (imports, part.import_components),
471
+ (exports, part.export_components),
472
+ (fixed, part.fixed_components),
473
+ ):
474
+ for key, value in source.items():
475
+ target[key] = target.get(key, 0.0) + value
476
+
477
+ for bucket in part.buckets:
478
+ slot = (bucket.season, bucket.period)
479
+ running = buckets.get(slot)
480
+ buckets[slot] = UsageBucket(
481
+ season=bucket.season,
482
+ period=bucket.period,
483
+ imported=(running.imported if running else 0.0) + bucket.imported,
484
+ exported=(running.exported if running else 0.0) + bucket.exported,
485
+ import_charge=(running.import_charge if running else 0.0) + bucket.import_charge,
486
+ export_credit=(running.export_credit if running else 0.0) + bucket.export_credit,
487
+ )
488
+
489
+ # Attributed, because "no tax vintage covers 3 days" is a different
490
+ # problem depending on which tariff was in force when it happened.
491
+ warnings.extend(
492
+ f"{segment.period.start}..{segment.period.end} ({segment.config.tariff}): {warning}"
493
+ for warning in part.warnings
494
+ )
495
+ complete = complete and part.complete
496
+
497
+ return Bill(
498
+ period=whole,
499
+ buckets=tuple(buckets.values()),
500
+ import_components=imports,
501
+ export_components=exports,
502
+ fixed_components=fixed,
503
+ warnings=tuple(warnings),
504
+ complete=complete,
505
+ )