diff --git a/tests/adapter_fidelity/harness.py b/tests/adapter_fidelity/harness.py index 1057231..705704c 100644 --- a/tests/adapter_fidelity/harness.py +++ b/tests/adapter_fidelity/harness.py @@ -542,10 +542,21 @@ class _Instance: deferred: bool = False +OUTSIDE_PANEL_ROLE: Final = "meter-outside-panel" +"""The role of a circuit-typed device that declares no breaker space: a meter outside the panel. + +Its own role, because the library reads it in the opposite frame from a hosted +circuit (`circuits.build_circuit` and `SpanCircuitSnapshot.measures_outside_panel`), +so one row could not document both. +""" + + def _role(capture: _Capture, device: _Device) -> str: if device.device_id == capture.root_id: return "panel" short = device.device_type.removeprefix(DEVICE_TYPE_PREFIX) + if device.device_type == TYPE_CIRCUIT and "info/spaces" not in device.declared: + return OUTSIDE_PANEL_ROLE if device.device_type.startswith(TYPE_LUGS): direction = device.values.get("info/direction", "").strip().lower() return f"lugs-{direction}" if direction else "lugs" diff --git a/tests/adapter_fidelity/rules.py b/tests/adapter_fidelity/rules.py index 2577908..169c43c 100644 --- a/tests/adapter_fidelity/rules.py +++ b/tests/adapter_fidelity/rules.py @@ -20,7 +20,7 @@ from span_panel_api.models import FEEDS_ROLES, PROTECTION_FUNCTIONS from span_panel_api_schema_1.const import PANEL_POSITIONS_BY_MODEL -from .harness import ANY_LEAF, CellRules, Derivation, DropReason, RowKey, Transform, TransformKind +from .harness import ANY_LEAF, OUTSIDE_PANEL_ROLE, CellRules, Derivation, DropReason, RowKey, Transform, TransformKind _IDENTITY: Final = TransformKind.IDENTITY _NEGATE: Final = TransformKind.NEGATE @@ -317,6 +317,32 @@ def add( _is_ok_link, ) + # --- a circuit-typed device with no breaker space ----------------------- + outside = "circuits.build_circuit: a meter outside the panel reads import-positive, as published" + add(OUTSIDE_PANEL_ROLE, "meter/active-power", "circuit.instant_power_w", _IDENTITY, outside) + add( + OUTSIDE_PANEL_ROLE, + "meter/imported-energy", + "circuit.consumed_energy_wh", + _IDENTITY, + f"{outside}: imported is consumed", + ) + add( + OUTSIDE_PANEL_ROLE, + "meter/exported-energy", + "circuit.produced_energy_wh", + _IDENTITY, + f"{outside}: exported is produced", + ) + add(OUTSIDE_PANEL_ROLE, "meter/current", "circuit.current_a", _IDENTITY, outside) + add( + OUTSIDE_PANEL_ROLE, + "meter/shared-with-device-ids", + "circuit.meter_shared_with", + _DERIVED, + "circuits._shared_with: the listed ids naming another circuit of the snapshot, in device_id_order", + ) + # --- lugs -------------------------------------------------------------- lugs_meter = "panel.PanelFields: the lugs read import-positive and pass through" for role, power, consumed, produced, current_a, current_b in ( @@ -440,6 +466,14 @@ def add( "evse", "info/serial-number", ANY_LEAF, _DERIVED, "snapshot.harmonised_evse_keys: the charger is keyed by its serial" ) add("evse", "info/firmware-version", "evse.software_version", _LITERAL, evse) + # The charge-current pair, in either spelling the charger's description declares. + charge_limit = f"{evse} through charge_limit.resolve_charge_limit, read as an integer" + for node, ceiling, limit in ( + ("config", "max-charge-current", "user-max-charge-current"), + ("charge-limit", "installer-max", "owner-limit"), + ): + add("evse", f"{node}/{ceiling}", "evse.charge_current_ceiling_a", _INT, charge_limit) + add("evse", f"{node}/{limit}", "evse.charge_current_limit_a", _INT, charge_limit) return table @@ -501,4 +535,9 @@ def add( ), } -MAIN32_RULES: Final = CellRules(transforms=_transforms(), derivations=_DERIVATIONS, drops=_DROPS, deferred=_DEFERRED) +TRANSFORMS: Final = _transforms() +"""Every documented transform, shared by every cell: a transform states what the library does, whichever capture shows it.""" + +DROPS: Final = _DROPS + +MAIN32_RULES: Final = CellRules(transforms=TRANSFORMS, derivations=_DERIVATIONS, drops=_DROPS, deferred=_DEFERRED) diff --git a/tests/adapter_fidelity/rules_captured.py b/tests/adapter_fidelity/rules_captured.py new file mode 100644 index 0000000..83aadd6 --- /dev/null +++ b/tests/adapter_fidelity/rules_captured.py @@ -0,0 +1,75 @@ +"""What is right for the reference-capture cells, and the code that makes each entry so. + +The transforms and the drop reasons are the ones every cell shares +(`rules.TRANSFORMS`, `rules.DROPS`): a transform states what the library does, +whichever capture shows it. The derivations are only the ones these captures +need, as `test_every_derivation_is_needed` holds them to, so they are chosen +here rather than inherited. + +Four of them are exceptions rather than derivations, and say so: the snapshot +holds `"UNKNOWN"` in a `str` field where the panel publishes nothing for it. +That is the field's long-standing absence value, not a reading; whether those +fields become optional is a type question deferred past 3.8.0b1. +""" + +from __future__ import annotations + +from typing import Final + +from .harness import CellRules, Derivation +from .rules import DROPS, MAIN32_RULES, TRANSFORMS + +_SHARED: Final = frozenset( + { + "battery.present", + "circuit.circuit_id", + "circuit.device_type", + "circuit.is_never_backup", + "circuit.is_sheddable", + "circuit.measures_outside_panel", + "ctl.dps_target", + "ctl.priority_target", + "panel.publishes_solar_roles", + } +) +"""The MAIN 32 derivations these captures need as well.""" + +_NO_RELAY = ( + "circuits.build_circuit: a device that declares no switch node has no relay, " + "so it is not controllable, not sheddable and not always on" +) +_STR_SENTINEL = "pre-existing str sentinel; type question deferred past 3.8.0b1" + +_DERIVATIONS: Final = ( + *(derivation for derivation in MAIN32_RULES.derivations if derivation.family in _SHARED), + Derivation("circuit.is_user_controllable", _NO_RELAY, values=(False,)), + Derivation("circuit.is_sheddable", _NO_RELAY, values=(False,)), + Derivation("circuit.always_on", _NO_RELAY, values=(False,)), + Derivation( + "panel.dominant_power_source", + "panel.resolve_dominant_power_source: with no MID, GRID is an elimination, since nothing else can form the grid", + values=("GRID",), + ), + Derivation( + "circuit.relay_state", + f"{_STR_SENTINEL}: circuits.build_circuit reads UNKNOWN where the device publishes no switch/relay", + values=("UNKNOWN",), + ), + Derivation( + "circuit.relay_requester", + f"{_STR_SENTINEL}: circuits.build_circuit reads UNKNOWN where the device publishes no switch/relay-requester", + values=("UNKNOWN",), + ), + Derivation( + "circuit.priority", + f"{_STR_SENTINEL}: circuits.build_circuit reads UNKNOWN where the device publishes no load-shed/priority", + values=("UNKNOWN",), + ), + Derivation( + "panel.main_relay_state", + f"{_STR_SENTINEL}: panel.PanelFields reads UNKNOWN while status/relay is unpublished", + values=("UNKNOWN",), + ), +) + +CAPTURED_RULES: Final = CellRules(transforms=TRANSFORMS, derivations=_DERIVATIONS, drops=DROPS, deferred={}) diff --git a/tests/adapter_fidelity/test_capture_fidelity.py b/tests/adapter_fidelity/test_capture_fidelity.py new file mode 100644 index 0000000..e33e0e4 --- /dev/null +++ b/tests/adapter_fidelity/test_capture_fidelity.py @@ -0,0 +1,153 @@ +"""Every property the r202639 reference captures declare reaches the snapshot, faithfully. + +The same measurement as the MAIN 32 cells, over each vendored reference capture +(`tests/fixtures/captures/`, pinned by `test_captures_unchanged.py`): each +declared property is perturbed to see what it moves, and every value is held to +the transform the rules document for it. A property that moves nothing and is +not dropped on purpose by code that says so is silently lost; a value with no +wire behind it is fabricated. + +What the cells pin is keyed by row, never by capture, so a capture added to the +directory is measured by every test here without an edit. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from functools import cache +from typing import Final + +import pytest + +from reference_payloads.captures import CAPTURES, capture_path + +from .harness import DropReason, Fate, FidelityReport, RowKey, run_cell +from .rules_captured import CAPTURED_RULES + +_PEER_TYPE: Final = ( + "the declared type of the device the record names, which the library reads from that device's own " + "$description when it sorts the tree" +) +_DISPATCH: Final = "chooses which adapter parses the tree, so it is consumed before any snapshot exists" +_NODE_RULE: Final = ( + "an identity or topology property, which extension.build_extension_properties skips by node: " + "a label or a wiring fact, never a reading" +) +_SITE: Final = ( + "where the panel is installed: on the identity node, which is never a reading, and on " + "const.SITE_PROPERTIES wherever else a device declares it" +) + +DROPPED_WITH_A_VALUE: Final[Mapping[RowKey, str]] = { + RowKey("circuit", "connection/count"): _NODE_RULE, + RowKey("circuit", "connection/feeds-device-type"): _PEER_TYPE, + RowKey("circuit", "info/dedicated"): _NODE_RULE, + RowKey("circuit", "info/locations"): _NODE_RULE, + RowKey("circuit", "info/tags"): _NODE_RULE, + RowKey("lugs-downstream", "connection/feeds-role"): _NODE_RULE, + RowKey("lugs-upstream", "connection/service-rating"): _NODE_RULE, + RowKey("panel", "info/address-lines"): _SITE, + RowKey("panel", "info/data-model-version"): _DISPATCH, + RowKey("panel", "info/latitude"): _SITE, + RowKey("panel", "info/locality"): _SITE, + RowKey("panel", "info/longitude"): _SITE, + RowKey("panel", "info/name"): _SITE, + RowKey("panel", "info/region"): _SITE, + RowKey("panel", "info/utility-meter-serial-number"): _SITE, +} +"""The valued rows a drop rule covers in some reference capture, each reviewed, with why dropping it is right.""" + +DROPPED_BY_THE_NODE_RULE: Final = ( + RowKey("circuit", "info/tags"), + RowKey("circuit", "info/locations"), + RowKey("circuit", "info/dedicated"), + RowKey("circuit", "connection/backed-up"), + RowKey("lugs-upstream", "connection/service-rating"), + RowKey("lugs-downstream", "connection/feeds-role"), +) +"""Properties the library reads into nothing on purpose, by the identity and topology node rule.""" + + +@cache +def _report(stem: str) -> FidelityReport: + return run_cell(capture_path(stem), CAPTURED_RULES) + + +def _why(report: FidelityReport, leaves: tuple[str, ...]) -> str: + return "\n".join(f"{leaf}: {report.notes[leaf]}" for leaf in leaves) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_no_property_is_silently_dropped(stem: str) -> None: + assert _report(stem).silently_dropped == () + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_nothing_is_deferred(stem: str) -> None: + assert _report(stem).deferred == () + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_only_the_reviewed_rows_are_dropped_with_a_value(stem: str) -> None: + assert set(_report(stem).dropped_with_a_value) <= set(DROPPED_WITH_A_VALUE) + + +def test_every_reviewed_drop_is_seen() -> None: + """A reviewed row no capture drops any more is a stale review.""" + seen = set().union(*(_report(stem).dropped_with_a_value for stem in CAPTURES)) + assert seen == set(DROPPED_WITH_A_VALUE) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_the_node_rule_drops_what_it_should(stem: str) -> None: + rows = _report(stem).rows + for key in DROPPED_BY_THE_NODE_RULE: + if key in rows: + assert rows[key] == (Fate.INTENTIONAL,), key + + +def test_the_captures_declare_what_the_node_rule_drops() -> None: + declared = set().union(*(_report(stem).rows for stem in CAPTURES)) + assert set(DROPPED_BY_THE_NODE_RULE) <= declared + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_no_value_is_fabricated(stem: str) -> None: + report = _report(stem) + assert report.fabricated == (), _why(report, report.fabricated) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_every_mapped_value_matches_its_documented_transform(stem: str) -> None: + report = _report(stem) + assert report.mismatched == (), _why(report, report.mismatched) + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_no_leaf_is_fabricated_outside_any_row(stem: str) -> None: + assert _report(stem).unowned_leaves() == frozenset() + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_the_matrix_covers_every_declared_property(stem: str) -> None: + report = _report(stem) + assert sum(1 for fates in report.rows.values() if fates) == len(report.rows) + assert report.declared_property_instances > 0 + assert sum(report.instance_fates.values()) == report.declared_property_instances + + +@pytest.mark.parametrize("stem", CAPTURES) +def test_no_device_is_named_by_its_own_id(stem: str) -> None: + names = _report(stem).grid_forming_names + assert sorted(device_id for device_id, name in names.items() if name == device_id) == [] + + +def test_every_intentional_drop_and_derivation_cites_code() -> None: + assert set(CAPTURED_RULES.drops) == set(DropReason) + assert all(reason.strip() for reason in CAPTURED_RULES.justifications.values()) + + +def test_every_derivation_is_needed() -> None: + """A derivation no leaf needs is a standing excuse for the next fabrication.""" + used = frozenset().union(*(_report(stem).derivations_used for stem in CAPTURES)) + assert {derivation.family for derivation in CAPTURED_RULES.derivations} == used