Skip to content

Commit c0aede3

Browse files
committed
chore(scripts): add v1/v2 feedthrough derivation validation harness
scripts/validate_lug_derivation/ compares the panel's native downstream-lugs readings against the v1 REST API readings against a Kirchhoff-derived value (main − Σcircuits), using both span-panel-api==1.1.15 (for v1 REST) and the current workspace library (for v2 MQTT) in parallel ephemeral environments driven by uv run. Used to diagnose the feedthrough power/energy defect on MQTT v2 and to confirm that the Kirchhoff derivation produces physically- consistent results across both APIs. See v1-only "Unmapped Tab" circuits vs v2-only "Commissioned PV System" virtual circuit: both represent the same physical PV in opposite sign conventions, which the partitioning logic in compare.py accounts for. A gitignore rule for scripts/validate_lug_derivation/run_local.sh keeps a credentialed driver script out of git — users copy and edit that template with their own host / v1 token / v2 passphrase.
1 parent 1ef43cd commit c0aede3

4 files changed

Lines changed: 489 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ dmypy.json
3434
coverage_output.log
3535
**/.DS_Store
3636
.local_coverage_data
37+
38+
# Local credentialed runner for validate_lug_derivation
39+
scripts/validate_lug_derivation/run_local.sh
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Drive v1 and v2 readers in parallel; derive feedthrough from main minus
2+
Σcircuits; print a side-by-side comparison for each sample.
3+
4+
Usage:
5+
6+
python compare.py \
7+
--host 192.168.65.70 \
8+
--v1-token "$V1_TOKEN" \
9+
--v2-passphrase "$V2_PASSPHRASE" \
10+
--samples 5 --interval 3
11+
12+
Physics — Kirchhoff at the main bus (grid-perspective on main/feedthrough,
13+
load-perspective on branch circuits where positive = consumption):
14+
15+
P_main = P_feedthrough + Σ(branches, load-perspective)
16+
=> P_feedthrough_derived = P_main - Σ(branches)
17+
18+
PV handling. A solar inverter connected to a branch appears as:
19+
* v1 REST: two raw physical tab circuits in grid-perspective (positive =
20+
power flowing INTO the bus from the inverter). No virtual PV entry.
21+
* v2 MQTT: one synthesized "PV" virtual circuit in load-perspective
22+
(negative = producing), AND the underlying physical tabs are suppressed.
23+
24+
The v1-only circuits (by UUID set-difference with v2) therefore identify the
25+
physical PV tabs. To get comparable load-perspective totals, we negate them:
26+
27+
Σ_v1_load = Σ_v1_raw - 2 * Σ(v1-only circuits)
28+
29+
Energy uses the Kirchhoff identity on NET counters:
30+
31+
net_feedthrough = (main_consumed − main_produced)
32+
− Σ(c.consumed − c.produced)
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import argparse
38+
import asyncio
39+
import json
40+
from pathlib import Path
41+
import sys
42+
from typing import Any
43+
44+
HERE = Path(__file__).resolve().parent
45+
SPAN_API_ROOT = HERE.parent.parent
46+
47+
48+
def _fmt(x: float | None, width: int = 12) -> str:
49+
if x is None:
50+
return f"{'—':>{width}}"
51+
return f"{x:>{width}.2f}"
52+
53+
54+
async def _run(cmd: list[str], cwd: Path) -> dict[str, Any]:
55+
proc = await asyncio.create_subprocess_exec(
56+
*cmd,
57+
cwd=str(cwd),
58+
stdout=asyncio.subprocess.PIPE,
59+
stderr=asyncio.subprocess.PIPE,
60+
)
61+
stdout_b, stderr_b = await proc.communicate()
62+
if proc.returncode != 0:
63+
raise RuntimeError(
64+
f"reader exited {proc.returncode}: {stderr_b.decode(errors='replace')}"
65+
)
66+
return json.loads(stdout_b.decode())
67+
68+
69+
def _partition_v1(v1: dict[str, Any], shared_ids: set[str]) -> dict[str, float]:
70+
"""Partition v1 circuits into 'load' (shared with v2) and 'pv_tabs' (v1-only,
71+
grid-perspective) and return comparable sums."""
72+
load_p = load_c = load_pe = 0.0
73+
pv_p = pv_c = pv_pe = 0.0
74+
for c in v1["circuits"]:
75+
p = float(c["instant_power_w"])
76+
cons = float(c["consumed_energy_wh"])
77+
prod = float(c["produced_energy_wh"])
78+
if c["circuit_id"] in shared_ids:
79+
load_p += p
80+
load_c += cons
81+
load_pe += prod
82+
else:
83+
pv_p += p
84+
pv_c += cons
85+
pv_pe += prod
86+
return {
87+
"load_power_w": load_p,
88+
"load_consumed_wh": load_c,
89+
"load_produced_wh": load_pe,
90+
"pv_tabs_power_w_grid": pv_p,
91+
"pv_tabs_consumed_wh_grid": pv_c,
92+
"pv_tabs_produced_wh_grid": pv_pe,
93+
# Load-perspective total for Kirchhoff: flip pv_tabs sign for power.
94+
# For energy we can't symmetrically swap consumed/produced without
95+
# knowing which counter corresponds to which direction in raw REST.
96+
# Power-space correction is what we need for Kirchhoff balance.
97+
"sigma_load_persp_power_w": load_p - pv_p,
98+
"sigma_all_raw_power_w": load_p + pv_p,
99+
}
100+
101+
102+
def _sum_v2_circuits(v2: dict[str, Any]) -> dict[str, float]:
103+
p = sum(float(c["instant_power_w"]) for c in v2["circuits"])
104+
cons = sum(float(c["consumed_energy_wh"]) for c in v2["circuits"])
105+
prod = sum(float(c["produced_energy_wh"]) for c in v2["circuits"])
106+
return {
107+
"sigma_power_w": p,
108+
"sigma_consumed_wh": cons,
109+
"sigma_produced_wh": prod,
110+
}
111+
112+
113+
def _print_sample(idx: int, v1: dict[str, Any], v2: dict[str, Any]) -> None:
114+
shared_ids = {c["circuit_id"] for c in v1["circuits"]} & {
115+
c["circuit_id"] for c in v2["circuits"]
116+
}
117+
p1 = _partition_v1(v1, shared_ids)
118+
s2 = _sum_v2_circuits(v2)
119+
120+
main_v1 = float(v1["main_power_w"])
121+
main_v2 = float(v2["main_power_w"])
122+
feed_v1 = float(v1["feedthrough_power_w"])
123+
feed_v2 = float(v2["feedthrough_power_w"])
124+
125+
# Derived feedthrough power (Kirchhoff, load-perspective Σ)
126+
derived_v1 = main_v1 - p1["sigma_load_persp_power_w"]
127+
derived_v2 = main_v2 - s2["sigma_power_w"]
128+
129+
# Energy nets
130+
net_main_v1 = float(v1["main_consumed_wh"]) - float(v1["main_produced_wh"])
131+
net_main_v2 = float(v2["main_consumed_wh"]) - float(v2["main_produced_wh"])
132+
net_feed_rpt_v1 = float(v1["feedthrough_consumed_wh"]) - float(v1["feedthrough_produced_wh"])
133+
net_feed_rpt_v2 = float(v2["feedthrough_consumed_wh"]) - float(v2["feedthrough_produced_wh"])
134+
net_circ_v1 = p1["load_consumed_wh"] + p1["pv_tabs_consumed_wh_grid"] - (
135+
p1["load_produced_wh"] + p1["pv_tabs_produced_wh_grid"]
136+
)
137+
net_circ_v2 = s2["sigma_consumed_wh"] - s2["sigma_produced_wh"]
138+
net_feed_der_v1 = net_main_v1 - net_circ_v1
139+
net_feed_der_v2 = net_main_v2 - net_circ_v2
140+
141+
dt = float(v2["t"]) - float(v1["t"])
142+
print(f"\n=== sample {idx} (v2 vs v1 capture offset: {dt:+.2f}s) ===")
143+
print(f" shared circuits: {len(shared_ids)} "
144+
f"v1-only (PV tabs): {len(v1['circuits']) - len(shared_ids)} "
145+
f"v2-only (PV virtual): {len(v2['circuits']) - len(shared_ids)}")
146+
147+
pv = v2.get("pv") or {}
148+
if pv.get("feed_circuit_id"):
149+
print(f" v2 pv: feed={pv['feed_circuit_id'][:8]} "
150+
f"vendor={pv.get('vendor_name')} "
151+
f"capacity={pv.get('nameplate_capacity_w')} W "
152+
f"position={pv.get('relative_position')}")
153+
154+
print("\n power (W):")
155+
print(f"{' field':<44}{'v1':>12}{'v2':>12}{'Δ(v2-v1)':>12}")
156+
rows_p: list[tuple[str, float, float]] = [
157+
("main_power_w", main_v1, main_v2),
158+
("feedthrough_power_w (reported)", feed_v1, feed_v2),
159+
("Σ circuits (raw, v1 grid+load mixed)",
160+
p1["sigma_all_raw_power_w"], s2["sigma_power_w"]),
161+
("Σ circuits (load-perspective)",
162+
p1["sigma_load_persp_power_w"], s2["sigma_power_w"]),
163+
("Σ v1-only / v2-only (PV)",
164+
p1["pv_tabs_power_w_grid"],
165+
sum(float(c["instant_power_w"]) for c in v2["circuits"]
166+
if c["circuit_id"] not in shared_ids)),
167+
("feedthrough_power_w (derived)", derived_v1, derived_v2),
168+
]
169+
for label, a, b in rows_p:
170+
print(f"{' ' + label:<44}{_fmt(a)}{_fmt(b)}{_fmt(b - a)}")
171+
172+
# v2-only: power flows indicators
173+
pfp = v2.get("power_flow_pv")
174+
pfb = v2.get("power_flow_battery")
175+
pfg = v2.get("power_flow_grid")
176+
pfs = v2.get("power_flow_site")
177+
print("\n v2 power_flows (W):")
178+
print(f" pv={_fmt(pfp, 9)} battery={_fmt(pfb, 9)} "
179+
f"grid={_fmt(pfg, 9)} site={_fmt(pfs, 9)}")
180+
181+
print("\n energy net (Wh = consumed - produced):")
182+
print(f"{' field':<44}{'v1':>12}{'v2':>12}{'Δ(v2-v1)':>12}")
183+
rows_e: list[tuple[str, float, float]] = [
184+
("net_main", net_main_v1, net_main_v2),
185+
("net_feedthrough (reported)", net_feed_rpt_v1, net_feed_rpt_v2),
186+
("net_Σcircuits", net_circ_v1, net_circ_v2),
187+
("net_feedthrough (derived)", net_feed_der_v1, net_feed_der_v2),
188+
]
189+
for label, a, b in rows_e:
190+
print(f"{' ' + label:<44}{_fmt(a)}{_fmt(b)}{_fmt(b - a)}")
191+
192+
# Cross-api consistency: derived should match across v1 and v2
193+
# Reported may disagree with derived (the "defect").
194+
flags: list[str] = []
195+
if abs(derived_v1 - derived_v2) > 100.0:
196+
flags.append(
197+
f"derived feedthrough power diverges across APIs: "
198+
f"v1={derived_v1:+.1f} W vs v2={derived_v2:+.1f} W"
199+
)
200+
dp1 = derived_v1 - feed_v1
201+
dp2 = derived_v2 - feed_v2
202+
if abs(dp1) > 100.0:
203+
flags.append(f"v1 reported feedthrough off Kirchhoff by {dp1:+.1f} W")
204+
if abs(dp2) > 100.0:
205+
flags.append(f"v2 reported feedthrough off Kirchhoff by {dp2:+.1f} W <<< MQTT defect")
206+
if float(v1["feedthrough_consumed_wh"]) < 0:
207+
flags.append(
208+
f"v1 feedthrough_consumed_wh is NEGATIVE ({v1['feedthrough_consumed_wh']:.0f}) — "
209+
f"counter cannot decrease"
210+
)
211+
de1 = net_feed_der_v1 - net_feed_rpt_v1
212+
de2 = net_feed_der_v2 - net_feed_rpt_v2
213+
if abs(de1) > 1000.0:
214+
flags.append(f"v1 reported net energy off Kirchhoff by {de1:+,.0f} Wh")
215+
if abs(de2) > 1000.0:
216+
flags.append(f"v2 reported net energy off Kirchhoff by {de2:+,.0f} Wh")
217+
218+
for f in flags:
219+
print(f" ! {f}")
220+
221+
222+
async def main() -> int:
223+
parser = argparse.ArgumentParser()
224+
parser.add_argument("--host", required=True)
225+
parser.add_argument("--v1-token", required=True)
226+
parser.add_argument("--v2-passphrase", required=True)
227+
parser.add_argument("--port", type=int, default=80)
228+
parser.add_argument("--samples", type=int, default=5)
229+
parser.add_argument("--interval", type=float, default=3.0)
230+
parser.add_argument("--dump-json", type=Path)
231+
args = parser.parse_args()
232+
233+
v1_cmd = [
234+
"uv", "run", "--no-project", "--with", "span-panel-api==1.1.15",
235+
"python", str(HERE / "v1_reader.py"),
236+
"--host", args.host,
237+
"--token", args.v1_token,
238+
"--port", str(args.port),
239+
"--samples", str(args.samples),
240+
"--interval", str(args.interval),
241+
]
242+
v2_cmd = [
243+
"uv", "run",
244+
"python", str(HERE / "v2_reader.py"),
245+
"--host", args.host,
246+
"--passphrase", args.v2_passphrase,
247+
"--port", str(args.port),
248+
"--samples", str(args.samples),
249+
"--interval", str(args.interval),
250+
]
251+
252+
v1_task = asyncio.create_task(_run(v1_cmd, cwd=HERE))
253+
v2_task = asyncio.create_task(_run(v2_cmd, cwd=SPAN_API_ROOT))
254+
v1_result, v2_result = await asyncio.gather(v1_task, v2_task)
255+
256+
v1_samples = v1_result["samples"]
257+
v2_samples = v2_result["samples"]
258+
n = min(len(v1_samples), len(v2_samples))
259+
if n == 0:
260+
print("no samples captured", file=sys.stderr)
261+
return 1
262+
263+
for i in range(n):
264+
_print_sample(i, v1_samples[i], v2_samples[i])
265+
266+
if args.dump_json is not None:
267+
args.dump_json.write_text(
268+
json.dumps({"v1": v1_result, "v2": v2_result}, indent=2)
269+
)
270+
print(f"\nraw JSON written to {args.dump_json}")
271+
272+
return 0
273+
274+
275+
if __name__ == "__main__":
276+
sys.exit(asyncio.run(main()))
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Read N v1 snapshots from a live panel and print JSON to stdout.
2+
3+
Run in an isolated environment with span-panel-api==1.1.15:
4+
5+
uv run --no-project --with 'span-panel-api==1.1.15' \
6+
python v1_reader.py --host 192.168.X.Y --token T --samples 5 --interval 3
7+
8+
Emits a single JSON object: {"api": "v1", "samples": [{...}, ...]}.
9+
Each sample records `t` (unix seconds), main power/energy, feedthrough
10+
power/energy, and per-circuit instant_power_w / consumed_energy_wh /
11+
produced_energy_wh.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import asyncio
18+
import json
19+
import sys
20+
import time
21+
22+
from span_panel_api import SpanPanelClient
23+
24+
25+
def _circuit_to_dict(circuit_id: str, circuit: object) -> dict[str, object]:
26+
return {
27+
"circuit_id": circuit_id,
28+
"name": getattr(circuit, "name", "") or "",
29+
"instant_power_w": float(getattr(circuit, "instant_power_w", 0.0)),
30+
"consumed_energy_wh": float(getattr(circuit, "consumed_energy_wh", None) or 0.0),
31+
"produced_energy_wh": float(getattr(circuit, "produced_energy_wh", None) or 0.0),
32+
"tabs": list(getattr(circuit, "tabs", None) or []),
33+
"relay_state": getattr(circuit, "relay_state").value,
34+
}
35+
36+
37+
async def read_once(client: SpanPanelClient) -> dict[str, object]:
38+
panel = await client.get_panel_state()
39+
circuits_out = await client.get_circuits()
40+
41+
main_energy = panel.main_meter_energy
42+
feed_energy = panel.feedthrough_energy
43+
44+
circuits = [
45+
_circuit_to_dict(cid, c)
46+
for cid, c in circuits_out.circuits.additional_properties.items()
47+
]
48+
49+
return {
50+
"t": time.time(),
51+
"main_power_w": float(panel.instant_grid_power_w),
52+
"feedthrough_power_w": float(panel.feedthrough_power_w),
53+
"main_consumed_wh": float(main_energy.consumed_energy_wh),
54+
"main_produced_wh": float(main_energy.produced_energy_wh),
55+
"feedthrough_consumed_wh": float(feed_energy.consumed_energy_wh),
56+
"feedthrough_produced_wh": float(feed_energy.produced_energy_wh),
57+
"circuits": circuits,
58+
}
59+
60+
61+
async def main() -> int:
62+
parser = argparse.ArgumentParser()
63+
parser.add_argument("--host", required=True)
64+
parser.add_argument("--token", required=True)
65+
parser.add_argument("--port", type=int, default=80)
66+
parser.add_argument("--samples", type=int, default=1)
67+
parser.add_argument("--interval", type=float, default=3.0)
68+
args = parser.parse_args()
69+
70+
samples: list[dict[str, object]] = []
71+
async with SpanPanelClient(
72+
host=args.host, port=args.port, use_ssl=False, timeout=15.0
73+
) as client:
74+
client.set_access_token(args.token)
75+
for i in range(args.samples):
76+
if i > 0:
77+
await asyncio.sleep(args.interval)
78+
samples.append(await read_once(client))
79+
80+
json.dump({"api": "v1", "samples": samples}, sys.stdout)
81+
sys.stdout.write("\n")
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
sys.exit(asyncio.run(main()))

0 commit comments

Comments
 (0)