hems 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hems-0.1.0/LICENSE +72 -0
- hems-0.1.0/PKG-INFO +52 -0
- hems-0.1.0/README.md +32 -0
- hems-0.1.0/pyproject.toml +25 -0
- hems-0.1.0/setup.cfg +4 -0
- hems-0.1.0/src/hems/__init__.py +3 -0
- hems-0.1.0/src/hems/billing.py +400 -0
- hems-0.1.0/src/hems/ledger.py +82 -0
- hems-0.1.0/src/hems/periods.py +45 -0
- hems-0.1.0/src/hems/plans.py +58 -0
- hems-0.1.0/src/hems/prices.py +59 -0
- hems-0.1.0/src/hems/profile_v1.py +146 -0
- hems-0.1.0/src/hems/setup.py +47 -0
- hems-0.1.0/src/hems/windows.py +64 -0
- hems-0.1.0/src/hems.egg-info/PKG-INFO +52 -0
- hems-0.1.0/src/hems.egg-info/SOURCES.txt +19 -0
- hems-0.1.0/src/hems.egg-info/dependency_links.txt +1 -0
- hems-0.1.0/src/hems.egg-info/requires.txt +11 -0
- hems-0.1.0/src/hems.egg-info/top_level.txt +1 -0
- hems-0.1.0/tests/test_engine.py +253 -0
- hems-0.1.0/tests/test_outus_v1.py +59 -0
hems-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 david2069
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
================================================================================
|
|
25
|
+
ADDITIONAL TERMS
|
|
26
|
+
================================================================================
|
|
27
|
+
|
|
28
|
+
These Additional Terms accompany and supplement the MIT License above. By using,
|
|
29
|
+
copying, modifying, or distributing the Software you accept them.
|
|
30
|
+
|
|
31
|
+
1. UNOFFICIAL — NO AFFILIATION
|
|
32
|
+
This is UNOFFICIAL software, NOT affiliated with, endorsed by, or supported by
|
|
33
|
+
FranklinWH. It interfaces with FranklinWH's undocumented Direct Connect (local)
|
|
34
|
+
API and its cloud API — technically the same private API the official FranklinWH
|
|
35
|
+
mobile app and the FleetView installer portal use, intended for those apps and not
|
|
36
|
+
for third-party use — plus the standard SunSpec Modbus TCP API (with undocumented
|
|
37
|
+
FranklinWH extensions). Any of these may change, break, or become unavailable
|
|
38
|
+
without notice. Do
|
|
39
|
+
NOT contact FranklinWH support about this Software — raise issues, defects, or
|
|
40
|
+
feature requests on GitHub:
|
|
41
|
+
https://github.com/david2069/franklinwh-direct-connect-bridge/issues
|
|
42
|
+
|
|
43
|
+
2. COMPLIANCE & ANTI-CIRCUMVENTION
|
|
44
|
+
You agree to keep your gateway and battery settings, controls, and operation
|
|
45
|
+
compliant with the configuration set through the official FranklinWH Mobile App
|
|
46
|
+
and the installer/support FleetView portal.
|
|
47
|
+
|
|
48
|
+
FranklinWH-managed settings may exist to, among other things:
|
|
49
|
+
- protect the safety and longevity of the battery and gateway;
|
|
50
|
+
- apply automatic software updates and restrict your ability to view or change
|
|
51
|
+
battery, grid-import, or grid-export controls;
|
|
52
|
+
- enforce your local electricity grid profile and any import/export limits or
|
|
53
|
+
prohibitions;
|
|
54
|
+
- enforce restrictions tied to contractual incentives, subsidies, or VPP/grid
|
|
55
|
+
programmes.
|
|
56
|
+
|
|
57
|
+
If you use this Software to bypass or override any such control, restriction, or
|
|
58
|
+
compliance measure — deliberately or inadvertently — you do so entirely at your
|
|
59
|
+
own risk. This is neither condoned nor encouraged by the authors or any other
|
|
60
|
+
party.
|
|
61
|
+
|
|
62
|
+
3. INTENT & RISK
|
|
63
|
+
This Software is provided for informational and educational purposes, AS-IS,
|
|
64
|
+
with no warranty of fitness for any purpose. Any use — personal or commercial
|
|
65
|
+
— is permitted under the MIT License above and is entirely at your own risk.
|
|
66
|
+
|
|
67
|
+
4. NOTICE
|
|
68
|
+
As the MIT License above requires, the copyright notice and this permission
|
|
69
|
+
notice (including these Additional Terms) shall be included in all copies or
|
|
70
|
+
substantial portions of the Software. Retaining it in copies, forks, derivatives,
|
|
71
|
+
and AI-assisted works keeps the unofficial-app, compliance, and support context
|
|
72
|
+
travelling with the code.
|
hems-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hems
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An OUTUS v1 engine: plans, the usage ledger, dynamic prices and explainable bills
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/david2069/hems
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: schema
|
|
11
|
+
Requires-Dist: jsonschema>=4.18; extra == "schema"
|
|
12
|
+
Requires-Dist: referencing; extra == "schema"
|
|
13
|
+
Requires-Dist: rfc3339-validator; extra == "schema"
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest; extra == "test"
|
|
16
|
+
Requires-Dist: jsonschema>=4.18; extra == "test"
|
|
17
|
+
Requires-Dist: referencing; extra == "test"
|
|
18
|
+
Requires-Dist: rfc3339-validator; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# hems
|
|
22
|
+
|
|
23
|
+
An engine for [OUTUS](https://github.com/david2069/outus), the Open Utility Tariff & Usage
|
|
24
|
+
Specification. It reads OUTUS plans and price series, keeps the usage ledger, and writes
|
|
25
|
+
OUTUS bills with every line explained.
|
|
26
|
+
|
|
27
|
+
- **Implements:** OUTUS v1 (electricity). The spec is published separately; hems is one
|
|
28
|
+
implementation of it.
|
|
29
|
+
- **Python ≥ 3.11, no dependencies, no I/O.** Callers supply readings, prices and the plan,
|
|
30
|
+
and store the results.
|
|
31
|
+
- **Used by:** the FranklinWH Direct Connect and Modbus bridges.
|
|
32
|
+
|
|
33
|
+
| Module | Job |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `hems.ledger` | grid readings from the devices behind a meter → 5-minute usage intervals |
|
|
36
|
+
| `hems.prices` | a price series: price at an interval's start, later kinds replace earlier ones |
|
|
37
|
+
| `hems.billing` | an OUTUS bill from a plan and the ledger, with formulas; provisional bills project to the period's end; `now_rates` for the live band and rate |
|
|
38
|
+
| `hems.periods` | billing period bounds (monthly, bimonthly, quarterly) |
|
|
39
|
+
| `hems.plans` | checks a schema can't express (overlapping plans, wrong charge class, unpriced gaps) |
|
|
40
|
+
| `hems.setup` | setup steps in order: site → meter → plan → tariffs |
|
|
41
|
+
| `hems.profile_v1` | imports a `franklinwh-bridge/tariff-profile` v1 file as an OUTUS plan |
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
pip install -e ".[test]"
|
|
45
|
+
pytest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The tests check every bill hems writes against the OUTUS v1 schemas, copied in
|
|
49
|
+
`tests/outus-v1/`.
|
|
50
|
+
|
|
51
|
+
## Licence
|
|
52
|
+
MIT
|
hems-0.1.0/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# hems
|
|
2
|
+
|
|
3
|
+
An engine for [OUTUS](https://github.com/david2069/outus), the Open Utility Tariff & Usage
|
|
4
|
+
Specification. It reads OUTUS plans and price series, keeps the usage ledger, and writes
|
|
5
|
+
OUTUS bills with every line explained.
|
|
6
|
+
|
|
7
|
+
- **Implements:** OUTUS v1 (electricity). The spec is published separately; hems is one
|
|
8
|
+
implementation of it.
|
|
9
|
+
- **Python ≥ 3.11, no dependencies, no I/O.** Callers supply readings, prices and the plan,
|
|
10
|
+
and store the results.
|
|
11
|
+
- **Used by:** the FranklinWH Direct Connect and Modbus bridges.
|
|
12
|
+
|
|
13
|
+
| Module | Job |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `hems.ledger` | grid readings from the devices behind a meter → 5-minute usage intervals |
|
|
16
|
+
| `hems.prices` | a price series: price at an interval's start, later kinds replace earlier ones |
|
|
17
|
+
| `hems.billing` | an OUTUS bill from a plan and the ledger, with formulas; provisional bills project to the period's end; `now_rates` for the live band and rate |
|
|
18
|
+
| `hems.periods` | billing period bounds (monthly, bimonthly, quarterly) |
|
|
19
|
+
| `hems.plans` | checks a schema can't express (overlapping plans, wrong charge class, unpriced gaps) |
|
|
20
|
+
| `hems.setup` | setup steps in order: site → meter → plan → tariffs |
|
|
21
|
+
| `hems.profile_v1` | imports a `franklinwh-bridge/tariff-profile` v1 file as an OUTUS plan |
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install -e ".[test]"
|
|
25
|
+
pytest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The tests check every bill hems writes against the OUTUS v1 schemas, copied in
|
|
29
|
+
`tests/outus-v1/`.
|
|
30
|
+
|
|
31
|
+
## Licence
|
|
32
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hems"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An OUTUS v1 engine: plans, the usage ledger, dynamic prices and explainable bills"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
schema = ["jsonschema>=4.18", "referencing", "rfc3339-validator"]
|
|
16
|
+
test = ["pytest", "jsonschema>=4.18", "referencing", "rfc3339-validator"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/david2069/hems"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
pythonpath = ["src"]
|
hems-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,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
|