Skip to content

Fix three flat-emitter wire defects: power-flows frame, lugs energy, node descriptions - #41

Merged
cayossarian merged 4 commits into
mainfrom
fix/power-flow-sign-convention
Aug 19, 2026
Merged

Fix three flat-emitter wire defects: power-flows frame, lugs energy, node descriptions#41
cayossarian merged 4 commits into
mainfrom
fix/power-flow-sign-convention

Conversation

@cayossarian

Copy link
Copy Markdown
Member

Three defects found while checking the flat emitter's retained tree against what a shipping panel publishes. Independent of each other; one commit each.

1. power-flows was published in the meter frame

The emitter published pv, grid and battery positive-for-consumption — the frame each individual meter reports in. But 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.

In the meter frame the sum missed zero by exactly twice the site load, and a producing array read positive where a panel reads negative — which is what surfaced this: a simulated PV system showed the opposite sign from the hardware it stands in for.

Restated in the node frame — positive = power leaving the panel node — the signs match what SPAN documents:

property positive negative
grid exporting to the utility utility delivering into the panel
pv (not produced in normal operation) PV generating into the panel
battery charging discharging into the panel
site site consuming site net-producing

Source: 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.

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

2. Lugs energy was summed from the circuits, not read from the lugs

imported-energy and exported-energy on each lugs were built by summing the gross consumption and gross production of the circuits behind it. A meter reads one net quantity, so only one of its two accumulators can advance in a tick. The gross sum 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 accumulators reported 6000 Wh consumed and 2000 Wh produced.

Each lugs is now registered with the energy integrator and observes its own active power, so both accumulators derive from the same signed reading the lugs publishes as active-power.

3. Node $description entries declared no properties

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 nothing ever asked the description what it contained.

Each node's entry is now built from Node.description(), which the SDK already models correctly, and the payload carries the version, name, children and extensions keys a panel publishes. The id key is dropped — a panel does not publish it at this level.

Verification

413 tests pass; ruff clean; mypy introduces no new errors (the 55 pre-existing errors on main are unchanged and none are in the touched files).

Every new assertion was falsified before being trusted — the fix reverted, the test watched to fail, then restored:

assertion failures when the fix is reverted
power_flow_pv 5 of 9 balance cases
power_flow_battery 6 of 9
power_flow_grid 5 of 9
PV-surplus clamp 2 of 9
lugs energy 6000/2000 Wh vs the correct 4000/0
node descriptions declared keys {'type'} vs {'name','properties','type'}

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.
…nd 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.
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.
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.
@cayossarian
cayossarian merged commit 9b7702b into main Aug 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant