Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/schema-1/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions packages/schema-1/src/span_panel_api_schema_1/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -56,13 +56,16 @@
PROP_RELAY,
PROP_RELAY_CONTROLLABLE,
PROP_RELAY_REQUESTER,
PROP_SHARED_WITH_DEVICE_IDS,
PROP_SPACES,
UNKNOWN,
)
from span_panel_api_schema_1.description import declared_settable, node_properties
from span_panel_api_schema_1.panel import declares_node

if TYPE_CHECKING:
from collections.abc import Collection

from ebus_sdk.homie import DiscoveredDevice


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/schema-1/src/span_panel_api_schema_1/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------
Expand Down
5 changes: 4 additions & 1 deletion packages/schema-1/src/span_panel_api_schema_1/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)))

Expand Down
3 changes: 3 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
V2HomieSchema,
V2StatusInfo,
is_discovery_path,
shared_meter_groups,
)
from .mqtt import (
ControlCommand,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

Expand Down
12 changes: 0 additions & 12 deletions tests/expected_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions tests/test_public_api_unchanged.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion tests/test_schema_one_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand Down
115 changes: 115 additions & 0 deletions tests/test_schema_one_shared_meters.py
Original file line number Diff line number Diff line change
@@ -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) == {}
Loading
Loading