From e723a8d71fd3951567ffac470a6d88782e5fc762 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:57:54 -0700 Subject: [PATCH] fix(readings): an unreported meter value is absent, not zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Energy and power fields were filled with 0.0 whenever the property behind them carried no value. A retained-topic replay delivers `$description` before the values it declares, so on every connect — and again after every reconnect, since the adapter rebuilds from a clean accumulator — every circuit exists, is described, and has reported nothing. Throughout that window the snapshot asserted each was drawing no power and had accumulated no energy since installation. A consumer cannot tell that from a meter that genuinely reads zero, and zero is exactly what a lifetime counter drops to when firmware resets it. So a consumer compensating for counter resets books the whole counter as an offset and does it again on the next replay: SpanPanel/span#259, where that reached 8.18 MWh on one circuit and ~10 MWh of fictional energy across a panel. The rule is a discrimination rather than a new default — unreported is None, a reported `0` stays 0.0 — and it is per-property, so a circuit that has published half its meter reports that half. A synthesised `unmapped_tab_*` entry still reads zero, because an unoccupied breaker position genuinely draws nothing. Two consequences of the same rule. The panel-level fields were the worst case: both lugs devices declare one type and are told apart by the `info/direction` they publish, so until that property arrives neither role resolves and all six — the site's whole import and export — were fabricated together. And `dsm_state` stops inferring islanding from silence; its heuristic read "no power crossing the service entrance" out of "nothing has reported yet" and now answers UNKNOWN, which it already returns when it cannot tell. ADAPTER_CONTRACT_VERSION does not move: it guards the calling convention, and both adapters ship this with the bootstrap in one unpublished release. --- CHANGELOG.md | 23 ++ .../src/span_panel_api_schema_0/consumer.py | 70 +++-- .../src/span_panel_api_schema_1/circuits.py | 10 +- .../src/span_panel_api_schema_1/panel.py | 12 +- src/span_panel_api/models.py | 33 +- tests/test_absent_readings_are_not_zero.py | 293 ++++++++++++++++++ tests/test_mqtt_homie.py | 13 +- tests/test_schema_one_panel.py | 14 +- tests/test_schema_one_snapshot.py | 4 +- 9 files changed, 425 insertions(+), 47 deletions(-) create mode 100644 tests/test_absent_readings_are_not_zero.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdabb6..482f681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ install of the adapter distribution does not, and a 1.0.0 adapter against this b ### Fixed +- **A meter reading the panel has not sent is reported as absent instead of as zero.** Every energy and power field on `SpanCircuitSnapshot` and the six panel-level ones read off the lugs were filled with `0.0` whenever the property behind them carried no + value. A retained-topic replay hands a subscriber `$description` before the values it declares, so there is a window — on every connect, and again after every broker reconnect, because the adapter is rebuilt from a clean accumulator — in which every + circuit on the panel exists, is described, and has reported nothing. Throughout that window the snapshot stated that each of them was drawing no power and had accumulated no energy since it was installed. + + **On a cumulative counter that is destructive rather than cosmetic.** A consumer cannot tell the fabricated zero from a meter that genuinely reads zero, and the reading a lifetime counter drops to when firmware resets it _is_ zero — so a consumer + compensating for counter resets books the entire counter as a compensation offset, and does it again on the next replay. `SpanPanel/span#259` is that failure on real hardware: an "energy dip" reported against essentially every circuit on each restart, + each dip equal to that circuit's whole lifetime counter, offsets reaching 8.18 MWh on a single circuit and roughly 10 MWh of fictional energy pushed into long-term statistics across one panel. + + The fix is the discrimination rather than a new default: an unreported reading is `None`, a reported `0` is `0.0`, and the two no longer collapse into each other. It is per-property, so a circuit that has published half its meter reports the half it has. + **A synthesised `unmapped_tab_*` entry still reads zero** — an unoccupied breaker position genuinely draws nothing, and that is an assertion the adapter is entitled to make rather than a reading it failed to receive. + + Two further consequences fall out of the same rule. **The panel-level fields were the worst case, not an edge case**: both lugs devices declare the same type and are told apart by the `info/direction` value they publish, so until that one property + arrives neither role resolves and all six fields — the whole site's import and export — were fabricated together. And **`dsm_state` no longer infers islanding from silence**: its fallback heuristic asks whether power is crossing the service entrance and + read "no power" out of "nothing has reported", declaring a site off-grid on the strength of a measurement nobody had made. With neither grid signal reported it now answers `UNKNOWN`, which the same function already returns when it cannot tell. + - **A relay or shed-priority command aimed at a circuit the panel declares non-commandable is refused instead of published.** `set_circuit_relay_target` and `set_circuit_priority_target` were pure string formatting from a circuit id and consulted no declaration at all, so both setters published to a circuit commissioned always-on or never-backup — while the same adapters were already reading exactly that refusal into `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup`. Both now return `ControlTarget | None`, matching the two controls that already refused, and `set_circuit_relay` / `set_circuit_priority` raise `SpanPanelServerError` the way `set_evse_charge_limit` does. @@ -111,6 +126,14 @@ install of the adapter distribution does not, and a 1.0.0 adapter against this b ### Changed +- **BREAKING FOR CONSUMERS: the energy and power fields on `SpanCircuitSnapshot` and `SpanPanelSnapshot` become `float | None`.** `instant_power_w`, `produced_energy_wh` and `consumed_energy_wh` on a circuit; `instant_grid_power_w`, `feedthrough_power_w` + and the four `*_energy_*_wh` on the panel. `None` means the panel has not reported that reading — see the entry under **Fixed** for why the previous `0.0` was not a safe stand-in. Anything doing arithmetic straight off one of these fields is the code + that has to change, and mypy names every site rather than leaving it to a runtime `TypeError`. Coalescing with `or 0` is rarely the right repair: it reintroduces exactly the fabrication this removes, one layer further out. A consumer rendering a value + should render "unknown"; a consumer accumulating one should skip the sample. + + `ADAPTER_CONTRACT_VERSION` does not move. It guards the bootstrap-to-adapter calling convention — an `__init__` arity or a member whose meaning changed under its own name — and both adapters ship this change with the bootstrap in the same unpublished + release, so no adapter carrying the old behaviour is reachable. The already-published 1.0.0 adapters are refused at discovery on the existing floor. + - **`ControlCommand.topic` and `PublishOutcome.topic` become `str | None`.** A refusal made while resolving the address has no topic, and a command reported with one would name a string nothing was ever going to publish to. `None` appears only alongside `PublishState.FAILED`. Additive for a consumer that only reads `state` and `detail`; an interceptor that passes `command.topic` somewhere expecting a `str` is the one that has to change, and does so under mypy rather than silently. diff --git a/packages/schema-0/src/span_panel_api_schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py index a94cbb3..3869235 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -56,6 +56,27 @@ def _parse_float(value: str, default: float = 0.0) -> float: return default +def _reading(value: str) -> float | None: + """Parse a meter reading, or `None` when the panel has not reported one. + + `get_prop` answers `""` for a property whose retained value has not arrived, + and a replay hands over `$description` before the values it declares — so + every circuit exists, briefly, with nothing behind its meter. Parsing that + to `0.0` asserts a reading the panel never made, and on a cumulative counter + a consumer cannot tell the fabrication from a genuine reset to zero. That is + `SpanPanel/span#259`. + + Distinct from `_parse_float` because most flat properties do want a + substituted default; only readings must keep absence tellable from zero. + """ + if not value: + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + def _parse_int(value: str, default: int = 0) -> int: """Parse an integer string, returning default on failure.""" try: @@ -298,13 +319,13 @@ def _build_circuit(self, node_id: str, device_type: str = "circuit", relative_po # active-power is in watts; negate so positive = consumption. # Guard against -0.0 creeping in when raw_power_w is 0.0. - raw_power_w = _parse_float(self._acc.get_prop(node_id, "active-power")) - instant_power_w = 0.0 if raw_power_w == 0.0 else -raw_power_w + raw_power_w = _reading(self._acc.get_prop(node_id, "active-power")) + instant_power_w = None if raw_power_w is None else (0.0 if raw_power_w == 0.0 else -raw_power_w) # Energy: exported-energy = consumption (panel exports TO circuit) - consumed_wh = _parse_float(self._acc.get_prop(node_id, "exported-energy")) + consumed_wh = _reading(self._acc.get_prop(node_id, "exported-energy")) # imported-energy = production (panel imports FROM circuit) - produced_wh = _parse_float(self._acc.get_prop(node_id, "imported-energy")) + produced_wh = _reading(self._acc.get_prop(node_id, "imported-energy")) # Tabs: derived from space + dipole # Dipole circuits occupy two consecutive spaces on the same bus bar @@ -435,14 +456,20 @@ def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]: ) return result - def _derive_dsm_state(self, core_node: str | None, grid_power: float, power_flow_grid: float | None) -> str: + def _derive_dsm_state(self, core_node: str | None, grid_power: float | None, power_flow_grid: float | None) -> str: """Derive dsm_state from multiple signals. Priority: 1. bess/grid-state — authoritative when BESS is commissioned 2. dominant-power-source == GRID — grid is the primary source 3. grid_power or power_flow_grid non-zero — grid exchanging power - 4. both grid signals zero AND DPS != GRID — islanded + 4. both grid signals *reported* and zero AND DPS != GRID — islanded + + Step 4 needs a reading, not merely the absence of one. Both signals are + `None` until the meter reports, and reading "no power is crossing the + service entrance" out of "nothing has told me yet" declares the site + islanded on the strength of a measurement nobody made. Unknown is the + honest answer, and this function already had a word for it. """ # 1. BESS grid-state is authoritative when available bess_node = self._acc.find_node_by_type(TYPE_BESS) @@ -460,9 +487,10 @@ def _derive_dsm_state(self, core_node: str | None, grid_power: float, power_flow return "DSM_ON_GRID" if dps in ("BATTERY", "PV", "GENERATOR"): - grid_exchanging = abs(grid_power) > _GRID_POWER_EPSILON_W or ( - power_flow_grid is not None and abs(power_flow_grid) > _GRID_POWER_EPSILON_W - ) + reported = [signal for signal in (grid_power, power_flow_grid) if signal is not None] + if not reported: + return "UNKNOWN" + grid_exchanging = any(abs(signal) > _GRID_POWER_EPSILON_W for signal in reported) return "DSM_ON_GRID" if grid_exchanging else "DSM_OFF_GRID" return "UNKNOWN" @@ -577,15 +605,15 @@ def _build_snapshot(self) -> SpanPanelSnapshot: # Upstream lugs → main meter (grid connection) # imported-energy = energy imported from the grid = consumed by the house # exported-energy = energy exported to the grid = produced (solar) - grid_power = 0.0 - main_consumed = 0.0 - main_produced = 0.0 + grid_power: float | None = None + main_consumed: float | None = None + main_produced: float | None = None upstream_l1_current: float | None = None upstream_l2_current: float | None = None if upstream_lugs is not None: - grid_power = _parse_float(self._acc.get_prop(upstream_lugs, "active-power")) - main_consumed = _parse_float(self._acc.get_prop(upstream_lugs, "imported-energy")) - main_produced = _parse_float(self._acc.get_prop(upstream_lugs, "exported-energy")) + grid_power = _reading(self._acc.get_prop(upstream_lugs, "active-power")) + main_consumed = _reading(self._acc.get_prop(upstream_lugs, "imported-energy")) + main_produced = _reading(self._acc.get_prop(upstream_lugs, "exported-energy")) l1_i = self._acc.get_prop(upstream_lugs, "l1-current") upstream_l1_current = _parse_float(l1_i) if l1_i else None @@ -593,15 +621,15 @@ def _build_snapshot(self) -> SpanPanelSnapshot: upstream_l2_current = _parse_float(l2_i) if l2_i else None # Downstream lugs → feedthrough - feedthrough_power = 0.0 - feedthrough_consumed = 0.0 - feedthrough_produced = 0.0 + feedthrough_power: float | None = None + feedthrough_consumed: float | None = None + feedthrough_produced: float | None = None downstream_l1_current: float | None = None downstream_l2_current: float | None = None if downstream_lugs is not None: - feedthrough_power = _parse_float(self._acc.get_prop(downstream_lugs, "active-power")) - feedthrough_consumed = _parse_float(self._acc.get_prop(downstream_lugs, "imported-energy")) - feedthrough_produced = _parse_float(self._acc.get_prop(downstream_lugs, "exported-energy")) + feedthrough_power = _reading(self._acc.get_prop(downstream_lugs, "active-power")) + feedthrough_consumed = _reading(self._acc.get_prop(downstream_lugs, "imported-energy")) + feedthrough_produced = _reading(self._acc.get_prop(downstream_lugs, "exported-energy")) dl1_i = self._acc.get_prop(downstream_lugs, "l1-current") downstream_l1_current = _parse_float(dl1_i) if dl1_i else None 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 3250bed..1e10dd5 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 @@ -250,10 +250,12 @@ def build_circuit( device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" ) -> SpanCircuitSnapshot: """Build one circuit snapshot from its v1.0 device.""" - raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) # Negate so positive means consumption. The guard keeps -0.0 out of the # snapshot, where it would compare equal to 0.0 but format as "-0.0". - instant_power_w = 0.0 if raw_power == 0.0 else -raw_power + # A meter that has not reported stays `None` rather than becoming 0.0 W — + # see `SpanCircuitSnapshot` for why absent and zero must not collapse. + instant_power_w = None if raw_power is None else (0.0 if raw_power == 0.0 else -raw_power) relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True) priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN) @@ -267,8 +269,8 @@ def build_circuit( # The panel *imported* this energy from the circuit, so the circuit # produced it. Named from the panel's perspective, reported from the # circuit's. - produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0, - consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0, + produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY), + consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY), tabs=_tabs(device), priority=priority, # `always-on` is `not relay-controllable`, and the flat schema derived 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 34cb74d..23eaaa1 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 @@ -444,15 +444,15 @@ def __init__( # `power_flow_grid` is the site-level figure; `lugs_at_service_entrance` # above is how a consumer tells the two apart. The reading itself is # correct in either topology -- it is the label that is conditional. - self.instant_grid_power_w = number(upstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 - self.main_meter_energy_consumed_wh = number(upstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 - self.main_meter_energy_produced_wh = number(upstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.instant_grid_power_w = number(upstream_lugs, NODE_METER, PROP_ACTIVE_POWER) + self.main_meter_energy_consumed_wh = number(upstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) + self.main_meter_energy_produced_wh = number(upstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) self.upstream_l1_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_A) self.upstream_l2_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_B) - self.feedthrough_power_w = number(downstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 - self.feedthrough_energy_consumed_wh = number(downstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 - self.feedthrough_energy_produced_wh = number(downstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.feedthrough_power_w = number(downstream_lugs, NODE_METER, PROP_ACTIVE_POWER) + self.feedthrough_energy_consumed_wh = number(downstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) + self.feedthrough_energy_produced_wh = number(downstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) self.downstream_l1_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_A) self.downstream_l2_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_B) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 24b65f3..4436423 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -23,9 +23,18 @@ class SpanCircuitSnapshot: circuit_id: str # UUID (dashless, normalized) name: str relay_state: str # OPEN | CLOSED | UNKNOWN - instant_power_w: float # Positive = consumption - produced_energy_wh: float # Generation/backfeed (Wh) - consumed_energy_wh: float # Consumption (Wh) + # `None` means the meter has not reported, which is not the same as a meter + # reporting zero. A retained-topic replay delivers a device's description + # before its values, so a circuit is known to exist for a window in which it + # has said nothing; filling that window with `0.0` publishes a reading the + # panel never made. On a cumulative counter that is destructive rather than + # cosmetic — a consumer compensating for firmware counter resets reads the + # fabricated zero as a reset and books the whole counter as an offset + # (`SpanPanel/span#259`). A new circuit legitimately reads `0.0`, and the + # two must stay tellable apart. + instant_power_w: float | None # Positive = consumption + produced_energy_wh: float | None # Generation/backfeed (Wh) + consumed_energy_wh: float | None # Consumption (Wh) tabs: list[int] priority: str # v1: MUST_HAVE | NICE_TO_HAVE | NON_ESSENTIAL | UNKNOWN # v2: NEVER | SOC_THRESHOLD | OFF_GRID | UNKNOWN @@ -931,14 +940,18 @@ class SpanPanelSnapshot: serial_number: str firmware_version: str - # Panel-level power and energy + # Panel-level power and energy. `None` for the same reason it appears on + # `SpanCircuitSnapshot`, plus one more that is specific to these six: they + # are read off the lugs devices, which are resolved by their `direction` + # property. Until that property arrives there is no lugs device to read at + # all, so these were the panel's whole import and export fabricated as zero. main_relay_state: str - instant_grid_power_w: float - feedthrough_power_w: float - main_meter_energy_consumed_wh: float - main_meter_energy_produced_wh: float - feedthrough_energy_consumed_wh: float - feedthrough_energy_produced_wh: float + instant_grid_power_w: float | None + feedthrough_power_w: float | None + main_meter_energy_consumed_wh: float | None + main_meter_energy_produced_wh: float | None + feedthrough_energy_consumed_wh: float | None + feedthrough_energy_produced_wh: float | None # v1 field names preserved — MQTT transport derives these from v2 data dsm_state: str # v1: direct | v2: multi-signal heuristic diff --git a/tests/test_absent_readings_are_not_zero.py b/tests/test_absent_readings_are_not_zero.py new file mode 100644 index 0000000..cd63dcb --- /dev/null +++ b/tests/test_absent_readings_are_not_zero.py @@ -0,0 +1,293 @@ +"""A reading the panel has not sent is absent, and absent is not zero. + +A retained-topic replay delivers a device's `$description` before its property +values, so there is a window in which the parser knows a circuit exists and +knows nothing it reports. Filling that window with `0.0` publishes a reading +the panel never made, and a consumer cannot tell it apart from a meter that +genuinely sits at zero. + +For a cumulative counter the difference is not cosmetic. A consumer that +compensates for firmware counter resets sees the fabricated zero as a reset, +books the whole lifetime counter as an offset, and adds it again on the next +replay. `SpanPanel/span#259` is that failure on real hardware: megawatt-hours +of fictional energy in long-term statistics, one restart at a time. + +So the rule these tests pin is a discrimination, not a default: an absent +reading is `None`, a reported zero is `0.0`, and the two never collapse into +each other. +""" + +from __future__ import annotations + +import json + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from conftest import flat_schema +from reference_payloads.schema_one import device_from_topics, parent_child_tree +from span_panel_api.models import SpanCircuitSnapshot, SpanPanelSnapshot +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0.const import TYPE_CIRCUIT, TYPE_CORE, TYPE_POWER_FLOWS +from span_panel_api_schema_1.circuits import build_circuit +from span_panel_api_schema_1.panel import PanelFields, find_lugs + +_TREE = parent_child_tree() + +# From the fixture: a 1-pole load, and the two lugs devices. +KITCHEN_LIGHTS = "0ab966b95f92a6a51ec548485aa85f54" +LUGS_UPSTREAM = "lugs-upstream" +LUGS_DOWNSTREAM = "lugs-downstream" +PANEL = "example-40t-001" + + +def _described_only(device_id: str) -> DiscoveredDevice: + """The device mid-replay: described and ready, no property value yet. + + Exactly what `$description` + `$state` alone produce, which is the state a + broker leaves a fresh subscriber in until the retained property burst lands. + """ + topics = _TREE[device_id] + return device_from_topics(device_id, {"$description": topics["$description"], "$state": "ready"}) + + +def _with_meter(device_id: str, **readings: str) -> DiscoveredDevice: + """The same device, plus only the named `meter/...` values.""" + topics = _TREE[device_id] + replay = {"$description": topics["$description"], "$state": "ready"} + replay.update({f"meter/{prop}": value for prop, value in readings.items()}) + return device_from_topics(device_id, replay) + + +class TestCircuitReadings: + """`build_circuit` on a described-but-silent circuit.""" + + def test_energy_is_none_before_the_meter_reports(self) -> None: + circuit = build_circuit(_described_only(KITCHEN_LIGHTS)) + + assert circuit.consumed_energy_wh is None + assert circuit.produced_energy_wh is None + + def test_power_is_none_before_the_meter_reports(self) -> None: + assert build_circuit(_described_only(KITCHEN_LIGHTS)).instant_power_w is None + + def test_a_reported_zero_stays_zero(self) -> None: + """The discrimination the whole change exists for. + + A meter that has published `0` is not the same as one that has published + nothing, and a new circuit legitimately reads zero. + """ + circuit = build_circuit( + _with_meter(KITCHEN_LIGHTS, **{"exported-energy": "0", "imported-energy": "0", "active-power": "0"}) + ) + + assert circuit.consumed_energy_wh == 0.0 + assert circuit.produced_energy_wh == 0.0 + assert circuit.instant_power_w == 0.0 + + def test_one_absent_reading_does_not_take_down_the_others(self) -> None: + """Absence is per-property; a circuit reporting some of its meter is + not wholly unknown.""" + circuit = build_circuit(_with_meter(KITCHEN_LIGHTS, **{"exported-energy": "163562.4"})) + + assert circuit.consumed_energy_wh == pytest.approx(163562.4) + assert circuit.produced_energy_wh is None + + def test_an_unparseable_reading_is_absent_rather_than_zero(self) -> None: + """Unparseable was already treated as absent; it must not become a + fabricated zero on the way through.""" + assert build_circuit(_with_meter(KITCHEN_LIGHTS, **{"exported-energy": "n/a"})).consumed_energy_wh is None + + +class TestPanelReadings: + """`PanelFields` on described-but-silent lugs.""" + + def _panel(self) -> DiscoveredDevice: + return device_from_topics(PANEL, _TREE[PANEL]) + + def test_lugs_energy_is_none_before_the_meter_reports(self) -> None: + fields = PanelFields( + self._panel(), + upstream_lugs=_described_only(LUGS_UPSTREAM), + downstream_lugs=_described_only(LUGS_DOWNSTREAM), + mid=None, + ) + + assert fields.main_meter_energy_consumed_wh is None + assert fields.main_meter_energy_produced_wh is None + assert fields.feedthrough_energy_consumed_wh is None + assert fields.feedthrough_energy_produced_wh is None + + def test_lugs_power_is_none_before_the_meter_reports(self) -> None: + fields = PanelFields( + self._panel(), + upstream_lugs=_described_only(LUGS_UPSTREAM), + downstream_lugs=_described_only(LUGS_DOWNSTREAM), + mid=None, + ) + + assert fields.instant_grid_power_w is None + assert fields.feedthrough_power_w is None + + def test_unresolved_lugs_report_nothing_rather_than_zero(self) -> None: + """The hole a readiness gate cannot see. + + `find_lugs` resolves a lugs device by its `direction` property. Until + that property arrives the panel has no lugs to read, and the panel-level + energy fields were being filled with `0.0` — the same fabrication as a + circuit's, on the sensors that carry the whole site's import and export. + """ + fields = PanelFields(self._panel(), upstream_lugs=None, downstream_lugs=None, mid=None) + + assert fields.main_meter_energy_consumed_wh is None + assert fields.main_meter_energy_produced_wh is None + assert fields.feedthrough_energy_consumed_wh is None + assert fields.feedthrough_energy_produced_wh is None + assert fields.instant_grid_power_w is None + assert fields.feedthrough_power_w is None + + def test_lugs_are_unresolvable_while_direction_is_absent(self) -> None: + """Pins the premise of the test above rather than assuming it.""" + described = [_described_only(LUGS_UPSTREAM), _described_only(LUGS_DOWNSTREAM)] + + assert find_lugs(described, upstream=True) is None + assert find_lugs(described, upstream=False) is None + + +class TestFlatSchemaReadings: + """The same discrimination in the flat adapter. + + Flat has no per-device tree — one `$description` names every node — so a + replay reaches the same state by a shorter route: node types known, values + not yet delivered. The `SpanPanel/span#259` report is from a flat-schema + panel, so this is the adapter the defect was actually observed on. + """ + + SERIAL = "sim-40t-001" + CIRCUIT = "ac3dccda46a94b98878a227df6fed588" + + def _adapter(self, **values: str) -> SchemaZeroAdapter: + adapter = SchemaZeroAdapter(serial_number=self.SERIAL, schema=flat_schema(40)) + adapter.handle_message( + f"ebus/5/{self.SERIAL}/$description", + json.dumps({"nodes": {self.CIRCUIT: {"type": TYPE_CIRCUIT}}}), + ) + adapter.handle_message(f"ebus/5/{self.SERIAL}/$state", "ready") + for prop, value in values.items(): + adapter.handle_message(f"ebus/5/{self.SERIAL}/{self.CIRCUIT}/{prop}", value) + return adapter + + def _circuit(self, **values: str) -> SpanCircuitSnapshot: + return self._adapter(**values).build_snapshot().circuits[self.CIRCUIT] + + def test_readings_are_none_before_the_circuit_reports(self) -> None: + circuit = self._circuit() + + assert circuit.consumed_energy_wh is None + assert circuit.produced_energy_wh is None + assert circuit.instant_power_w is None + + def test_a_reported_zero_stays_zero(self) -> None: + circuit = self._circuit(**{"exported-energy": "0", "imported-energy": "0", "active-power": "0"}) + + assert circuit.consumed_energy_wh == 0.0 + assert circuit.produced_energy_wh == 0.0 + assert circuit.instant_power_w == 0.0 + + def test_panel_energy_is_none_while_the_lugs_are_unresolved(self) -> None: + """Flat resolves lugs by node type or a `direction` property, exactly as + the tree schema does, and fabricated the same panel-level zeros.""" + snapshot = self._adapter().build_snapshot() + + assert snapshot.main_meter_energy_consumed_wh is None + assert snapshot.main_meter_energy_produced_wh is None + assert snapshot.feedthrough_energy_consumed_wh is None + assert snapshot.feedthrough_energy_produced_wh is None + assert snapshot.instant_grid_power_w is None + assert snapshot.feedthrough_power_w is None + + def test_an_unoccupied_breaker_position_still_reads_zero(self) -> None: + """Not every zero is a fabrication. + + An unmapped tab is synthesised, not parsed: it is a breaker position + with nothing behind it, so zero power and zero energy are what it + genuinely reports. Only readings the panel was asked for and did not + give become `None`. + """ + unmapped = self._adapter().build_snapshot().circuits.get("unmapped_tab_2") + + assert unmapped is not None + assert unmapped.instant_power_w == 0.0 + assert unmapped.consumed_energy_wh == 0.0 + assert unmapped.produced_energy_wh == 0.0 + + +class TestIslandingIsNotInferredFromSilence: + """`dsm_state` reads a grid-power measurement, so it needed the same rule. + + The heuristic asks whether power is crossing the service entrance, and + concluded "islanded" from a reading at zero. While that zero was fabricated, + a panel that had reported nothing at all was declared off-grid — the + fabrication propagating into a state a consumer acts on. + """ + + SERIAL = "sim-40t-001" + CORE = "core" + FLOWS = "power-flows" + + def _snapshot(self, grid_flow: str | None = None) -> SpanPanelSnapshot: + """A panel on battery, with no lugs — so `instant_grid_power_w` is + unreported — optionally publishing a `power-flows/grid` reading. + + Battery as the dominant source is what sends the heuristic past its two + authoritative answers and down to the measurement. + """ + adapter = SchemaZeroAdapter(serial_number=self.SERIAL, schema=flat_schema(40)) + adapter.handle_message( + f"ebus/5/{self.SERIAL}/$description", + json.dumps( + { + "nodes": { + self.CORE: {"type": TYPE_CORE}, + self.FLOWS: {"type": TYPE_POWER_FLOWS}, + } + } + ), + ) + adapter.handle_message(f"ebus/5/{self.SERIAL}/$state", "ready") + adapter.handle_message(f"ebus/5/{self.SERIAL}/{self.CORE}/dominant-power-source", "BATTERY") + if grid_flow is not None: + adapter.handle_message(f"ebus/5/{self.SERIAL}/{self.FLOWS}/grid", grid_flow) + return adapter.build_snapshot() + + def test_unknown_while_no_grid_power_has_been_reported(self) -> None: + assert self._snapshot().dsm_state == "UNKNOWN" + + def test_a_reported_zero_still_means_islanded(self) -> None: + """The measurement, when it exists, is read exactly as before.""" + assert self._snapshot(grid_flow="0").dsm_state == "DSM_OFF_GRID" + + def test_a_reported_flow_still_means_on_grid(self) -> None: + assert self._snapshot(grid_flow="1500").dsm_state == "DSM_ON_GRID" + + +class TestTheCaptureStillReads: + """The fixture is a real panel's retained state; it must be unaffected.""" + + def test_a_fully_reported_circuit_keeps_its_readings(self) -> None: + circuit = build_circuit(device_from_topics(KITCHEN_LIGHTS, _TREE[KITCHEN_LIGHTS])) + + assert circuit.consumed_energy_wh is not None + assert circuit.produced_energy_wh is not None + assert circuit.instant_power_w is not None + + def test_the_capture_declares_the_meter_it_reports(self) -> None: + """Guards the fixtures above: `_described_only` is only a meaningful + stand-in for mid-replay if the capture really does carry these values + as separate retained topics.""" + topics = _TREE[KITCHEN_LIGHTS] + declared = json.loads(topics["$description"])["nodes"]["meter"]["properties"] + + assert "exported-energy" in declared + assert "meter/exported-energy" in topics diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index 9fd3ef9..324dec6 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -1257,13 +1257,22 @@ def test_invalid_description_json(self): assert not consumer.is_ready() def test_empty_property_values(self): - """Circuit with no properties should still build with defaults.""" + """Circuit with no properties should still build, reporting absence. + + This is the `SpanPanel/span#259` window itself: the device has described + its nodes and published no value behind any of them. Descriptive fields + fall back to a placeholder a consumer can render, but a *reading* has no + honest placeholder — `0.0` is a measurement the panel never made, and a + consumer compensating for counter resets cannot tell it from one. + """ acc, consumer = _build_ready_consumer() snapshot = consumer.build_snapshot() circuit = snapshot.circuits["aabbccdd112233445566778899001122"] assert circuit.name == "" assert circuit.relay_state == "UNKNOWN" - assert circuit.instant_power_w == 0.0 + assert circuit.instant_power_w is None + assert circuit.consumed_energy_wh is None + assert circuit.produced_energy_wh is None assert circuit.tabs == [] def test_multiple_circuits(self): diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index fd99493..125d28b 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -165,11 +165,19 @@ def test_lugs_are_found_by_declared_direction_not_device_id() -> None: assert find_lugs(devices, upstream=False).device_id == "lugs-downstream" -def test_missing_lugs_yield_zeros_not_errors() -> None: - """A panel without lugs devices must still produce a snapshot.""" +def test_missing_lugs_report_nothing_rather_than_erroring() -> None: + """A panel without lugs devices must still produce a snapshot. + + It reports `None` rather than `0.0` for the readings those lugs would have + carried. The lugs are resolved by their `direction` property, so "no lugs + yet" is an ordinary moment in a retained-topic replay rather than a broken + panel — and answering it with zero states a site import of nothing on the + sensors that carry the whole panel's energy. See + `test_absent_readings_are_not_zero`. + """ fields = PanelFields(panel=_device(PANEL), upstream_lugs=None, downstream_lugs=None, mid=None) - assert fields.instant_grid_power_w == 0.0 + assert fields.instant_grid_power_w is None assert fields.upstream_l1_current_a is None assert fields.grid_state is None diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 1f34063..a54a04b 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -134,7 +134,9 @@ def test_a_panel_with_no_children_still_builds() -> None: snapshot = build_snapshot(_device(PANEL), []) assert snapshot.serial_number == "example-40t-001" - assert snapshot.instant_grid_power_w == 0.0 + # Nothing has reported a reading yet, which is not the same as a reading of + # zero — see `test_absent_readings_are_not_zero`. + assert snapshot.instant_grid_power_w is None assert snapshot.battery.soe_percentage is None # Every position is unoccupied, so all 40 are synthesised. assert len(snapshot.circuits) == 40