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 ADDED
@@ -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"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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