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. 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/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/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/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 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 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