diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 7222937..7009099 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ # Raised to 3.6.1, whose SpanPanelSnapshot.pv documents what this parser builds. # Raised to 3.6.2, which waits on this parser's missing labels after a bridge # rebuild as well as on connect; the fix spans both, so the pair moves together. + # To rise to 3.8.0b1 when that release is cut: this parser fills snapshot + # fields new in it and imports `device_id_order` from its models. "span-panel-api>=3.6.2,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py index 3b857e4..46c2d46 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/circuits.py +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING -from span_panel_api.models import FEEDS_ROLES, FeedsRole, SpanCircuitSnapshot +from span_panel_api.models import FEEDS_ROLES, FeedsRole, SpanCircuitSnapshot, device_id_order from span_panel_api_schema_1.const import ( NODE_BREAKER, NODE_CONNECTION, @@ -56,6 +56,7 @@ PROP_RELAY, PROP_RELAY_CONTROLLABLE, PROP_RELAY_REQUESTER, + PROP_SHARED_WITH_DEVICE_IDS, PROP_SPACES, UNKNOWN, ) @@ -63,6 +64,8 @@ from span_panel_api_schema_1.panel import declares_node if TYPE_CHECKING: + from collections.abc import Collection + from ebus_sdk.homie import DiscoveredDevice @@ -269,10 +272,31 @@ def _feeds_role(device: DiscoveredDevice) -> FeedsRole | None: return next((role for role in FEEDS_ROLES if role == published), None) +def _shared_with(device: DiscoveredDevice, node: str, circuit_ids: Collection[str]) -> tuple[str, ...] | None: + """One node's `shared-with-device-ids`, resolved to the other circuits of the snapshot. + + A circuit's snapshot key is its device id, so resolving keeps the listed ids + that name another circuit, in `device_id_order`. `None` while the list is + undeclared or unpublished; an empty tuple where it names no known circuit. + """ + published = device.get_property(node, PROP_SHARED_WITH_DEVICE_IDS) + if published is None: + return None + listed = {part.strip() for part in str(published).split(",")} + return tuple(sorted((listed & set(circuit_ids)) - {device.device_id}, key=device_id_order)) + + def build_circuit( - device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" + device: DiscoveredDevice, + device_type: str = "circuit", + relative_position: str = "", + circuit_ids: Collection[str] = frozenset(), ) -> SpanCircuitSnapshot: - """Build one circuit snapshot from its v1.0 device.""" + """Build one circuit snapshot from its v1.0 device. + + `circuit_ids` is every circuit device id in the tree, against which the + circuit's shared-with lists are resolved. + """ outside = not declares_spaces(device) raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) # A hosted circuit is negated so positive means consumption; a meter outside @@ -313,6 +337,8 @@ def build_circuit( device_type=device_type, relative_position=relative_position, feeds_role=_feeds_role(device), + meter_shared_with=_shared_with(device, NODE_METER, circuit_ids), + relay_shared_with=_shared_with(device, NODE_SWITCH, circuit_ids), # Unknown without a pole count: a meter outside the panel has no # breaker, and a breaker whose count has not arrived has not said. is_240v=None if poles is None else poles >= 2, diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index f3e984f..b6631ba 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -45,6 +45,7 @@ PROP_FEEDS_DEVICE_ID = "feeds-device-id" PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" PROP_FEEDS_ROLE = "feeds-role" +PROP_SHARED_WITH_DEVICE_IDS = "shared-with-device-ids" PROP_FED_BY_DEVICE_ID = "fed-by-device-id" PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" NODE_DOOR = "door" diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py index 7e2105d..f51ab58 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -433,6 +433,9 @@ def _lookup( # --- Circuit topology and PCS membership -------------------------------- (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-id"), (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-role"), + # Device references, resolved to the circuit ids of the snapshot. + (TYPE_CIRCUIT, NODE_METER, "shared-with-device-ids"), + (TYPE_CIRCUIT, NODE_SWITCH, "shared-with-device-ids"), (TYPE_CIRCUIT, NODE_PCS, "managed"), (TYPE_CIRCUIT, NODE_PCS, "priority"), # --- Lugs direction and the upstream device's link ----------------------- diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 30cbcac..7604ddb 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -160,8 +160,11 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) -> # uses, and this loop is where a circuit's is decided -- deriving it a # second time downstream would be a second implementation free to drift. circuit_subjects: list[tuple[DiscoveredDevice, ExtensionSubject]] = [] + circuit_ids = frozenset(circuit.device_id for circuit in roles.circuits) for circuit in roles.circuits: - snapshot = build_circuit(circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit")) + snapshot = build_circuit( + circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit"), circuit_ids=circuit_ids + ) circuits[snapshot.circuit_id] = snapshot circuit_subjects.append((circuit, ExtensionSubject(kind="circuit", instance_key=snapshot.circuit_id))) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index b1ab08c..3c11422 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -63,6 +63,7 @@ V2HomieSchema, V2StatusInfo, is_discovery_path, + shared_meter_groups, ) from .mqtt import ( ControlCommand, @@ -155,6 +156,8 @@ # `SpanCircuitSnapshot.feeds_role`. "FEEDS_ROLES", "FeedsRole", + # The circuits one meter measures, from `SpanCircuitSnapshot.meter_shared_with`. + "shared_meter_groups", # Factory "create_span_client", # Detection diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index cc72d60..e346367 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -11,6 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field +import re from typing import Final, Literal # Homie schema type: {type_name: {property_name: {attribute: value}}} @@ -95,6 +96,22 @@ class SpanCircuitSnapshot: has no relay, no breaker and no shed priority, and occupies no position. """ + meter_shared_with: tuple[str, ...] | None = None + """The other circuits this circuit's meter also measures, by circuit id. + + From `meter/shared-with-device-ids`, without the circuit itself or an id + naming no circuit in the snapshot, ordered by `device_id_order`. `None` + where the circuit declares no such list or has not published it; `()` where + it lists no circuit the snapshot holds. The peers' readings are the same + meter's, so a consumer never adds them up; `shared_meter_groups` groups + them. + """ + relay_shared_with: tuple[str, ...] | None = None + """The other circuits this circuit's relay also switches, by circuit id. + + From `switch/shared-with-device-ids`, read as `meter_shared_with` is. + """ + feeds_role: FeedsRole | None = None """What the circuit feeds, from its `connection/feeds-role`. @@ -590,6 +607,48 @@ class FieldMetadata: _DISCOVERY_PREFIX = f"{DISCOVERY_NAMESPACE}." +def device_id_order(device_id: str) -> tuple[str | int, ...]: + """Sort key for device ids under which digit runs compare as numbers. + + `circuit-9` sorts before `circuit-10`. Splitting on a captured digit run + leaves the runs at the odd positions and the text between them at the even + ones, so every position holds the same type in every key. + """ + parts = re.split(r"(\d+)", device_id) + return tuple(int(part) if index % 2 else part for index, part in enumerate(parts)) + + +def shared_meter_groups(circuits: Mapping[str, SpanCircuitSnapshot]) -> dict[str, tuple[str, ...]]: + """The circuits one meter measures, grouped and keyed by their first member. + + A group holds every circuit linked through `meter_shared_with`, in + `device_id_order`, and its key is the first of them. A circuit that names no + known peer and is named by none is in no group. Only membership: no reading + is combined, because every member already reports the one meter's values. + """ + links: dict[str, set[str]] = {} + for circuit_id, circuit in circuits.items(): + for peer in circuit.meter_shared_with or (): + if peer in circuits and peer != circuit_id: + links.setdefault(circuit_id, set()).add(peer) + links.setdefault(peer, set()).add(circuit_id) + groups: dict[str, tuple[str, ...]] = {} + seen: set[str] = set() + for start in sorted(links, key=device_id_order): + if start in seen: + continue + members = {start} + pending = [start] + while pending: + for peer in links[pending.pop()] - members: + members.add(peer) + pending.append(peer) + seen |= members + ordered = tuple(sorted(members, key=device_id_order)) + groups[ordered[0]] = ordered + return groups + + def discovery_path(device_type: str, node_id: str, property_id: str) -> str: """The namespaced field path for one declared-but-unaddressed property. diff --git a/tests/expected_failures.py b/tests/expected_failures.py index 0e96b9e..27b283b 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -45,21 +45,9 @@ ), f"{_ACCEPTANCE}::test_every_model_property_is_none_where_not_published": _UNPUBLISHED_IS_NONE, f"{_ACCEPTANCE}::test_the_reference_main_32_publishes_none_of_them": _UNPUBLISHED_IS_NONE, - f"{_ACCEPTANCE}::test_shared_with_resolves_to_other_circuits_in_device_id_order": ( - f"shared-with resolved to circuit ids lands with {_SHARED}" - ), - f"{_ACCEPTANCE}::test_shared_with_orders_instance_numbers_numerically": ( - f"shared-with in numeric instance order lands with {_SHARED}" - ), - f"{_ACCEPTANCE}::test_a_shared_group_naming_no_known_circuit_is_still_shared": ( - f"an empty shared-with for a declared group lands with {_SHARED}" - ), f"{_COMPANIONS}::test_the_shipped_tree_reads_busbar_current_and_frequency": ( f"busbar current and frequency land with {_READINGS}" ), - f"{_COMPANIONS}::test_the_shipped_tree_resolves_its_shared_relay": ( - f"shared-with resolved to circuit ids lands with {_SHARED}" - ), } _DEVICE_TYPE_AS_PUBLISHED = "the library keeps device_type as published; a SOLAR feeds-role is carried by feeds_role" diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index 3e527bc..0682c4f 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -88,6 +88,10 @@ # the field defaults to `None`, and the flat schema declares no roles. "FEEDS_ROLES", "FeedsRole", + # Added 2026-10-09: groups the circuits that share one meter, from + # `SpanCircuitSnapshot.meter_shared_with`. Purely additive -- a snapshot in + # which no circuit shares a meter has no groups. + "shared_meter_groups", # Factory "create_span_client", # Detection diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py index 2d6b754..dccc13d 100644 --- a/tests/test_schema_one_discovery.py +++ b/tests/test_schema_one_discovery.py @@ -267,7 +267,9 @@ def test_an_off_snapshot_route_that_became_observable_must_be_retired() -> None: _FEEDS_ROLE: Declaration = (TYPE_CIRCUIT, "connection", "feeds-role") -_ABSENT_FROM_THE_CAPTURE: frozenset[Declaration] = frozenset({_FEEDS_ROLE}) +_METER_SHARED_WITH: Declaration = (TYPE_CIRCUIT, "meter", "shared-with-device-ids") +_SWITCH_SHARED_WITH: Declaration = (TYPE_CIRCUIT, "switch", "shared-with-device-ids") +_ABSENT_FROM_THE_CAPTURE: frozenset[Declaration] = frozenset({_FEEDS_ROLE, _METER_SHARED_WITH, _SWITCH_SHARED_WITH}) """Addressed entries the capture never declares, each proved on a synthetic declaration below.""" _A_LOAD_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" @@ -310,6 +312,30 @@ def test_feeds_role_moves_the_snapshot_on_a_circuit_that_declares_it() -> None: assert _FEEDS_ROLE in _CONSUMED_WITHOUT_A_ROW +_ANOTHER_LOAD_CIRCUIT = "d3724e0d660ba506aa79c1cafe5d1181" + + +@pytest.mark.parametrize( + ("declaration", "field"), + [(_METER_SHARED_WITH, "meter_shared_with"), (_SWITCH_SHARED_WITH, "relay_shared_with")], +) +def test_shared_with_moves_the_snapshot_on_a_circuit_that_declares_it(declaration: Declaration, field: str) -> None: + """The experiment above, run on a circuit given the declaration the capture lacks.""" + _type, node, prop = declaration + tree = _tree() + description = json.loads(tree[_A_LOAD_CIRCUIT]["$description"]) + description["nodes"][node]["properties"][prop] = {"datatype": "string"} + tree[_A_LOAD_CIRCUIT]["$description"] = json.dumps(description) + tree[_A_LOAD_CIRCUIT][f"{node}/{prop}"] = "probe-value" + baseline = _snapshot_fields(_snapshot(tree)) + tree[_A_LOAD_CIRCUIT][f"{node}/{prop}"] = _ANOTHER_LOAD_CIRCUIT + after = _snapshot_fields(_snapshot(tree)) + + moved = {path for path, value in after.items() if baseline.get(path) != value} + assert moved == {f"circuits@{_A_LOAD_CIRCUIT}.{field}"} + assert declaration in _CONSUMED_WITHOUT_A_ROW + + def test_no_addressed_entry_duplicates_a_metadata_row() -> None: """The four tables partition the addressed set; they do not overlap. diff --git a/tests/test_schema_one_shared_meters.py b/tests/test_schema_one_shared_meters.py new file mode 100644 index 0000000..2a6bb96 --- /dev/null +++ b/tests/test_schema_one_shared_meters.py @@ -0,0 +1,115 @@ +"""Circuits that share a meter or a relay name each other. + +`meter/` and `switch/shared-with-device-ids` list the other devices one meter +measures or one relay switches. They are resolved to the circuit ids of the +snapshot: the circuit itself and ids naming no circuit are dropped, and the +rest are ordered by device id with instance numbers compared as numbers. +Undeclared or not yet published reads `None`; declared with no known peer reads +`()`. `shared_meter_groups` groups the circuits one meter measures; nothing is +summed. +""" + +from __future__ import annotations + +from conftest import flat_schema +from reference_payloads.schema_one import parent_child_tree, replay +from reference_payloads.synthetic_trees import ( + PANEL_ID, + SyntheticDevice, + hosted_circuit, + panel, + shared_pair, + tree, + without_values, +) +from span_panel_api import SpanPanelSnapshot, shared_meter_groups +from span_panel_api.models import device_id_order +from span_panel_api_schema_0 import SchemaZeroAdapter + +_REFERENCE_PANEL = "example-40t-001" + + +def _snapshot(*circuits: SyntheticDevice) -> SpanPanelSnapshot: + return replay(tree(panel(), *circuits), PANEL_ID).build_snapshot() + + +def test_a_shared_pair_names_each_other_for_meter_and_relay() -> None: + circuits = _snapshot(*shared_pair("c-1", "c-2")).circuits + + assert (circuits["c-1"].meter_shared_with, circuits["c-1"].relay_shared_with) == (("c-2",), ("c-2",)) + assert (circuits["c-2"].meter_shared_with, circuits["c-2"].relay_shared_with) == (("c-1",), ("c-1",)) + + +def test_the_circuit_itself_and_unknown_ids_are_dropped() -> None: + circuits = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-1", "ghost", "c-2"), relay_shared_with=("ghost",)), + hosted_circuit("c-2", (3,)), + ).circuits + + assert circuits["c-1"].meter_shared_with == ("c-2",) + assert circuits["c-1"].relay_shared_with == () + + +def test_instance_numbers_are_ordered_as_numbers() -> None: + circuits = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-10", "c-9")), + hosted_circuit("c-9", (3,)), + hosted_circuit("c-10", (5,)), + ).circuits + + assert circuits["c-1"].meter_shared_with == ("c-9", "c-10") + assert sorted(["c-10", "c-9", "c-1", "meter-a"], key=device_id_order) == ["c-1", "c-9", "c-10", "meter-a"] + + +def test_undeclared_shared_with_reads_none() -> None: + circuit = _snapshot(hosted_circuit("c-1", (1,))).circuits["c-1"] + + assert (circuit.meter_shared_with, circuit.relay_shared_with) == (None, None) + + +def test_declared_shared_with_not_yet_published_reads_none() -> None: + first, second = shared_pair("c-1", "c-2") + unpublished = without_values(first, "meter/shared-with-device-ids", "switch/shared-with-device-ids") + snapshot = _snapshot(unpublished, second) + + assert (snapshot.circuits["c-1"].meter_shared_with, snapshot.circuits["c-1"].relay_shared_with) == (None, None) + + +def test_declared_with_no_known_peer_reads_empty_and_has_no_group() -> None: + snapshot = _snapshot(hosted_circuit("c-1", (1,), meter_shared_with=("ghost",))) + + assert snapshot.circuits["c-1"].meter_shared_with == () + assert shared_meter_groups(snapshot.circuits) == {} + + +def test_groups_are_keyed_by_their_first_member() -> None: + snapshot = _snapshot(*shared_pair("c-10", "c-9", space=1), *shared_pair("c-2", "c-3", space=3)) + + assert shared_meter_groups(snapshot.circuits) == {"c-2": ("c-2", "c-3"), "c-9": ("c-9", "c-10")} + + +def test_a_group_holds_every_circuit_one_meter_measures() -> None: + """Named by any member, a circuit is in the group, even where its own list has not arrived.""" + snapshot = _snapshot( + hosted_circuit("c-1", (1,), meter_shared_with=("c-2",)), + hosted_circuit("c-2", (1,), meter_shared_with=("c-3",)), + hosted_circuit("c-3", (1,)), + ) + + assert shared_meter_groups(snapshot.circuits) == {"c-1": ("c-1", "c-2", "c-3")} + + +def test_the_reference_tree_shares_nothing() -> None: + snapshot = replay(parent_child_tree(), _REFERENCE_PANEL).build_snapshot() + + assert all( + (circuit.meter_shared_with, circuit.relay_shared_with) == (None, None) for circuit in snapshot.circuits.values() + ) + assert shared_meter_groups(snapshot.circuits) == {} + + +def test_a_flat_panel_shares_nothing() -> None: + snapshot = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)).build_snapshot() + + assert all(circuit.meter_shared_with is None for circuit in snapshot.circuits.values()) + assert shared_meter_groups(snapshot.circuits) == {} diff --git a/tests/test_shared_meters_captures.py b/tests/test_shared_meters_captures.py new file mode 100644 index 0000000..553f1b3 --- /dev/null +++ b/tests/test_shared_meters_captures.py @@ -0,0 +1,60 @@ +"""Shared meters and relays, over every reference capture. + +Each circuit's peers and each group are compared with what the capture's own +`shared-with-device-ids` values list. +""" + +from __future__ import annotations + +import pytest + +from reference_payloads.captures import CAPTURES, CaptureTree, capture_adapter, capture_snapshot, capture_tree +from span_panel_api import shared_meter_groups +from span_panel_api.models import device_id_order, is_discovery_path + +_SHARED_WITH = ("meter/shared-with-device-ids", "switch/shared-with-device-ids") + + +def _declared_peers(tree: CaptureTree, circuit_id: str, topic: str) -> tuple[str, ...] | None: + published = tree.value(circuit_id, topic) + if published is None: + return None + circuits = set(tree.circuits()) + listed = {peer.strip() for peer in published.split(",") if peer.strip()} + return tuple(sorted((listed & circuits) - {circuit_id}, key=device_id_order)) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_each_circuit_names_the_peers_its_capture_lists(stem: str) -> None: + tree, circuits = capture_tree(stem), capture_snapshot(stem).circuits + for circuit_id in tree.circuits(): + circuit = circuits[circuit_id] + assert circuit.meter_shared_with == _declared_peers(tree, circuit_id, _SHARED_WITH[0]), circuit_id + assert circuit.relay_shared_with == _declared_peers(tree, circuit_id, _SHARED_WITH[1]), circuit_id + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_shared_meters_group_exactly_their_declared_peers(stem: str) -> None: + tree = capture_tree(stem) + members: set[frozenset[str]] = set() + for circuit_id in tree.circuits(): + peers = _declared_peers(tree, circuit_id, _SHARED_WITH[0]) + if peers: + members.add(frozenset({circuit_id, *peers})) + expected = {group[0]: group for group in (tuple(sorted(member, key=device_id_order)) for member in members)} + + assert shared_meter_groups(capture_snapshot(stem).circuits) == expected + + +def test_the_captures_hold_shared_meters() -> None: + assert any(shared_meter_groups(capture_snapshot(stem).circuits) for stem in CAPTURES) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_shared_with_is_neither_discovered_nor_an_extension_reading(stem: str) -> None: + adapter = capture_adapter(stem) + discovered = [path for path in adapter.build_field_metadata() if is_discovery_path(path)] + extension = {f"{row.node_id}/{row.property_id}" for row in adapter.build_snapshot().extension_properties} + + assert not [path for path in discovered if path.endswith(tuple(f"/{topic}" for topic in _SHARED_WITH))] + assert not extension & set(_SHARED_WITH)