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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
70 changes: 49 additions & 21 deletions packages/schema-0/src/span_panel_api_schema_0/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -577,31 +605,31 @@ 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
l2_i = self._acc.get_prop(upstream_lugs, "l2-current")
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
Expand Down
10 changes: 6 additions & 4 deletions packages/schema-1/src/span_panel_api_schema_1/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions packages/schema-1/src/span_panel_api_schema_1/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
33 changes: 23 additions & 10 deletions src/span_panel_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading