From 03311eb37a4c50018fa51af68c431d8230e44a88 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:19:43 -0700 Subject: [PATCH 1/2] feat(snapshot): read busbar current, frequency, protection rating, nominal voltage and protection functions The snapshot reads five properties where a panel declares them, each None where it is undeclared or unpublished: - SpanPanelSnapshot.busbar_current_a and frequency_hz from the panel's meter/busbar-current and meter/frequency; - SpanPanelSnapshot.upstream_protection_rating_a from the upstream lugs' connection/overcurrent-protection; - SpanCircuitSnapshot.nominal_voltage_v from info/nominal-voltage; - SpanCircuitSnapshot.protection_functions from breaker/protection-functions, as ProtectionFunction members in published order, dropping any value outside PROTECTION_FUNCTIONS. Each carries field metadata from its declaration and is no longer a discovery row or an extension reading. A node that leaves one of them out is not a gap, so no unresolved row appears where a panel does not declare it. ProtectionFunction and PROTECTION_FUNCTIONS are exported. The acceptance tests these satisfy leave the expected-failure registry. The one that expects the lugs' overcurrent-protection to stay a discovery row is registered as a known divergence, with a companion test asserting the rest of it. Part of the spanos3/r202639/03 batch 2 work. --- .../src/span_panel_api_schema_1/circuits.py | 26 +++- .../src/span_panel_api_schema_1/const.py | 5 + .../span_panel_api_schema_1/field_metadata.py | 56 ++++++-- .../src/span_panel_api_schema_1/panel.py | 6 + .../src/span_panel_api_schema_1/snapshot.py | 3 + src/span_panel_api/__init__.py | 6 + src/span_panel_api/models.py | 35 +++++ tests/expected_failures.py | 34 +---- tests/test_panel_properties_captures.py | 71 ++++++++++ tests/test_public_api_unchanged.py | 5 + .../test_schema_one_acceptance_companions.py | 28 +++- tests/test_schema_one_conformance.py | 31 ++++ tests/test_schema_one_panel_properties.py | 134 ++++++++++++++++++ 13 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 tests/test_panel_properties_captures.py create mode 100644 tests/test_schema_one_panel_properties.py 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 46c2d46..c155241 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,14 @@ from typing import TYPE_CHECKING -from span_panel_api.models import FEEDS_ROLES, FeedsRole, SpanCircuitSnapshot, device_id_order +from span_panel_api.models import ( + FEEDS_ROLES, + PROTECTION_FUNCTIONS, + FeedsRole, + ProtectionFunction, + SpanCircuitSnapshot, + device_id_order, +) from span_panel_api_schema_1.const import ( NODE_BREAKER, NODE_CONNECTION, @@ -50,8 +57,10 @@ PROP_IMPORTED_ENERGY, PROP_MANAGED, PROP_NAME, + PROP_NOMINAL_VOLTAGE, PROP_POLES, PROP_PRIORITY, + PROP_PROTECTION_FUNCTIONS, PROP_RATING, PROP_RELAY, PROP_RELAY_CONTROLLABLE, @@ -272,6 +281,19 @@ def _feeds_role(device: DiscoveredDevice) -> FeedsRole | None: return next((role for role in FEEDS_ROLES if role == published), None) +def _protection_functions(device: DiscoveredDevice) -> tuple[ProtectionFunction, ...] | None: + """The published `breaker/protection-functions`, in order, or None when unpublished. + + A comma-separated list of the enum's members; a value outside + `PROTECTION_FUNCTIONS` is dropped rather than passed on. + """ + published = device.get_property(NODE_BREAKER, PROP_PROTECTION_FUNCTIONS) + if published is None: + return None + listed = [part.strip() for part in str(published).split(",")] + return tuple(function for part in listed for function in PROTECTION_FUNCTIONS if function == part) + + 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. @@ -336,6 +358,8 @@ def build_circuit( is_never_backup=not priority_settable, device_type=device_type, relative_position=relative_position, + nominal_voltage_v=_number(device, NODE_INFO, PROP_NOMINAL_VOLTAGE), + protection_functions=_protection_functions(device), 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), 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 b6631ba..ea1bd86 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 @@ -46,6 +46,11 @@ PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" PROP_FEEDS_ROLE = "feeds-role" PROP_SHARED_WITH_DEVICE_IDS = "shared-with-device-ids" +PROP_OVERCURRENT_PROTECTION = "overcurrent-protection" +PROP_BUSBAR_CURRENT = "busbar-current" +PROP_FREQUENCY = "frequency" +PROP_NOMINAL_VOLTAGE = "nominal-voltage" +PROP_PROTECTION_FUNCTIONS = "protection-functions" 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 f51ab58..2599e61 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 @@ -44,6 +44,7 @@ PROP_ACTIVE_POWER, PROP_EXPORTED_ENERGY, PROP_IMPORTED_ENERGY, + PROP_OVERCURRENT_PROTECTION, TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, @@ -85,6 +86,8 @@ (TYPE_PANEL, NODE_STATUS, "cloud-connection", "panel.vendor_cloud"), (TYPE_PANEL, NODE_METER, "voltage-a", "panel.l1_voltage"), (TYPE_PANEL, NODE_METER, "voltage-b", "panel.l2_voltage"), + (TYPE_PANEL, NODE_METER, "busbar-current", "panel.busbar_current_a"), + (TYPE_PANEL, NODE_METER, "frequency", "panel.frequency_hz"), (TYPE_PANEL, NODE_BREAKER, "rating", "panel.main_breaker_rating_a"), (TYPE_PANEL, NODE_POWER_FLOWS, "pv", "panel.power_flow_pv"), (TYPE_PANEL, NODE_POWER_FLOWS, "battery", "panel.power_flow_battery"), @@ -131,6 +134,8 @@ (TYPE_CIRCUIT, NODE_METER, "exported-energy", "circuit.consumed_energy_wh"), (TYPE_CIRCUIT, NODE_BREAKER, "rating", "circuit.breaker_rating_a"), (TYPE_CIRCUIT, NODE_BREAKER, "poles", "circuit.is_240v"), + (TYPE_CIRCUIT, NODE_BREAKER, "protection-functions", "circuit.protection_functions"), + (TYPE_CIRCUIT, NODE_INFO, "nominal-voltage", "circuit.nominal_voltage_v"), # --- Circuit `connection` -> the DER the circuit feeds --------------------- # The one place a row's device type and its field path deliberately disagree. # v1.0 states the enclosure/DER relationship on the *circuit*, so the panel's @@ -179,6 +184,23 @@ ) +_OPTIONAL_PROPERTIES: frozenset[str] = frozenset( + { + "panel.busbar_current_a", + "panel.frequency_hz", + "panel.upstream_protection_rating_a", + "circuit.nominal_voltage_v", + "circuit.protection_functions", + } +) +"""Rows whose property a device may leave out of a node it does declare. + +A device that declares the node without the property yields no entry for these, +rather than `resolved=False`: the property is optional on that node, so its +absence is not a gap. +""" + + def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: """Collect metadata for every mapped field the tree actually declares. @@ -216,12 +238,13 @@ def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMeta if found is not None: unit, datatype = found metadata[field_path] = FieldMetadata(unit=unit, datatype=datatype) - elif _node_declared(present_type_nodes, device_type, node_id): + elif field_path not in _OPTIONAL_PROPERTIES and _node_declared(present_type_nodes, device_type, node_id): # The node is here and does not declare the property: a real gap, # distinct from the hardware simply not being installed. metadata[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_FIELDS)) metadata.update(_lugs_metadata(devices, upstream=False, fields=_DOWNSTREAM_LUGS_FIELDS)) + metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_CONNECTION_FIELDS, node_id=NODE_CONNECTION)) metadata.update(_charge_limit_metadata(devices)) # Namespaced, so a consumer partitions them out before it reads the map as # an inventory of produced fields. See `build_discovery`. @@ -323,9 +346,19 @@ def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, n (PROP_CURRENT_B, "panel.downstream_l2_current_a"), ) +# The protection ahead of the upstream lugs, from their `connection` node rather +# than their meter, and resolved by direction for the same reason. +_UPSTREAM_LUGS_CONNECTION_FIELDS: tuple[tuple[str, str], ...] = ( + (PROP_OVERCURRENT_PROTECTION, "panel.upstream_protection_rating_a"), +) + def _lugs_metadata( - devices: list[DiscoveredDevice], *, upstream: bool, fields: tuple[tuple[str, str], ...] + devices: list[DiscoveredDevice], + *, + upstream: bool, + fields: tuple[tuple[str, str], ...], + node_id: str = NODE_METER, ) -> dict[str, FieldMetadata]: """Metadata for one lugs device, resolved by direction rather than by type. @@ -333,10 +366,11 @@ def _lugs_metadata( value can never disagree about which device is which. Carries the same three-way contract as the table-driven loop, on the same - (device, node) granularity: no lugs device in this direction, or no `meter` - node on it, means no entry, while a `meter` node that omits a property is a - declared gap. Both directions run through here so the two halves of - `panel.*` cannot drift into answering to different rules. + (device, node) granularity: no lugs device in this direction, or no + `node_id` node on it, means no entry, while a node that omits a property is + a declared gap unless the property is optional (`_OPTIONAL_PROPERTIES`). + Both directions run through here so the two halves of `panel.*` cannot + drift into answering to different rules. A lugs device that publishes no `info/direction` is invisible to `find_lugs` and so yields no entry, which is deliberate: the mapper reads its values @@ -346,16 +380,17 @@ def _lugs_metadata( if lugs is None: return {} - meter = declared_nodes(lugs.description or {}).get(NODE_METER) - if meter is None: + node = declared_nodes(lugs.description or {}).get(node_id) + if node is None: return {} - declared = declared_properties(meter) + declared = declared_properties(node) found: dict[str, FieldMetadata] = {} for property_id, field_path in fields: definition = declared.get(property_id) if definition is None: - found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) + if field_path not in _OPTIONAL_PROPERTIES: + found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) continue found[field_path] = FieldMetadata( unit=optional_str(definition.get("unit")), @@ -504,6 +539,7 @@ def _lookup( | frozenset( (TYPE_LUGS, NODE_METER, property_id) for property_id, _ in (*_UPSTREAM_LUGS_FIELDS, *_DOWNSTREAM_LUGS_FIELDS) ) + | frozenset((TYPE_LUGS, NODE_CONNECTION, property_id) for property_id, _ in _UPSTREAM_LUGS_CONNECTION_FIELDS) | frozenset(_CONSUMED_WITHOUT_A_ROW) | frozenset(_CONSUMED_OFF_SNAPSHOT) ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py index d8f68ca..d232428 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/panel.py +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -59,6 +59,7 @@ PROP_ACTIVE_POWER, PROP_ASSERTED_ISLANDING_STATE, PROP_BINDING_CONSTRAINT, + PROP_BUSBAR_CURRENT, PROP_CAPABLE, PROP_CLOUD_CONNECTION, PROP_CONFIDENCE, @@ -67,6 +68,7 @@ PROP_EXPORTED_ENERGY, PROP_FED_BY_DEVICE_ID, PROP_FIRMWARE_VERSION, + PROP_FREQUENCY, PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, PROP_GRID_FORMING_ENTITY, @@ -74,6 +76,7 @@ PROP_IMPORT_LIMIT, PROP_IMPORTED_ENERGY, PROP_MODEL, + PROP_OVERCURRENT_PROTECTION, PROP_POLICY, PROP_RATING, PROP_RELAY, @@ -427,6 +430,9 @@ def __init__( # the entity does not change meaning between adapters. self.wwan_link = self.vendor_cloud == CLOUD_CONNECTED + self.busbar_current_a = number(panel, NODE_METER, PROP_BUSBAR_CURRENT) + self.frequency_hz = number(panel, NODE_METER, PROP_FREQUENCY) + self.upstream_protection_rating_a = integer(upstream_lugs, NODE_CONNECTION, PROP_OVERCURRENT_PROTECTION) self.l1_voltage = number(panel, NODE_METER, PROP_VOLTAGE_A) self.l2_voltage = number(panel, NODE_METER, PROP_VOLTAGE_B) rating = number(panel, NODE_BREAKER, PROP_RATING) 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 7604ddb..085c495 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 @@ -282,6 +282,9 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) -> vendor_name=fields.vendor_name, model=fields.model, hardware_version=fields.hardware_version, + busbar_current_a=fields.busbar_current_a, + frequency_hz=fields.frequency_hz, + upstream_protection_rating_a=fields.upstream_protection_rating_a, first_position=first_position, last_position=last_position, publishes_solar_roles=any( diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 63b7a7a..218ba11 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -42,6 +42,7 @@ ADOPTION_TOPOLOGY_NODE, DISCOVERY_NAMESPACE, FEEDS_ROLES, + PROTECTION_FUNCTIONS, AdoptedDevice, AdoptedProperty, ControlTarget, @@ -52,6 +53,7 @@ FieldMetadata, HomieSchemaTypes, PassphraseRotation, + ProtectionFunction, SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, @@ -152,6 +154,10 @@ # `SpanCircuitSnapshot.feeds_role`. "FEEDS_ROLES", "FeedsRole", + # What a circuit's `breaker/protection-functions` lists, read into + # `SpanCircuitSnapshot.protection_functions`. + "PROTECTION_FUNCTIONS", + "ProtectionFunction", # The circuits one meter measures, from `SpanCircuitSnapshot.meter_shared_with`. "shared_meter_groups", # Factory diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index e284611..f827e6a 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -24,6 +24,12 @@ FEEDS_ROLES: Final[tuple[FeedsRole, ...]] = ("LOADS", "SUBPANEL", "SOLAR", "STORAGE", "GENERATOR", "MIXED", "UNUSED") """Every `FeedsRole`, for reading a wire value into one.""" +type ProtectionFunction = Literal["OVERCURRENT", "SHORT_CIRCUIT", "GROUND_FAULT", "ARC_FAULT"] +"""One protection a circuit's breaker provides, as `breaker/protection-functions` lists it.""" + +PROTECTION_FUNCTIONS: Final[tuple[ProtectionFunction, ...]] = ("OVERCURRENT", "SHORT_CIRCUIT", "GROUND_FAULT", "ARC_FAULT") +"""Every `ProtectionFunction`, for reading a wire value into one.""" + @dataclass(frozen=True, slots=True) class SpanCircuitSnapshot: @@ -112,6 +118,18 @@ class SpanCircuitSnapshot: From `switch/shared-with-device-ids`, read as `meter_shared_with` is. """ + nominal_voltage_v: float | None = None + """The circuit's rated voltage, from `info/nominal-voltage`. + + `None` where the circuit declares none or has not published it. + """ + protection_functions: tuple[ProtectionFunction, ...] | None = None + """The protections the circuit's breaker provides, from `breaker/protection-functions`. + + In published order, without any value outside `PROTECTION_FUNCTIONS`. + `None` where the breaker declares none or has not published them. + """ + feeds_role: FeedsRole | None = None """What the circuit feeds, from its `connection/feeds-role`. @@ -1221,6 +1239,23 @@ class SpanPanelSnapshot: and `DeviceInfo(hw_version=...)` is the consumer's abbreviation, not ours. """ + busbar_current_a: float | None = None + """The panel's busbar current, from its `meter/busbar-current`. v1.0 only. + + `None` where the panel declares none or has not published it. + """ + frequency_hz: float | None = None + """The line frequency, from the panel's `meter/frequency`. v1.0 only. + + `None` where the panel declares none or has not published it. + """ + upstream_protection_rating_a: int | None = None + """The rating of the protection ahead of the upstream lugs, in amperes. v1.0 only. + + From the upstream lugs' `connection/overcurrent-protection`; `None` where + they declare none or have not published it. + """ + first_position: int | None = None """The panel's first breaker position. v1.0 only. diff --git a/tests/expected_failures.py b/tests/expected_failures.py index 27b283b..f7236cf 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -19,42 +19,18 @@ from typing import Final _ACCEPTANCE = "tests/test_schema_one_other_models.py" -_COMPANIONS = "tests/test_schema_one_acceptance_companions.py" -_READINGS = "the panel and circuit readings" -_SHARED = "the shared meters and relays" - -_UNPUBLISHED_IS_NONE = f"None for an undeclared reading or shared-with lands with {_READINGS} and {_SHARED}" - -PENDING: Final[Mapping[str, str]] = { - f"{_ACCEPTANCE}::test_the_panel_meter_reads_busbar_current_and_frequency": ( - f"busbar current and frequency land with {_READINGS}" - ), - f"{_ACCEPTANCE}::test_a_circuit_reads_its_nominal_voltage_and_protection_functions": ( - f"nominal voltage and protection functions land with {_READINGS}" - ), - f"{_ACCEPTANCE}::test_the_model_properties_carry_metadata_from_their_declarations": ( - f"field metadata for the new readings lands with {_READINGS}, " - f"and shared-with stops being a discovered property with {_SHARED}" - ), - f"{_ACCEPTANCE}::test_the_shipped_tree_resolves_every_mapped_row": ( - f"field metadata for the new readings lands with {_READINGS}" - ), - f"{_ACCEPTANCE}::test_the_model_properties_are_not_extension_properties": ( - f"the new readings and shared-with leave the extension rows with {_READINGS} and {_SHARED}" - ), - 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"{_COMPANIONS}::test_the_shipped_tree_reads_busbar_current_and_frequency": ( - f"busbar current and frequency land with {_READINGS}" - ), -} +PENDING: Final[Mapping[str, str]] = {} _DEVICE_TYPE_AS_PUBLISHED = "the library keeps device_type as published; a SOLAR feeds-role is carried by feeds_role" DIVERGENT: Final[Mapping[str, str]] = { f"{_ACCEPTANCE}::test_a_circuit_whose_feeds_role_is_solar_is_labeled_pv": _DEVICE_TYPE_AS_PUBLISHED, f"{_ACCEPTANCE}::test_the_shipped_tree_has_the_other_model_shape": _DEVICE_TYPE_AS_PUBLISHED, + f"{_ACCEPTANCE}::test_the_shipped_tree_reports_its_other_model_only_properties_as_discovered": ( + "the upstream lugs' connection/overcurrent-protection is read into upstream_protection_rating_a, " + "so it is an addressed property rather than a discovered one" + ), } assert not PENDING.keys() & DIVERGENT.keys(), "a test is either pending or divergent, never both" diff --git a/tests/test_panel_properties_captures.py b/tests/test_panel_properties_captures.py new file mode 100644 index 0000000..f924ae3 --- /dev/null +++ b/tests/test_panel_properties_captures.py @@ -0,0 +1,71 @@ +"""Panel and circuit readings, over every reference capture. + +Each reading equals the wire value its capture declares. +""" + +from __future__ import annotations + +import pytest + +from reference_payloads.captures import CAPTURES, capture_adapter, capture_snapshot, capture_tree +from span_panel_api import PROTECTION_FUNCTIONS + +_LUGS_TYPE = "energy.ebus.device.lugs" +_NEW_ROWS = ( + "panel.busbar_current_a", + "panel.frequency_hz", + "circuit.nominal_voltage_v", + "circuit.protection_functions", +) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_the_panel_readings_equal_their_wire_values(stem: str) -> None: + tree, snapshot = capture_tree(stem), capture_snapshot(stem) + upstream = [ + device_id + for device_id in tree.devices + if tree.device_type(device_id) == _LUGS_TYPE and tree.value(device_id, "info/direction") == "UPSTREAM" + ] + rating = tree.number(upstream[0], "connection/overcurrent-protection") if upstream else None + + assert snapshot.busbar_current_a == tree.number(tree.root_id, "meter/busbar-current") + assert snapshot.frequency_hz == tree.number(tree.root_id, "meter/frequency") + assert snapshot.upstream_protection_rating_a == (None if rating is None else int(rating)) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_each_circuit_reading_equals_its_wire_value(stem: str) -> None: + tree, circuits = capture_tree(stem), capture_snapshot(stem).circuits + for circuit_id in tree.circuits(): + published = tree.value(circuit_id, "breaker/protection-functions") + functions = ( + None + if published is None + else tuple(part.strip() for part in published.split(",") if part.strip() in PROTECTION_FUNCTIONS) + ) + assert circuits[circuit_id].nominal_voltage_v == tree.number(circuit_id, "info/nominal-voltage") + assert circuits[circuit_id].protection_functions == functions + + +def test_the_captures_publish_the_new_readings() -> None: + snapshots = [capture_snapshot(stem) for stem in CAPTURES] + + assert any(snapshot.busbar_current_a is not None for snapshot in snapshots) + assert any(snapshot.frequency_hz is not None for snapshot in snapshots) + assert any(snapshot.upstream_protection_rating_a is not None for snapshot in snapshots) + circuits = [circuit for snapshot in snapshots for circuit in snapshot.circuits.values()] + assert any(circuit.protection_functions for circuit in circuits) + assert any(circuit.nominal_voltage_v is not None for circuit in circuits) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_the_new_readings_carry_resolved_metadata_where_declared(stem: str) -> None: + metadata = capture_adapter(stem).build_field_metadata() + + assert not [path for path in _NEW_ROWS if path in metadata and not metadata[path].resolved] + assert not [ + path + for path in metadata + if path.endswith(("/busbar-current", "/frequency", "/nominal-voltage", "/protection-functions")) + ] diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py index c7c8cf2..92ed686 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -85,6 +85,11 @@ # the field defaults to `None`, and the flat schema declares no roles. "FEEDS_ROLES", "FeedsRole", + # Added 2026-10-09: the closed set a circuit's `breaker/protection-functions` + # lists, read into `SpanCircuitSnapshot.protection_functions`. Purely + # additive -- the field defaults to `None`. + "PROTECTION_FUNCTIONS", + "ProtectionFunction", # 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. diff --git a/tests/test_schema_one_acceptance_companions.py b/tests/test_schema_one_acceptance_companions.py index cf85800..2e28415 100644 --- a/tests/test_schema_one_acceptance_companions.py +++ b/tests/test_schema_one_acceptance_companions.py @@ -1,16 +1,17 @@ """What the divergent acceptance tests assert after their divergent line. -`expected_failures.py` lists two acceptance tests as divergent: each first -asserts a `device_type` this library deliberately does not report, and a strict -xfail stops there, so nothing after that line ever runs. These tests assert the -rest, over the same trees, so a regression in it still fails the run. Where an -assertion needs a later change, its test is listed as pending instead. +`expected_failures.py` lists three acceptance tests as divergent: two first +assert a `device_type` this library deliberately does not report, and one a +discovery row for a property this library reads. A strict xfail stops there, +so nothing after that line ever runs. These tests assert the rest, over the +same trees, so a regression in it still fails the run. Where an assertion needs +a later change, its test is listed as pending instead. """ from __future__ import annotations -from span_panel_api.models import SpanPVSnapshot -from test_schema_one_other_models import KITCHEN, SOLAR, _shipped, _snapshot, _tree +from span_panel_api.models import SpanPVSnapshot, is_discovery_path +from test_schema_one_other_models import KITCHEN, SOLAR, _shipped, _shipped_metadata, _snapshot, _tree def test_a_solar_role_circuit_keeps_its_reading_and_its_neighbour_its_type() -> None: @@ -41,3 +42,16 @@ def test_the_shipped_tree_reads_busbar_current_and_frequency() -> None: def test_the_shipped_tree_resolves_its_shared_relay() -> None: assert _shipped().circuits["circuit-1"].relay_shared_with == ("circuit-2",) + + +def test_the_shipped_tree_reports_its_unread_properties_as_discovered() -> None: + """Every discovery row the divergent test expects, except the one this library reads.""" + discovered = {path for path in _shipped_metadata() if is_discovery_path(path)} + + for prop in ("name", "latitude", "longitude", "address-lines", "utility-meter-serial-number"): + assert f"discovered.distribution-enclosure/info/{prop}" in discovered + for path in ("info/tags", "info/locations", "info/dedicated", "connection/backed-up"): + assert f"discovered.circuit/{path}" in discovered + for prop in ("service-rating", "feeds-role"): + assert f"discovered.lugs/connection/{prop}" in discovered + assert "discovered.lugs/connection/overcurrent-protection" not in discovered diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index d2de2b6..054c6c8 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -233,6 +233,14 @@ def _emitter_declared() -> set[tuple[str, str]]: "which is stored energy with an abstract unit — a different quantity with a confusable name." ), (const.NODE_SWITCH, "lock-state"): "EVSE connector lock", + (const.NODE_METER, "busbar-current"): ( + "the panel's busbar current. Not in the public meter catalog (0.4), which carries " + "the line frequency the panel publishes beside it." + ), + (const.NODE_INFO, "nominal-voltage"): ( + "a circuit's rated voltage. Not in the public info catalog (0.3); the panel declares " + "it on the circuit's identity node." + ), ("config", "max-charge-current"): ( "the EVSE's commissioned charge-current ceiling, in SPAN's pre-catalog spelling. " "No `config` capability exists upstream at all; the catalogued surface is " @@ -282,6 +290,29 @@ def _emitter_declared() -> set[tuple[str, str]]: "in test_schema_one_charge_limit.py drives it from a synthetic description, which is " "evidence of a parser and not of a producer -- which is what this entry records." ), + ("meter", "busbar-current"): ( + "a panel reading the emitter's reference tree does not declare. The reference captures " + "publish it, and test_panel_properties_captures.py reads it from each of them." + ), + ("meter", "frequency"): ( + "the line frequency, which the emitter's reference tree does not declare. The reference " + "captures publish it, and test_panel_properties_captures.py reads it from each of them." + ), + ("connection", "overcurrent-protection"): ( + "the upstream lugs' protection rating, which the emitter's reference tree does not " + "declare. The reference captures publish it, and test_panel_properties_captures.py " + "reads it from each of them." + ), + ("info", "nominal-voltage"): ( + "a circuit's rated voltage, which the emitter's reference tree does not declare. The " + "reference captures publish it, and test_panel_properties_captures.py reads it from " + "each of them." + ), + ("breaker", "protection-functions"): ( + "the protections a circuit's breaker provides, which the emitter's reference tree " + "does not declare. The reference captures publish them, and " + "test_panel_properties_captures.py reads them from each of them." + ), ("connection", "feeds-role"): ( "what a circuit feeds when that is not itself a device on the tree. The emitter's " "reference tree declares no role on any circuit; the reference captures declare it on " diff --git a/tests/test_schema_one_panel_properties.py b/tests/test_schema_one_panel_properties.py new file mode 100644 index 0000000..489237a --- /dev/null +++ b/tests/test_schema_one_panel_properties.py @@ -0,0 +1,134 @@ +"""Panel and circuit readings, read where the panel declares them. + +The panel's busbar current and line frequency, the upstream lugs' overcurrent +protection rating, and each circuit's nominal voltage and protection functions +are read where the panel declares them and are `None` where it does not, or +has not published them. Their field metadata comes from the declarations, and +an undeclared one is absent rather than a gap. +""" + +from __future__ import annotations + +from typing import get_args + +from conftest import flat_schema +from reference_payloads.schema_one import devices_from_tree, replay +from reference_payloads.synthetic_trees import ( + PANEL_ID, + SyntheticDevice, + hosted_circuit, + lugs, + panel, + tree, + without_values, +) +from span_panel_api import PROTECTION_FUNCTIONS, ProtectionFunction, SpanPanelSnapshot +from span_panel_api.models import FieldMetadata +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1.field_metadata import build_field_metadata + + +def _snapshot(*children: SyntheticDevice, root: SyntheticDevice | None = None) -> SpanPanelSnapshot: + return replay(tree(root or panel(), *children), PANEL_ID).build_snapshot() + + +def test_the_protection_function_set_is_the_literal() -> None: + assert set(PROTECTION_FUNCTIONS) == set(get_args(ProtectionFunction.__value__)) + + +def test_the_panel_readings_are_read_where_declared() -> None: + snapshot = _snapshot( + lugs("UPSTREAM", overcurrent_protection_a=150), root=panel(busbar_current_a=41.5, frequency_hz=59.98) + ) + + assert (snapshot.busbar_current_a, snapshot.frequency_hz) == (41.5, 59.98) + assert snapshot.upstream_protection_rating_a == 150 + + +def test_the_panel_readings_are_none_where_undeclared() -> None: + snapshot = _snapshot(lugs("UPSTREAM"), lugs("DOWNSTREAM", overcurrent_protection_a=60)) + + assert (snapshot.busbar_current_a, snapshot.frequency_hz, snapshot.upstream_protection_rating_a) == ( + None, + None, + None, + ) + + +def test_the_panel_readings_are_none_until_published() -> None: + root = without_values(panel(busbar_current_a=41.5, frequency_hz=60.0), "meter/busbar-current", "meter/frequency") + upstream = without_values(lugs("UPSTREAM", overcurrent_protection_a=150), "connection/overcurrent-protection") + snapshot = _snapshot(upstream, root=root) + + assert (snapshot.busbar_current_a, snapshot.frequency_hz, snapshot.upstream_protection_rating_a) == ( + None, + None, + None, + ) + + +def test_a_circuit_reads_its_nominal_voltage_and_protection_functions() -> None: + circuit = _snapshot( + hosted_circuit("c-1", (1,), nominal_voltage_v=240.0, protection_functions=("ARC_FAULT", "OVERCURRENT")) + ).circuits["c-1"] + + assert circuit.nominal_voltage_v == 240.0 + assert circuit.protection_functions == ("ARC_FAULT", "OVERCURRENT") + + +def test_a_protection_function_outside_the_set_is_dropped() -> None: + circuit = _snapshot(hosted_circuit("c-1", (1,), protection_functions=("GROUND_FAULT", "NOT_A_FUNCTION"))).circuits["c-1"] + + assert circuit.protection_functions == ("GROUND_FAULT",) + + +def test_circuit_readings_are_none_where_undeclared_or_unpublished() -> None: + declared = hosted_circuit("c-2", (3,), nominal_voltage_v=120.0, protection_functions=("OVERCURRENT",)) + circuits = _snapshot( + hosted_circuit("c-1", (1,)), + without_values(declared, "info/nominal-voltage", "breaker/protection-functions"), + ).circuits + + for circuit_id in ("c-1", "c-2"): + assert (circuits[circuit_id].nominal_voltage_v, circuits[circuit_id].protection_functions) == (None, None) + + +def test_the_readings_carry_metadata_from_their_declarations() -> None: + metadata = build_field_metadata( + devices_from_tree( + tree( + panel(busbar_current_a=41.5, frequency_hz=60.0), + hosted_circuit("c-1", (1,), nominal_voltage_v=120.0, protection_functions=("OVERCURRENT",)), + ) + ) + ) + + assert metadata["panel.busbar_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.frequency_hz"] == FieldMetadata(unit="Hz", datatype="float") + assert metadata["circuit.nominal_voltage_v"] == FieldMetadata(unit="V", datatype="float") + assert metadata["circuit.protection_functions"] == FieldMetadata(unit=None, datatype="enum") + + +def test_an_undeclared_reading_has_no_metadata_rather_than_a_gap() -> None: + metadata = build_field_metadata(devices_from_tree(tree(panel(), hosted_circuit("c-1", (1,))))) + + for path in ( + "panel.busbar_current_a", + "panel.frequency_hz", + "circuit.nominal_voltage_v", + "circuit.protection_functions", + ): + assert path not in metadata + + +def test_a_flat_panel_reports_none_of_them() -> None: + snapshot = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)).build_snapshot() + + assert (snapshot.busbar_current_a, snapshot.frequency_hz, snapshot.upstream_protection_rating_a) == ( + None, + None, + None, + ) + assert all( + (circuit.nominal_voltage_v, circuit.protection_functions) == (None, None) for circuit in snapshot.circuits.values() + ) From 13814dcde7f9767ddb9ade7d587f6fa8ef99dcf3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:20:24 -0700 Subject: [PATCH 2/2] feat(privacy): keep site properties out of extension and adopted readings SITE_PROPERTIES lists the property ids that say where a panel is installed: its address lines, locality, region, country code, coordinates and utility meter serial. Extension and adopted readings skip them on whatever node a device declares them. Where the panel declares them, under info beside the site's info/name, the identity node already keeps them out; the list holds wherever else a device puts them. They stay discovery rows, which carry no values, and the panel's postal code and time zone readings are unchanged. Part of the spanos3/r202639/03 batch 2 work. --- .../src/span_panel_api_schema_1/adoption.py | 5 ++- .../src/span_panel_api_schema_1/const.py | 19 ++++++++++ .../src/span_panel_api_schema_1/extension.py | 12 +++++-- tests/test_panel_properties_captures.py | 20 +++++++++-- tests/test_schema_one_panel_properties.py | 36 ++++++++++++++++++- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py index 715990a..ad8a5f0 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adoption.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -25,6 +25,7 @@ from span_panel_api_schema_1.const import ( HOMIE_DOMAIN, HOMIE_VERSION, + SITE_PROPERTIES, TYPE_BESS, TYPE_CIRCUIT, TYPE_EVSE, @@ -132,7 +133,7 @@ def card(property_id: str) -> str | None: def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, object]]) -> tuple[AdoptedProperty, ...]: - """Every declared property outside the identity and topology nodes. + """Every declared property outside the identity and topology nodes, and no site property. Those two are excluded by *node*, which is what the eBus vocabulary defines, rather than by property name. The catalogs carry no marker for "this string @@ -146,6 +147,8 @@ def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, obje if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): continue for property_id, definition in properties(node).items(): + if property_id in SITE_PROPERTIES: + continue raw = device.get_property(node_id, property_id) # The same reading the curated controls get: this one gates a set # topic on an uncurated device, so a declaration that has not said 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 ea1bd86..a5457db 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 @@ -51,6 +51,25 @@ PROP_FREQUENCY = "frequency" PROP_NOMINAL_VOLTAGE = "nominal-voltage" PROP_PROTECTION_FUNCTIONS = "protection-functions" + +SITE_PROPERTIES: Final[frozenset[str]] = frozenset( + { + "address-lines", + "locality", + "region", + "country-code", + "latitude", + "longitude", + "utility-meter-serial-number", + } +) +"""Property ids that say where a panel is installed, which never become a reading. + +Skipped by `extension` and `adoption` on whatever node a device declares them. +Where the panel declares them, under `info` beside the site's `info/name`, the +identity node is skipped as a whole already; this holds if a device declares +them anywhere else. They stay discovery rows, which carry no values. +""" 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/extension.py b/packages/schema-1/src/span_panel_api_schema_1/extension.py index 4a20358..cae2291 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/extension.py +++ b/packages/schema-1/src/span_panel_api_schema_1/extension.py @@ -33,6 +33,7 @@ from typing import TYPE_CHECKING from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, ExtensionProperty, ExtensionSubject +from span_panel_api_schema_1.const import SITE_PROPERTIES from span_panel_api_schema_1.description import declared_settable, nodes, optional_str, properties from span_panel_api_schema_1.field_metadata import is_addressed @@ -69,12 +70,19 @@ def build_extension_properties( if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): continue declarations = properties(node) + # A site property never becomes a reading, but it still counts as + # an unaddressed sibling below: it is not one this adapter reads. unaddressed = { property_id: definition for property_id, definition in declarations.items() if not is_addressed(addressed, declared, node_id, property_id) } - if not unaddressed: + readable = { + property_id: definition + for property_id, definition in unaddressed.items() + if property_id not in SITE_PROPERTIES + } + if not readable: continue # True when the node carries at least one property this adapter does # read. One bit rather than the node-to-field map: a vendor @@ -82,7 +90,7 @@ def build_extension_properties( # a consumer can act on. Exporting which fields would freeze this # adapter's internals as API for a signal the design ranks last. has_curated_siblings = len(unaddressed) < len(declarations) - for property_id, definition in unaddressed.items(): + for property_id, definition in readable.items(): raw = device.get_property(node_id, property_id) found.append( ExtensionProperty( diff --git a/tests/test_panel_properties_captures.py b/tests/test_panel_properties_captures.py index f924ae3..bf2cabf 100644 --- a/tests/test_panel_properties_captures.py +++ b/tests/test_panel_properties_captures.py @@ -1,6 +1,8 @@ -"""Panel and circuit readings, over every reference capture. +"""Panel and circuit readings and the site properties, over every reference capture. -Each reading equals the wire value its capture declares. +Each reading equals the wire value its capture declares. No site property +becomes an extension or adopted reading, while the two site readings the panel +already offered, its postal code and time zone, still do. """ from __future__ import annotations @@ -9,8 +11,10 @@ from reference_payloads.captures import CAPTURES, capture_adapter, capture_snapshot, capture_tree from span_panel_api import PROTECTION_FUNCTIONS +from span_panel_api_schema_1.const import SITE_PROPERTIES _LUGS_TYPE = "energy.ebus.device.lugs" +_KEPT_SITE_READINGS = ("status/postal-code", "status/time-zone") _NEW_ROWS = ( "panel.busbar_current_a", "panel.frequency_hz", @@ -59,6 +63,18 @@ def test_the_captures_publish_the_new_readings() -> None: assert any(circuit.nominal_voltage_v is not None for circuit in circuits) +@pytest.mark.parametrize("stem", CAPTURES) +def test_no_site_property_becomes_a_reading_but_postal_code_and_time_zone_still_do(stem: str) -> None: + tree, snapshot = capture_tree(stem), capture_snapshot(stem) + readings = {f"{row.node_id}/{row.property_id}" for row in snapshot.extension_properties} + readings |= {f"{row.node_id}/{row.property_id}" for device in snapshot.adopted_devices for row in device.properties} + + assert not [reading for reading in readings if reading.rsplit("/", 1)[-1] in SITE_PROPERTIES] + assert "info/name" not in readings + for topic in _KEPT_SITE_READINGS: + assert (topic in readings) == tree.declares(tree.root_id, topic) + + @pytest.mark.parametrize("stem", CAPTURES) def test_the_new_readings_carry_resolved_metadata_where_declared(stem: str) -> None: metadata = capture_adapter(stem).build_field_metadata() diff --git a/tests/test_schema_one_panel_properties.py b/tests/test_schema_one_panel_properties.py index 489237a..ff470cb 100644 --- a/tests/test_schema_one_panel_properties.py +++ b/tests/test_schema_one_panel_properties.py @@ -1,20 +1,26 @@ -"""Panel and circuit readings, read where the panel declares them. +"""Panel and circuit readings, and the site properties that never become readings. The panel's busbar current and line frequency, the upstream lugs' overcurrent protection rating, and each circuit's nominal voltage and protection functions are read where the panel declares them and are `None` where it does not, or has not published them. Their field metadata comes from the declarations, and an undeclared one is absent rather than a gap. + +The site's name, address, coordinates and utility meter serial never become an +extension or adopted reading, on whatever node a device declares them. """ from __future__ import annotations from typing import get_args +import pytest + from conftest import flat_schema from reference_payloads.schema_one import devices_from_tree, replay from reference_payloads.synthetic_trees import ( PANEL_ID, + PropertyDeclaration, SyntheticDevice, hosted_circuit, lugs, @@ -25,8 +31,11 @@ from span_panel_api import PROTECTION_FUNCTIONS, ProtectionFunction, SpanPanelSnapshot from span_panel_api.models import FieldMetadata from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1.const import SITE_PROPERTIES from span_panel_api_schema_1.field_metadata import build_field_metadata +_STRING: PropertyDeclaration = {"name": "Site", "datatype": "string"} + def _snapshot(*children: SyntheticDevice, root: SyntheticDevice | None = None) -> SpanPanelSnapshot: return replay(tree(root or panel(), *children), PANEL_ID).build_snapshot() @@ -121,6 +130,31 @@ def test_an_undeclared_reading_has_no_metadata_rather_than_a_gap() -> None: assert path not in metadata +@pytest.mark.parametrize("property_id", sorted(SITE_PROPERTIES)) +def test_a_site_property_on_any_node_never_becomes_a_reading(property_id: str) -> None: + """The identity node keeps them out where the panel declares them; this holds anywhere else.""" + base = panel() + root = SyntheticDevice( + device_id=base.device_id, + device_type=base.device_type, + name=base.name, + nodes={**base.nodes, "site": {property_id: _STRING}}, + values={**base.values, f"site/{property_id}": "example"}, + ) + vendor = SyntheticDevice( + device_id="vendor-a", + device_type="energy.ebus.device.example", + name="Vendor", + nodes={"site": {property_id: _STRING, "label": _STRING}}, + values={f"site/{property_id}": "example", "site/label": "example"}, + ) + snapshot = _snapshot(vendor, root=root) + + assert not [row for row in snapshot.extension_properties if row.property_id == property_id] + adopted = {row.property_id for device in snapshot.adopted_devices for row in device.properties} + assert adopted == {"label"} + + def test_a_flat_panel_reports_none_of_them() -> None: snapshot = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)).build_snapshot()