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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## 1.0.13 — 2026-07-31 — circuit energy reference frame

### Fixed

- **Clone energy seeds were read in the wrong reference frame.** `clone.py` seeded
`initial_consumed_energy_wh` from a scraped panel's `imported-energy` and
`initial_produced_energy_wh` from its `exported-energy`. The wire is enclosure-framed:
`exported-energy` is energy the enclosure exported *to* a circuit (normal load
consumption) and `imported-energy` is energy it imported *from* a circuit (backfeed).
The two are now read the correct way round, in both the initial-translation path
(`_translate_circuit`) and the refresh path (`update_config_from_scrape`).

This mirrors the fix in `ebus-emitter` 0.2.1, which corrected the same inversion on the
publish side. The two were previously wrong in a mutually cancelling way — clone read
`imported-energy` into "consumed" and the emitter published "consumed" back out as
`imported-energy` — so a cloned panel round-tripped its wire values faithfully while
every value carried the wrong meaning. Correcting only one side would have broken the
round-trip, so they move together.

- **Test fixtures encoded the same inversion.** `test_clone.py` gave a load circuit a
rising `imported-energy` and a backfeeding solar circuit a rising `exported-energy`,
which is the reverse of what a real panel publishes, and one fixture comment described
positive `active-power` as "export" when on the wire it means the enclosure is importing
from the circuit. Fixtures and the two energy-seeding test names now describe the
enclosure frame.

### Requires

- **ebus-emitter >= 0.2.1**, which carries the matching publish-side fix. Pairing this
release with an older emitter reinstates the inversion.

## 1.0.12 — 2026-07-30 — emitter live-schema alignment and abstraction

### Changed
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "span-panel-simulator"
version = "1.0.12"
version = "1.0.13"
description = "Standalone eBus simulator for SPAN panels"
requires-python = ">=3.14"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion span_panel_simulator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ EXPOSE 18883 8081 18080
LABEL io.hass.name="SPAN Panel Simulator" \
io.hass.description="Simulates a SPAN electrical panel for testing and upgrade modeling" \
io.hass.type="addon" \
io.hass.version="1.0.12" \
io.hass.version="1.0.13" \
io.hass.arch="aarch64|amd64"

CMD ["/run.sh"]
2 changes: 1 addition & 1 deletion span_panel_simulator/config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: "SPAN Panel Simulator"
description: "Simulates a SPAN electrical panel for testing and upgrade modeling"
version: "1.0.12"
version: "1.0.13"
slug: "span_panel_simulator"
url: "https://github.com/SpanPanel/simulator"
image: "ghcr.io/spanpanel/simulator/{arch}"
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Standalone eBus simulator for SPAN panels."""

__version__ = "1.0.12"
__version__ = "1.0.13"
41 changes: 26 additions & 15 deletions src/span_panel_simulator/clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,23 +246,26 @@ def update_config_from_scrape(
if not isinstance(ep, dict):
continue

# Update energy seeds
imported = _float_prop(scraped.properties, prefix, node_uuid, "imported-energy")
# Update energy seeds. Enclosure frame: `exported-energy` is the
# enclosure exporting to the circuit (consumption), `imported-energy` is
# the circuit backfeeding the enclosure (production). See the seeding
# comment in `_translate_circuit`.
exported = _float_prop(scraped.properties, prefix, node_uuid, "exported-energy")
if (
imported is not None
and imported > 0
and ep.get("initial_consumed_energy_wh") != imported
exported is not None
and exported > 0
and ep.get("initial_consumed_energy_wh") != exported
):
ep["initial_consumed_energy_wh"] = imported
ep["initial_consumed_energy_wh"] = exported
changed = True

exported = _float_prop(scraped.properties, prefix, node_uuid, "exported-energy")
imported = _float_prop(scraped.properties, prefix, node_uuid, "imported-energy")
if (
exported is not None
and exported > 0
and ep.get("initial_produced_energy_wh") != exported
imported is not None
and imported > 0
and ep.get("initial_produced_energy_wh") != imported
):
ep["initial_produced_energy_wh"] = exported
ep["initial_produced_energy_wh"] = imported
changed = True

# Update last_synced timestamp
Expand Down Expand Up @@ -539,7 +542,15 @@ def _translate_circuit(
power_range = [0.0, max_power]
typical_power = typical

# Seed energy accumulators from scraped values
# Seed energy accumulators from scraped values.
#
# The wire is enclosure-framed: a circuit's `exported-energy` is energy the
# enclosure exported TO the circuit (normal load consumption), and
# `imported-energy` is energy the enclosure imported FROM the circuit
# (backfeed). The simulator's own accumulators are device-framed, so
# consumption seeds from `exported-energy` and production from
# `imported-energy`. Requires ebus-emitter >= 0.2.1, which publishes the
# enclosure frame on both power and energy.
imported_energy = _float_prop(properties, prefix, node_uuid, "imported-energy")
exported_energy = _float_prop(properties, prefix, node_uuid, "exported-energy")

Expand All @@ -551,10 +562,10 @@ def _translate_circuit(
"power_variation": 0.1,
}

if imported_energy is not None and imported_energy > 0:
energy_profile["initial_consumed_energy_wh"] = imported_energy
if exported_energy is not None and exported_energy > 0:
energy_profile["initial_produced_energy_wh"] = exported_energy
energy_profile["initial_consumed_energy_wh"] = exported_energy
if imported_energy is not None and imported_energy > 0:
energy_profile["initial_produced_energy_wh"] = imported_energy

template: dict[str, object] = {
"energy_profile": energy_profile,
Expand Down
6 changes: 4 additions & 2 deletions src/span_panel_simulator/config_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ class EnergyProfileExtended(EnergyProfile, total=False):

efficiency: float # Energy conversion efficiency (0.0 to 1.0)
nameplate_capacity_w: float # PV nameplate rating in watts (positive)
initial_consumed_energy_wh: float # seed from real panel's imported-energy
initial_produced_energy_wh: float # seed from real panel's exported-energy
# The wire is enclosure-framed, these fields are device-framed, so the two
# cross over: a circuit's consumption is what the enclosure exported to it.
initial_consumed_energy_wh: float # seed from real panel's exported-energy
initial_produced_energy_wh: float # seed from real panel's imported-energy


class CircuitTemplate(TypedDict):
Expand Down
32 changes: 21 additions & 11 deletions tests/test_clone.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ def _set(node: str, prop: str, val: str) -> None:
_set("aaa111", "breaker-rating", "15")
_set("aaa111", "relay", "CLOSED")
_set("aaa111", "shed-priority", "NEVER")
_set("aaa111", "active-power", "-150.0")
_set("aaa111", "active-power", "-150.0") # consuming (negative = enclosure → circuit)
_set("aaa111", "always-on", "false")
_set("aaa111", "imported-energy", "54321.0")
_set("aaa111", "exported-energy", "0.0")
# Enclosure frame: a load accumulates exported-energy (enclosure → circuit).
_set("aaa111", "imported-energy", "0.0")
_set("aaa111", "exported-energy", "54321.0")

# Circuit 2: Kitchen Outlets — 240V, space 3/5
_set("bbb222", "name", "Kitchen Outlets")
Expand All @@ -84,10 +85,12 @@ def _set(node: str, prop: str, val: str) -> None:
_set("ccc333", "breaker-rating", "30")
_set("ccc333", "relay", "CLOSED")
_set("ccc333", "shed-priority", "NEVER")
_set("ccc333", "active-power", "3000.0") # producing (positive on eBus = export)
# Positive on the wire = the enclosure is importing from the circuit (backfeed).
_set("ccc333", "active-power", "3000.0")
_set("ccc333", "always-on", "true")
_set("ccc333", "imported-energy", "0.0")
_set("ccc333", "exported-energy", "1234567.0")
# Enclosure frame: a backfeeding circuit accumulates imported-energy.
_set("ccc333", "imported-energy", "1234567.0")
_set("ccc333", "exported-energy", "0.0")

# Circuit 4: Battery Storage — 240V, space 11/13, fed by bess-0
_set("ddd444", "name", "Battery Storage")
Expand Down Expand Up @@ -319,8 +322,11 @@ def test_roundtrip_validates(self, tmp_path: Path) -> None:
class TestEnergySeeding:
"""Tests for initial energy accumulator seeding from scraped data."""

def test_consumer_imported_energy_seeded(self) -> None:
"""Consumer circuit gets initial_consumed_energy_wh from imported-energy."""
def test_consumer_exported_energy_seeded(self) -> None:
"""Consumer circuit gets initial_consumed_energy_wh from exported-energy.

Enclosure frame: energy the enclosure exported to the circuit is that
circuit's consumption."""
config = translate_scraped_panel(_make_scraped())
templates = config["circuit_templates"]
assert isinstance(templates, dict)
Expand All @@ -341,8 +347,11 @@ def test_zero_energy_not_seeded(self) -> None:
assert isinstance(ep, dict)
assert "initial_produced_energy_wh" not in ep

def test_producer_exported_energy_seeded(self) -> None:
"""Producer circuit gets initial_produced_energy_wh from exported-energy."""
def test_producer_imported_energy_seeded(self) -> None:
"""Producer circuit gets initial_produced_energy_wh from imported-energy.

Enclosure frame: energy the enclosure imported from the circuit is that
circuit's production (backfeed)."""
config = translate_scraped_panel(_make_scraped())
templates = config["circuit_templates"]
assert isinstance(templates, dict)
Expand Down Expand Up @@ -444,7 +453,8 @@ def test_energy_seeds_updated(self) -> None:
config = translate_scraped_panel(_make_scraped(), host="192.168.1.100", passphrase=None)

props = _base_properties()
props[f"{_PREFIX}/aaa111/imported-energy"] = "99999.0"
# aaa111 is a load, so its consumption accumulator is exported-energy.
props[f"{_PREFIX}/aaa111/exported-energy"] = "99999.0"
updated_scraped = _make_scraped(props=props)

changed = update_config_from_scrape(config, updated_scraped)
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.