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
5 changes: 4 additions & 1 deletion packages/schema-1/src/span_panel_api_schema_1/adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from span_panel_api_schema_1.const import (
HOMIE_DOMAIN,
HOMIE_VERSION,
SITE_PROPERTIES,
TYPE_BESS,
TYPE_CIRCUIT,
TYPE_EVSE,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
26 changes: 25 additions & 1 deletion 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,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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 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 @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions packages/schema-1/src/span_panel_api_schema_1/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -69,20 +70,27 @@ 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
# extending `meter` is probably extending the meter, and that is all
# 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(
Expand Down
56 changes: 46 additions & 10 deletions packages/schema-1/src/span_panel_api_schema_1/field_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
PROP_ACTIVE_POWER,
PROP_EXPORTED_ENERGY,
PROP_IMPORTED_ENERGY,
PROP_OVERCURRENT_PROTECTION,
TYPE_BESS,
TYPE_CIRCUIT,
TYPE_EVSE,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -323,20 +346,31 @@ 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.

Uses the same `find_lugs` the snapshot mapper uses, so the metadata and the
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
Expand All @@ -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")),
Expand Down Expand Up @@ -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)
)
Expand Down
6 changes: 6 additions & 0 deletions packages/schema-1/src/span_panel_api_schema_1/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
PROP_ACTIVE_POWER,
PROP_ASSERTED_ISLANDING_STATE,
PROP_BINDING_CONSTRAINT,
PROP_BUSBAR_CURRENT,
PROP_CAPABLE,
PROP_CLOUD_CONNECTION,
PROP_CONFIDENCE,
Expand All @@ -67,13 +68,15 @@
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,
PROP_HARDWARE_VERSION,
PROP_IMPORT_LIMIT,
PROP_IMPORTED_ENERGY,
PROP_MODEL,
PROP_OVERCURRENT_PROTECTION,
PROP_POLICY,
PROP_RATING,
PROP_RELAY,
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/schema-1/src/span_panel_api_schema_1/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/span_panel_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ADOPTION_TOPOLOGY_NODE,
DISCOVERY_NAMESPACE,
FEEDS_ROLES,
PROTECTION_FUNCTIONS,
AdoptedDevice,
AdoptedProperty,
ControlTarget,
Expand All @@ -53,6 +54,7 @@
FieldMetadata,
HomieSchemaTypes,
PassphraseRotation,
ProtectionFunction,
SpanBatterySnapshot,
SpanCircuitSnapshot,
SpanEvseSnapshot,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading