From 0e56e7ea5dac09bf3243446bef58ec0adf83027d Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:33:56 -0700 Subject: [PATCH] fix(clone): read circuit energy seeds in the enclosure reference frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). Both the initial-translation path and the refresh path now read them the correct way round. This is the companion to 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. Fixing only one side breaks the round-trip, so they move together. The test fixtures carried the same inversion: a load circuit was given a rising imported-energy and a backfeeding solar circuit a rising exported-energy, the reverse of what a real panel publishes, and one 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. --- CHANGELOG.md | 31 ++++++++++++++++++ pyproject.toml | 2 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- src/span_panel_simulator/clone.py | 41 +++++++++++++++--------- src/span_panel_simulator/config_types.py | 6 ++-- tests/test_clone.py | 32 +++++++++++------- uv.lock | 2 +- 9 files changed, 87 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45baf38..863fa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d2074cd..25271c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/span_panel_simulator/Dockerfile b/span_panel_simulator/Dockerfile index af5f165..33cd75e 100644 --- a/span_panel_simulator/Dockerfile +++ b/span_panel_simulator/Dockerfile @@ -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"] diff --git a/span_panel_simulator/config.yaml b/span_panel_simulator/config.yaml index 4c987e6..37c047c 100644 --- a/span_panel_simulator/config.yaml +++ b/span_panel_simulator/config.yaml @@ -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}" diff --git a/src/span_panel_simulator/__init__.py b/src/span_panel_simulator/__init__.py index 7c78be3..3681244 100644 --- a/src/span_panel_simulator/__init__.py +++ b/src/span_panel_simulator/__init__.py @@ -1,3 +1,3 @@ """Standalone eBus simulator for SPAN panels.""" -__version__ = "1.0.12" +__version__ = "1.0.13" diff --git a/src/span_panel_simulator/clone.py b/src/span_panel_simulator/clone.py index ac48994..7619b21 100644 --- a/src/span_panel_simulator/clone.py +++ b/src/span_panel_simulator/clone.py @@ -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 @@ -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") @@ -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, diff --git a/src/span_panel_simulator/config_types.py b/src/span_panel_simulator/config_types.py index b68faa1..0ce53db 100644 --- a/src/span_panel_simulator/config_types.py +++ b/src/span_panel_simulator/config_types.py @@ -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): diff --git a/tests/test_clone.py b/tests/test_clone.py index 6d85770..1704cf5 100644 --- a/tests/test_clone.py +++ b/tests/test_clone.py @@ -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") @@ -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") @@ -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) @@ -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) @@ -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) diff --git a/uv.lock b/uv.lock index 00f86f9..ecaec54 100644 --- a/uv.lock +++ b/uv.lock @@ -888,7 +888,7 @@ wheels = [ [[package]] name = "span-panel-simulator" -version = "1.0.12" +version = "1.0.13" source = { editable = "." } dependencies = [ { name = "aiohttp" },