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
2 changes: 2 additions & 0 deletions packages/schema-1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the u

- **A circuit whose shed priority is `UNKNOWN` is no longer reported as sheddable.**
- **The grid-forming device name falls back to vendor and model instead of showing a device id.**
- **A panel with no MID reports `dsm_state` `DSM_ON_GRID` and `current_run_config` `PANEL_ON_GRID` whenever it is publishing**, where both dropped to `UNKNOWN` each time `power-flows/grid` read within 1 W of zero
([#215](https://github.com/SpanPanel/span-panel-api/issues/215)).

## [1.2.2]

Expand Down
50 changes: 27 additions & 23 deletions packages/schema-1/src/span_panel_api_schema_1/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,11 +623,6 @@ def build_pcs(panel: DiscoveredDevice) -> SpanPcsSnapshot | None:
)


# Matches `schema_0`'s epsilon so the no-MID heuristic answers identically on the two
# adapters — the tier exists precisely for panels where nothing authoritative is
# published, and disagreeing about the threshold would make it schema-dependent.
_GRID_POWER_EPSILON_W = 1.0

ISLANDING_ON_GRID = "ON_GRID"
ISLANDING_OFF_GRID = "OFF_GRID"

Expand All @@ -639,8 +634,8 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
| --- | --- | --- |
| 1 | `shed/asserted-islanding-state` is `ON_GRID` or `OFF_GRID` | asserted |
| 2 | MID `$state` is `ready` and `islanding-state` present | sensed |
| 3 | no MID at all | `power-flows/grid` heuristic |
| 4 | none of the above | unknown |
| 3 | no MID at all | on-grid, by elimination |
| 4 | a MID that has not answered | unknown |

**An assertion in force is the effective state, so it outranks the MID.** When the
panel's link to the battery is not healthy (the battery's `status/communication-state`
Expand All @@ -651,12 +646,25 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
different one. The assertion clears itself back to `NONE` once the battery link
recovers, so the MID's answer returns without anything here having to expire it.

**Tier 3 never answers `OFF_GRID`, and never asserts on-grid from a missing MID.** An
earlier draft reasoned that no MID means no islanding authority means on-grid. That is
wrong: a missing MID means *SPAN* is not the islanding authority, and says nothing
about whether the site is islanded — a generator-fed island is the plain
counterexample. Grid power flowing is positive evidence of being on-grid; its absence
is not evidence of the opposite.
**No MID at all means on-grid, and tier 3 never answers `OFF_GRID`.** A commissioned
MID is what a SPAN panel islands with, so a panel without one cannot separate from
whatever feeds it, and what feeds it is what SPAN treats as the grid. This is the
elimination `resolve_dominant_power_source` makes, and the two now agree on every
tree. A panel that loses that feed loses power and stops publishing, so the answer
is withdrawn with the panel rather than left standing.

Tier 3 read `power-flows/grid` before, and answered on-grid only above 1 W, on the
reasoning that a missing MID says nothing about whether the site is islanded — a
generator-fed island being the counterexample. The threshold never defended that
case: a generator-fed panel carrying any load reads far above it and was reported
on-grid all the same. What it did was withdraw the answer each time the reading
touched zero, which a sub-panel with a small net draw does many times a day, and a
consumer saw each return as the grid coming back. How much power is flowing says
nothing about where it comes from, so the reading no longer takes part.

`mid is None` means no MID is declared, not that one has yet to be discovered: the
adapter builds no snapshot until every device the tree declares has described
itself. A MID that exists and has not answered is tier 4, and stays unknown.
"""
asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE)
if asserted in (ISLANDING_ON_GRID, ISLANDING_OFF_GRID):
Expand All @@ -669,10 +677,7 @@ def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevic
return sensed
return None

grid_power = number(panel, NODE_POWER_FLOWS, "grid")
if grid_power is not None and abs(grid_power) > _GRID_POWER_EPSILON_W:
return ISLANDING_ON_GRID
return None
return ISLANDING_ON_GRID


def resolve_dsm_state(islanding: str | None) -> str:
Expand Down Expand Up @@ -800,12 +805,11 @@ def resolve_dominant_power_source(
A site genuinely running off-grid without storage is not a counterexample; it goes
dark at sunset.

This deliberately does **not** follow `resolve_islanding_state`, which refuses the
same shortcut. The two answer different questions and the counterexample that defeats
it there is the one that supports it here: a generator-fed island is islanded — so
inferring on-grid from a missing MID would be wrong — while its grid-forming entity
really is what SPAN calls the grid. Islanding is a safety fact about separation; this
is a class of source.
`resolve_islanding_state` rests on the same elimination and answers on-grid for the
same trees. It refused the shortcut at first, on the grounds that a generator-fed
island is islanded, and read grid power instead — which reported that same island
on-grid whenever it carried load. A source with no MID interface is the grid as far
as SPAN can tell, and both fields now say so.

It is also no worse than flat, which is the bar. Flat could not see an uninterfaced
generator either and published `GRID` regardless; a panel upgrading to v1.0 keeps the
Expand Down
4 changes: 2 additions & 2 deletions packages/schema-1/src/span_panel_api_schema_1/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@ def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice]) ->
feedthrough_energy_produced_wh=fields.feedthrough_energy_produced_wh,
# Read, not derived. Flat had to infer both from `dominant-power-source`
# plus grid power because nothing stated them; v1.0 states them on the MID,
# so the multi-signal heuristic is gone and only the no-MID tier remains a
# heuristic. The user-visible value set is unchanged.
# so the multi-signal heuristic is gone, and a panel with no MID is on-grid
# by elimination. The user-visible value set is unchanged.
dsm_state=resolve_dsm_state(islanding),
current_run_config=resolve_run_config(roles.mid, islanding, device_types),
door_state=fields.door_state,
Expand Down
7 changes: 0 additions & 7 deletions tests/adapter_fidelity/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,6 @@ def add(
_DERIVED,
"devices.build_battery: compared in sign with the BESS meter where the firmware names no release build",
)
add(
"panel",
"power-flows/grid",
ANY_LEAF,
_DERIVED,
"panel.resolve_islanding_state: grid power decides on-grid only where no MID is published",
)
add("panel", "shed/policy", "panel.shed_policy", _LITERAL, panel_fields)
for policy_field in ("shed_policy_algorithm", "shed_soc_threshold_shed_percent", "shed_soc_threshold_release_percent"):
add("panel", "shed/policy", f"panel.{policy_field}", _DERIVED, "panel._shed_policy parses the policy document")
Expand Down
10 changes: 10 additions & 0 deletions tests/adapter_fidelity/rules_captured.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@
"panel.resolve_dominant_power_source: with no MID, GRID is an elimination, since nothing else can form the grid",
values=("GRID",),
),
Derivation(
"panel.dsm_state",
"panel.resolve_islanding_state: with no MID, on-grid is the same elimination, since nothing can island the panel",
values=("DSM_ON_GRID",),
),
Derivation(
"panel.current_run_config",
"panel.resolve_islanding_state: with no MID, on-grid is the same elimination, since nothing can island the panel",
values=("PANEL_ON_GRID",),
),
Derivation(
"circuit.relay_state",
f"{_STR_SENTINEL}: circuits.build_circuit reads UNKNOWN where the device publishes no switch/relay",
Expand Down
22 changes: 12 additions & 10 deletions tests/test_schema_one_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,17 +342,19 @@ def test_an_assertion_outside_the_enum_is_not_an_answer() -> None:
assert resolve_islanding_state(mid, panel) == "OFF_GRID"


def test_no_mid_reads_grid_power_and_never_asserts_off_grid() -> None:
"""Tier 3, and the error worth keeping a test on.

An earlier draft reasoned that no MID means no islanding authority means on-grid.
A missing MID means *SPAN* is not the authority and says nothing about whether the
site is islanded — a generator-fed island is the counterexample. Grid power flowing
is positive evidence of being on-grid; its absence is not evidence of the opposite.
def test_no_mid_is_on_grid_whatever_grid_power_reads() -> None:
"""Tier 3. A panel with no MID cannot island, so a panel that is publishing is on-grid.

This read `power-flows/grid` until 2026-10-13 and answered on-grid only above 1 W.
A sub-panel whose net draw sits near zero then lost the answer on every sample that
read `0.0` and regained it on the next, which an automation saw as the grid coming
back (SpanPanel/span-panel-api#215). Grid power is a measurement of how much is
flowing, and the amount says nothing about where it comes from.
"""
assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="2400.0")) == "ON_GRID"
assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="0.0")) is None
assert resolve_islanding_state(None, _synthetic(PANEL)) is None
for grid_power in ("2400.0", "8.7", "0.0", "-0.4", "-3100.0"):
panel = _synthetic(PANEL, power_flows__grid=grid_power)
assert resolve_islanding_state(None, panel) == "ON_GRID", grid_power
assert resolve_islanding_state(None, _synthetic(PANEL)) == "ON_GRID"


def test_run_config_names_the_forming_device_rather_than_guessing_it() -> None:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_schema_one_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ class is recoverable from the tree.
assert snapshot.current_run_config == "PANEL_ON_GRID"


def test_a_panel_with_no_mid_keeps_its_grid_answers_through_a_zero_grid_reading() -> None:
"""Both entities hold steady while grid power passes through zero.

A panel with no MID has no battery either, so the tree here is the reference one
without those two devices. The readings are the samples around one drop reported in
SpanPanel/span-panel-api#215, where each `0.0` turned both answers unknown for one
update.
"""
children = [device for device in _children() if device.device_id not in ("bess", "bess-mid")]
assert TreeRoles(children).mid is None

for grid_power in ("11.2", "0.0", "8.7"):
panel = _device(PANEL)
panel.update_property("power-flows", "grid", grid_power)

snapshot = build_snapshot(panel, children)

assert snapshot.power_flow_grid == float(grid_power)
assert snapshot.dsm_state == "DSM_ON_GRID", grid_power
assert snapshot.current_run_config == "PANEL_ON_GRID", grid_power


def test_an_unsizable_panel_reports_no_size_and_keeps_its_circuits() -> None:
"""A panel whose model we cannot size reports no total rather than a guess,
and its circuits do not depend on the total."""
Expand Down
Loading