hems 0.1.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.
- hems/__init__.py +3 -0
- hems/billing.py +400 -0
- hems/ledger.py +82 -0
- hems/periods.py +45 -0
- hems/plans.py +58 -0
- hems/prices.py +59 -0
- hems/profile_v1.py +146 -0
- hems/setup.py +47 -0
- hems/windows.py +64 -0
- hems-0.1.0.dist-info/METADATA +52 -0
- hems-0.1.0.dist-info/RECORD +14 -0
- hems-0.1.0.dist-info/WHEEL +5 -0
- hems-0.1.0.dist-info/licenses/LICENSE +72 -0
- hems-0.1.0.dist-info/top_level.txt +1 -0
hems/__init__.py
ADDED
hems/billing.py
ADDED
|
@@ -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
|
hems/ledger.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The energy ledger: grid readings in, clock-aligned 5-minute intervals out.
|
|
2
|
+
|
|
3
|
+
Each device behind a meter (an inverter or battery gateway, say) reports the grid power
|
|
4
|
+
through its own connection, so the meter's power is the sum of their latest readings;
|
|
5
|
+
a device silent for ``FRESH_S`` is left out until it reports again.
|
|
6
|
+
|
|
7
|
+
State is a plain dict (JSON-safe) that the caller saves; nothing here does I/O.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
INTERVAL_S = 300 #: OUTUS ledger granularity
|
|
15
|
+
FRESH_S = 300 #: a reading older than this is stale
|
|
16
|
+
MAX_GAP_S = 3600 #: a longer gap between samples is not integrated
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_state(meter_id: str, channel: str = "general") -> dict[str, Any]:
|
|
20
|
+
return {"schema_version": SCHEMA_VERSION, "meter_id": meter_id, "channel": channel,
|
|
21
|
+
"latest": {}, "last_ts": None, "open": None}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _blank(start: int) -> dict[str, Any]:
|
|
25
|
+
return {"start": start, "duration_s": INTERVAL_S, "quantity_in": 0.0, "quantity_out": 0.0,
|
|
26
|
+
"peak_rate": 0.0, "seconds": 0.0}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def combined(state: dict, ts: float) -> float | None:
|
|
30
|
+
"""The meter's power now: the sum of fresh device readings (W), or None."""
|
|
31
|
+
fresh = [w for t, w in state["latest"].values() if ts - t <= FRESH_S]
|
|
32
|
+
return sum(fresh) if fresh else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add_reading(state: dict, device_id: str, ts: float, grid_w: float | None) -> list[dict]:
|
|
36
|
+
"""Record one device's grid power (W, + = import) at ``ts`` (UTC epoch seconds).
|
|
37
|
+
|
|
38
|
+
Integrates the meter's power since the previous reading into the open interval and
|
|
39
|
+
returns the intervals that closed (oldest first) as OUTUS usage intervals with
|
|
40
|
+
``start`` in epoch seconds.
|
|
41
|
+
"""
|
|
42
|
+
if grid_w is not None:
|
|
43
|
+
state["latest"][device_id] = (float(ts), float(grid_w))
|
|
44
|
+
closed: list[dict] = []
|
|
45
|
+
last = state["last_ts"]
|
|
46
|
+
if last is not None and ts <= last:
|
|
47
|
+
return closed
|
|
48
|
+
power = combined(state, ts)
|
|
49
|
+
if last is not None and power is not None and ts - last <= MAX_GAP_S:
|
|
50
|
+
t = float(last)
|
|
51
|
+
while t < ts:
|
|
52
|
+
slot = int(t // INTERVAL_S * INTERVAL_S)
|
|
53
|
+
cur = state["open"]
|
|
54
|
+
if cur is None or cur["start"] != slot:
|
|
55
|
+
if cur is not None:
|
|
56
|
+
closed.append(_finish(cur))
|
|
57
|
+
cur = state["open"] = _blank(slot)
|
|
58
|
+
seg_end = min(ts, slot + INTERVAL_S)
|
|
59
|
+
sec = seg_end - t
|
|
60
|
+
kwh = abs(power) * sec / 3_600_000.0
|
|
61
|
+
if power >= 0:
|
|
62
|
+
cur["quantity_in"] += kwh
|
|
63
|
+
else:
|
|
64
|
+
cur["quantity_out"] += kwh
|
|
65
|
+
cur["seconds"] += sec
|
|
66
|
+
t = seg_end
|
|
67
|
+
elif state["open"] is not None and ts >= state["open"]["start"] + INTERVAL_S:
|
|
68
|
+
closed.append(_finish(state["open"]))
|
|
69
|
+
state["open"] = None
|
|
70
|
+
state["last_ts"] = float(ts)
|
|
71
|
+
return closed
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _finish(cur: dict) -> dict:
|
|
75
|
+
out = {k: v for k, v in cur.items() if k != "seconds"}
|
|
76
|
+
out["quantity_in"] = round(out["quantity_in"], 6)
|
|
77
|
+
out["quantity_out"] = round(out["quantity_out"], 6)
|
|
78
|
+
# Average import power over the interval, kW: the basis for any demand window.
|
|
79
|
+
out["peak_rate"] = round(out["quantity_in"] * 3600.0 / INTERVAL_S, 4)
|
|
80
|
+
if cur["seconds"] < INTERVAL_S * 0.9:
|
|
81
|
+
out["source"] = "estimated"
|
|
82
|
+
return out
|
hems/periods.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Billing periods: where the period containing a moment starts and ends."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import calendar
|
|
5
|
+
import datetime as dt
|
|
6
|
+
|
|
7
|
+
MONTHS_PER_CYCLE = {"monthly": 1, "bimonthly": 2, "quarterly": 3}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _on_day(year: int, month: int, day: int, like: dt.datetime) -> dt.datetime:
|
|
11
|
+
return like.replace(year=year, month=month, day=min(day, calendar.monthrange(year, month)[1]),
|
|
12
|
+
hour=0, minute=0, second=0, microsecond=0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _add_months(year: int, month: int, n: int) -> tuple[int, int]:
|
|
16
|
+
i = year * 12 + (month - 1) + n
|
|
17
|
+
return i // 12, i % 12 + 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bounds(local: dt.datetime, cycle: str = "monthly", cycle_day: int = 1,
|
|
21
|
+
anchor_month: int = 1) -> tuple[dt.datetime, dt.datetime]:
|
|
22
|
+
"""(start, end) of the billing period containing ``local`` (site local time).
|
|
23
|
+
|
|
24
|
+
Periods start on ``cycle_day`` (clamped to the month's length). Bimonthly and
|
|
25
|
+
quarterly periods are counted from ``anchor_month``.
|
|
26
|
+
"""
|
|
27
|
+
step = MONTHS_PER_CYCLE.get(cycle, 1)
|
|
28
|
+
day = max(1, min(28, int(cycle_day or 1)))
|
|
29
|
+
# The latest cycle-day on or before `local`, in a month on the cycle's grid.
|
|
30
|
+
y, m = local.year, local.month
|
|
31
|
+
for _ in range(step + 1):
|
|
32
|
+
if (m - anchor_month) % step == 0:
|
|
33
|
+
start = _on_day(y, m, day, local)
|
|
34
|
+
if start <= local:
|
|
35
|
+
break
|
|
36
|
+
y, m = _add_months(y, m, -1)
|
|
37
|
+
else: # pragma: no cover
|
|
38
|
+
raise ValueError("no period start found")
|
|
39
|
+
ey, em = _add_months(start.year, start.month, step)
|
|
40
|
+
return start, _on_day(ey, em, day, local)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def days(start: dt.datetime, end: dt.datetime) -> float:
|
|
44
|
+
"""Length in days (a DST change makes it fractional; bills count whole days)."""
|
|
45
|
+
return round((end - start).total_seconds() / 86400.0)
|
hems/plans.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Checks on OUTUS plans that a JSON Schema can't express."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
|
|
6
|
+
from .billing import INTERVAL_BASES
|
|
7
|
+
from .windows import hhmm
|
|
8
|
+
|
|
9
|
+
BANDS = ("super_off_peak", "off_peak", "mid_peak", "on_peak")
|
|
10
|
+
PERIOD_BASES = {"per_day", "per_month", "per_year", "demand", "excess_over_free_band", "tier",
|
|
11
|
+
"minimum_bill", "true_up", "percent_of_bill"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _date(v):
|
|
15
|
+
return dt.date.fromisoformat(v) if v else None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def overlaps(plans: list[dict]) -> list[str]:
|
|
19
|
+
"""Plans on one meter must not overlap (blank end = open-ended)."""
|
|
20
|
+
issues = []
|
|
21
|
+
spans = sorted(((_date(p.get("effective_start")) or dt.date.min,
|
|
22
|
+
_date(p.get("effective_end")) or dt.date.max, p.get("name") or p.get("id"))
|
|
23
|
+
for p in plans), key=lambda x: x[0])
|
|
24
|
+
for (s1, e1, n1), (s2, _e2, n2) in zip(spans, spans[1:]):
|
|
25
|
+
if s2 <= e1:
|
|
26
|
+
issues.append(f"{n1} and {n2} overlap from {s2.isoformat()}")
|
|
27
|
+
return issues
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check(plan: dict) -> list[str]:
|
|
31
|
+
"""Problems that would price wrongly. Empty = fine."""
|
|
32
|
+
issues = []
|
|
33
|
+
if not plan.get("tariffs"):
|
|
34
|
+
issues.append("the plan has no tariffs")
|
|
35
|
+
s, e = _date(plan.get("effective_start")), _date(plan.get("effective_end"))
|
|
36
|
+
if s and e and e < s:
|
|
37
|
+
issues.append("effective_end is before effective_start")
|
|
38
|
+
for t in plan.get("tariffs") or []:
|
|
39
|
+
tn = t.get("name") or t.get("id")
|
|
40
|
+
for c in t.get("charges") or []:
|
|
41
|
+
cn = f"{tn} › {c.get('label') or c.get('id')}"
|
|
42
|
+
cls, basis = c.get("class"), c.get("basis")
|
|
43
|
+
if cls == "interval" and basis not in INTERVAL_BASES:
|
|
44
|
+
issues.append(f"{cn}: {basis} is a period basis, not interval")
|
|
45
|
+
if cls == "period" and basis not in PERIOD_BASES:
|
|
46
|
+
issues.append(f"{cn}: {basis} is an interval basis, not period")
|
|
47
|
+
if basis in ("series", "series_plus_adder") and (t.get("price_source") or {}).get("kind") != "series":
|
|
48
|
+
issues.append(f"{cn}: priced from a series, but the tariff has no series price source")
|
|
49
|
+
for r in c.get("rates") or []:
|
|
50
|
+
if r.get("band") and r["band"] not in BANDS:
|
|
51
|
+
issues.append(f"{cn}: unknown band {r['band']}")
|
|
52
|
+
w = r.get("window") or {}
|
|
53
|
+
for k in ("start", "end"):
|
|
54
|
+
if w.get(k) is not None and hhmm(w[k]) is None:
|
|
55
|
+
issues.append(f"{cn}: bad time {w[k]}")
|
|
56
|
+
if basis == "per_unit" and c.get("rates") and not any(not r.get("window") for r in c["rates"]):
|
|
57
|
+
issues.append(f"{cn}: no catch-all rate; times outside its windows go unpriced")
|
|
58
|
+
return issues
|
hems/prices.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Dynamic prices (OUTUS price series): the price at an interval's start holds for its
|
|
2
|
+
duration, and a later kind replaces an earlier one for the same start."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import datetime as dt
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
RANK = {"forecast": 0, "estimate": 1, "confirmed": 2, "actual": 3}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _epoch(v: Any) -> int:
|
|
13
|
+
if isinstance(v, (int, float)):
|
|
14
|
+
return int(v)
|
|
15
|
+
return int(dt.datetime.fromisoformat(str(v).replace("Z", "+00:00")).timestamp())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PriceBook:
|
|
19
|
+
"""One source's price intervals, keyed by start (UTC epoch seconds)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, provider: str = "", region: str = "") -> None:
|
|
22
|
+
self.provider, self.region = provider, region
|
|
23
|
+
self._by_start: dict[int, dict] = {}
|
|
24
|
+
self._starts: list[int] = []
|
|
25
|
+
|
|
26
|
+
def add(self, intervals: list[dict]) -> list[int]:
|
|
27
|
+
"""Merge intervals; returns the starts whose price changed (to re-price)."""
|
|
28
|
+
changed = []
|
|
29
|
+
for iv in intervals:
|
|
30
|
+
start = _epoch(iv["start"])
|
|
31
|
+
old = self._by_start.get(start)
|
|
32
|
+
if old is not None and RANK.get(iv.get("kind"), 0) < RANK.get(old.get("kind"), 0):
|
|
33
|
+
continue
|
|
34
|
+
new = dict(iv, start=start)
|
|
35
|
+
if old is None:
|
|
36
|
+
bisect.insort(self._starts, start)
|
|
37
|
+
if old is None or (old.get("import"), old.get("export"), old.get("kind")) != (
|
|
38
|
+
new.get("import"), new.get("export"), new.get("kind")):
|
|
39
|
+
changed.append(start)
|
|
40
|
+
self._by_start[start] = new
|
|
41
|
+
return changed
|
|
42
|
+
|
|
43
|
+
def at(self, ts: float) -> dict | None:
|
|
44
|
+
"""The price in force at ``ts``. Past the last interval, the last known price is
|
|
45
|
+
carried forward and marked ``estimate``; None before the first."""
|
|
46
|
+
i = bisect.bisect_right(self._starts, int(ts)) - 1
|
|
47
|
+
if i < 0:
|
|
48
|
+
return None
|
|
49
|
+
iv = self._by_start[self._starts[i]]
|
|
50
|
+
if ts < iv["start"] + int(iv.get("duration_s") or 300):
|
|
51
|
+
return iv
|
|
52
|
+
return dict(iv, kind="estimate", carried=True)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_document(cls, doc: dict) -> PriceBook:
|
|
56
|
+
src = doc.get("source") or {}
|
|
57
|
+
book = cls(src.get("provider", ""), src.get("region", ""))
|
|
58
|
+
book.add(doc.get("intervals") or [])
|
|
59
|
+
return book
|
hems/profile_v1.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Import a ``franklinwh-bridge/tariff-profile`` v1 file as an OUTUS plan.
|
|
2
|
+
|
|
3
|
+
A v1 profile is one tariff: seasons of TOU blocks naming a time period with buy/sell
|
|
4
|
+
rates, a tariff-wide default rate (a number or a tier ladder), optional demand, export
|
|
5
|
+
bonus and export charge windows, and fixed charges. It becomes a plan with a ``general``
|
|
6
|
+
tariff (buy) and, when anything is paid or charged for export, an ``export`` tariff. The
|
|
7
|
+
original profile is kept under ``x-tariff-profile-v1`` so nothing is lost.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
PROFILE_TYPE = "franklinwh-bridge/tariff-profile"
|
|
15
|
+
_FIXED = {"daily": ("per_day", 1.0), "weekly": ("per_day", 1 / 7.0),
|
|
16
|
+
"monthly": ("per_month", 1.0), "quarterly": ("per_month", 1 / 3.0),
|
|
17
|
+
"annual": ("per_year", 1.0)}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _days(days: list | None, out: dict) -> None:
|
|
21
|
+
"""v1 weekday numbers (Monday 0) → OUTUS day types where exact, else kept as ``days``."""
|
|
22
|
+
d = set(days or [])
|
|
23
|
+
if not d or d == set(range(7)):
|
|
24
|
+
return
|
|
25
|
+
if d == {0, 1, 2, 3, 4}:
|
|
26
|
+
out["day_types"] = ["weekday"]
|
|
27
|
+
elif d == {5, 6}:
|
|
28
|
+
out["day_types"] = ["weekend"]
|
|
29
|
+
else:
|
|
30
|
+
out["days"] = sorted(d)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _window(w: dict | None) -> dict | None:
|
|
34
|
+
if not isinstance(w, dict) or not w:
|
|
35
|
+
return None
|
|
36
|
+
out = {k: w[k] for k in ("months", "start", "end") if w.get(k)}
|
|
37
|
+
_days(w.get("days"), out)
|
|
38
|
+
return out or None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tou(seasons: list, side: str) -> list[dict]:
|
|
42
|
+
rates = []
|
|
43
|
+
for s in seasons or []:
|
|
44
|
+
periods = s.get("time_periods") or s.get("waves") or {}
|
|
45
|
+
for b in s.get("blocks") or []:
|
|
46
|
+
band = b.get("time_period") or b.get("wave")
|
|
47
|
+
rate = (periods.get(band) or {}).get(side)
|
|
48
|
+
if not isinstance(rate, (int, float)):
|
|
49
|
+
continue
|
|
50
|
+
win = {"start": b.get("start"), "end": b.get("end")}
|
|
51
|
+
if s.get("months"):
|
|
52
|
+
win["months"] = list(s["months"])
|
|
53
|
+
_days(b.get("days"), win)
|
|
54
|
+
rates.append({"window": win, "band": band, "rate": float(rate)})
|
|
55
|
+
return rates
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _flat_or_tiers(value: Any) -> tuple[float | None, list[dict] | None]:
|
|
59
|
+
if isinstance(value, (int, float)):
|
|
60
|
+
return float(value), None
|
|
61
|
+
if isinstance(value, list) and value:
|
|
62
|
+
tiers = [{"up_to": t.get("up_to_kwh"), "rate": float(t["rate"])}
|
|
63
|
+
for t in value if isinstance(t, dict) and isinstance(t.get("rate"), (int, float))]
|
|
64
|
+
return None, tiers or None
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def from_tariff_profile(doc: dict) -> dict:
|
|
69
|
+
"""A v1 tariff-profile document (or its inner ``profile``) → an OUTUS plan."""
|
|
70
|
+
p = doc.get("profile", doc) if doc.get("type") == PROFILE_TYPE else doc
|
|
71
|
+
pr = p.get("pricing") or {}
|
|
72
|
+
seasons = pr.get("seasons") or []
|
|
73
|
+
default = pr.get("default_rate") or {}
|
|
74
|
+
general: list[dict] = []
|
|
75
|
+
export: list[dict] = []
|
|
76
|
+
|
|
77
|
+
buy_flat, buy_tiers = _flat_or_tiers(default.get("buy"))
|
|
78
|
+
tou_buy = _tou(seasons, "buy")
|
|
79
|
+
if tou_buy or buy_flat is not None:
|
|
80
|
+
rates = tou_buy + ([{"rate": buy_flat}] if buy_flat is not None else [])
|
|
81
|
+
general.append({"id": "energy", "label": "Energy", "class": "interval", "basis": "per_unit",
|
|
82
|
+
"unit": "kWh", "rates": rates})
|
|
83
|
+
if buy_tiers:
|
|
84
|
+
general.append({"id": "energy_tiers", "label": "Energy (tiered)", "class": "period",
|
|
85
|
+
"basis": "tier", "unit": "kWh", "tiers": buy_tiers})
|
|
86
|
+
|
|
87
|
+
if pr.get("demand_rate") and _window(p.get("demand_window")):
|
|
88
|
+
per = "per_month" if pr.get("demand_charge_basis") == "per_kw_month" else "per_day"
|
|
89
|
+
general.append({"id": "demand", "label": "Peak demand", "class": "period", "basis": "demand",
|
|
90
|
+
"rate": float(pr["demand_rate"]), "unit": "kW",
|
|
91
|
+
"window": _window(p.get("demand_window")),
|
|
92
|
+
"demand": {"method": "monthly_max",
|
|
93
|
+
"interval_min": int(pr.get("demand_interval_min") or 30),
|
|
94
|
+
"unit": "kW", "frequency": per}})
|
|
95
|
+
|
|
96
|
+
sell_flat, _ = _flat_or_tiers(default.get("sell"))
|
|
97
|
+
tou_sell = _tou(seasons, "sell")
|
|
98
|
+
if tou_sell or sell_flat is not None:
|
|
99
|
+
rates = tou_sell + ([{"rate": sell_flat}] if sell_flat is not None else [])
|
|
100
|
+
export.append({"id": "feed_in", "label": "Feed-in credit", "class": "interval",
|
|
101
|
+
"basis": "per_unit", "direction": "export", "effect": "credit", "unit": "kWh",
|
|
102
|
+
"rates": rates})
|
|
103
|
+
if pr.get("export_bonus_rate") and _window(p.get("bonus_window")):
|
|
104
|
+
export.append({"id": "bonus", "label": "Export bonus", "class": "interval", "basis": "per_unit",
|
|
105
|
+
"direction": "export", "effect": "credit", "unit": "kWh",
|
|
106
|
+
"window": _window(p.get("bonus_window")), "rate": float(pr["export_bonus_rate"])})
|
|
107
|
+
ec = pr.get("export_charge") or {}
|
|
108
|
+
if ec.get("rate"):
|
|
109
|
+
export.append({"id": "export_charge", "label": "Export charge", "class": "period",
|
|
110
|
+
"basis": "excess_over_free_band", "direction": "export", "unit": "kWh",
|
|
111
|
+
"rate": float(ec["rate"]), "window": _window(ec.get("window")),
|
|
112
|
+
"free_band": {"quantity": float(ec.get("free_kwh_per_day") or 0.0), "per": "day"}})
|
|
113
|
+
|
|
114
|
+
charges = []
|
|
115
|
+
for i, f in enumerate(pr.get("fixed_charges") or []):
|
|
116
|
+
if not isinstance(f, dict) or not isinstance(f.get("rate"), (int, float)):
|
|
117
|
+
continue
|
|
118
|
+
basis, k = _FIXED.get(f.get("frequency") or "daily", ("per_day", 1.0))
|
|
119
|
+
charges.append({"id": f"fixed_{i + 1}", "label": f.get("description") or f.get("type") or "Fixed charge",
|
|
120
|
+
"class": "period", "basis": basis,
|
|
121
|
+
"rate": round(float(f["rate"]) * k * (1 + float(f.get("tax_rate") or 0.0)), 6),
|
|
122
|
+
"unit": basis.removeprefix("per_")})
|
|
123
|
+
|
|
124
|
+
tariffs = []
|
|
125
|
+
if general:
|
|
126
|
+
tariffs.append({"id": "general", "name": "General", "applies_to": "general",
|
|
127
|
+
"price_source": {"kind": "static"}, "charges": general})
|
|
128
|
+
if export:
|
|
129
|
+
tariffs.append({"id": "export", "name": "Export", "applies_to": "export", "charges": export})
|
|
130
|
+
plan: dict[str, Any] = {
|
|
131
|
+
"type": "outus/plan", "version": 1,
|
|
132
|
+
"name": p.get("name") or "Imported plan",
|
|
133
|
+
"retailer": {k: v for k, v in (("name", p.get("retailer")), ("network", p.get("network")),
|
|
134
|
+
("country", p.get("country"))) if v},
|
|
135
|
+
"currency": {"AU": "AUD", "US": "USD", "NZ": "NZD", "GB": "GBP"}.get(p.get("country") or "", "AUD"),
|
|
136
|
+
"billing": {"cycle": "monthly", "cycle_day": max(1, min(28, int(pr.get("billing_cycle_day") or 1)))},
|
|
137
|
+
"tax": {"rate": 0.10 if p.get("country") == "AU" else 0.0, "rates_include_tax": True},
|
|
138
|
+
"export_compensation": "feed_in",
|
|
139
|
+
"charges": charges,
|
|
140
|
+
"tariffs": tariffs,
|
|
141
|
+
"x-tariff-profile-v1": copy.deepcopy(p),
|
|
142
|
+
}
|
|
143
|
+
for k in ("timezone", "effective_start", "effective_end"):
|
|
144
|
+
if p.get(k):
|
|
145
|
+
plan[k] = p[k]
|
|
146
|
+
return plan
|
hems/setup.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Billing setup steps, in the model's order: site → meter → plan → tariffs.
|
|
2
|
+
|
|
3
|
+
One source for every place an application tells the user what is missing, so all of
|
|
4
|
+
them say the same thing.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
|
|
10
|
+
from .billing import plan_in_force
|
|
11
|
+
|
|
12
|
+
STEPS = (
|
|
13
|
+
("site", "Site", "Add your site"),
|
|
14
|
+
("meter", "Meter", "Add your meter"),
|
|
15
|
+
("plan", "Plan", "Add your retailer plan on the meter"),
|
|
16
|
+
("tariffs", "Tariffs", "Add the plan's tariffs"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def steps(sites: list[dict], meters: list[dict], plans: list[dict], meter_id: str | None,
|
|
21
|
+
today: dt.date) -> list[dict]:
|
|
22
|
+
"""Each step with ``done`` and, while missing, what to do. ``plans`` are that meter's plans."""
|
|
23
|
+
meter = next((m for m in meters if m.get("id") == meter_id), None)
|
|
24
|
+
plan = plan_in_force(plans, today) if meter else None
|
|
25
|
+
done = {
|
|
26
|
+
"site": bool(sites),
|
|
27
|
+
"meter": meter is not None,
|
|
28
|
+
"plan": plan is not None,
|
|
29
|
+
"tariffs": bool(plan and plan.get("tariffs")),
|
|
30
|
+
}
|
|
31
|
+
out, blocked = [], False
|
|
32
|
+
for key, label, todo in STEPS:
|
|
33
|
+
step = {"key": key, "label": label, "done": done[key]}
|
|
34
|
+
if not done[key]:
|
|
35
|
+
step["todo"] = todo
|
|
36
|
+
step["blocked"] = blocked # an earlier step is missing
|
|
37
|
+
blocked = True
|
|
38
|
+
if key == "meter" and meter:
|
|
39
|
+
step["detail"] = meter.get("name")
|
|
40
|
+
if key == "plan" and plan:
|
|
41
|
+
step["detail"] = plan.get("name")
|
|
42
|
+
out.append(step)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def complete(steps_: list[dict]) -> bool:
|
|
47
|
+
return all(s["done"] for s in steps_)
|
hems/windows.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Time windows: does a local time fall inside an OUTUS window?
|
|
2
|
+
|
|
3
|
+
A window is ``{months, day_types, start, end, holiday_region, clock}`` (OUTUS) or the
|
|
4
|
+
older tariff-profile ``{months, days, start, end}`` (``days`` = weekday numbers, Monday 0).
|
|
5
|
+
Omitted parts mean always. An end at or before the start wraps past midnight, and
|
|
6
|
+
``24:00`` ends the day.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
#: A callable telling whether a local date is a public holiday in a region.
|
|
15
|
+
HolidayCheck = Callable[[dt.date, str | None], bool]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hhmm(raw: Any) -> int | None:
|
|
19
|
+
"""``"HH:MM"`` → minutes since midnight, or None."""
|
|
20
|
+
if not isinstance(raw, str) or ":" not in raw:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
h, m = (int(x) for x in raw.split(":", 1))
|
|
24
|
+
except ValueError:
|
|
25
|
+
return None
|
|
26
|
+
if not (0 <= h <= 24 and 0 <= m < 60) or (h == 24 and m):
|
|
27
|
+
return None
|
|
28
|
+
return h * 60 + m
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def day_type(local: dt.datetime, region: str | None = None,
|
|
32
|
+
is_holiday: HolidayCheck | None = None) -> str:
|
|
33
|
+
"""``holiday``, ``weekend`` or ``weekday`` for a local time."""
|
|
34
|
+
if is_holiday is not None and is_holiday(local.date(), region):
|
|
35
|
+
return "holiday"
|
|
36
|
+
return "weekend" if local.weekday() >= 5 else "weekday"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def in_window(win: Any, local: dt.datetime, is_holiday: HolidayCheck | None = None) -> bool:
|
|
40
|
+
"""Is ``local`` (the site's local time) inside ``win``? No window means always."""
|
|
41
|
+
if win is None:
|
|
42
|
+
return True
|
|
43
|
+
if not isinstance(win, dict):
|
|
44
|
+
return False
|
|
45
|
+
months = win.get("months") or []
|
|
46
|
+
if months and local.month not in months:
|
|
47
|
+
return False
|
|
48
|
+
days = win.get("days") or []
|
|
49
|
+
if days and local.weekday() not in days:
|
|
50
|
+
return False
|
|
51
|
+
types = win.get("day_types") or []
|
|
52
|
+
# A public holiday is its own day type: a window listing only weekdays excludes it.
|
|
53
|
+
# Without a holiday check, no day is a holiday.
|
|
54
|
+
if types and day_type(local, win.get("holiday_region"), is_holiday) not in types:
|
|
55
|
+
return False
|
|
56
|
+
start, end = hhmm(win.get("start")), hhmm(win.get("end"))
|
|
57
|
+
if start is None or end is None:
|
|
58
|
+
return True
|
|
59
|
+
if win.get("clock") == "standard" and local.dst():
|
|
60
|
+
local = local - local.dst()
|
|
61
|
+
minute = local.hour * 60 + local.minute
|
|
62
|
+
if end <= start:
|
|
63
|
+
return minute >= start or minute < end
|
|
64
|
+
return start <= minute < end
|
|
@@ -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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
hems/__init__.py,sha256=rfB2ESRRnGmKxwpjqWuRrdLD4A7U9L9_mvmxE7aF8og,187
|
|
2
|
+
hems/billing.py,sha256=hhwq1c7aHhiGPNDaPQLnPJ4VDZ8JVTJKQzYeSkafriw,19091
|
|
3
|
+
hems/ledger.py,sha256=pHnyYmvp0_oY6E2p2TX5SYMFst0zIttXmvgfMHrujGE,3337
|
|
4
|
+
hems/periods.py,sha256=yIWDBOUQD_-K54O2dz7c54nmf4Z7VaUU20NM7NJOHzM,1830
|
|
5
|
+
hems/plans.py,sha256=IkH6KCagp7KP1i54_YYDaxMa9lLn6tObr8arl0Tpywk,2730
|
|
6
|
+
hems/prices.py,sha256=VO8Ndrf8HxYnYpmO-FhWSgd-U7xvdZs0iU1ySDJN5FE,2316
|
|
7
|
+
hems/profile_v1.py,sha256=QQw3Y1ii03uQqGTtf6YxDDMrAxlTtaCwAk0f0xL8FSw,7145
|
|
8
|
+
hems/setup.py,sha256=ryOMjfc1-1vS10RrWs1D-2dbpjbXAEpX8RKst49aC-g,1614
|
|
9
|
+
hems/windows.py,sha256=FBqgHGkXGaB6x69_8YlYJgii-uMLwhC-UD76RHpBkgI,2438
|
|
10
|
+
hems-0.1.0.dist-info/licenses/LICENSE,sha256=C8ltdG5x2gcOmXk68ypMIdob1FzV7vxlOPEM0vbGG1w,3760
|
|
11
|
+
hems-0.1.0.dist-info/METADATA,sha256=TH9Wd8zyb1LXwulzK4T7huYow44jZrPHkaI3w75cDgY,2120
|
|
12
|
+
hems-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
hems-0.1.0.dist-info/top_level.txt,sha256=dvmCq4wXZ-2M-ahK0Xn68ajbIG3d9BYos6Jdj1e56Yo,5
|
|
14
|
+
hems-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hems
|