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/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..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 @@ -46,6 +46,30 @@ 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" + +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/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 2bbce03..beb9b00 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, @@ -445,6 +448,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 d3cc568..df6f3ba 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 8050b13..4a5bfca 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -43,6 +43,7 @@ ADOPTION_TOPOLOGY_NODE, DISCOVERY_NAMESPACE, FEEDS_ROLES, + PROTECTION_FUNCTIONS, AdoptedDevice, AdoptedProperty, ControlTarget, @@ -53,6 +54,7 @@ FieldMetadata, HomieSchemaTypes, PassphraseRotation, + ProtectionFunction, SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, @@ -157,6 +159,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 55055e3..7892cf0 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`. @@ -1251,6 +1269,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 2b5cb24..6945800 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -22,36 +22,8 @@ _ACCEPTANCE = "tests/test_schema_one_other_models.py" _RATE_LIMIT = "tests/test_register_rate_limit.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" @@ -63,6 +35,10 @@ 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" + ), f"{_RATE_LIMIT}::test_raises_the_rate_limit_class_with_its_status": _RETRY_AFTER_IN_SECONDS, f"{_RATE_LIMIT}::test_retry_after_is_none_without_a_usable_header": _RETRY_AFTER_IN_SECONDS, } diff --git a/tests/test_panel_properties_captures.py b/tests/test_panel_properties_captures.py new file mode 100644 index 0000000..bf2cabf --- /dev/null +++ b/tests/test_panel_properties_captures.py @@ -0,0 +1,87 @@ +"""Panel and circuit readings and the site properties, over every reference capture. + +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 + +import pytest + +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", + "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_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() + + 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 6c2070c..db6fc8a 100644 --- a/tests/test_public_api_unchanged.py +++ b/tests/test_public_api_unchanged.py @@ -88,6 +88,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..ff470cb --- /dev/null +++ b/tests/test_schema_one_panel_properties.py @@ -0,0 +1,168 @@ +"""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, + 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.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() + + +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 + + +@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() + + 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() + )