hems 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hems-0.1.0/LICENSE ADDED
@@ -0,0 +1,72 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 david2069
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+
24
+ ================================================================================
25
+ ADDITIONAL TERMS
26
+ ================================================================================
27
+
28
+ These Additional Terms accompany and supplement the MIT License above. By using,
29
+ copying, modifying, or distributing the Software you accept them.
30
+
31
+ 1. UNOFFICIAL — NO AFFILIATION
32
+ This is UNOFFICIAL software, NOT affiliated with, endorsed by, or supported by
33
+ FranklinWH. It interfaces with FranklinWH's undocumented Direct Connect (local)
34
+ API and its cloud API — technically the same private API the official FranklinWH
35
+ mobile app and the FleetView installer portal use, intended for those apps and not
36
+ for third-party use — plus the standard SunSpec Modbus TCP API (with undocumented
37
+ FranklinWH extensions). Any of these may change, break, or become unavailable
38
+ without notice. Do
39
+ NOT contact FranklinWH support about this Software — raise issues, defects, or
40
+ feature requests on GitHub:
41
+ https://github.com/david2069/franklinwh-direct-connect-bridge/issues
42
+
43
+ 2. COMPLIANCE & ANTI-CIRCUMVENTION
44
+ You agree to keep your gateway and battery settings, controls, and operation
45
+ compliant with the configuration set through the official FranklinWH Mobile App
46
+ and the installer/support FleetView portal.
47
+
48
+ FranklinWH-managed settings may exist to, among other things:
49
+ - protect the safety and longevity of the battery and gateway;
50
+ - apply automatic software updates and restrict your ability to view or change
51
+ battery, grid-import, or grid-export controls;
52
+ - enforce your local electricity grid profile and any import/export limits or
53
+ prohibitions;
54
+ - enforce restrictions tied to contractual incentives, subsidies, or VPP/grid
55
+ programmes.
56
+
57
+ If you use this Software to bypass or override any such control, restriction, or
58
+ compliance measure — deliberately or inadvertently — you do so entirely at your
59
+ own risk. This is neither condoned nor encouraged by the authors or any other
60
+ party.
61
+
62
+ 3. INTENT & RISK
63
+ This Software is provided for informational and educational purposes, AS-IS,
64
+ with no warranty of fitness for any purpose. Any use — personal or commercial
65
+ — is permitted under the MIT License above and is entirely at your own risk.
66
+
67
+ 4. NOTICE
68
+ As the MIT License above requires, the copyright notice and this permission
69
+ notice (including these Additional Terms) shall be included in all copies or
70
+ substantial portions of the Software. Retaining it in copies, forks, derivatives,
71
+ and AI-assisted works keeps the unofficial-app, compliance, and support context
72
+ travelling with the code.
hems-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: hems
3
+ Version: 0.1.0
4
+ Summary: An OUTUS v1 engine: plans, the usage ledger, dynamic prices and explainable bills
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/david2069/hems
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: schema
11
+ Requires-Dist: jsonschema>=4.18; extra == "schema"
12
+ Requires-Dist: referencing; extra == "schema"
13
+ Requires-Dist: rfc3339-validator; extra == "schema"
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest; extra == "test"
16
+ Requires-Dist: jsonschema>=4.18; extra == "test"
17
+ Requires-Dist: referencing; extra == "test"
18
+ Requires-Dist: rfc3339-validator; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # hems
22
+
23
+ An engine for [OUTUS](https://github.com/david2069/outus), the Open Utility Tariff & Usage
24
+ Specification. It reads OUTUS plans and price series, keeps the usage ledger, and writes
25
+ OUTUS bills with every line explained.
26
+
27
+ - **Implements:** OUTUS v1 (electricity). The spec is published separately; hems is one
28
+ implementation of it.
29
+ - **Python ≥ 3.11, no dependencies, no I/O.** Callers supply readings, prices and the plan,
30
+ and store the results.
31
+ - **Used by:** the FranklinWH Direct Connect and Modbus bridges.
32
+
33
+ | Module | Job |
34
+ |---|---|
35
+ | `hems.ledger` | grid readings from the devices behind a meter → 5-minute usage intervals |
36
+ | `hems.prices` | a price series: price at an interval's start, later kinds replace earlier ones |
37
+ | `hems.billing` | an OUTUS bill from a plan and the ledger, with formulas; provisional bills project to the period's end; `now_rates` for the live band and rate |
38
+ | `hems.periods` | billing period bounds (monthly, bimonthly, quarterly) |
39
+ | `hems.plans` | checks a schema can't express (overlapping plans, wrong charge class, unpriced gaps) |
40
+ | `hems.setup` | setup steps in order: site → meter → plan → tariffs |
41
+ | `hems.profile_v1` | imports a `franklinwh-bridge/tariff-profile` v1 file as an OUTUS plan |
42
+
43
+ ```
44
+ pip install -e ".[test]"
45
+ pytest
46
+ ```
47
+
48
+ The tests check every bill hems writes against the OUTUS v1 schemas, copied in
49
+ `tests/outus-v1/`.
50
+
51
+ ## Licence
52
+ MIT
hems-0.1.0/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # hems
2
+
3
+ An engine for [OUTUS](https://github.com/david2069/outus), the Open Utility Tariff & Usage
4
+ Specification. It reads OUTUS plans and price series, keeps the usage ledger, and writes
5
+ OUTUS bills with every line explained.
6
+
7
+ - **Implements:** OUTUS v1 (electricity). The spec is published separately; hems is one
8
+ implementation of it.
9
+ - **Python ≥ 3.11, no dependencies, no I/O.** Callers supply readings, prices and the plan,
10
+ and store the results.
11
+ - **Used by:** the FranklinWH Direct Connect and Modbus bridges.
12
+
13
+ | Module | Job |
14
+ |---|---|
15
+ | `hems.ledger` | grid readings from the devices behind a meter → 5-minute usage intervals |
16
+ | `hems.prices` | a price series: price at an interval's start, later kinds replace earlier ones |
17
+ | `hems.billing` | an OUTUS bill from a plan and the ledger, with formulas; provisional bills project to the period's end; `now_rates` for the live band and rate |
18
+ | `hems.periods` | billing period bounds (monthly, bimonthly, quarterly) |
19
+ | `hems.plans` | checks a schema can't express (overlapping plans, wrong charge class, unpriced gaps) |
20
+ | `hems.setup` | setup steps in order: site → meter → plan → tariffs |
21
+ | `hems.profile_v1` | imports a `franklinwh-bridge/tariff-profile` v1 file as an OUTUS plan |
22
+
23
+ ```
24
+ pip install -e ".[test]"
25
+ pytest
26
+ ```
27
+
28
+ The tests check every bill hems writes against the OUTUS v1 schemas, copied in
29
+ `tests/outus-v1/`.
30
+
31
+ ## Licence
32
+ MIT
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hems"
7
+ version = "0.1.0"
8
+ description = "An OUTUS v1 engine: plans, the usage ledger, dynamic prices and explainable bills"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ schema = ["jsonschema>=4.18", "referencing", "rfc3339-validator"]
16
+ test = ["pytest", "jsonschema>=4.18", "referencing", "rfc3339-validator"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/david2069/hems"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ pythonpath = ["src"]
hems-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """hems: an OUTUS v1 engine (Open Utility Tariff & Usage Specification). Plans, the usage
2
+ ledger, prices, explainable bills and setup steps. Pure Python, no I/O."""
3
+ __version__ = "0.1.0"
@@ -0,0 +1,400 @@
1
+ """Bills from an OUTUS plan and the ledger.
2
+
3
+ Interval charges are priced per ledger interval at the rate in force at its start;
4
+ period charges are worked out from the period's ledger. ``bill`` returns an OUTUS bill
5
+ whose line items carry their inputs and formula. For an open period it is
6
+ ``provisional``: period charges are prorated to the elapsed days, and ``progress`` holds
7
+ the projection to the period's end.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ from collections import defaultdict
13
+ from typing import Any
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from . import periods
17
+ from .prices import PriceBook
18
+ from .windows import HolidayCheck, day_type, in_window
19
+
20
+ #: Which ledger channel each tariff reads.
21
+ CHANNEL = {"general": "general", "export": "general", "controlled_load": "controlled_load",
22
+ "ev": "ev", "rider": "general"}
23
+ INTERVAL_BASES = {"per_unit", "series", "series_plus_adder", "value_table"}
24
+ SUPPORTED_DEMAND = {"monthly_max", "top_n_avg", "daily_excess"}
25
+
26
+
27
+ def plan_in_force(plans: list[dict], day: dt.date) -> dict | None:
28
+ """The plan whose dates cover ``day`` (blank end = still in force)."""
29
+ for p in plans:
30
+ start = p.get("effective_start")
31
+ end = p.get("effective_end")
32
+ if start and day < dt.date.fromisoformat(start):
33
+ continue
34
+ if end and day > dt.date.fromisoformat(end):
35
+ continue
36
+ return p
37
+ return None
38
+
39
+
40
+ def _direction(tariff: dict, charge: dict) -> str:
41
+ return charge.get("direction") or ("export" if tariff.get("applies_to") == "export" else "import")
42
+
43
+
44
+ def _sign(charge: dict) -> float:
45
+ return -1.0 if charge.get("effect") == "credit" else 1.0
46
+
47
+
48
+ def rate_at(charge: dict, local: dt.datetime, is_holiday: HolidayCheck | None = None
49
+ ) -> tuple[float | None, str | None]:
50
+ """A static charge's (rate, band) at ``local``: its rate table's first matching
51
+ entry, else its flat ``rate``."""
52
+ for entry in charge.get("rates") or []:
53
+ if in_window(entry.get("window"), local, is_holiday):
54
+ return entry.get("rate"), entry.get("band")
55
+ if "rates" in charge and charge.get("rate") is None:
56
+ return None, None
57
+ return charge.get("rate"), None
58
+
59
+
60
+ def _value_table(charge: dict, local: dt.datetime) -> float | None:
61
+ month = (charge.get("value_table") or {}).get(str(local.month)) or {}
62
+ row = month.get("weekend" if local.weekday() >= 5 else "weekday")
63
+ return row[local.hour] if row and len(row) == 24 else None
64
+
65
+
66
+ def _interval_rate(tariff: dict, charge: dict, local: dt.datetime, ts: float,
67
+ prices: PriceBook | None, is_holiday: HolidayCheck | None
68
+ ) -> tuple[float | None, str | None, str]:
69
+ """(rate, band, price kind) for one interval charge."""
70
+ basis = charge["basis"]
71
+ if basis == "value_table":
72
+ return _value_table(charge, local), None, "static"
73
+ if basis in ("series", "series_plus_adder"):
74
+ p = prices.at(ts) if prices is not None else None
75
+ if p is None:
76
+ return None, None, "missing"
77
+ side = p.get("export") if _direction(tariff, charge) == "export" else p.get("import")
78
+ if side is None:
79
+ return None, None, "missing"
80
+ adder = 0.0
81
+ band = p.get("band")
82
+ if basis == "series_plus_adder":
83
+ a, band2 = rate_at(charge, local, is_holiday)
84
+ adder, band = (a or 0.0), (band2 or band)
85
+ return side + adder, band, p.get("kind", "actual")
86
+ rate, band = rate_at(charge, local, is_holiday)
87
+ return rate, band, "static"
88
+
89
+
90
+ def _local(ts: float, tz: ZoneInfo) -> dt.datetime:
91
+ return dt.datetime.fromtimestamp(ts, tz)
92
+
93
+
94
+ def _money(v: float) -> float:
95
+ return round(v + 0.0, 2)
96
+
97
+
98
+ def _fmt(v: float, dp: int = 2) -> str:
99
+ return f"{v:,.{dp}f}"
100
+
101
+
102
+ def _tariffs(plan: dict) -> list[dict]:
103
+ return [t for t in plan.get("tariffs") or [] if isinstance(t, dict)]
104
+
105
+
106
+ def _qty(iv: dict, direction: str) -> float:
107
+ return float(iv.get("quantity_out" if direction == "export" else "quantity_in") or 0.0)
108
+
109
+
110
+ def _interval_lines(plan: dict, ledger: dict[str, list[dict]], tz: ZoneInfo,
111
+ prices: PriceBook | None, is_holiday: HolidayCheck | None) -> list[dict]:
112
+ lines = []
113
+ for t in _tariffs(plan):
114
+ chan = ledger.get(CHANNEL.get(t.get("applies_to"), "general")) or []
115
+ for c in t.get("charges") or []:
116
+ if c.get("class") != "interval" or c.get("basis") not in INTERVAL_BASES:
117
+ continue
118
+ d = _direction(t, c)
119
+ qty = amount = unpriced = 0.0
120
+ bands: dict[str, float] = defaultdict(float)
121
+ rates_seen, n, kinds = set(), 0, set()
122
+ for iv in chan:
123
+ q = _qty(iv, d)
124
+ if q <= 0:
125
+ continue
126
+ local = _local(iv["start"], tz)
127
+ if not in_window(c.get("window"), local, is_holiday):
128
+ continue
129
+ rate, band, kind = _interval_rate(t, c, local, iv["start"], prices, is_holiday)
130
+ if rate is None:
131
+ unpriced += q
132
+ continue
133
+ qty += q
134
+ amount += q * rate
135
+ n += 1
136
+ rates_seen.add(round(rate, 6))
137
+ kinds.add(kind)
138
+ if band:
139
+ bands[band] += q
140
+ if qty <= 0 and unpriced <= 0:
141
+ continue
142
+ unit = c.get("unit") or "kWh"
143
+ one_rate = next(iter(rates_seen)) if len(rates_seen) == 1 else None
144
+ if one_rate is not None:
145
+ formula = f"{_fmt(qty, 3)} {unit} × ${one_rate}"
146
+ else:
147
+ what = "prices" if c["basis"] != "per_unit" else "TOU rates"
148
+ formula = f"{_fmt(qty, 3)} {unit} × {what} ({n:,} intervals)"
149
+ inputs: dict[str, Any] = {f"{b}_{unit}": round(v, 3) for b, v in bands.items()}
150
+ if unpriced:
151
+ inputs["unpriced_" + unit] = round(unpriced, 3)
152
+ if kinds - {"static"}:
153
+ inputs["price_kinds"] = sorted(kinds)
154
+ lines.append({"tariff_id": t.get("id"), "charge_id": c.get("id"),
155
+ "label": c.get("label") or c.get("id"), "quantity": round(qty, 3),
156
+ "unit": unit, **({"rate": one_rate} if one_rate is not None else {}),
157
+ "amount": _money(_sign(c) * amount), "formula": formula,
158
+ "inputs": inputs, "class": "interval"})
159
+ return lines
160
+
161
+
162
+ def _demand(t: dict, c: dict, chan: list[dict], tz: ZoneInfo,
163
+ is_holiday: HolidayCheck | None) -> tuple[float, dict]:
164
+ spec = c.get("demand") or {}
165
+ step = int(spec.get("interval_min") or 30) * 60
166
+ buckets: dict[int, float] = defaultdict(float)
167
+ for iv in chan:
168
+ start = int(iv["start"]) // step * step
169
+ if in_window(c.get("window"), _local(start, tz), is_holiday):
170
+ buckets[start] += float(iv.get("quantity_in") or 0.0)
171
+ kw = {s: q * 3600.0 / step for s, q in buckets.items()}
172
+ if spec.get("unit") == "kVA" and spec.get("power_factor"):
173
+ kw = {s: v / float(spec["power_factor"]) for s, v in kw.items()}
174
+ method = spec.get("method") or "monthly_max"
175
+ inputs: dict[str, Any] = {"method": method}
176
+ if not kw:
177
+ return 0.0, inputs
178
+ if method == "top_n_avg":
179
+ n = int(spec.get("n") or 1)
180
+ ranked = sorted(kw.items(), key=lambda x: -x[1])
181
+ if spec.get("distinct_days"):
182
+ seen, picked = set(), []
183
+ for s, v in ranked:
184
+ day = _local(s, tz).date()
185
+ if day not in seen:
186
+ seen.add(day)
187
+ picked.append((s, v))
188
+ ranked = picked
189
+ top = ranked[:n]
190
+ value = sum(v for _, v in top) / len(top)
191
+ inputs["peaks"] = [{"at": _local(s, tz).isoformat(), "kw": round(v, 3)} for s, v in top]
192
+ elif method == "daily_excess":
193
+ by_day: dict[dt.date, float] = defaultdict(float)
194
+ for s, v in kw.items():
195
+ d = _local(s, tz).date()
196
+ by_day[d] = max(by_day[d], v)
197
+ thr = float(spec.get("threshold") or 0.0)
198
+ value = sum(max(0.0, v - thr) for v in by_day.values())
199
+ inputs["threshold"] = thr
200
+ else:
201
+ s, value = max(kw.items(), key=lambda x: x[1])
202
+ inputs["peak_at"] = _local(s, tz).isoformat()
203
+ value = max(value, float(spec.get("min") or 0.0))
204
+ inputs["peak"] = round(value, 3)
205
+ return value, inputs
206
+
207
+
208
+ def _period_lines(plan: dict, ledger: dict[str, list[dict]], tz: ZoneInfo, start: dt.datetime,
209
+ end: dt.datetime, used_days: float, period_days: float,
210
+ is_holiday: HolidayCheck | None) -> tuple[list[dict], list[tuple[dict, dict]]]:
211
+ """Period charges, except those that need the subtotal (minimum bill, percent)."""
212
+ lines, deferred = [], []
213
+ cycle = (plan.get("billing") or {}).get("cycle", "monthly")
214
+ months = periods.MONTHS_PER_CYCLE.get(cycle, 1)
215
+ frac = used_days / period_days if period_days else 0.0
216
+ owners = [(None, c) for c in plan.get("charges") or []] + [
217
+ (t, c) for t in _tariffs(plan) for c in t.get("charges") or []]
218
+ for t, c in owners:
219
+ if c.get("class") != "period":
220
+ continue
221
+ basis, rate = c.get("basis"), float(c.get("rate") or 0.0)
222
+ tid = t.get("id") if t else None
223
+ base = {"tariff_id": tid, "charge_id": c.get("id"), "label": c.get("label") or c.get("id"),
224
+ "class": "period"}
225
+ chan = ledger.get(CHANNEL.get((t or {}).get("applies_to"), "general")) or []
226
+ if basis == "per_day":
227
+ lines.append({**base, "quantity": round(used_days, 3), "unit": "day", "rate": rate,
228
+ "amount": _money(_sign(c) * rate * used_days),
229
+ "formula": f"{_fmt(used_days, 0 if used_days == int(used_days) else 2)} days × ${rate}"})
230
+ elif basis == "per_month":
231
+ q = months * frac
232
+ lines.append({**base, "quantity": round(q, 4), "unit": "month", "rate": rate,
233
+ "amount": _money(_sign(c) * rate * q), "formula": f"{_fmt(q, 2)} months × ${rate}"})
234
+ elif basis == "per_year":
235
+ q = used_days / 365.0
236
+ lines.append({**base, "quantity": round(q, 4), "unit": "year", "rate": rate,
237
+ "amount": _money(_sign(c) * rate * q), "formula": f"{used_days:g}/365 years × ${rate}"})
238
+ elif basis == "demand":
239
+ spec = c.get("demand") or {}
240
+ method = spec.get("method") or "monthly_max"
241
+ if method not in SUPPORTED_DEMAND:
242
+ lines.append({**base, "amount": 0.0, "formula": f"{method}: not supported yet",
243
+ "inputs": {"unsupported": method}})
244
+ continue
245
+ peak, inputs = _demand(t or {}, c, chan, tz, is_holiday)
246
+ per = spec.get("frequency", "per_month")
247
+ mult, how = (used_days, f" × {used_days:g} days") if per == "per_day" else (
248
+ months * frac, f" × {_fmt(months * frac, 2)} months" if months * frac != 1 else "")
249
+ unit = spec.get("unit", "kW")
250
+ at = inputs.get("peak_at")
251
+ when = f" ({_local_short(at)})" if at else ""
252
+ lines.append({**base, "quantity": round(peak, 3), "unit": unit, "rate": rate,
253
+ "amount": _money(_sign(c) * peak * rate * mult),
254
+ "formula": f"peak {_fmt(peak, 2)} {unit}{when} × ${rate}{how}", "inputs": inputs})
255
+ elif basis == "excess_over_free_band":
256
+ fb = c.get("free_band") or {}
257
+ free = float(fb.get("quantity") or 0.0)
258
+ d = _direction(t or {}, c)
259
+ by_day: dict[dt.date, float] = defaultdict(float)
260
+ for iv in chan:
261
+ local = _local(iv["start"], tz)
262
+ if in_window(c.get("window"), local, is_holiday):
263
+ by_day[local.date()] += _qty(iv, d)
264
+ total = sum(by_day.values())
265
+ if fb.get("per") == "day":
266
+ excess = sum(max(0.0, v - free) for v in by_day.values())
267
+ today = max(by_day) if by_day else None
268
+ left = max(0.0, free - by_day[today]) if today else free
269
+ desc = f"{_fmt(free, 0)} kWh/day free"
270
+ else:
271
+ allow = free if fb.get("per") == "period" else free * used_days / 365.0
272
+ excess, left = max(0.0, total - allow), max(0.0, allow - total)
273
+ desc = f"{_fmt(allow, 1)} kWh free"
274
+ lines.append({**base, "quantity": round(excess, 3), "unit": c.get("unit") or "kWh",
275
+ "rate": rate, "amount": _money(_sign(c) * excess * rate),
276
+ "formula": f"{_fmt(excess, 3)} kWh above {desc} × ${rate}",
277
+ "inputs": {"in_window": round(total, 3), "free_left": round(left, 3)}})
278
+ elif basis == "tier":
279
+ used = sum(_qty(iv, _direction(t or {}, c)) for iv in chan)
280
+ lines.append({**base, **_tier(c, used, start, used_days)})
281
+ elif basis in ("minimum_bill", "percent_of_bill"):
282
+ deferred.append((base, c))
283
+ # true_up is an annual settlement: informational, not a line in a period bill.
284
+ return lines, deferred
285
+
286
+
287
+ def _local_short(iso: str) -> str:
288
+ d = dt.datetime.fromisoformat(iso)
289
+ return f"{d.day} {d:%b %H:%M}"
290
+
291
+
292
+ def _tier(c: dict, used: float, start: dt.datetime, used_days: float) -> dict:
293
+ """Tiered quantity over the period. With a ``baseline`` the first tier's allowance is
294
+ the baseline kWh/day for the period's season × days."""
295
+ tiers = [x for x in c.get("tiers") or [] if isinstance(x, dict)]
296
+ allowance = None
297
+ for b in c.get("baseline") or []:
298
+ if not b.get("months") or start.month in b["months"]:
299
+ allowance = float(b["kwh_per_day"]) * used_days
300
+ break
301
+ amount, left, prev, parts = 0.0, used, 0.0, []
302
+ for i, tier in enumerate(tiers):
303
+ cap = allowance if (i == 0 and allowance is not None) else tier.get("up_to")
304
+ span = left if cap is None else max(0.0, min(left, float(cap) - prev))
305
+ if span > 0:
306
+ amount += span * float(tier["rate"])
307
+ parts.append(f"{_fmt(span, 1)} × ${tier['rate']}")
308
+ left -= span
309
+ if cap is not None:
310
+ prev = float(cap)
311
+ inputs = {"used": round(used, 3)}
312
+ if allowance is not None:
313
+ inputs["baseline_allowance"] = round(allowance, 3)
314
+ return {"quantity": round(used, 3), "unit": c.get("unit") or "kWh",
315
+ "amount": _money(_sign(c) * amount), "formula": " + ".join(parts) or "0",
316
+ "inputs": inputs}
317
+
318
+
319
+ def bill(plan: dict, ledger: dict[str, list[dict]], start: dt.datetime, end: dt.datetime,
320
+ now: dt.datetime | None = None, prices: PriceBook | None = None,
321
+ is_holiday: HolidayCheck | None = None) -> dict:
322
+ """An OUTUS bill for ``plan`` over [start, end) from the ledger intervals
323
+ (``{channel: [interval]}``, ``start`` in epoch seconds). ``now`` before ``end`` makes
324
+ it provisional, with period charges to date and a projection."""
325
+ tz = ZoneInfo(plan.get("timezone") or "UTC")
326
+ start, end = start.astimezone(tz), end.astimezone(tz)
327
+ s_ts, e_ts = start.timestamp(), end.timestamp()
328
+ led = {ch: [iv for iv in ivs if s_ts <= iv["start"] < e_ts] for ch, ivs in ledger.items()}
329
+ period_days = periods.days(start, end)
330
+ final = now is None or now >= end
331
+ used_days = period_days if final else max(0.0, round((now - start).total_seconds() / 86400.0, 3))
332
+ lines = _interval_lines(plan, led, tz, prices, is_holiday)
333
+ plines, deferred = _period_lines(plan, led, tz, start, end, used_days, period_days, is_holiday)
334
+ lines += plines
335
+ subtotal = sum(x["amount"] for x in lines)
336
+ for base, c in deferred:
337
+ if c.get("basis") == "minimum_bill":
338
+ floor = float(c.get("amount") or 0.0) * (used_days / period_days if period_days else 0)
339
+ top_up = max(0.0, floor - subtotal)
340
+ if top_up:
341
+ lines.append({**base, "amount": _money(top_up),
342
+ "formula": f"minimum ${_fmt(floor)} − charges ${_fmt(subtotal)}"})
343
+ else:
344
+ r = float(c.get("rate") or 0.0)
345
+ lines.append({**base, "rate": r, "amount": _money(_sign(c) * r * subtotal),
346
+ "formula": f"{r:.2%} × ${_fmt(subtotal)}"})
347
+ net = _money(sum(x["amount"] for x in lines))
348
+ tax_cfg = plan.get("tax") or {}
349
+ rate = float(tax_cfg.get("rate") or 0.0)
350
+ if tax_cfg.get("rates_include_tax", True):
351
+ tax = _money(net * rate / (1 + rate)) if rate else 0.0
352
+ else:
353
+ tax = _money(net * rate)
354
+ net = _money(net + tax)
355
+ for x in lines:
356
+ x.pop("class", None)
357
+ out = {
358
+ "type": "outus/bill", "version": 1,
359
+ "plan_id": plan.get("id"), "plan_name": plan.get("name"),
360
+ "retailer": (plan.get("retailer") or {}).get("name"),
361
+ "currency": plan.get("currency"),
362
+ "period_start": start.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
363
+ "period_end": end.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
364
+ "source": "calculated", "status": "final" if final else "provisional",
365
+ "line_items": lines, "tax": tax, "net_total": net,
366
+ }
367
+ if plan.get("meter"):
368
+ out["meter"] = plan["meter"]
369
+ if plan.get("account_number"):
370
+ out["account_number"] = plan["account_number"]
371
+ if not final:
372
+ out["progress"] = _progress(lines, used_days, period_days, rate, tax_cfg)
373
+ return {k: v for k, v in out.items() if v is not None}
374
+
375
+
376
+ def _progress(lines: list[dict], used_days: float, period_days: float, tax_rate: float,
377
+ tax_cfg: dict) -> dict:
378
+ """Day n of N and a straight-line projection of every line to the period's end."""
379
+ scale = period_days / used_days if used_days else 0.0
380
+ projected = sum(x["amount"] for x in lines) * scale
381
+ if not tax_cfg.get("rates_include_tax", True):
382
+ projected *= 1 + tax_rate
383
+ return {"days_elapsed": used_days, "days": period_days, "projected_total": _money(projected)}
384
+
385
+
386
+ def now_rates(plan: dict, at: dt.datetime, prices: PriceBook | None = None,
387
+ is_holiday: HolidayCheck | None = None) -> list[dict]:
388
+ """Each interval charge's rate and band now: the live inputs of Bill detail."""
389
+ tz = ZoneInfo(plan.get("timezone") or "UTC")
390
+ local = at.astimezone(tz)
391
+ out = []
392
+ for t in _tariffs(plan):
393
+ for c in t.get("charges") or []:
394
+ if c.get("class") != "interval" or not in_window(c.get("window"), local, is_holiday):
395
+ continue
396
+ rate, band, kind = _interval_rate(t, c, local, local.timestamp(), prices, is_holiday)
397
+ out.append({"tariff_id": t.get("id"), "charge_id": c.get("id"),
398
+ "label": c.get("label") or c.get("id"), "rate": rate, "band": band,
399
+ "kind": kind, "day_type": day_type(local, None, is_holiday)})
400
+ return out