|
| 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())) |
0 commit comments