span-panel-api 2.6.2__tar.gz → 2.6.3__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.
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.gitignore +3 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/CHANGELOG.md +12 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/PKG-INFO +1 -1
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pyproject.toml +1 -1
- span_panel_api-2.6.3/scripts/validate_lug_derivation/README.md +71 -0
- span_panel_api-2.6.3/scripts/validate_lug_derivation/compare.py +311 -0
- span_panel_api-2.6.3/scripts/validate_lug_derivation/run_local.sh.example +22 -0
- span_panel_api-2.6.3/scripts/validate_lug_derivation/v1_reader.py +86 -0
- span_panel_api-2.6.3/scripts/validate_lug_derivation/v2_reader.py +168 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/field_metadata.py +13 -2
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/homie.py +71 -9
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_homie.py +92 -15
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/uv.lock +1 -1
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.codefactor +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.codefactor.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.deps-installed +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/dependabot.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/ci.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/dependabot-auto-approve.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.github/workflows/release.yml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.markdownlint-cli2.jsonc +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.markdownlint.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.pre-commit-config.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.prettierrc.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.vscode/extensions.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/.vscode/tasks.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/DEVELOPMENT.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/LICENSE +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/README.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/SECURITY.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/conftest.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/developer_attribute_readme.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/openapi.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pytest.ini +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/pytest_output.log +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/coverage.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/format.sh +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/format_markdown.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/scripts/test_live_auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/setup-hooks.sh +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/_http.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/const.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/detection.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/exceptions.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/factory.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/models.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/__init__.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/accumulator.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/async_client.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/client.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/connection.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/const.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/mqtt/models.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/phase_validation.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/protocol.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/src/span_panel_api/py.typed +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/conftest.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/README.md +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/homie_schema.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/fixtures/v2/status.json +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/circuits.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/panel.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/soe.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/simulation_fixtures/status.response.txt +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_accumulator.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_async_mqtt_client.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_auth_and_homie_helpers.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_detection_auth.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_exceptions.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_field_metadata.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_bridge.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_client_connection.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_connect_flow.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_mqtt_debounce.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_phase_validation_configs.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_phase_validation_errors.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_protocol_conformance.py +0 -0
- {span_panel_api-2.6.2 → span_panel_api-2.6.3}/tests/test_protocol_models.py +0 -0
|
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.6.3] - 04/2026
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Feedthrough values now derived via Kirchhoff instead of read from `downstream-lugs`** — `SpanPanelSnapshot.feedthrough_power_w`, `feedthrough_energy_consumed_wh`, and `feedthrough_energy_produced_wh` are computed as `main − Σ(branches)` inside
|
|
12
|
+
`HomieDeviceConsumer._build_snapshot` (and mirrored in the dirty-circuit rebuild path) rather than sourced from the native `energy.ebus.device.lugs.downstream` `active-power` / `imported-energy` / `exported-energy` properties. Kirchhoff at the main bus
|
|
13
|
+
produces a physically-consistent result: `P_main = P_feedthrough + Σ(branches, load-perspective)` for instantaneous power, and the same identity applied to _net_ energy (`main.consumed − main.produced − Σ(branch.net)`) split into non-negative consumed /
|
|
14
|
+
produced counters. Net-based energy handling is required because a per-direction subtraction would emit negative cumulative counters whenever circuits flow bidirectionally (the classic case is PV self-consumption: `Σ(branch.consumed)` can exceed
|
|
15
|
+
`main.consumed` even when the net balance is valid). The synthesized PV virtual circuit participates with the correct load-perspective sign, and unmapped tab entries are zero-power, so both contribute safely to the sum. No public interface change — field
|
|
16
|
+
names and types are unchanged; only the source of the values shifts. `downstream_l1_current_a` / `downstream_l2_current_a` continue to be read directly from the downstream-lugs node — those per-phase readings are orthogonal to the defect. The underlying
|
|
17
|
+
firmware defect is tracked upstream at [spanio/SPAN-API-Client-Docs#13](https://github.com/spanio/SPAN-API-Client-Docs/issues/13).
|
|
18
|
+
|
|
7
19
|
## [2.6.2] - 04/2026
|
|
8
20
|
|
|
9
21
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: span-panel-api
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: A client library for SPAN Panel API
|
|
5
5
|
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
6
|
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# validate_lug_derivation
|
|
2
|
+
|
|
3
|
+
Diagnostic harness that compares **v1 REST**, **v2 MQTT (via the current library)**, and **Kirchhoff-derived** values for the SPAN panel's downstream-lugs (feedthrough) power and energy. Used to:
|
|
4
|
+
|
|
5
|
+
1. Validate that the library's Kirchhoff derivation (introduced in `span-panel-api` 2.6.3) stays consistent with v1 REST as an independent ground truth.
|
|
6
|
+
2. **Track when the upstream firmware defect on `downstream-lugs` is fixed.** The SPAN API is in beta and carries no version signal, so we detect the fix empirically by watching the raw MQTT properties converge on the Kirchhoff-derived values. Upstream
|
|
7
|
+
issue: [spanio/SPAN-API-Client-Docs#13](https://github.com/spanio/SPAN-API-Client-Docs/issues/13).
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
- **`v1_reader.py`** — Captures N v1-REST snapshots using an isolated `span-panel-api==1.1.15` env (the last version that still shipped the v1 client). Output: JSON on stdout.
|
|
12
|
+
- **`v2_reader.py`** — Captures N v2-MQTT snapshots from the current workspace install. Also reaches into the library's accumulator to grab the **raw** `downstream-lugs/active-power`, `imported-energy`, `exported-energy` — pre-derivation — for firmware-fix
|
|
13
|
+
tracking.
|
|
14
|
+
- **`compare.py`** — Driver. Runs both readers in parallel via `uv run`, zips samples by index, prints a side-by-side table per sample, and flags anomalies.
|
|
15
|
+
- **`run_local.sh.example`** — Template for the credentialed wrapper. Copy to `run_local.sh` (which is gitignored), fill in the three credential fields, run.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
1. Obtain credentials for a live panel:
|
|
20
|
+
|
|
21
|
+
- v1 token: existing pre-issued JWT (no re-registration needed).
|
|
22
|
+
- v2 passphrase: `hopPassphrase` for `/api/v2/auth/register`.
|
|
23
|
+
|
|
24
|
+
2. Copy `run_local.sh.example` to `run_local.sh` (the latter is gitignored, so credentials won't leak), then edit:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
HOST="192.168.X.Y"
|
|
28
|
+
V1_TOKEN="..."
|
|
29
|
+
V2_PASSPHRASE="..."
|
|
30
|
+
SAMPLES=5
|
|
31
|
+
INTERVAL=3
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. Run: `./run_local.sh`.
|
|
35
|
+
|
|
36
|
+
Requires `uv`. The v1 reader is auto-provisioned via `uv run --no-project --with span-panel-api==1.1.15`; the v2 reader uses the current workspace install.
|
|
37
|
+
|
|
38
|
+
## Output — what to look for
|
|
39
|
+
|
|
40
|
+
Per sample, `compare.py` prints:
|
|
41
|
+
|
|
42
|
+
- **Power table** — `main_power_w`, reported vs derived feedthrough, Σcircuits partitioned (PV vs loads), for both APIs.
|
|
43
|
+
- **v2 `power_flows`** — the panel's own `pv/battery/grid/site` aggregates, as indicators.
|
|
44
|
+
- **v2 downstream-lugs raw (pre-derivation)** — the three raw MQTT properties the library **stopped** reading into the snapshot in 2.6.3. This is the firmware-fix tracker.
|
|
45
|
+
- **Energy net** — `(consumed − produced)` for main, reported feedthrough, Σcircuits, and derived feedthrough.
|
|
46
|
+
- **Flags** — anomalies (see next section).
|
|
47
|
+
|
|
48
|
+
Post-2.6.3, `feedthrough_power_w (reported)` equals `(derived)` by construction on v2 (the library derives internally). That's expected — convergence on that row is itself the confirmation signal that the library-side fix is working. The interesting row
|
|
49
|
+
for ongoing tracking is the **raw** block.
|
|
50
|
+
|
|
51
|
+
## Flags
|
|
52
|
+
|
|
53
|
+
- **`firmware downstream-lugs active-power still offset by X W vs Kirchhoff — upstream defect present`** — The raw MQTT `active-power` differs from the Kirchhoff-derived feedthrough by more than 100 W. Current state while the upstream firmware defect is
|
|
54
|
+
unpatched.
|
|
55
|
+
- **`firmware downstream-lugs active-power within X W of Kirchhoff — upstream defect MAY be fixed (confirm over sustained samples)`** — The delta has dropped below 50 W. Could be sensor noise on a single sample — confirm across a longer run (e.g.
|
|
56
|
+
`SAMPLES=30 INTERVAL=10`) before declaring the upstream fix has shipped.
|
|
57
|
+
- **`firmware downstream-lugs imported-energy is NEGATIVE (X Wh) — upstream counter still broken`** — The cumulative `imported-energy` counter went negative, which is physically impossible for a monotonic counter. Historically observed; current live panels
|
|
58
|
+
sometimes emit positive values, so the flag is only armed when `< 0`.
|
|
59
|
+
- **`v1 feedthrough_consumed_wh is NEGATIVE (...)`** — The v1 REST feedthrough energy counter is broken on this panel too, independent of the MQTT defect. Included for completeness; v1 is not a viable fallback.
|
|
60
|
+
- **`v1 reported net energy off Kirchhoff by X Wh`** / **`v1 reported feedthrough off Kirchhoff by X W`** — Same shape of check on the v1 side. v1 active-power tends to track Kirchhoff within sensor noise; v1 energy diverges heavily due to the broken
|
|
61
|
+
counter.
|
|
62
|
+
- **`derived feedthrough power diverges across APIs: v1=A vs v2=B`** — The Kirchhoff-derived values from v1 and v2 disagree by more than 100 W. Usually explained by sample-timing skew when load is shifting quickly; a persistent gap would warrant
|
|
63
|
+
investigation of the sign-partitioning logic.
|
|
64
|
+
|
|
65
|
+
## When the firmware is fixed
|
|
66
|
+
|
|
67
|
+
Watch for the `active-power ... MAY be fixed` flag to fire on every sample across a sustained run (e.g. 30+ samples over several minutes). When that holds:
|
|
68
|
+
|
|
69
|
+
1. Verify `imported-energy` stays non-negative and its delta from the derived `consumed_wh` stabilizes near zero.
|
|
70
|
+
2. Consider whether the library should switch back to reading the native `downstream-lugs` values directly, or continue deriving. Deriving is robust regardless of firmware state, so the change is optional — potentially valuable only if the panel's own
|
|
71
|
+
measurement is more precise than the computed one (which is not yet established).
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
# v2 raw downstream-lugs (pre-derivation) — captured directly from the
|
|
182
|
+
# MQTT accumulator before the library applies its Kirchhoff fix.
|
|
183
|
+
# Watch the delta vs derived to detect when the upstream firmware is
|
|
184
|
+
# patched (spanio/SPAN-API-Client-Docs#13). Because the SPAN API is in
|
|
185
|
+
# beta, there is no version signal that would announce the fix — we
|
|
186
|
+
# detect it empirically.
|
|
187
|
+
raw = v2.get("downstream_lugs_raw") or {}
|
|
188
|
+
raw_ap = raw.get("active_power_w")
|
|
189
|
+
raw_ie = raw.get("imported_energy_wh")
|
|
190
|
+
raw_ee = raw.get("exported_energy_wh")
|
|
191
|
+
print("\n v2 downstream-lugs raw (pre-derivation, upstream-firmware view):")
|
|
192
|
+
delta_ap = (raw_ap - derived_v2) if raw_ap is not None else None
|
|
193
|
+
print(f" active-power: {_fmt(raw_ap, 12)} "
|
|
194
|
+
f"Δ vs derived: {_fmt(delta_ap, 10)}")
|
|
195
|
+
print(f" imported-energy: {_fmt(raw_ie, 12)} "
|
|
196
|
+
f"(library-derived consumed: {_fmt(float(v2['feedthrough_consumed_wh']), 10)})")
|
|
197
|
+
print(f" exported-energy: {_fmt(raw_ee, 12)} "
|
|
198
|
+
f"(library-derived produced: {_fmt(float(v2['feedthrough_produced_wh']), 10)})")
|
|
199
|
+
|
|
200
|
+
print("\n energy net (Wh = consumed - produced):")
|
|
201
|
+
print(f"{' field':<44}{'v1':>12}{'v2':>12}{'Δ(v2-v1)':>12}")
|
|
202
|
+
rows_e: list[tuple[str, float, float]] = [
|
|
203
|
+
("net_main", net_main_v1, net_main_v2),
|
|
204
|
+
("net_feedthrough (reported)", net_feed_rpt_v1, net_feed_rpt_v2),
|
|
205
|
+
("net_Σcircuits", net_circ_v1, net_circ_v2),
|
|
206
|
+
("net_feedthrough (derived)", net_feed_der_v1, net_feed_der_v2),
|
|
207
|
+
]
|
|
208
|
+
for label, a, b in rows_e:
|
|
209
|
+
print(f"{' ' + label:<44}{_fmt(a)}{_fmt(b)}{_fmt(b - a)}")
|
|
210
|
+
|
|
211
|
+
# Cross-API consistency: derived should match across v1 and v2.
|
|
212
|
+
# Note: v2 library >= 2.6.3 derives feedthrough, so `feed_v2 ==
|
|
213
|
+
# derived_v2` by construction — the defect signal now lives on the raw
|
|
214
|
+
# downstream-lugs values (see raw_ap / raw_ie / raw_ee above).
|
|
215
|
+
flags: list[str] = []
|
|
216
|
+
if abs(derived_v1 - derived_v2) > 100.0:
|
|
217
|
+
flags.append(
|
|
218
|
+
f"derived feedthrough power diverges across APIs: "
|
|
219
|
+
f"v1={derived_v1:+.1f} W vs v2={derived_v2:+.1f} W"
|
|
220
|
+
)
|
|
221
|
+
dp1 = derived_v1 - feed_v1
|
|
222
|
+
if abs(dp1) > 100.0:
|
|
223
|
+
flags.append(f"v1 reported feedthrough off Kirchhoff by {dp1:+.1f} W")
|
|
224
|
+
if float(v1["feedthrough_consumed_wh"]) < 0:
|
|
225
|
+
flags.append(
|
|
226
|
+
f"v1 feedthrough_consumed_wh is NEGATIVE ({v1['feedthrough_consumed_wh']:.0f}) — "
|
|
227
|
+
f"counter cannot decrease"
|
|
228
|
+
)
|
|
229
|
+
de1 = net_feed_der_v1 - net_feed_rpt_v1
|
|
230
|
+
if abs(de1) > 1000.0:
|
|
231
|
+
flags.append(f"v1 reported net energy off Kirchhoff by {de1:+,.0f} Wh")
|
|
232
|
+
|
|
233
|
+
# Raw downstream-lugs vs derived: this is the upstream-firmware tracker.
|
|
234
|
+
# When SPAN ships the fix, raw_ap will converge to derived_v2 and raw_ie
|
|
235
|
+
# will stop emitting negative values.
|
|
236
|
+
if raw_ap is not None and delta_ap is not None:
|
|
237
|
+
if abs(delta_ap) > 100.0:
|
|
238
|
+
flags.append(
|
|
239
|
+
f"firmware downstream-lugs active-power still offset by "
|
|
240
|
+
f"{delta_ap:+.1f} W vs Kirchhoff — upstream defect present"
|
|
241
|
+
)
|
|
242
|
+
elif abs(delta_ap) < 50.0:
|
|
243
|
+
flags.append(
|
|
244
|
+
f"firmware downstream-lugs active-power within {abs(delta_ap):.1f} W of "
|
|
245
|
+
f"Kirchhoff — upstream defect MAY be fixed (confirm over sustained samples)"
|
|
246
|
+
)
|
|
247
|
+
if raw_ie is not None and raw_ie < 0.0:
|
|
248
|
+
flags.append(
|
|
249
|
+
f"firmware downstream-lugs imported-energy is NEGATIVE ({raw_ie:.0f} Wh) — "
|
|
250
|
+
f"upstream counter still broken"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
for f in flags:
|
|
254
|
+
print(f" ! {f}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def main() -> int:
|
|
258
|
+
parser = argparse.ArgumentParser()
|
|
259
|
+
parser.add_argument("--host", required=True)
|
|
260
|
+
parser.add_argument("--v1-token", required=True)
|
|
261
|
+
parser.add_argument("--v2-passphrase", required=True)
|
|
262
|
+
parser.add_argument("--port", type=int, default=80)
|
|
263
|
+
parser.add_argument("--samples", type=int, default=5)
|
|
264
|
+
parser.add_argument("--interval", type=float, default=3.0)
|
|
265
|
+
parser.add_argument("--dump-json", type=Path)
|
|
266
|
+
args = parser.parse_args()
|
|
267
|
+
|
|
268
|
+
v1_cmd = [
|
|
269
|
+
"uv", "run", "--no-project", "--with", "span-panel-api==1.1.15",
|
|
270
|
+
"python", str(HERE / "v1_reader.py"),
|
|
271
|
+
"--host", args.host,
|
|
272
|
+
"--token", args.v1_token,
|
|
273
|
+
"--port", str(args.port),
|
|
274
|
+
"--samples", str(args.samples),
|
|
275
|
+
"--interval", str(args.interval),
|
|
276
|
+
]
|
|
277
|
+
v2_cmd = [
|
|
278
|
+
"uv", "run",
|
|
279
|
+
"python", str(HERE / "v2_reader.py"),
|
|
280
|
+
"--host", args.host,
|
|
281
|
+
"--passphrase", args.v2_passphrase,
|
|
282
|
+
"--port", str(args.port),
|
|
283
|
+
"--samples", str(args.samples),
|
|
284
|
+
"--interval", str(args.interval),
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
v1_task = asyncio.create_task(_run(v1_cmd, cwd=HERE))
|
|
288
|
+
v2_task = asyncio.create_task(_run(v2_cmd, cwd=SPAN_API_ROOT))
|
|
289
|
+
v1_result, v2_result = await asyncio.gather(v1_task, v2_task)
|
|
290
|
+
|
|
291
|
+
v1_samples = v1_result["samples"]
|
|
292
|
+
v2_samples = v2_result["samples"]
|
|
293
|
+
n = min(len(v1_samples), len(v2_samples))
|
|
294
|
+
if n == 0:
|
|
295
|
+
print("no samples captured", file=sys.stderr)
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
for i in range(n):
|
|
299
|
+
_print_sample(i, v1_samples[i], v2_samples[i])
|
|
300
|
+
|
|
301
|
+
if args.dump_json is not None:
|
|
302
|
+
args.dump_json.write_text(
|
|
303
|
+
json.dumps({"v1": v1_result, "v2": v2_result}, indent=2)
|
|
304
|
+
)
|
|
305
|
+
print(f"\nraw JSON written to {args.dump_json}")
|
|
306
|
+
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
sys.exit(asyncio.run(main()))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Template for validate_lug_derivation. Copy to `run_local.sh` (gitignored),
|
|
3
|
+
# fill in the three credential fields, and run: `./run_local.sh`.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
HOST="192.168.X.Y"
|
|
8
|
+
V1_TOKEN="<paste v1 JWT here>"
|
|
9
|
+
V2_PASSPHRASE="<paste v2 hopPassphrase here>"
|
|
10
|
+
|
|
11
|
+
SAMPLES=5
|
|
12
|
+
INTERVAL=3
|
|
13
|
+
DUMP="/tmp/lug-readings.json"
|
|
14
|
+
|
|
15
|
+
cd "$(dirname "$0")"
|
|
16
|
+
exec python compare.py \
|
|
17
|
+
--host "$HOST" \
|
|
18
|
+
--v1-token "$V1_TOKEN" \
|
|
19
|
+
--v2-passphrase "$V2_PASSPHRASE" \
|
|
20
|
+
--samples "$SAMPLES" \
|
|
21
|
+
--interval "$INTERVAL" \
|
|
22
|
+
--dump-json "$DUMP"
|
|
@@ -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()))
|