diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index ffc8358..f13cb0d 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -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] 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 dab7ce0..29f8498 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 @@ -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" @@ -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` @@ -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): @@ -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: @@ -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 diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py index 0e51f31..3357073 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -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, diff --git a/tests/adapter_fidelity/rules.py b/tests/adapter_fidelity/rules.py index 169c43c..0ad63dd 100644 --- a/tests/adapter_fidelity/rules.py +++ b/tests/adapter_fidelity/rules.py @@ -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") diff --git a/tests/adapter_fidelity/rules_captured.py b/tests/adapter_fidelity/rules_captured.py index 83aadd6..6e75c4a 100644 --- a/tests/adapter_fidelity/rules_captured.py +++ b/tests/adapter_fidelity/rules_captured.py @@ -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", diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 71491d8..e63f23b 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -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: diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index 46182ef..6438750 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -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."""