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
11 changes: 11 additions & 0 deletions tests/adapter_fidelity/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 41 additions & 2 deletions tests/adapter_fidelity/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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


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