From b1d38551d19bfa7bb57aef3772593c83d46e4081 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:10:11 -0700 Subject: [PATCH 1/4] fix: publish power-flows in the node frame so the four flows balance The emitter published pv, grid and battery in the meter frame -- positive = consumption, the frame each individual meter reports in. The four power-flows properties are not four meters; they are the four terms of one balance at the panel node, and a balance only closes when every term shares a frame. Published in the meter frame the sum missed zero by twice the site load, and a producing array read positive where a panel reads negative. Restated in the node frame -- positive = power leaving the panel node -- the signs now match what SPAN documents for a shipping panel: grid positive while exporting, pv negative while producing, battery positive while charging, site positive while consuming. See SPAN-API-Client-Docs, docs/public/power-and-energy-conventions.md, "The power-flows capability is an exception", which also records that these signs are longstanding and did not change in the parent/child migration. Also drops the PV-surplus clamp from the grid computation. It existed to stop a charging battery adding grid import, but that import is real: charging beyond what PV covers is drawn from the utility, and suppressing it broke the balance. test_power_flows_sum_to_zero holds the identity across nine operating states. grid is derived from the lugs and BESS rather than back-solved from the other three, so the assertion has teeth -- a residual would satisfy it by construction and detect nothing. --- .../flat_emitter/panel_meter.py | 57 ++++-- tests/flat_emitter/test_panel_meter.py | 180 +++++++++++++++++- 2 files changed, 213 insertions(+), 24 deletions(-) diff --git a/src/span_panel_simulator/flat_emitter/panel_meter.py b/src/span_panel_simulator/flat_emitter/panel_meter.py index 3ba93f8..2c3aaf1 100644 --- a/src/span_panel_simulator/flat_emitter/panel_meter.py +++ b/src/span_panel_simulator/flat_emitter/panel_meter.py @@ -5,7 +5,10 @@ Stateless — all integration / accumulation lives in ``EnergyIntegrator``. This module is just arithmetic over the current tick's inputs. -Sign conventions (consistent across the emitter): +Two sign frames live on this reading, and they are not the same frame. + +METER frame — positive = consumption, negative = production. Every field below +is a reading taken by one meter, about itself: - Per-circuit ``power_w``: positive = consume, negative = produce (PV/V2G). - ``battery_w``: positive = discharging (battery → panel), negative = charging. - ``instant_grid_power_w``: positive = importing from grid, negative = exporting. @@ -14,6 +17,23 @@ - ``feedthrough_power_w``: net power flowing through the lugs to downstream loads (panel-side meter perspective). +NODE frame — positive = power LEAVING the panel node, negative = power ENTERING +it. The four ``power_flow_*`` fields are not four meters; they are the four +terms of one balance at one node, and a balance only closes if every term is +in the same frame. So PV (injecting) is negative, loads (drawing) are positive, +export (leaving) is positive, and a charging battery (drawing) is positive. + +The two frames disagree about the same instant, on purpose. A live panel +exporting 2.5 kW publishes ``lugs-upstream/active-power`` negative and +``power-flows/grid`` positive simultaneously; both are correct, because they +answer different questions. Do not "make them consistent". + +The balance is what makes the node frame checkable: a real panel's four flows +sum to zero to the last digit it publishes. ``test_power_flows_sum_to_zero`` +holds this emitter to the same identity, which is why ``power_flow_grid`` is +derived from the physics below rather than back-solved from the other three — +a residual would satisfy the test by construction and detect nothing. + Off-grid: when ``grid_online`` is False, ``instant_grid_power_w`` is 0 by definition (grid is electrically disconnected); battery and PV cover load.""" @@ -106,8 +126,8 @@ def resolve( if grid_online: # Upstream lugs see the panel-side net flow. Utility grid flow is on the - # other side of an upstream BESS, so subtract BESS discharge. Charging is - # limited to PV surplus; a BESS must not turn load into extra grid import. + # other side of an upstream BESS, so remove the BESS contribution to get + # what the utility is actually supplying or absorbing. grid_w = _grid_power_from_lugs_and_bess(upstream_active_w, battery_w) grid_state: str | None = "ON_GRID" dsm_state = _DSM_ON @@ -167,9 +187,12 @@ def resolve( current_run_config=current_run_config, dominant_power_source=dominant_power_source, grid_islandable=panel.islandable, - power_flow_pv=pv_available_w, - power_flow_battery=battery_w, - power_flow_grid=grid_w, + # Node frame — see the module docstring. Each of these is the meter-frame + # quantity above it, restated as "power leaving the panel node", which is + # what makes the four sum to zero. + power_flow_pv=-pv_available_w, + power_flow_battery=-battery_w, + power_flow_grid=-grid_w, power_flow_site=load_demand_w, ) @@ -177,15 +200,21 @@ def resolve( def _grid_power_from_lugs_and_bess(upstream_active_w: float, battery_w: float) -> float: """Return utility-side grid power from panel-side lugs and BESS power. - BESS sign convention is positive=discharging, negative=charging. Charging is - only credited against PV surplus visible at the lugs; it never creates extra - grid import. + BESS sign convention is positive=discharging, negative=charging. The BESS sits + upstream of the lugs, so whatever it supplies the utility does not have to, and + whatever it absorbs the utility must: one subtraction, in both directions. + + Charging used to be credited only against the PV surplus visible at the lugs, so + that a charging BESS "never creates extra grid import". That clamp is gone. It + was a dispatch policy enforced in the wrong module, and it enforced it against + the one mode that does not want it: ``self-consumption`` already charges from + ``pv_surplus_w`` alone (``native_devices/bess.py``), so the clamp never bound + there, while ``backup-only`` deliberately charges from the utility -- and the + clamp silently deleted exactly that import from the reading. The energy did not + stop arriving; the meter stopped saying where it came from, which is the one + thing a meter is for. It also put the node balance out by the amount hidden. """ - if battery_w >= 0: - return upstream_active_w - battery_w - pv_surplus_w = max(0.0, -upstream_active_w) - pv_charge_w = min(abs(battery_w), pv_surplus_w) - return upstream_active_w + pv_charge_w + return upstream_active_w - battery_w def _per_leg_current( diff --git a/tests/flat_emitter/test_panel_meter.py b/tests/flat_emitter/test_panel_meter.py index d2124fe..f249084 100644 --- a/tests/flat_emitter/test_panel_meter.py +++ b/tests/flat_emitter/test_panel_meter.py @@ -2,7 +2,11 @@ from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg from span_panel_simulator.flat_emitter.manifest_physics import CircuitPhysics, PanelPhysics -from span_panel_simulator.flat_emitter.panel_meter import circuit_current_a, resolve +from span_panel_simulator.flat_emitter.panel_meter import ( + PanelMeterReading, + circuit_current_a, + resolve, +) def _panel(**overrides: object) -> PanelPhysics: @@ -86,7 +90,9 @@ def test_on_grid_consumer_only() -> None: assert r.instant_grid_power_w == 1000.0 assert r.power_flow_pv == 0.0 assert r.power_flow_battery == 0.0 - assert r.power_flow_grid == 1000.0 + # Node frame: the grid is feeding the panel, so power enters -- negative. + # ``instant_grid_power_w`` is the meter frame and stays positive for import. + assert r.power_flow_grid == -1000.0 assert r.power_flow_site == 1000.0 assert r.grid_state == "ON_GRID" assert r.dominant_power_source == "GRID" @@ -111,8 +117,9 @@ def test_on_grid_with_pv_export() -> None: ) # load - pv - battery = 500 - 2000 - 0 = -1500 (exporting to grid) assert r.instant_grid_power_w == -1500.0 - assert r.power_flow_pv == 2000.0 - assert r.power_flow_grid == -1500.0 + # Node frame inverts both: the array feeds the node, the export leaves it. + assert r.power_flow_pv == -2000.0 + assert r.power_flow_grid == 1500.0 def test_on_grid_with_battery_discharging() -> None: @@ -130,7 +137,8 @@ def test_on_grid_with_battery_discharging() -> None: # grid = load - pv - battery_supply = 3000 - 0 - 2000 = 1000 assert r.instant_grid_power_w == 1000.0 assert r.upstream_active_power_w == 3000.0 - assert r.power_flow_battery == 2000.0 + # Node frame: a discharging battery feeds the node, like the array does. + assert r.power_flow_battery == -2000.0 def test_on_grid_with_battery_charging_from_pv_surplus() -> None: @@ -148,10 +156,12 @@ def test_on_grid_with_battery_charging_from_pv_surplus() -> None: grid_online=True, has_battery=True, ) - # PV surplus charges the BESS without creating utility grid import. + # PV surplus charges the BESS without creating utility grid import -- not + # because anything clamps it, but because 500 - 2000 - (-1500) is 0. assert r.instant_grid_power_w == 0.0 assert r.upstream_active_power_w == -1500.0 - assert r.power_flow_battery == -1500.0 + # Node frame: a charging battery draws from the node, like a load. + assert r.power_flow_battery == 1500.0 def test_pv_surplus_exports_when_battery_charges_less_than_surplus() -> None: @@ -173,7 +183,16 @@ def test_pv_surplus_exports_when_battery_charges_less_than_surplus() -> None: assert r.instant_grid_power_w == -1000.0 -def test_battery_charging_never_adds_grid_import() -> None: +def test_battery_charging_with_no_pv_imports_from_the_grid() -> None: + """500 W of load and a BESS pulling 1.5 kW with no array: the utility + supplies all 2 kW. + + This asserted 500 W until the lugs-vs-BESS clamp came out. The clamp existed + to keep a charging BESS from "adding grid import", but with no PV there is + nowhere else for 1.5 kW to come from, so what it really did was hide the + import that ``backup-only`` charging deliberately creates -- and put the node + balance out by the same 1.5 kW. + """ panel = _panel() circuits = {"kitchen": _circuit(tabs=(1,))} powers = {"kitchen": 500.0} @@ -186,7 +205,8 @@ def test_battery_charging_never_adds_grid_import() -> None: has_battery=True, ) assert r.upstream_active_power_w == 500.0 - assert r.instant_grid_power_w == 500.0 + assert r.instant_grid_power_w == 2000.0 + assert r.power_flow_grid == -2000.0 def test_without_bess_upstream_lug_power_is_grid_power() -> None: @@ -305,7 +325,7 @@ def test_feedthrough_is_downstream_only() -> None: assert r.feedthrough_power_w == 2500.0 # Site / grid use ALL circuits. assert r.power_flow_site == 3000.0 - assert r.power_flow_grid == 3000.0 + assert r.power_flow_grid == -3000.0 def test_feedthrough_per_leg_currents() -> None: @@ -367,3 +387,143 @@ def test_dsm_and_run_config_track_grid_state() -> None: assert on.current_run_config == "PANEL_ON_GRID" assert off.dsm_state == "DSM_OFF_GRID" assert off.current_run_config == "PANEL_OFF_GRID" + + +# -- power-flows node balance ------------------------------------------------ +# +# The four ``power-flows`` values are one balance at one node, so they sum to +# zero. This is not a style rule; it is the identity a shipping panel satisfies +# to the last digit it publishes, and it follows from the signs SPAN documents: +# grid positive while exporting, pv negative while producing, battery positive +# while charging, site positive while consuming -- every term stated as power +# leaving the panel node. See SPAN-API-Client-Docs, +# ``docs/public/power-and-energy-conventions.md``, "The ``power-flows`` +# capability is an exception", which also records that these signs are +# longstanding and did not change in the parent/child migration. +# +# The emitter used to publish pv, grid and battery in the meter frame instead, +# which put the sum out by twice the site load and made a producing array read +# as negative in Home Assistant. +# +# ``power_flow_grid`` is computed from the lugs and the BESS, not back-solved +# from the other three -- these assertions have teeth only because grid arrives +# independently. + + +def _flow_sum(r: PanelMeterReading) -> float: + return r.power_flow_pv + r.power_flow_grid + r.power_flow_site + r.power_flow_battery + + +@pytest.mark.parametrize( + ("label", "powers", "battery_w", "grid_online", "has_battery"), + [ + ("consumer only", {"kitchen": 1000.0}, 0.0, True, False), + ("pv exporting", {"kitchen": 500.0, "solar": -2000.0}, 0.0, True, False), + ("pv covering load exactly", {"kitchen": 2000.0, "solar": -2000.0}, 0.0, True, False), + ("battery discharging", {"kitchen": 3000.0}, 2000.0, True, True), + ( + "battery charging from pv surplus", + {"kitchen": 500.0, "solar": -2000.0}, + -1500.0, + True, + True, + ), + ( + "battery charging beyond pv surplus", + {"kitchen": 500.0, "solar": -2000.0}, + -2500.0, + True, + True, + ), + ("battery charging with no pv at all", {"kitchen": 500.0}, -1500.0, True, True), + ("off grid, battery covers load", {"kitchen": 1000.0}, 1000.0, False, True), + ( + "off grid, battery covers load net of pv", + {"kitchen": 1800.0, "solar": -800.0}, + 1000.0, + False, + True, + ), + ], +) +def test_power_flows_sum_to_zero( + label: str, + powers: dict[str, float], + battery_w: float, + grid_online: bool, + has_battery: bool, +) -> None: + circuits = {cid: _circuit(tabs=(i * 2 + 1,)) for i, cid in enumerate(powers)} + r = resolve( + panel=_panel(), + circuits=circuits, + gated_powers=powers, + battery_w=battery_w, + grid_online=grid_online, + has_battery=has_battery, + ) + assert _flow_sum(r) == pytest.approx(0.0, abs=1e-9), label + + +def test_power_flow_signs_match_a_producing_panel() -> None: + """Exporting solar, no battery -- the case the hardware check covered. + + PV is negative because it feeds the node, grid is positive because power + leaves through it, site is positive because loads draw from it. + """ + r = resolve( + panel=_panel(), + circuits={"kitchen": _circuit(tabs=(1,)), "solar": _circuit(tabs=(3,))}, + gated_powers={"kitchen": 500.0, "solar": -2000.0}, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + assert r.power_flow_pv == -2000.0 + assert r.power_flow_site == 500.0 + assert r.power_flow_grid == 1500.0 + assert r.power_flow_battery == 0.0 + # The meter frame disagrees at the same instant, and that is correct: the + # upstream lugs read negative while exporting. + assert r.upstream_active_power_w == -1500.0 + + +def test_charging_battery_is_positive_and_discharging_is_negative() -> None: + """The sign the live panel could not settle -- it has no BESS. + + Fixed by the balance rather than by observation: a charging battery draws + from the node exactly as a load does, so it carries a load's sign. + """ + charging = resolve( + panel=_panel(), + circuits={"kitchen": _circuit(tabs=(1,))}, + gated_powers={"kitchen": 500.0}, + battery_w=-1500.0, + grid_online=True, + has_battery=True, + ) + discharging = resolve( + panel=_panel(), + circuits={"kitchen": _circuit(tabs=(1,))}, + gated_powers={"kitchen": 3000.0}, + battery_w=2000.0, + grid_online=True, + has_battery=True, + ) + assert charging.power_flow_battery == 1500.0 + assert discharging.power_flow_battery == -2000.0 + + +def test_charging_beyond_pv_surplus_shows_the_grid_import_it_causes() -> None: + """500 W of load, 2 kW of PV, a BESS pulling 2.5 kW: 1 kW has to come from + the utility. The old lugs-vs-BESS clamp reported that as zero import.""" + r = resolve( + panel=_panel(), + circuits={"kitchen": _circuit(tabs=(1,)), "solar": _circuit(tabs=(3,))}, + gated_powers={"kitchen": 500.0, "solar": -2000.0}, + battery_w=-2500.0, + grid_online=True, + has_battery=True, + ) + assert r.instant_grid_power_w == 1000.0 + assert r.power_flow_grid == -1000.0 From a0ca0273dcd6df5876d73a961b2557c38aedac9a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:10:28 -0700 Subject: [PATCH 2/4] fix: integrate lugs energy from the lugs meter, not the circuits behind it The lugs imported- and exported-energy accumulators were built by summing the gross consumption and gross production of the circuits behind the lugs. A meter reads one net quantity, so only one of its two accumulators can advance in a given tick. Summing gross totals advanced both at once: with 2 kW of production against 6 kW of load the lugs carry ~4 kW in a single direction, but the gross sum reported 6000 Wh consumed and 2000 Wh produced. Register each lugs with the energy integrator and observe its own active power, so imported- and exported-energy derive from the same signed reading the lugs publishes as active-power. test_lugs_energy_integrates_its_own_meter_not_the_circuits_behind_it asserts that at most one accumulator advances per tick and that the advancing one matches the net. --- .../flat_emitter/emitter.py | 30 +++++++----- tests/flat_emitter/test_publish_tick.py | 47 ++++++++++++++++++- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/span_panel_simulator/flat_emitter/emitter.py b/src/span_panel_simulator/flat_emitter/emitter.py index 2e1785b..efa080c 100644 --- a/src/span_panel_simulator/flat_emitter/emitter.py +++ b/src/span_panel_simulator/flat_emitter/emitter.py @@ -125,6 +125,16 @@ def __init__( ) for eid in self._physics.all_evse(): self._energy.register(eid) + # Lugs are metered points, so their energy registers integrate the power + # THEIR OWN meter reports. They used to be handed the sum of the circuits + # behind them instead, which is a different quantity: with 7 kW of PV and + # 6 kW of load the lugs carry ~1 kW in one direction, but the gross sum + # advanced `imported-energy` AND `exported-energy` in the same tick. A + # capture of a live panel never does that -- the spec calls + # `imported-energy` "the energy counterpart of positive `active-power`", + # and a counterpart that integrates a different signal is not one. + for lugs_id in self._physics.all_lugs(): + self._energy.register(lugs_id) # Seed any configured BESS whose manifest physics declares an initial SOE. for bess_id, bphys in self._physics.all_bess().items(): if bphys.initial_soe_kwh is not None and bess_id in self._bess: @@ -527,22 +537,18 @@ def _build_snapshot_from_tick(self, tick: TickInputs) -> EbusPanelSnapshot: # Upstream lugs are panel-side. With an upstream BESS, utility # grid flow is computed beyond the BESS and can differ. active_w = meter.upstream_active_power_w - imported_wh = sum(s.consumed_energy_wh for s in circuit_snaps.values()) - exported_wh = sum(s.produced_energy_wh for s in circuit_snaps.values()) else: # downstream l1 = meter.downstream_l1_current_a l2 = meter.downstream_l2_current_a active_w = meter.feedthrough_power_w - imported_wh = sum( - s.consumed_energy_wh - for cid, s in circuit_snaps.items() - if circuits_phys[cid].placement == "downstream-of-lugs" - ) - exported_wh = sum( - s.produced_energy_wh - for cid, s in circuit_snaps.items() - if circuits_phys[cid].placement == "downstream-of-lugs" - ) + # Integrate what this meter reads, in this meter's own frame: a lugs + # meter takes the default reference direction, so positive is power + # arriving through it and accrues `imported-energy`. One direction can + # accrue per tick, which is the property the gross sum broke. + self._energy.observe(lugs_id, active_w, tick.current_time) + lugs_energy = self._energy.state(lugs_id) + imported_wh = lugs_energy.consumed_wh + exported_wh = lugs_energy.produced_wh lugs_snaps[lugs_id] = EbusLugsSnapshot( instance_id=lugs_id, direction=("upstream" if lphys.direction == "upstream" else "downstream"), diff --git a/tests/flat_emitter/test_publish_tick.py b/tests/flat_emitter/test_publish_tick.py index 3472d63..757e8cb 100644 --- a/tests/flat_emitter/test_publish_tick.py +++ b/tests/flat_emitter/test_publish_tick.py @@ -149,7 +149,8 @@ async def test_publish_tick_emits_circuit_power(emitter_no_bess: Emitter) -> Non assert snap.circuits["kitchen"].relay_state == "CLOSED" assert snap.circuits["kitchen"].current_a == pytest.approx(500.0 / 120.0) assert snap.meter.instant_grid_power_w == 500.0 - assert snap.power_flows.grid == 500.0 + # power-flows is the node frame: importing means power enters the panel. + assert snap.power_flows.grid == -500.0 assert snap.pcs.grid_state == "ON_GRID" @@ -279,7 +280,10 @@ async def test_publish_tick_pv_export_drives_grid_negative(emitter_no_bess: Emit ) # load - pv = 500 - 2000 = -1500 (exporting) assert snap.meter.instant_grid_power_w == -1500.0 - assert snap.power_flows.pv == 2000.0 + # ... and the node frame reports the same instant with the opposite sign on + # both terms: the array feeds the panel, the surplus leaves through the grid. + assert snap.power_flows.pv == -2000.0 + assert snap.power_flows.grid == 1500.0 @pytest.mark.asyncio @@ -724,3 +728,42 @@ async def test_dipole_circuit_per_leg_currents() -> None: # Per-circuit current uses line-to-line voltage for dipole. assert snap.circuits["hvac"].current_a == pytest.approx(20.0) assert snap.circuits["hvac"].is_240v is True + + +@pytest.mark.asyncio +async def test_lugs_energy_integrates_its_own_meter_not_the_circuits_behind_it() -> None: + """A lugs meter's registers are the counterpart of its own ``active-power``. + + With PV and load running at once the lugs carry only the net, in one + direction. Summing the circuits behind them advanced ``imported-energy`` AND + ``exported-energy`` in the same tick -- a state a live panel never produces, + and one that makes ``imported - exported`` describe something other than what + actually flowed through the lugs. + """ + manifest = DeviceManifest( + instances=( + _panel_inst(), + DeviceInstance("lugs", "lugs-upstream", "Upstream lugs", {"direction": "upstream"}), + _circuit_inst("kitchen", tabs="1"), + _circuit_inst("solar", tabs="3"), + ) + ) + powers = {"kitchen": 2000.0, "solar": -6000.0} + em = Emitter(manifest, _registry(), FakeMqttClient()) + await em.start() + # The first observation only establishes the integrator's clock. + await em.publish_tick(TickInputs(current_time=0.0, grid_online=True, circuits=powers)) + snap = await em.publish_tick( + TickInputs(current_time=3600.0, grid_online=True, circuits=powers) + ) + + lugs = snap.lugs["lugs-upstream"] + # 2000 W of load against 6000 W of PV: 4000 W leaves through the lugs, for an + # hour. Exactly one register may move, and by the integral of that power. + assert lugs.active_power_w == pytest.approx(-4000.0) + assert lugs.exported_energy_wh == pytest.approx(4000.0) + assert lugs.imported_energy_wh == 0.0 + # The circuits behind it are busy in both directions at once -- which is what + # the registers used to be handed. + assert snap.circuits["kitchen"].consumed_energy_wh > 0.0 + assert snap.circuits["solar"].produced_energy_wh > 0.0 From 3bcf0e5bb5f3193120ce0a647804c9f64bc6cd87 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:10:37 -0700 Subject: [PATCH 3/4] fix: declare each node's properties in the device $description The root $description published {"type": ...} per node and nothing else, so the tree a consumer discovers the Homie way was empty: 39 nodes declaring zero properties, where a panel on the flat data model declares 438. A consumer that discovers properties from the description rather than by watching retained topics arrive would find nothing to subscribe to. It stayed invisible because our own consumer reads values off the wire and takes only type from the description, so no test and no integration ever asked the description what it contained. Build each node's entry from Node.description(), which the SDK already models correctly, and carry the version, name, children and extensions keys a panel publishes. Drop the id key, which a panel does not publish at this level. test_description_declares_the_properties_each_node_publishes compares the declared count against the built graph's property count rather than restating the naming rules, so it cannot drift from them. --- .../flat_emitter/wire/graph_builder.py | 27 ++++++-- tests/flat_emitter/wire/test_graph_builder.py | 68 ++++++++++++++++++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/span_panel_simulator/flat_emitter/wire/graph_builder.py b/src/span_panel_simulator/flat_emitter/wire/graph_builder.py index d53290e..d9e8a08 100644 --- a/src/span_panel_simulator/flat_emitter/wire/graph_builder.py +++ b/src/span_panel_simulator/flat_emitter/wire/graph_builder.py @@ -151,16 +151,35 @@ def build_graph( for device_id, device in graph.devices.items(): name = device.name() if callable(device.name) else device.name if device_id == root_instance.instance_id: + # Nodes are described by the SDK node objects that were just built, + # not restated here. The hand-written version published only + # ``{"type": ...}``, so every property this panel publishes was + # absent from its own ``$description`` -- 39 nodes declaring nothing, + # against 438 properties on a live panel's. A consumer that discovers + # a tree the Homie way found an empty one, and nothing failed loudly + # because our own accumulator reads values off the wire and only ever + # takes ``type`` from here. + # + # ``Device.as_dict()`` would be the obvious call and cannot be used: + # ebus-sdk 0.1.5 builds its node map with ``nodes.update({node_id, + # node.as_dict()})``, a set literal rather than a pair, which + # ``dict.update`` rejects. ``Node.description()`` is correct, so the + # nodes are asked one at a time. graph.description_payloads[device_id] = { "homie": "5.0", - "version": profiles[root_class].version, + # Epoch-ms, as a live panel publishes it. Homie 5 uses ``version`` + # to tell a consumer the description changed; a constant means a + # consumer caching on it never re-reads the tree. + "version": ebus_sdk.Device.now_ems(), "type": profiles[root_class].type, "name": name, - "id": device_id, "nodes": { - node_id: {"type": node_type} - for node_id, node_type in sorted(graph.node_types.items()) + node_id: node.description() for node_id, node in sorted(device.nodes().items()) }, + # Present and empty rather than absent: flat puts every capability + # on the one device, and a live panel publishes both keys. + "children": [], + "extensions": [], } else: graph.description_payloads[device_id] = { diff --git a/tests/flat_emitter/wire/test_graph_builder.py b/tests/flat_emitter/wire/test_graph_builder.py index d376d66..4b2a361 100644 --- a/tests/flat_emitter/wire/test_graph_builder.py +++ b/tests/flat_emitter/wire/test_graph_builder.py @@ -3,6 +3,19 @@ from span_panel_simulator.flat_emitter.wire.mapping_loader import load_mapping_table from span_panel_simulator.flat_emitter.wire.profile_loader import load_profiles +# 2023-11-14 in epoch-ms. Any build of this simulator is later, and a seconds-epoch +# value is ~1000x smaller, so this separates the two representations. +_MILLISECONDS_EPOCH_FLOOR = 1_700_000_000_000 + + +def _without_version( + payloads: dict[str, dict[str, object]], +) -> dict[str, dict[str, object]]: + return { + device_id: {k: v for k, v in payload.items() if k != "version"} + for device_id, payload in payloads.items() + } + def _manifest_panel_with_one_circuit() -> DeviceManifest: return DeviceManifest( @@ -31,7 +44,19 @@ def test_build_graph_is_deterministic() -> None: g1 = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) g2 = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) assert sorted(g1.properties.keys()) == sorted(g2.properties.keys()) - assert g1.description_payloads == g2.description_payloads + # Every part of a description except ``version`` is a pure function of the + # manifest. ``version`` is epoch-ms on purpose: Homie 5 uses it to tell a + # consumer the description changed, and a live panel varies it per build for + # exactly that reason. So it is checked for shape here, not for equality -- + # asserting two builds agree on it would be asserting the clock stood still. + assert _without_version(g1.description_payloads) == _without_version(g2.description_payloads) + versions = [p["version"] for p in g1.description_payloads.values() if "version" in p] + assert versions, "root description should carry a version" + for version in versions: + assert isinstance(version, int) + # Milliseconds, not seconds: a seconds-epoch value here would still look + # like a plausible integer and would compare wrong against a panel's. + assert version > _MILLISECONDS_EPOCH_FLOOR def test_build_graph_includes_panel_settable_property() -> None: @@ -39,3 +64,44 @@ def test_build_graph_includes_panel_settable_property() -> None: mapping = load_mapping_table() g = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) assert ("panel", "p1", "core/dominant-power-source") in g.properties + + +def test_description_declares_the_properties_each_node_publishes() -> None: + """A node's ``$description`` entry carries its properties, as a panel's does. + + This published ``{"type": ...}`` and nothing else, so the tree a consumer + discovers the Homie way was empty: 39 nodes declaring zero properties, where a + panel on the flat data model declares 438. It stayed invisible because + our own consumer reads values off the wire and takes only ``type`` from here, + so no test and no integration ever asked the description what it contained. + """ + profiles = load_profiles() + mapping = load_mapping_table() + g = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) + root = g.description_payloads["p1"] + + nodes = root["nodes"] + assert isinstance(nodes, dict) + assert nodes, "root description declares no nodes" + + for node_id, body in nodes.items(): + assert set(body) == {"name", "type", "properties"}, node_id + assert body["properties"], f"node {node_id} declares no properties" + + # Every property the graph will publish is declared, and nothing is declared + # that will not be published -- the invariant that was broken, rather than the + # weaker "some properties exist". + # + # Counted rather than matched name-by-name on purpose. Wire node ids are the + # capability name for root entities and the instance id for node-on-parent + # ones, so pairing a `g.properties` key to its node means restating + # `_attach_profile`'s naming rule here -- and a test that restates the thing it + # checks passes while mirroring its own copy. Both sides are built from the + # same walk, so their cardinality is the honest comparison. + declared_count = sum(len(body["properties"]) for body in nodes.values()) + assert declared_count == len(g.properties) + + # Shape a live panel also publishes, and this did not. + assert root["children"] == [] + assert root["extensions"] == [] + assert "id" not in root From 086583bf9b86c5f7c4aa0f398b6063deba07167d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:31:32 -0700 Subject: [PATCH 4/4] docs: write down the sign-frame rule the power-flows fix establishes Three of the defects found in this sweep are the same defect wearing different clothes: a device-frame value published where the panel's mirror of it belongs. None could be caught mechanically -- reference direction has no machine-readable form in the catalogs, so every conformance check stayed green while three of four power-flows properties were inverted. Writing the rule down is currently the only thing between it and the next contributor. Records the frame table, that the panel's reading is the mirror of the device's because the panel is the interface between them, why site has no mirror and was therefore already correct, that the negation belongs in the wire layer rather than in a snapshot field every other reader shares, and the node-balance invariant. Also records the stakes, which are easy to understate: these values feed Home Assistant long-term statistics, so a wrong sign is persisted and aggregated rather than merely displayed, and fixing the publisher afterwards does not repair what the recorder already stored. --- AGENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a2fc8e5..55beeb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,3 +32,41 @@ replace scattered inline logic and must not be eroded. - New energy behaviors (e.g. demand response, rate optimization) must be added inside the energy package, not grafted onto the engine. **Test discipline:** Tests drive BESS behavior through `BESSConfig` (charge_mode, charge_hours, discharge_hours), not by injecting state into `PowerInputs`. + +## Sign Frames — the panel reading is the mirror of the device reading + +The panel is an **interface** to the devices around it, so its reading of a device is the mirror image of that device's reading of itself. What the device +calls "out of me", the panel calls "into me". Two frames therefore coexist on purpose, and they disagree about the same instant. + +| | the device's own view | the panel's view (what we publish) | +|---|---|---| +| PV | positive = generating | **negative** while generating into the panel | +| BESS | positive = discharging | **positive** while charging, out of the panel | +| grid | positive = supplying the home | **positive** while exporting, out of the panel | +| circuit | positive = consuming | **negative** while consuming, out of the busbar | +| `site` | — not a device at the interface | positive = consuming; no mirror to take | + +`site` is the exception because there is no device on the other side of it to mirror — which is why it is the one `power-flows` property that was already +correct when the other three were inverted. + +**Rules:** + +- **Never "reconcile" the two frames.** A panel exporting publishes `lugs-upstream/active-power` negative and `power-flows/grid` positive at the same instant. + Both are right. Code or tests that make them agree are removing information. +- **The snapshot is device-frame; the wire layer mirrors it.** Snapshot dataclasses carry the producer-side quantity (`instant_power_w` positive = consuming, + `active_power_w` positive = discharging). The negation belongs in the `bag_builder` resolver, next to the docstring that explains it — never by redefining + what a snapshot field means, which would silently change every other reader. +- **The four `power-flows` values sum to zero.** They are four terms of one balance at one node, not four independent meters. `test_power_flows_sum_to_zero` + holds this. Derive `power_flow_grid` from the physics, never by back-solving from the other three — a residual satisfies the balance by construction and + detects nothing. +- **A new metered surface states its frame in a docstring before it is published.** These defects are silently wrong at the consumer, and the damage is + **persisted, not displayed**. Home Assistant feeds these values into long-term statistics — the Energy dashboard, cost attribution, monthly totals. A + renamed or removed property fails loudly; an inverted sign keeps producing plausible numbers, is recorded for weeks, and **fixing the simulator afterwards + does not repair what the recorder already stored**. Energy registers are worse: `imported-energy` and `exported-energy` are monotonic, so a tick advancing + both writes an import and an export that never happened, and neither can be subtracted back out. +- **This is why the simulator must match hardware rather than be internally consistent.** It is what the integration is developed and regression-tested + against, so a frame the panel does not use gets baked into the integration and reaches the field, where it corrupts real users' statistics. + +**Authority:** SPAN's published behavior, not the eBus catalog, which states the opposite for `power-flows` and is a documented, deliberate divergence. See +`spanio/SPAN-API-Client-Docs`, `docs/public/power-and-energy-conventions.md` — the tables there are normative; note that the prose sentence calling +`power-flows` a "source-centric summary" describes the un-mirrored view and contradicts the table beneath it.