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
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 18 additions & 12 deletions src/span_panel_simulator/flat_emitter/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
Expand Down
57 changes: 43 additions & 14 deletions src/span_panel_simulator/flat_emitter/panel_meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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."""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -167,25 +187,34 @@ 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,
)


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(
Expand Down
27 changes: 23 additions & 4 deletions src/span_panel_simulator/flat_emitter/wire/graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down
Loading