From 213afda4a3eec38777c717484c3f6aa4fb22dce9 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:46:30 -0700 Subject: [PATCH] feat: vendor the flat emitter, fixing an add-on image that could never start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ebus_emitter is a hard, unconditional import (app.py -> emitter_adapter/ runtime.py), but the add-on Dockerfile installs only `pip install .` and the package was never a declared dependency — it is not on PyPI and was installed editable from EBUS_EMITTER_PATH by scripts/dev-setup.sh. The image therefore built successfully and died at container start with ModuleNotFoundError. The same applied to anyone who cloned this repo and ran `uv sync` without dev-setup.sh. Only developers who had run dev-setup.sh ever had a working install. Vendor the emitter at src/span_panel_simulator/flat_emitter, copied from ebus-emitter 0.2.1 (commit 5b84de8) — MIT, same copyright holders. Upstream has permanently diverged onto the parent/child v1.0 Homie data model while this simulator continues to publish the flat schema, so the dependency delivered no upstream changes while costing path configuration, stale editable metadata, and an unsolvable distribution problem for the add-on. It also closes a correctness hazard. clone.py seeds energy accumulators against what this code publishes; while the two lived in separate repos each side could look locally correct while jointly inverting circuit energy, which is exactly what happened and what no single test suite could see. Both ends now sit in one repo under one test run. The emitter's tests come with it (tests/flat_emitter/, 154 tests), including the circuit energy reference-frame regression tests. Suite is now 395 tests. Supporting changes, each a consequence of the move: - ebus-sdk pinned to ==0.1.5 rather than the range upstream declared, so vendoring is behaviour-neutral. 0.1.5 is what the emitter's lockfile resolved and what this code was tested against; floating within <0.2 resolves 0.1.10, which drops the module-level setLevel(INFO) on the homie logger that tests/test_main_logging.py guards. The add-on installs from pyproject rather than the lockfile, so the declared bound is the operative control in production. Raising it is a deliberate follow-up. - [tool.ruff.lint] now declares ignore = TC001/TC002/TC003. The existing comment already described this ignore but the key was never present — none of the simulator's own modules triggered the rules, so the omission was invisible. The vendored code was authored under an identical select list plus this ignore. - ChargeMode is exported from the vendored package and annotates the charge_mode derivation in engine.py and emitter_adapter/runtime.py. Both sites already produced only valid values; mypy could not see it while ebus_emitter was an ignore_missing_imports module and BESSConfig was therefore Any. - ebus_sdk mypy overrides move here from the emitter's pyproject. - dev-setup.sh becomes a thin uv sync wrapper; .env.example no longer defines EBUS_EMITTER_PATH. No force-include is added for the vendored wire/profiles and wire/mapping data: packages = ["src/span_panel_simulator"] already ships them, and re-declaring them would reintroduce the duplicate-path collision fixed in 1.0.12. Verified by installing into a clean venv the way the Dockerfile does and confirming the entry point imports and all twelve data files are packaged. --- .env.example | 14 +- CHANGELOG.md | 49 ++ pyproject.toml | 55 +- scripts/dev-setup.sh | 50 +- scripts/run-local.sh | 7 +- span_panel_simulator/Dockerfile | 2 +- span_panel_simulator/config.yaml | 2 +- src/span_panel_simulator/__init__.py | 2 +- .../emitter_adapter/runtime.py | 11 +- .../emitter_adapter/spec_generator.py | 3 +- src/span_panel_simulator/energy/__init__.py | 3 +- src/span_panel_simulator/energy/components.py | 2 +- src/span_panel_simulator/energy/system.py | 2 +- src/span_panel_simulator/energy/types.py | 4 +- src/span_panel_simulator/engine.py | 11 +- .../flat_emitter/__init__.py | 143 ++++ .../flat_emitter/conventions/__init__.py | 11 + .../flat_emitter/conventions/tab_legs.py | 47 ++ .../flat_emitter/emitter.py | 657 ++++++++++++++++ .../flat_emitter/energy_integrator.py | 89 +++ .../flat_emitter/exceptions.py | 38 + .../flat_emitter/manifest.py | 35 + .../flat_emitter/manifest_physics.py | 418 ++++++++++ .../flat_emitter/native_devices/__init__.py | 35 + .../flat_emitter/native_devices/bess.py | 185 +++++ .../native_devices/load_shedding.py | 60 ++ .../flat_emitter/native_devices/protocol.py | 38 + .../flat_emitter/panel_meter.py | 218 ++++++ .../flat_emitter/relay_resolver.py | 110 +++ .../flat_emitter/snapshot.py | 241 ++++++ .../flat_emitter/tick_inputs.py | 66 ++ .../flat_emitter/wire/__init__.py | 0 .../flat_emitter/wire/_sdk_seam.py | 57 ++ .../flat_emitter/wire/bag_builder.py | 384 +++++++++ .../flat_emitter/wire/graph_builder.py | 313 ++++++++ .../flat_emitter/wire/lifecycle.py | 134 ++++ .../flat_emitter/wire/mapping/.gitkeep | 0 .../flat_emitter/wire/mapping/bess.yaml | 16 + .../flat_emitter/wire/mapping/circuit.yaml | 16 + .../flat_emitter/wire/mapping/evse.yaml | 16 + .../flat_emitter/wire/mapping/lugs.yaml | 16 + .../flat_emitter/wire/mapping/panel.yaml | 15 + .../flat_emitter/wire/mapping/pv.yaml | 16 + .../flat_emitter/wire/mapping_loader.py | 144 ++++ .../flat_emitter/wire/profile_loader.py | 79 ++ .../flat_emitter/wire/profiles/.gitkeep | 0 .../flat_emitter/wire/profiles/bess.json | 23 + .../flat_emitter/wire/profiles/circuit.json | 27 + .../flat_emitter/wire/profiles/evse.json | 20 + .../flat_emitter/wire/profiles/lugs.json | 18 + .../flat_emitter/wire/profiles/panel.json | 58 ++ .../flat_emitter/wire/profiles/pv.json | 18 + .../flat_emitter/wire/property_bag.py | 56 ++ .../flat_emitter/wire/publisher.py | 73 ++ .../flat_emitter/wire/set_router.py | 146 ++++ .../flat_emitter/wire/wire_paths.py | 43 ++ src/span_panel_simulator/panel.py | 3 +- tests/flat_emitter/__init__.py | 0 tests/flat_emitter/conftest.py | 5 + tests/flat_emitter/conventions/__init__.py | 0 .../flat_emitter/conventions/test_tab_legs.py | 40 + .../test_emitter_public_surface.py | 171 +++++ tests/flat_emitter/test_energy_integrator.py | 125 +++ tests/flat_emitter/test_exceptions.py | 27 + tests/flat_emitter/test_manifest.py | 48 ++ tests/flat_emitter/test_manifest_physics.py | 289 +++++++ tests/flat_emitter/test_panel_meter.py | 369 +++++++++ tests/flat_emitter/test_publish_tick.py | 726 ++++++++++++++++++ tests/flat_emitter/test_relay_resolver.py | 127 +++ tests/flat_emitter/test_tick_inputs.py | 43 ++ tests/flat_emitter/wire/__init__.py | 0 .../wire/test_circuit_energy_frame.py | 119 +++ tests/flat_emitter/wire/test_graph_builder.py | 41 + .../wire/test_graph_builder_topology.py | 199 +++++ tests/flat_emitter/wire/test_lifecycle.py | 118 +++ .../wire/test_profile_mapping_validation.py | 84 ++ tests/flat_emitter/wire/test_property_bag.py | 80 ++ tests/flat_emitter/wire/test_sdk_seam.py | 41 + tests/flat_emitter/wire/test_set_router.py | 113 +++ tests/flat_emitter/wire/test_wire_paths.py | 34 + tests/test_panel.py | 3 +- uv.lock | 29 + 82 files changed, 7042 insertions(+), 88 deletions(-) create mode 100644 src/span_panel_simulator/flat_emitter/__init__.py create mode 100644 src/span_panel_simulator/flat_emitter/conventions/__init__.py create mode 100644 src/span_panel_simulator/flat_emitter/conventions/tab_legs.py create mode 100644 src/span_panel_simulator/flat_emitter/emitter.py create mode 100644 src/span_panel_simulator/flat_emitter/energy_integrator.py create mode 100644 src/span_panel_simulator/flat_emitter/exceptions.py create mode 100644 src/span_panel_simulator/flat_emitter/manifest.py create mode 100644 src/span_panel_simulator/flat_emitter/manifest_physics.py create mode 100644 src/span_panel_simulator/flat_emitter/native_devices/__init__.py create mode 100644 src/span_panel_simulator/flat_emitter/native_devices/bess.py create mode 100644 src/span_panel_simulator/flat_emitter/native_devices/load_shedding.py create mode 100644 src/span_panel_simulator/flat_emitter/native_devices/protocol.py create mode 100644 src/span_panel_simulator/flat_emitter/panel_meter.py create mode 100644 src/span_panel_simulator/flat_emitter/relay_resolver.py create mode 100644 src/span_panel_simulator/flat_emitter/snapshot.py create mode 100644 src/span_panel_simulator/flat_emitter/tick_inputs.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/__init__.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/_sdk_seam.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/bag_builder.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/graph_builder.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/lifecycle.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/.gitkeep create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/bess.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/circuit.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/evse.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/lugs.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/panel.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping/pv.yaml create mode 100644 src/span_panel_simulator/flat_emitter/wire/mapping_loader.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/profile_loader.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/.gitkeep create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/bess.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/circuit.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/evse.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/lugs.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/panel.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/profiles/pv.json create mode 100644 src/span_panel_simulator/flat_emitter/wire/property_bag.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/publisher.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/set_router.py create mode 100644 src/span_panel_simulator/flat_emitter/wire/wire_paths.py create mode 100644 tests/flat_emitter/__init__.py create mode 100644 tests/flat_emitter/conftest.py create mode 100644 tests/flat_emitter/conventions/__init__.py create mode 100644 tests/flat_emitter/conventions/test_tab_legs.py create mode 100644 tests/flat_emitter/test_emitter_public_surface.py create mode 100644 tests/flat_emitter/test_energy_integrator.py create mode 100644 tests/flat_emitter/test_exceptions.py create mode 100644 tests/flat_emitter/test_manifest.py create mode 100644 tests/flat_emitter/test_manifest_physics.py create mode 100644 tests/flat_emitter/test_panel_meter.py create mode 100644 tests/flat_emitter/test_publish_tick.py create mode 100644 tests/flat_emitter/test_relay_resolver.py create mode 100644 tests/flat_emitter/test_tick_inputs.py create mode 100644 tests/flat_emitter/wire/__init__.py create mode 100644 tests/flat_emitter/wire/test_circuit_energy_frame.py create mode 100644 tests/flat_emitter/wire/test_graph_builder.py create mode 100644 tests/flat_emitter/wire/test_graph_builder_topology.py create mode 100644 tests/flat_emitter/wire/test_lifecycle.py create mode 100644 tests/flat_emitter/wire/test_profile_mapping_validation.py create mode 100644 tests/flat_emitter/wire/test_property_bag.py create mode 100644 tests/flat_emitter/wire/test_sdk_seam.py create mode 100644 tests/flat_emitter/wire/test_set_router.py create mode 100644 tests/flat_emitter/wire/test_wire_paths.py diff --git a/.env.example b/.env.example index 898117a..3bb5181 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # Local-developer environment variables for the SPAN simulator. # -# Copy to `.env` and edit the paths to match your checkout layout. -# `.env` is gitignored. - -# Absolute path to your local checkout of the ebus-emitter repo. -# The simulator depends on ebus-emitter via a local path source defined in `uv.toml` -# (see uv.toml.example). Keep this variable in sync with the path in your `uv.toml`. -EBUS_EMITTER_PATH=/absolute/path/to/ebus/emitter +# Copy to `.env` and edit to match your setup. `.env` is gitignored. +# +# EBUS_EMITTER_PATH is no longer needed: the flat emitter is vendored at +# `src/span_panel_simulator/flat_emitter` and all dependencies resolve from PyPI. +# `scripts/dev-setup.sh` is now just `uv sync --group dev`. +# +# No variables are currently required. Kept as a placeholder for future local settings. diff --git a/CHANGELOG.md b/CHANGELOG.md index 45baf38..f944852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## 1.0.13 — 2026-07-31 — vendor the flat emitter + +### Fixed + +- **The HA add-on image could never start.** `ebus_emitter` is a hard, unconditional import + (`app.py` → `emitter_adapter/runtime.py`), but the Dockerfile installs only + `pip install --no-cache-dir .`, and the package was not a declared dependency — it is not + on PyPI and was installed editable from `EBUS_EMITTER_PATH` by `scripts/dev-setup.sh`. + The image therefore built successfully and failed at container start with + `ModuleNotFoundError: No module named 'ebus_emitter'`. The same applied to anyone who + cloned this repo and ran `uv sync` without `dev-setup.sh`. Vendoring removes the external + dependency entirely, so both paths now work. + +### Changed + +- **The flat emitter is vendored at `src/span_panel_simulator/flat_emitter`**, copied from + `ebus-emitter` 0.2.1 (commit `5b84de8`) — MIT, same copyright holders. The upstream repo + has permanently diverged onto the parent/child (v1.0) Homie data model while this + simulator continues to publish the flat schema, so the dependency delivered no upstream + changes while costing path configuration, stale editable metadata, and an unsolvable + distribution problem for the add-on. See the package docstring for full provenance. + + It also closes a correctness hazard: `clone.py` seeds energy accumulators against what + this code publishes, and while the two lived in separate repos each side could look + locally correct while jointly inverting circuit energy — which is exactly what happened. + Both ends now sit in one repo under one test run. + +- **The emitter's test suite came with it** (`tests/flat_emitter/`, 154 tests), including + the circuit energy reference-frame regression tests. Total suite is now 395 tests. + +- **`scripts/dev-setup.sh` is now a thin `uv sync` wrapper** and `.env.example` no longer + defines `EBUS_EMITTER_PATH`; every dependency resolves from PyPI. + +- **`ebus-sdk` is pinned to `==0.1.5`** rather than the range upstream declared, so that + vendoring is behaviour-neutral: 0.1.5 is what the emitter's lockfile resolved and what + this code was tested against. Letting it float within `<0.2` resolves 0.1.10, which drops + the module-level `setLevel(INFO)` on the `homie` logger that `tests/test_main_logging.py` + guards. Raising it is a deliberate follow-up, not a side effect of moving code. + +- **`[tool.ruff.lint]` now declares `ignore = ["TC001", "TC002", "TC003"]`.** The existing + comment already described this ignore, but the key was never present — none of the + simulator's own modules happened to trigger the rules, so the omission was invisible. + The vendored code was authored under an identical select list plus this ignore. + +- **`ChargeMode` is exported from the vendored package** and used to annotate the + `charge_mode` derivation in `engine.py` and `emitter_adapter/runtime.py`. Both sites + already produced only valid values; mypy could not see it while `ebus_emitter` was an + `ignore_missing_imports` module and `BESSConfig` was therefore `Any`. + ## 1.0.12 — 2026-07-30 — emitter live-schema alignment and abstraction ### Changed diff --git a/pyproject.toml b/pyproject.toml index d2074cd..b146bb7 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 = [ @@ -16,13 +16,30 @@ dependencies = [ "pyyaml>=6.0", "zeroconf>=0.131.0", "timezonefinder>=6.0", + # Runtime dependencies of the vendored flat emitter + # (`src/span_panel_simulator/flat_emitter`, see its module docstring for provenance). + # + # ebus-sdk is upper-bounded at 0.2: the vendored `wire/graph_builder.py` targets the + # 0.1.x `Device` constructor. 0.2.0 introduced parent/child device trees, replaced the + # `children_ids`/`root_id`/`parent_id` kwargs with a `parent=` Device reference, removed + # `Device.add_child()`, and changed the `mqtt_cfg` default from `{}` to `None` — which + # makes the root Device we build as a passive topic/schema model raise in + # `connect_broker()`. Raising this bound requires porting graph_builder to the tree API + # and deciding who owns the broker connection (today the producer's client publishes + # everything). This simulator publishes the flat schema, so the port has no urgency. + # + # Pinned exactly, not ranged, so vendoring is behaviour-neutral: 0.1.5 is what the + # emitter's lockfile resolved and what this code was developed and tested against. + # Letting it float within <0.2 resolves 0.1.10, which drops the module-level + # `setLevel(INFO)` on the `homie` logger that `tests/test_main_logging.py` guards — + # the noise-filter premise documented in `__main__.py`. The add-on image installs from + # this file rather than the lockfile, so the declared bound is the operative control + # in production. Raising it is a deliberate follow-up with its own testing, not a + # side effect of moving code. + "ebus-sdk==0.1.5", + "paho-mqtt>=2.0.0", ] -# `ebus-emitter` is a runtime dependency but is not listed here because it is not on -# PyPI and its checkout location varies per developer. Run `scripts/dev-setup.sh` -# (which reads `.env` for `EBUS_EMITTER_PATH`) to install it editable into the venv. -# See `.env.example` for the expected variable. - [dependency-groups] dev = [ "pytest>=8.0", @@ -46,6 +63,12 @@ span-simulator = "span_panel_simulator.__main__:main" # added to the wheel archive at the same path"), breaking `pip install .` in the # add-on Docker build. Build isolation resolves `hatchling` fresh on every build, # so the older, tolerant version this was authored against is not coming back. +# +# The same applies to the vendored flat emitter's data files +# (`flat_emitter/wire/profiles/*.json`, `flat_emitter/wire/mapping/*.yaml`): they live +# under `src/span_panel_simulator`, so `packages` already ships them. Upstream declared +# them via `force-include` because its package root was elsewhere; re-adding that here +# would reintroduce the duplicate-path collision described above. packages = ["src/span_panel_simulator"] [tool.ruff] @@ -57,9 +80,16 @@ line-length = 99 [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM", "TCH", "RUF"] -# TC002 (move third-party import into TYPE_CHECKING) conflicts with mypy's need for runtime -# resolution of imported names referenced from generic functions/methods. Disable to allow -# straightforward runtime imports. +# TC001/TC002/TC003 (move imports into TYPE_CHECKING blocks) conflict with mypy's need for +# runtime resolution of imported names referenced from generic functions/methods. Disable to +# allow straightforward runtime imports. +# +# This ignore was previously described by this comment but never actually declared — none of +# the simulator's own modules happened to trigger the rules, so the omission was invisible. +# The vendored flat emitter does trigger them, and was authored under an identical select +# list plus this same ignore, so declaring it here restores the intent rather than relaxing +# the bar for vendored code. +ignore = ["TC001", "TC002", "TC003"] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -73,12 +103,15 @@ strict = true module = "timezonefinder" ignore_missing_imports = true +# ebus-sdk ships without a py.typed marker. Previously these overrides lived in the emitter's +# own pyproject; they move here with the vendored code. Both forms are needed — the wildcard +# alone does not match the top-level `ebus_sdk` import in `flat_emitter/wire/_sdk_seam.py`. [[tool.mypy.overrides]] -module = "ebus_emitter" +module = "ebus_sdk" ignore_missing_imports = true [[tool.mypy.overrides]] -module = "ebus_emitter.*" +module = "ebus_sdk.*" ignore_missing_imports = true # CloneRuntime is defined in runtime.py, but setter_handlers references it through a diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index c6e0452..427da1a 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -1,52 +1,18 @@ #!/usr/bin/env bash # -# Developer bootstrap — reads .env and installs the local ebus-emitter checkout -# in editable mode into the simulator's venv. +# Developer bootstrap. # -# Why not pin the path in pyproject.toml? `ebus-emitter` is not on PyPI and each -# contributor's checkout lives at a different absolute path. The path is provided -# via the EBUS_EMITTER_PATH env var (loaded from .env) instead of being hardcoded. +# The flat emitter used to be an external checkout installed editable from +# EBUS_EMITTER_PATH. It is now vendored at `src/span_panel_simulator/flat_emitter` +# (see that package's docstring for provenance and rationale), so every dependency +# resolves from PyPI and this script is a thin wrapper over `uv sync`. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${REPO_ROOT}" -if [[ ! -f .env ]]; then - echo "ERROR: .env not found. Copy .env.example to .env and edit the paths." >&2 - exit 1 -fi +echo "Syncing simulator deps…" +uv sync --group dev -# shellcheck disable=SC1091 -set -a -source .env -set +a - -if [[ -z "${EBUS_EMITTER_PATH:-}" ]]; then - echo "ERROR: EBUS_EMITTER_PATH not set in .env" >&2 - exit 1 -fi - -if [[ ! -d "${EBUS_EMITTER_PATH}" ]]; then - echo "ERROR: EBUS_EMITTER_PATH=${EBUS_EMITTER_PATH} does not exist." >&2 - exit 1 -fi - -echo "Syncing simulator deps (excluding ebus-emitter, which is local)…" -uv sync --group dev --no-install-package ebus-emitter - -# Install the emitter's *locked* runtime deps, then the emitter itself with -# --no-deps. A bare `uv pip install --editable ` re-resolves the emitter's -# constraints against PyPI and ignores its uv.lock, so a fresh bootstrap can pull -# a transitive dependency the emitter was never tested against — that is how -# ebus-sdk 0.12.0 (whose Device constructor is incompatible with the 0.1.x API -# the emitter targets) landed in this venv and broke `span-simulator` at startup. -# Sourcing from the lock keeps this venv on exactly what the emitter pins. -echo "Installing ebus-emitter's locked runtime deps…" -uv export --project "${EBUS_EMITTER_PATH}" --no-dev --no-emit-project --no-hashes \ - | uv pip install --requirements - - -echo "Installing ebus-emitter from ${EBUS_EMITTER_PATH} (editable, no re-resolution)…" -uv pip install --no-deps --editable "${EBUS_EMITTER_PATH}" - -echo "Done. Verify with: uv run python -c 'import ebus_emitter; print(ebus_emitter.__file__)'" +echo "Done. Verify with: uv run python -c 'import span_panel_simulator.flat_emitter as e; print(e.__file__)'" diff --git a/scripts/run-local.sh b/scripts/run-local.sh index 00f379b..1cc07c3 100755 --- a/scripts/run-local.sh +++ b/scripts/run-local.sh @@ -67,10 +67,9 @@ ensure_prerequisites() { ensure_venv() { echo "==> Syncing dependencies..." - # ``uv sync`` alone would strip the local-path ``ebus-emitter`` editable - # install (it isn't declared in pyproject.toml because the path is - # contributor-specific; see scripts/dev-setup.sh for the .env contract). - # dev-setup.sh wraps ``uv sync`` and re-installs ebus-emitter editable. + # Every dependency now resolves from PyPI — the flat emitter is vendored at + # src/span_panel_simulator/flat_emitter rather than installed from a local path — + # so a plain sync is sufficient. dev-setup.sh remains the documented entry point. bash "${REPO_DIR}/scripts/dev-setup.sh" >/dev/null # shellcheck disable=SC1091 source "${VENV_DIR}/bin/activate" 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/emitter_adapter/runtime.py b/src/span_panel_simulator/emitter_adapter/runtime.py index bcdaef0..9d285b7 100644 --- a/src/span_panel_simulator/emitter_adapter/runtime.py +++ b/src/span_panel_simulator/emitter_adapter/runtime.py @@ -17,8 +17,12 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable import aiomqtt -from ebus_emitter import ( + +from span_panel_simulator.emitter_adapter.instance_ids import stable_circuit_uuid +from span_panel_simulator.emitter_adapter.spec_generator import build_manifest +from span_panel_simulator.flat_emitter import ( BESSConfig, + ChargeMode, DeviceManifest, EbusPanelSnapshot, Emitter, @@ -28,9 +32,6 @@ TickInputs, ) -from span_panel_simulator.emitter_adapter.instance_ids import stable_circuit_uuid -from span_panel_simulator.emitter_adapter.spec_generator import build_manifest - if TYPE_CHECKING: from span_panel_simulator.config_types import ( BESSConfigYAML, @@ -163,7 +164,7 @@ def _build_bess_config(serial_number: str, bess: BESSConfigYAML) -> BESSConfig | if not bess.get("enabled"): return None raw_mode = bess.get("charge_mode", "self-consumption") - mode = "backup-only" if raw_mode == "backup-only" else "self-consumption" + mode: ChargeMode = "backup-only" if raw_mode == "backup-only" else "self-consumption" del serial_number return BESSConfig( instance_id=str(bess.get("instance_id", "bess")), diff --git a/src/span_panel_simulator/emitter_adapter/spec_generator.py b/src/span_panel_simulator/emitter_adapter/spec_generator.py index c316c46..66f85df 100644 --- a/src/span_panel_simulator/emitter_adapter/spec_generator.py +++ b/src/span_panel_simulator/emitter_adapter/spec_generator.py @@ -14,9 +14,8 @@ from typing import TYPE_CHECKING -from ebus_emitter import DeviceInstance, DeviceManifest - from span_panel_simulator.emitter_adapter.instance_ids import stable_circuit_uuid +from span_panel_simulator.flat_emitter import DeviceInstance, DeviceManifest from span_panel_simulator.panel_models import PANEL_SIZE_TO_MODEL if TYPE_CHECKING: diff --git a/src/span_panel_simulator/energy/__init__.py b/src/span_panel_simulator/energy/__init__.py index 6b40f47..bd5eae0 100644 --- a/src/span_panel_simulator/energy/__init__.py +++ b/src/span_panel_simulator/energy/__init__.py @@ -1,6 +1,7 @@ """Simulator-side energy package — grid + PV + load resolver only. -BESS dispatch lives in the emitter (`ebus_emitter.native_devices.bess`), driven +BESS dispatch lives in the emitter +(`span_panel_simulator.flat_emitter.native_devices.bess`), driven each tick by ``per_tick_context`` (load_demand_w, pv_available_w, grid_online, current_time). The simulator's ``DynamicSimulationEngine`` uses ``EnergySystem`` here to compute pre-battery grid power for the snapshot it hands the emitter.""" diff --git a/src/span_panel_simulator/energy/components.py b/src/span_panel_simulator/energy/components.py index 21db98c..9490ba1 100644 --- a/src/span_panel_simulator/energy/components.py +++ b/src/span_panel_simulator/energy/components.py @@ -5,7 +5,7 @@ values are non-negative magnitudes; direction is expressed by which field (``demand_w`` vs ``supply_w``) is populated. -BESS modeling lives in the emitter (`ebus_emitter.native_devices.bess`); the +BESS modeling lives in the emitter (`span_panel_simulator.flat_emitter.native_devices.bess`); the simulator's energy bus is grid + PV + load only. """ diff --git a/src/span_panel_simulator/energy/system.py b/src/span_panel_simulator/energy/system.py index 5366158..5c80c95 100644 --- a/src/span_panel_simulator/energy/system.py +++ b/src/span_panel_simulator/energy/system.py @@ -1,6 +1,6 @@ """EnergySystem — top-level energy balance resolver for grid + PV + load. -BESS dispatch lives in the emitter (`ebus_emitter.native_devices.bess`); this +BESS dispatch lives in the emitter (`span_panel_simulator.flat_emitter.native_devices.bess`); this module is battery-blind. The simulator computes pre-battery grid power; the emitter publishes battery state separately and consumers (HA, dashboards) correlate the two streams. diff --git a/src/span_panel_simulator/energy/types.py b/src/span_panel_simulator/energy/types.py index 1daa319..61d03ac 100644 --- a/src/span_panel_simulator/energy/types.py +++ b/src/span_panel_simulator/energy/types.py @@ -1,6 +1,6 @@ """Core types for the component-based energy system. Battery (BESS) modeling lives -in the emitter (`ebus_emitter.native_devices.bess`) — this module only covers the -grid + PV + load balance the simulator owns.""" +in the emitter (`span_panel_simulator.flat_emitter.native_devices.bess`) — this +module only covers the grid + PV + load balance the simulator owns.""" from __future__ import annotations diff --git a/src/span_panel_simulator/engine.py b/src/span_panel_simulator/engine.py index 14f6cba..ff9898d 100644 --- a/src/span_panel_simulator/engine.py +++ b/src/span_panel_simulator/engine.py @@ -30,13 +30,12 @@ from span_panel_simulator.exceptions import SimulationConfigurationError if TYPE_CHECKING: - from ebus_emitter import BESSDevice - from span_panel_simulator.config_types import ( CircuitTemplateExtended, SimulationConfig, ) from span_panel_simulator.energy import EnergySystem, PowerInputs + from span_panel_simulator.flat_emitter import BESSDevice from span_panel_simulator.recorder import RecorderDataSource from span_panel_simulator.hvac import hvac_seasonal_factor @@ -1362,11 +1361,11 @@ def _build_modeling_bess( if not (isinstance(bess_yaml, dict) and bess_yaml.get("enabled")): return None - from ebus_emitter import BESSConfig - from ebus_emitter import BESSDevice as _BESSDevice + from span_panel_simulator.flat_emitter import BESSConfig, ChargeMode + from span_panel_simulator.flat_emitter import BESSDevice as _BESSDevice raw_mode = bess_yaml.get("charge_mode", "self-consumption") - mode = "backup-only" if raw_mode == "backup-only" else "self-consumption" + mode: ChargeMode = "backup-only" if raw_mode == "backup-only" else "self-consumption" panel_cfg = config.get("panel_config") or {} if isinstance(panel_cfg, dict): serial = str(panel_cfg.get("serial_number", "modeling-panel")) @@ -1400,7 +1399,7 @@ def _bess_dispatch( no device is configured.""" if device is None: return 0.0 - from ebus_emitter.native_devices import NativeTickContext + from span_panel_simulator.flat_emitter.native_devices import NativeTickContext snap = device.tick( NativeTickContext( diff --git a/src/span_panel_simulator/flat_emitter/__init__.py b/src/span_panel_simulator/flat_emitter/__init__.py new file mode 100644 index 0000000..4771069 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/__init__.py @@ -0,0 +1,143 @@ +"""Flat-schema Homie wire publisher with native-device runtime. + +Vendored from ``ebus-emitter`` (https://github.com/electrification-bus/simulator) at +commit ``5b84de8``, version 0.2.1 — the release that corrected the circuit energy +reference frame. MIT licensed, same copyright holders as this repository. + +**This copy no longer tracks upstream, by design.** The two diverged permanently when +upstream moved to the parent/child (v1.0) Homie data model while this simulator continues +to publish the flat schema that SPAN firmware r202603-r202627 speaks. Upstream changes are +not ported; fix bugs here. + +Why vendored rather than depended on: + +- ``ebus-emitter`` is not published to PyPI, so the HA add-on image had no way to install + it — ``pip install .`` in the Dockerfile silently produced an image that failed at + startup with ``ModuleNotFoundError: No module named 'ebus_emitter'``. +- The previous arrangement (editable install from ``EBUS_EMITTER_PATH``) worked only for + developers who had run ``scripts/dev-setup.sh``, and left a cross-repo version skew that + nothing enforced. +- Because the fork is permanent, an external dependency delivered no upstream changes while + costing path configuration, stale editable metadata, and a distribution problem for a + package that will never be published. + +It also collapses a real correctness hazard: ``clone.py`` seeds energy accumulators against +what this code publishes. While they lived in separate repos each side could look locally +correct while jointly inverting circuit energy — which is exactly what happened, and what +no single test suite could see. Both ends now sit in one repo under one test run. + +Architecture (v0.3.0): + +- **Wire layer** (``wire/``): vendored Homie 5 device profiles + mapping descriptors, + graph builder, lifecycle controller, /set router, property bag diff cache, SDK seam. +- **Native devices** (``native_devices/``): emitter-resident, configured-and-self-driving + device runtimes (BESS dispatch, load shedding). +- **Manifest physics** (``manifest_physics.py``): typed accessor over + ``DeviceInstance.metadata`` for physics-relevant fields (voltage, breaker rating, + tabs/legs, placement, default priority, relay behaviour). +- **Tick pipeline** (``relay_resolver.py`` + ``energy_integrator.py`` + ``panel_meter.py`` + + ``conventions/tab_legs.py``): per-tick state machinery the emitter uses to + resolve circuit relay state, integrate energy, derive per-leg currents, and + aggregate panel-level fields. + +Producer contract (v0.3.0): build a ``DeviceManifest`` once at startup, then call +``Emitter.publish_tick(TickInputs)`` each tick with signed circuit/EVSE powers, +``current_time``, and ``grid_online``. The emitter does the rest.""" + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg, legs_for_tabs +from span_panel_simulator.flat_emitter.emitter import Emitter +from span_panel_simulator.flat_emitter.exceptions import ( + EmitterError, + EmitterStateError, + ManifestValidationError, + MissingSetterError, + ProfileValidationError, + RuntimeSpecValidationError, +) +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.manifest_physics import ( + BessPhysics, + CircuitPhysics, + EvsePhysics, + LugsPhysics, + ManifestPhysicsView, + PanelPhysics, + PvPhysics, +) +from span_panel_simulator.flat_emitter.native_devices import ( + BESSConfig, + BESSDevice, + ChargeMode, + LoadSheddingConfig, + LoadSheddingDevice, + NativeDevice, + NativeTickContext, +) +from span_panel_simulator.flat_emitter.relay_resolver import ( + RelayRequester, + RelayResolver, + RelayState, +) +from span_panel_simulator.flat_emitter.snapshot import ( + EbusBatterySnapshot, + EbusCircuitSnapshot, + EbusEvseSnapshot, + EbusLugsSnapshot, + EbusPanelDoor, + EbusPanelInfo, + EbusPanelMeter, + EbusPanelPcs, + EbusPanelPowerFlows, + EbusPanelSnapshot, + EbusPanelStatus, + EbusPvSnapshot, +) +from span_panel_simulator.flat_emitter.tick_inputs import PanelEnvelopeTick, TickInputs +from span_panel_simulator.flat_emitter.wire.set_router import SetterHandler, SetterRegistry + +__all__ = [ + "BESSConfig", + "BESSDevice", + "BessPhysics", + "ChargeMode", + "CircuitPhysics", + "DeviceInstance", + "DeviceManifest", + "EbusBatterySnapshot", + "EbusCircuitSnapshot", + "EbusEvseSnapshot", + "EbusLugsSnapshot", + "EbusPanelDoor", + "EbusPanelInfo", + "EbusPanelMeter", + "EbusPanelPcs", + "EbusPanelPowerFlows", + "EbusPanelSnapshot", + "EbusPanelStatus", + "EbusPvSnapshot", + "Emitter", + "EmitterError", + "EmitterStateError", + "EvsePhysics", + "Leg", + "LoadSheddingConfig", + "LoadSheddingDevice", + "LugsPhysics", + "ManifestPhysicsView", + "ManifestValidationError", + "MissingSetterError", + "NativeDevice", + "NativeTickContext", + "PanelEnvelopeTick", + "PanelPhysics", + "ProfileValidationError", + "PvPhysics", + "RelayRequester", + "RelayResolver", + "RelayState", + "RuntimeSpecValidationError", + "SetterHandler", + "SetterRegistry", + "TickInputs", + "legs_for_tabs", +] diff --git a/src/span_panel_simulator/flat_emitter/conventions/__init__.py b/src/span_panel_simulator/flat_emitter/conventions/__init__.py new file mode 100644 index 0000000..74d594c --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/conventions/__init__.py @@ -0,0 +1,11 @@ +"""Centralized panel-wiring conventions used across the emitter. + +Each module in this package isolates a single physical-layer convention so the +rest of the codebase can be convention-agnostic. Convention changes (e.g., +supporting European single-phase or 3-phase commercial panels) land here without +rippling into ``PanelMeter``, ``EnergyIntegrator``, or per-property derivations. +""" + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg, legs_for_tabs + +__all__ = ["Leg", "legs_for_tabs"] diff --git a/src/span_panel_simulator/flat_emitter/conventions/tab_legs.py b/src/span_panel_simulator/flat_emitter/conventions/tab_legs.py new file mode 100644 index 0000000..8f27384 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/conventions/tab_legs.py @@ -0,0 +1,47 @@ +"""Tab-to-leg assignment convention for US residential split-phase panels. + +US residential load centers alternate breaker slots between L1 and L2: odd-numbered +tabs land on L1, even-numbered tabs land on L2. A 240 V dipole circuit occupies +two adjacent tabs (e.g. 1 and 2) — one per leg. + +This module is the single source of truth for that convention. Per-leg current +calculations in ``PanelMeter`` and the per-circuit ``current_a`` derivation go +through ``legs_for_tabs`` rather than reaching for ``tab % 2`` directly. + +Future support for non-US panels (European single-phase, 3-phase commercial) lands +here as an additional ``Convention`` enum + dispatch, without touching call sites.""" + +from __future__ import annotations + +from enum import StrEnum + + +class Leg(StrEnum): + """A panel power leg. ``L1`` and ``L2`` are the two 120 V legs of a US + residential split-phase service; line-to-line voltage between them is 240 V.""" + + L1 = "L1" + L2 = "L2" + + +def legs_for_tabs(tabs: tuple[int, ...]) -> tuple[Leg, ...]: + """Return the leg assignment for each tab in ``tabs``, US residential + convention: odd tabs → L1, even tabs → L2. + + Examples: + >>> legs_for_tabs((1,)) + (,) + >>> legs_for_tabs((2,)) + (,) + >>> legs_for_tabs((1, 2)) # standard dipole + (, ) + >>> legs_for_tabs((1, 3)) # both on L1 — invalid for a dipole + (, ) + + The function does not enforce dipole leg-spanning — that's the validation + job of ``ManifestPhysicsView``, which sees the ``dipole`` flag. + + Raises ``ValueError`` if any tab is < 1 (panel tabs are 1-indexed).""" + if any(t < 1 for t in tabs): + raise ValueError(f"tab numbers must be >= 1; got {tabs!r}") + return tuple(Leg.L1 if t % 2 == 1 else Leg.L2 for t in tabs) diff --git a/src/span_panel_simulator/flat_emitter/emitter.py b/src/span_panel_simulator/flat_emitter/emitter.py new file mode 100644 index 0000000..2e1785b --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/emitter.py @@ -0,0 +1,657 @@ +"""Public Emitter facade — wire-layer publisher with native-device runtime. + +The producer hands the emitter a small per-tick driving signal via +``publish_tick(TickInputs)``: signed power per circuit/EVSE, current_time, +grid_online, panel envelope. The emitter resolves BESS dispatch, gates circuit +power through ``RelayResolver``, integrates energy via ``EnergyIntegrator``, +aggregates panel-level fields via ``PanelMeter``, builds the internal snapshot, +and publishes the Homie diff to MQTT. + +The internal snapshot type (``EbusPanelSnapshot`` and friends) is used for the +diff cache and read-back via ``last_snapshot``; producers do not construct it.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from span_panel_simulator.flat_emitter.energy_integrator import EnergyIntegrator +from span_panel_simulator.flat_emitter.exceptions import EmitterStateError +from span_panel_simulator.flat_emitter.manifest import DeviceManifest +from span_panel_simulator.flat_emitter.manifest_physics import ManifestPhysicsView +from span_panel_simulator.flat_emitter.native_devices import ( + BESSConfig, + BESSDevice, + LoadSheddingConfig, + LoadSheddingDevice, + NativeTickContext, +) +from span_panel_simulator.flat_emitter.panel_meter import circuit_current_a +from span_panel_simulator.flat_emitter.panel_meter import resolve as resolve_panel +from span_panel_simulator.flat_emitter.relay_resolver import RelayResolver, RelayState +from span_panel_simulator.flat_emitter.snapshot import ( + EbusBatterySnapshot, + EbusCircuitSnapshot, + EbusEvseSnapshot, + EbusLugsSnapshot, + EbusPanelDoor, + EbusPanelInfo, + EbusPanelMeter, + EbusPanelPcs, + EbusPanelPowerFlows, + EbusPanelSnapshot, + EbusPanelStatus, + EbusPvSnapshot, +) +from span_panel_simulator.flat_emitter.tick_inputs import TickInputs +from span_panel_simulator.flat_emitter.wire.bag_builder import BagBuilder +from span_panel_simulator.flat_emitter.wire.graph_builder import build_graph +from span_panel_simulator.flat_emitter.wire.lifecycle import LifecycleController +from span_panel_simulator.flat_emitter.wire.lifecycle import lwt_settings as _lwt +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 +from span_panel_simulator.flat_emitter.wire.publisher import Publisher +from span_panel_simulator.flat_emitter.wire.set_router import SetterRegistry, compute_subscriptions + + +@runtime_checkable +class _MqttClientLike(Protocol): + def is_connected(self) -> bool: ... + async def publish( + self, + topic: str, + payload: bytes, + qos: int = 0, + retain: bool = False, + ) -> None: ... + async def subscribe(self, topic: str) -> None: ... + + +class Emitter: + """One emitter per logical panel/clone.""" + + def __init__( + self, + manifest: DeviceManifest, + setter_registry: SetterRegistry, + mqtt_client: _MqttClientLike, + *, + bess_configs: tuple[BESSConfig, ...] = (), + load_shedding_config: LoadSheddingConfig | None = None, + ebus_domain: str = "ebus", + bus_version: str = "5", + ) -> None: + self._manifest = manifest + + self._profiles = load_profiles() + self._mapping = load_mapping_table() + self._mapping.validate_against(self._profiles) + + self._graph = build_graph(manifest, self._mapping, self._profiles) + + # ---- v0.3.0 internal state (must exist before internal /set handlers + # bind, which must happen before compute_subscriptions validates handler + # coverage). ---- + # BESS is pluralized: a panel can host multiple battery devices (e.g. a + # Powerwall plus an Enphase IQ, or two Powerwalls). Keyed by + # ``BESSConfig.instance_id``; duplicate IDs are a producer-side bug. + self._bess: dict[str, BESSDevice] = {} + for cfg in bess_configs: + if cfg.instance_id in self._bess: + raise EmitterStateError(f"duplicate bess_config instance_id={cfg.instance_id!r}") + self._bess[cfg.instance_id] = BESSDevice(config=cfg) + self._load_shedding: LoadSheddingDevice | None = ( + LoadSheddingDevice(config=load_shedding_config) + if load_shedding_config is not None + else None + ) + # ManifestPhysicsView raises ManifestValidationError if the manifest + # is missing required physics keys. publish_tick is the only publish + # path now, so a malformed manifest is a hard error at construction. + self._physics = ManifestPhysicsView(manifest) + self._relays = RelayResolver() + self._energy = EnergyIntegrator() + self._priority_overrides: dict[str, str] = {} + self._name_overrides: dict[str, str] = {} + self._dominant_power_source_override: str | None = None + + for cid, cphys in self._physics.all_circuits().items(): + self._relays.register(cid, always_on=cphys.always_on) + self._energy.register(cid) + if cphys.initial_consumed_wh or cphys.initial_produced_wh: + self._energy.seed( + cid, + consumed_wh=cphys.initial_consumed_wh, + produced_wh=cphys.initial_produced_wh, + ) + for eid in self._physics.all_evse(): + self._energy.register(eid) + # 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: + self._bess[bess_id].set_soe(bphys.initial_soe_kwh) + + # Internal default /set handlers — registered BEFORE compute_subscriptions + # so its missing-handler check passes. Producer-supplied handlers always + # win (the helper checks .get() first). + self._register_internal_setters(setter_registry) + + # ---- Wire layer subscriptions ---- + instances = [(i.entity_class, i.instance_id) for i in manifest.instances] + settables_by_class = { + ec: profile.settable_properties() for ec, profile in self._profiles.items() + } + + root_id = next( + i.instance_id + for i in manifest.instances + if any( + m.entity_class == i.entity_class and m.placement.kind == "root-device" + for m in self._mapping.values() + ) + ) + + def _device_id_for(ec: str, iid: str) -> str: + placement = self._mapping[ec].placement + if placement.kind == "node-on-parent": + return root_id + return iid + + def _node_id_for(ec: str, iid: str, cap: str) -> str: + placement = self._mapping[ec].placement + if placement.kind == "node-on-parent": + template = placement.node_id_template or "{instance_id}" + return template.format( + instance_id=iid, + instance_id_short=iid[:8], + display_name=self._manifest.get(ec, iid).display_name, + ) + return cap + + def _datatype_for(ec: str, cap: str, key: str) -> str: + return self._profiles[ec].capabilities[cap].properties[key].datatype + + self._subscriptions = compute_subscriptions( + instances=instances, + settables_by_class=settables_by_class, + registry=setter_registry, + domain=ebus_domain, + bus_version=bus_version, + device_id_for=_device_id_for, + node_id_for=_node_id_for, + datatype_for=_datatype_for, + ) + + self._lifecycle = LifecycleController( + manifest, + self._mapping, + self._profiles, + self._graph, + mqtt_client, + domain=ebus_domain, + bus_version=bus_version, + subscriptions=self._subscriptions, + ) + + self._publisher = Publisher( + self._graph, + mqtt_client, + domain=ebus_domain, + bus_version=bus_version, + ) + self._bag_builder = BagBuilder(self._graph, self._mapping, self._profiles) + self._last_snapshot: EbusPanelSnapshot | None = None + self._started = False + + @staticmethod + def lwt_settings(manifest: DeviceManifest) -> tuple[str, bytes, int, bool]: + # Derive the root entity class from the mapping table rather than + # hard-coding "panel"; future mapping tables may use a different root + # (e.g. an MID device parenting a panel node). + mapping = load_mapping_table() + return _lwt( + manifest, + domain="ebus", + bus_version="5", + root_entity_class=mapping.root_entity_class(), + ) + + async def start(self) -> None: + await self._lifecycle.start() + self._started = True + + async def publish_tick(self, tick_inputs: TickInputs) -> EbusPanelSnapshot: + """The v0.3.0 producer-facing publish path. Returns the constructed + snapshot for the producer to read back.""" + if not self._started: + raise EmitterStateError("Emitter.publish_tick() called before start()") + snapshot = self._build_snapshot_from_tick(tick_inputs) + await self._publish_diff(snapshot) + return snapshot + + def seed_energy( + self, + instance_id: str, + *, + consumed_wh: float = 0.0, + produced_wh: float = 0.0, + ) -> None: + """Overwrite an instance's energy accumulators. Typical use: producer + reads last-known values from persistent storage and seeds at startup + before the first ``publish_tick`` call. Raises ``KeyError`` for unknown + instance IDs (caught typos before they cause silent data loss).""" + self._energy.seed(instance_id, consumed_wh=consumed_wh, produced_wh=produced_wh) + + def seed_bess_soe(self, instance_id: str, soe_kwh: float) -> None: + """Overwrite a BESS device's stored SOE. Raises ``EmitterStateError`` + if no BESS is configured or if ``instance_id`` is not among the + configured BESS instances — both are producer-side programming + errors.""" + if not self._bess: + raise EmitterStateError( + f"seed_bess_soe({instance_id!r}, ...): no BESS configured on this emitter" + ) + if instance_id not in self._bess: + known = sorted(self._bess.keys()) + raise EmitterStateError( + f"seed_bess_soe: instance_id={instance_id!r} not among configured " + f"BESS instances {known!r}" + ) + self._bess[instance_id].set_soe(soe_kwh) + + async def stop(self, *, graceful: bool = True, clear_retained: bool = False) -> None: + await self._lifecycle.stop(graceful=graceful, clear_retained=clear_retained) + + def update_bess_config(self, config: BESSConfig) -> None: + """Replace (or add) a BESS device's configuration keyed by + ``config.instance_id``. Takes effect on the next publish call. + SOC/SOE state persists across in-place config swaps; freshly added + BESS instances start from their config's ``initial_soc_pct``.""" + existing = self._bess.get(config.instance_id) + if existing is None: + self._bess[config.instance_id] = BESSDevice(config=config) + else: + existing.update_config(config) + + def update_load_shedding_config(self, config: LoadSheddingConfig) -> None: + if self._load_shedding is None: + self._load_shedding = LoadSheddingDevice(config=config) + else: + self._load_shedding.update_config(config) + + @property + def last_snapshot(self) -> EbusPanelSnapshot | None: + return self._last_snapshot + + @property + def topology_version(self) -> int: + return next(iter(self._mapping.values())).profile_version + + @property + def relays(self) -> RelayResolver: + """Read-write access to the per-circuit relay resolver. Used by /set + handlers (registered by the emitter for ``circuit.switch/relay``) to + update operator overrides.""" + return self._relays + + @property + def dominant_power_source_override(self) -> str | None: + """Operator-set dominant power source override, or None if not set. + Set via /set ``panel.pcs/dominant-power-source`` topic.""" + return self._dominant_power_source_override + + # ---- internal -------------------------------------------------------- + + def _register_internal_setters(self, registry: SetterRegistry) -> None: + """Register default handlers for the four settable properties when the + producer hasn't already supplied one. The handlers update emitter- + internal state (RelayResolver, priority/name override maps, panel + dominant-power-source override). The next ``publish_tick`` call reflects + the change on the wire. + + Producers needing custom routing register their own handler before + constructing the ``Emitter`` and the registry's existing entry wins.""" + + async def on_circuit_relay( + entity_class: str, + instance_id: str, + prop_path: str, + value: object, + ) -> None: + del entity_class, prop_path + # Homie boolean: True = relay closed (energized), False = open. + closed = ( + bool(value) + if isinstance(value, bool) + else (str(value).strip().lower() in ("true", "1", "closed", "on")) + ) + new_state = RelayState.CLOSED if closed else RelayState.OPEN + if self._relays.known(instance_id): + self._relays.set_user_override(instance_id, new_state) + + async def on_shed_priority( + entity_class: str, + instance_id: str, + prop_path: str, + value: object, + ) -> None: + del entity_class, prop_path + self._priority_overrides[instance_id] = str(value).upper() + + async def on_circuit_name( + entity_class: str, + instance_id: str, + prop_path: str, + value: object, + ) -> None: + del entity_class, prop_path + self._name_overrides[instance_id] = str(value) + + async def on_dom_power_source( + entity_class: str, + instance_id: str, + prop_path: str, + value: object, + ) -> None: + del entity_class, instance_id, prop_path + self._dominant_power_source_override = str(value).upper() + + if registry.get("circuit", "circuit/relay") is None: + registry.register("circuit", "circuit/relay", on_circuit_relay) + if registry.get("circuit", "circuit/shed-priority") is None: + registry.register("circuit", "circuit/shed-priority", on_shed_priority) + if registry.get("circuit", "circuit/name") is None: + registry.register("circuit", "circuit/name", on_circuit_name) + if registry.get("panel", "core/dominant-power-source") is None: + registry.register("panel", "core/dominant-power-source", on_dom_power_source) + + async def _publish_diff(self, snapshot: EbusPanelSnapshot) -> None: + bag = self._bag_builder.build(snapshot) + await self._publisher.publish(bag) + self._last_snapshot = snapshot + + def _build_snapshot_from_tick(self, tick: TickInputs) -> EbusPanelSnapshot: + panel_phys = self._physics.panel + circuits_phys = self._physics.all_circuits() + + # Step 1: aggregate inputs for BESS dispatch (pre-shed: use raw producer + # power, not gated, since shedding decisions DEPEND on BESS SOC). + load_demand_w = sum(p for p in tick.circuits.values() if p > 0) + pv_available_w = -sum(p for p in tick.circuits.values() if p < 0) + + # Step 2: BESS dispatch + battery snapshots — one per configured BESS. + # ``battery_w`` is the SUM of signed dispatch across all batteries; it + # feeds the panel meter aggregation as a single combined contribution. + # The min-SOC across batteries is what drives load-shedding decisions + # (the most-depleted BESS is the binding constraint). + battery_snapshots: dict[str, EbusBatterySnapshot] = {} + battery_w = 0.0 + for bess_id, bess_dev in self._bess.items(): + bphys = self._physics.bess(bess_id) + snap = bess_dev.tick( + NativeTickContext( + current_time=tick.current_time, + grid_online=tick.grid_online, + load_demand_w=load_demand_w, + pv_available_w=pv_available_w, + ) + ) + snap.instance_id = bess_id + snap.vendor_name = bphys.vendor_name + snap.product_name = bphys.product_name + snap.model = bphys.model + snap.serial_number = bphys.serial_number + snap.firmware_version = bphys.firmware_version + snap.relative_position = bphys.relative_position + snap.feed_circuit_id = bphys.feed + snap.connected = snap.communication == "OK" + snap.grid_state = "ON_GRID" if tick.grid_online else "OFF_GRID" + battery_snapshots[bess_id] = snap + battery_w += snap.active_power_w + has_battery = bool(battery_snapshots) + # ``min_soc`` is None when no BESS reports a SOE value (all uninitialised); + # ``decide_shed`` then treats SOC as unknown. + soc_values = [ + s.soe_percentage for s in battery_snapshots.values() if s.soe_percentage is not None + ] + min_soc: float | None = min(soc_values) if soc_values else None + + # Step 3: load-shedding decisions written into RelayResolver. Always + # cleared first so a previous tick's shed state doesn't linger when the + # grid comes back online or SOC recovers. Operator-set priority + # overrides take precedence over manifest defaults. + self._relays.clear_all_shed() + if self._load_shedding is not None: + effective_priorities = { + cid: self._priority_overrides.get(cid, cphys.default_priority) + for cid, cphys in circuits_phys.items() + } + shed_ids = self._load_shedding.decide_shed( + grid_online=tick.grid_online, + bess_soc_pct=min_soc, + priorities=effective_priorities, + ) + for cid in shed_ids: + self._relays.set_shed(cid, open_relay=True) + + # Step 4: resolve final relay state per circuit (always-on > /set > shed + # > default-CLOSED) and gate producer-reported power. + gated_powers: dict[str, float] = {} + for cid in circuits_phys: + raw_power = tick.circuits.get(cid, 0.0) + relay_state, _requester = self._relays.state(cid) + gated_powers[cid] = 0.0 if relay_state == RelayState.OPEN else raw_power + + # Step 4: integrate energy per circuit using gated power (if relay open, + # no energy flows). + for cid, gated in gated_powers.items(): + self._energy.observe(cid, gated, tick.current_time) + for eid, evse_power in tick.evse.items(): + if self._energy.known(eid): + self._energy.observe(eid, evse_power, tick.current_time) + + # Step 5: panel-level aggregation. + meter = resolve_panel( + panel=panel_phys, + circuits=circuits_phys, + gated_powers=gated_powers, + battery_w=battery_w, + grid_online=tick.grid_online, + has_battery=has_battery, + ) + + # Step 6: build per-circuit snapshots — applying any operator name and + # priority overrides on top of manifest defaults. + circuit_snaps: dict[str, EbusCircuitSnapshot] = {} + for cid, cphys in circuits_phys.items(): + relay_state, requester = self._relays.state(cid) + gated_p = gated_powers[cid] + estate = self._energy.state(cid) + effective_priority = self._priority_overrides.get(cid, cphys.default_priority) + effective_name = self._name_overrides.get( + cid, + self._manifest.get("circuit", cid).display_name, + ) + circuit_snaps[cid] = EbusCircuitSnapshot( + circuit_id=cid, + name=effective_name, + relay_state=str(relay_state), + instant_power_w=gated_p, + produced_energy_wh=estate.produced_wh, + consumed_energy_wh=estate.consumed_wh, + tabs=list(cphys.tabs), + priority=effective_priority, + is_user_controllable=cphys.relay_behavior == "controllable", + is_sheddable=effective_priority in ("OFF_GRID", "SOC_THRESHOLD"), + is_never_backup=effective_priority == "NEVER", + is_240v=cphys.dipole, + current_a=circuit_current_a( + gated_p, + dipole=cphys.dipole, + line_voltage_v=panel_phys.line_voltage_v, + ), + breaker_rating_a=cphys.breaker_rating_a, + always_on=cphys.always_on, + pcs_managed=cphys.relay_behavior == "controllable", + pcs_priority=cphys.pcs_priority, + relay_requester=str(requester), + energy_accum_update_time_s=int(tick.current_time), + instant_power_update_time_s=int(tick.current_time), + ) + + # Step 7: PV snapshots — one entry per PV instance in the manifest. + # Per-PV power telemetry comes from the producer's circuit feed; the + # snapshot here carries the static identity from manifest physics. + pv_snaps: dict[str, EbusPvSnapshot] = {} + for pv_id, pv_phys in self._physics.all_pv().items(): + pv_snaps[pv_id] = EbusPvSnapshot( + node_id=pv_id, + feed_circuit_id=pv_phys.feed, + vendor_name=pv_phys.vendor_name, + product_name=pv_phys.product_name, + serial_number=pv_phys.serial_number, + nameplate_capacity_w=pv_phys.nameplate_capacity_w, + firmware_version=pv_phys.firmware_version, + relative_position=pv_phys.relative_position, + ) + + # Step 7b: Lugs snapshots — one per declared lugs instance. Per-leg + # currents and power/energy come from the panel meter aggregation; + # ``direction`` and ``feed`` come from manifest physics. Producers that + # only model a single lugs (most US split-phase setups) get a single + # entry here; OPNsense-fed multi-lugs panels get one per device. + lugs_snaps: dict[str, EbusLugsSnapshot] = {} + for lugs_id, lphys in self._physics.all_lugs().items(): + if lphys.direction == "upstream": + l1 = meter.upstream_l1_current_a + l2 = meter.upstream_l2_current_a + # 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" + ) + lugs_snaps[lugs_id] = EbusLugsSnapshot( + instance_id=lugs_id, + direction=("upstream" if lphys.direction == "upstream" else "downstream"), + feed=None, + l1_current_a=l1, + l2_current_a=l2, + active_power_w=active_w, + imported_energy_wh=imported_wh, + exported_energy_wh=exported_wh, + ) + + # Step 8: EVSE snapshots derived from per-tick power. + evse_snaps: dict[str, EbusEvseSnapshot] = {} + for eid, ephys in self._physics.all_evse().items(): + power = tick.evse.get(eid, 0.0) + charging = power > 100.0 + evse_snaps[eid] = EbusEvseSnapshot( + node_id=eid, + feed_circuit_id=ephys.feed, + status="CHARGING" if charging else "AVAILABLE", + lock_state="LOCKED" if charging else "UNLOCKED", + advertised_current_a=ephys.max_current_a, + vendor_name=ephys.vendor_name, + product_name=ephys.product_name, + part_number=ephys.part_number, + serial_number=ephys.serial_number, + firmware_version=ephys.firmware_version, + ) + + # Step 9: assemble the panel snapshot from capability sub-dataclasses. + info = EbusPanelInfo( + serial_number=panel_phys.serial_number, + firmware_version=panel_phys.firmware_version, + vendor_name=panel_phys.vendor_name, + hardware_version=panel_phys.hardware_version, + panel_size=panel_phys.panel_size, + panel_model=panel_phys.panel_model, + schema_topology=panel_phys.topology, + ) + door = EbusPanelDoor( + state=tick.envelope.door_state, + proximity_proven=tick.envelope.proximity_proven, + ) + consumed_total = sum(s.consumed_energy_wh for s in circuit_snaps.values()) + produced_total = sum(s.produced_energy_wh for s in circuit_snaps.values()) + feedthrough_consumed = sum( + s.consumed_energy_wh + for cid, s in circuit_snaps.items() + if circuits_phys[cid].placement == "downstream-of-lugs" + ) + feedthrough_produced = sum( + s.produced_energy_wh + for cid, s in circuit_snaps.items() + if circuits_phys[cid].placement == "downstream-of-lugs" + ) + meter_section = EbusPanelMeter( + instant_grid_power_w=meter.instant_grid_power_w, + main_meter_energy_consumed_wh=consumed_total, + main_meter_energy_produced_wh=produced_total, + feedthrough_power_w=meter.feedthrough_power_w, + feedthrough_energy_consumed_wh=feedthrough_consumed, + feedthrough_energy_produced_wh=feedthrough_produced, + l1_voltage=meter.line_voltage_v, + l2_voltage=meter.line_voltage_v, + upstream_l1_current_a=meter.upstream_l1_current_a, + upstream_l2_current_a=meter.upstream_l2_current_a, + downstream_l1_current_a=meter.downstream_l1_current_a, + downstream_l2_current_a=meter.downstream_l2_current_a, + ) + status = EbusPanelStatus( + main_relay_state=meter.main_relay_state, + eth0_link=tick.envelope.eth0_link, + wlan_link=tick.envelope.wlan_link, + wwan_link=tick.envelope.wwan_link, + wifi_ssid=tick.envelope.wifi_ssid, + cloud_connection=tick.envelope.cloud_connection, + postal_code=panel_phys.postal_code, + time_zone=panel_phys.time_zone, + uptime_s=tick.envelope.uptime_s, + ) + pcs = EbusPanelPcs( + main_breaker_rating_a=panel_phys.main_breaker_rating_a, + grid_islandable=meter.grid_islandable, + dominant_power_source=( + self._dominant_power_source_override + if self._dominant_power_source_override is not None + else meter.dominant_power_source + ), + grid_state=meter.grid_state, + dsm_state=meter.dsm_state, + current_run_config=meter.current_run_config, + ) + power_flows = EbusPanelPowerFlows( + pv=meter.power_flow_pv, + battery=meter.power_flow_battery, + grid=meter.power_flow_grid, + site=meter.power_flow_site, + ) + + return EbusPanelSnapshot( + info=info, + door=door, + meter=meter_section, + status=status, + pcs=pcs, + power_flows=power_flows, + circuits=circuit_snaps, + battery=battery_snapshots, + pv=pv_snaps, + evse=evse_snaps, + lugs=lugs_snaps, + ) diff --git a/src/span_panel_simulator/flat_emitter/energy_integrator.py b/src/span_panel_simulator/flat_emitter/energy_integrator.py new file mode 100644 index 0000000..273e0ce --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/energy_integrator.py @@ -0,0 +1,89 @@ +"""Per-instance energy accumulator with producer-supplied seed values. + +Each tick the producer reports an instantaneous power for an instance. The +integrator advances ``produced_wh`` and ``consumed_wh`` based on the elapsed +time since that instance's last tick, computed from ``current_time`` (epoch +seconds) the producer pushes. Power-sign convention matches the rest of the +emitter: positive = consumption (load), negative = production (PV/V2G). + +Seeding: +- ``register(instance_id)`` initializes the integrator at zero. +- ``seed(instance_id, *, consumed_wh, produced_wh)`` overwrites the running + accumulators (typical use: producer reads last-known values from persistent + storage and seeds at startup before the first tick). +- The manifest's ``initial-consumed-wh`` / ``initial-produced-wh`` keys are a + declarative alternative; ``Emitter`` calls ``seed()`` from those at startup. + +Time bookkeeping: +- The first observation for an instance establishes ``last_tick_time_s`` but + does NOT integrate (no prior interval). Subsequent observations integrate + ``power * (now - last)``. +- Backwards or zero ``dt`` is treated as a no-op (clock skew / duplicate tick).""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class EnergyState: + consumed_wh: float = 0.0 + produced_wh: float = 0.0 + last_tick_time_s: float | None = None + + +class EnergyIntegrator: + """Accumulators for many instances. One instance per circuit / PV / EVSE.""" + + def __init__(self) -> None: + self._states: dict[str, EnergyState] = {} + + def register(self, instance_id: str) -> None: + """Idempotent — repeated calls leave existing state untouched.""" + if instance_id not in self._states: + self._states[instance_id] = EnergyState() + + def seed( + self, + instance_id: str, + *, + consumed_wh: float = 0.0, + produced_wh: float = 0.0, + ) -> None: + """Overwrite accumulators for ``instance_id``. Raises ``KeyError`` if the + instance was never registered (catches typos before the first tick).""" + if instance_id not in self._states: + raise KeyError( + f"seed() called for unknown instance_id={instance_id!r}; call register() first" + ) + st = self._states[instance_id] + st.consumed_wh = consumed_wh + st.produced_wh = produced_wh + # Note: last_tick_time_s is intentionally NOT reset — seeding only + # overwrites the energy values, the time bookkeeping persists. + + def observe(self, instance_id: str, power_w: float, current_time: float) -> None: + """Advance the integrator for ``instance_id`` with the producer's signed + ``power_w`` reported at ``current_time``. The first call for an instance + establishes ``last_tick_time_s`` without integrating.""" + if instance_id not in self._states: + raise KeyError(f"observe() for unknown instance_id={instance_id!r}") + st = self._states[instance_id] + if st.last_tick_time_s is None: + st.last_tick_time_s = current_time + return + dt_s = current_time - st.last_tick_time_s + st.last_tick_time_s = current_time + if dt_s <= 0: + return + dt_h = dt_s / 3600.0 + if power_w > 0: + st.consumed_wh += power_w * dt_h + elif power_w < 0: + st.produced_wh += -power_w * dt_h + + def state(self, instance_id: str) -> EnergyState: + return self._states[instance_id] + + def known(self, instance_id: str) -> bool: + return instance_id in self._states diff --git a/src/span_panel_simulator/flat_emitter/exceptions.py b/src/span_panel_simulator/flat_emitter/exceptions.py new file mode 100644 index 0000000..3165da0 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/exceptions.py @@ -0,0 +1,38 @@ +"""Public exception hierarchy for ebus-emitter.""" + +from __future__ import annotations + + +class EmitterError(Exception): + """Base class for all emitter errors.""" + + +class ManifestValidationError(EmitterError): + """A DeviceManifest references unknown entity_class, missing required parent, or + contains a duplicate (entity_class, instance_id) pair.""" + + +class RuntimeSpecValidationError(EmitterError): + """A RuntimeSpec is structurally invalid or references manifest entities that do not + exist.""" + + +class MissingSetterError(EmitterError): + """A settable property declared by a vendored profile has no corresponding handler in + the SetterRegistry passed to Emitter.__init__. ``missing`` carries the offending + (entity_class, property_path) pairs.""" + + def __init__(self, missing: list[tuple[str, str]]) -> None: + self.missing = missing + rendered = ", ".join(f"({c!r}, {p!r})" for c, p in missing) + super().__init__(f"Settable properties without registered handlers: {rendered}") + + +class ProfileValidationError(EmitterError): + """A vendored profile or mapping descriptor failed internal-consistency validation at + load time. Defensive — should never fire in shipped code.""" + + +class EmitterStateError(EmitterError): + """Emitter operation called in the wrong state (e.g. tick() before start(), start() + against a disconnected MQTT client).""" diff --git a/src/span_panel_simulator/flat_emitter/manifest.py b/src/span_panel_simulator/flat_emitter/manifest.py new file mode 100644 index 0000000..a2c8bb7 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/manifest.py @@ -0,0 +1,35 @@ +"""Producer-supplied device identity manifest. + +Frozen, immutable declaration of which entity instances exist on a panel. The producer +hands one to ``Emitter`` at construction; the emitter validates it against the vendored +mapping descriptors and profiles. Manifest mutations require emitter restart (no live +mutation API). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True, slots=True) +class DeviceInstance: + entity_class: str + instance_id: str + display_name: str + metadata: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class DeviceManifest: + instances: tuple[DeviceInstance, ...] + + def get(self, entity_class: str, instance_id: str) -> DeviceInstance: + for inst in self.instances: + if inst.entity_class == entity_class and inst.instance_id == instance_id: + return inst + raise KeyError( + f"No DeviceInstance with entity_class={entity_class!r}, instance_id={instance_id!r}" + ) + + def of_class(self, entity_class: str) -> tuple[DeviceInstance, ...]: + return tuple(i for i in self.instances if i.entity_class == entity_class) diff --git a/src/span_panel_simulator/flat_emitter/manifest_physics.py b/src/span_panel_simulator/flat_emitter/manifest_physics.py new file mode 100644 index 0000000..f2143a4 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/manifest_physics.py @@ -0,0 +1,418 @@ +"""Typed accessor over ``DeviceInstance.metadata`` for physics-relevant fields. + +The producer puts strings in ``DeviceInstance.metadata``; the emitter reads them +through this view. One central place to define every key the emitter consumes, +its parser, its default, and its validation rules. Adding a new physics field +is a one-line addition here plus a docs note in the README. + +Validation runs once when ``ManifestPhysicsView`` is constructed (typically at +``Emitter.__init__``). Missing required keys, malformed values, and contradictory +physics (e.g. ``dipole`` flag inconsistent with ``tab-numbers`` count) raise +``ManifestValidationError`` with the offending instance_id.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg, legs_for_tabs +from span_panel_simulator.flat_emitter.exceptions import ManifestValidationError + +if TYPE_CHECKING: + from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest + + +# --------------------------------------------------------------------------- +# Per-entity-class typed views — built once, queried many times. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class PanelPhysics: + serial_number: str + vendor_name: str + firmware_version: str + hardware_version: str + panel_size: int + main_breaker_rating_a: int + panel_model: str + postal_code: str + time_zone: str + service_voltage_v: float + line_voltage_v: float + islandable: bool + # ``flat`` = legacy single-Homie-device shape (one device, many nodes). + # ``parent-child`` = post-migration shape where children become separate + # Homie devices. Producer-overridable via metadata key ``schema-topology``. + topology: Literal["flat", "parent-child"] = "flat" + + +@dataclass(frozen=True, slots=True) +class LugsPhysics: + direction: str # "upstream" | "downstream" + + +@dataclass(frozen=True, slots=True) +class CircuitPhysics: + tabs: tuple[int, ...] + legs: tuple[Leg, ...] + dipole: bool + breaker_rating_a: float + default_priority: str + relay_behavior: str # "controllable" | "always-on" | "non-controllable" + placement: str # "upstream-of-lugs" | "downstream-of-lugs" + always_on: bool + initial_consumed_wh: float + initial_produced_wh: float + pcs_priority: int = 0 + + +@dataclass(frozen=True, slots=True) +class BessPhysics: + vendor_name: str + nameplate_capacity_kwh: float + initial_soe_kwh: float | None + product_name: str | None + model: str | None + serial_number: str | None + firmware_version: str | None + relative_position: str | None + feed: str | None + + +@dataclass(frozen=True, slots=True) +class PvPhysics: + vendor_name: str + nameplate_capacity_w: float + inverter_type: str # "hybrid" | "ac-coupled" + product_name: str | None + serial_number: str | None + firmware_version: str | None + relative_position: str | None + feed: str | None + + +@dataclass(frozen=True, slots=True) +class EvsePhysics: + vendor_name: str + product_name: str + part_number: str + serial_number: str + firmware_version: str + max_current_a: float + feed: str | None + + +# --------------------------------------------------------------------------- +# Top-level view +# --------------------------------------------------------------------------- + + +_VALID_PRIORITIES = frozenset( + { + "MUST_HAVE", + "NICE_TO_HAVE", + "NON_ESSENTIAL", + "NEVER", + "SOC_THRESHOLD", + "OFF_GRID", + } +) +_VALID_RELAY_BEHAVIORS = frozenset({"controllable", "always-on", "non-controllable"}) +_VALID_PLACEMENTS = frozenset({"upstream-of-lugs", "downstream-of-lugs"}) +_VALID_LUGS_DIRECTIONS = frozenset({"upstream", "downstream"}) +_VALID_INVERTER_TYPES = frozenset({"hybrid", "ac-coupled"}) +_VALID_TOPOLOGIES = frozenset({"flat", "parent-child"}) + + +class ManifestPhysicsView: + """Validated, typed view over a ``DeviceManifest``'s metadata. + + Built once at ``Emitter`` construction time. Holds parsed physics for every + instance keyed by ``instance_id``. Raises ``ManifestValidationError`` at + construction if any instance is missing required keys or has malformed + values; the emitter never sees a partially-validated manifest.""" + + def __init__(self, manifest: DeviceManifest) -> None: + self._panel: PanelPhysics | None = None + self._lugs: dict[str, LugsPhysics] = {} + self._circuits: dict[str, CircuitPhysics] = {} + self._bess: dict[str, BessPhysics] = {} + self._pv: dict[str, PvPhysics] = {} + self._evse: dict[str, EvsePhysics] = {} + + for inst in manifest.instances: + ec = inst.entity_class + try: + if ec == "panel": + if self._panel is not None: + raise ManifestValidationError( + "Multiple panel instances in manifest; expected exactly one", + ) + self._panel = _parse_panel(inst) + elif ec == "lugs": + self._lugs[inst.instance_id] = _parse_lugs(inst) + elif ec == "circuit": + self._circuits[inst.instance_id] = _parse_circuit(inst) + elif ec == "bess": + self._bess[inst.instance_id] = _parse_bess(inst) + elif ec == "pv": + self._pv[inst.instance_id] = _parse_pv(inst) + elif ec == "evse": + self._evse[inst.instance_id] = _parse_evse(inst) + # Unknown entity_class: leave to graph builder to reject. + except ManifestValidationError as exc: + raise ManifestValidationError(f"{ec}/{inst.instance_id}: {exc}") from exc + + if self._panel is None: + raise ManifestValidationError("Manifest has no panel instance") + + # -- accessors ----------------------------------------------------------- + + @property + def panel(self) -> PanelPhysics: + assert self._panel is not None # checked in __init__ + return self._panel + + def lugs(self, instance_id: str) -> LugsPhysics: + return self._lugs[instance_id] + + def circuit(self, instance_id: str) -> CircuitPhysics: + return self._circuits[instance_id] + + def bess(self, instance_id: str) -> BessPhysics: + return self._bess[instance_id] + + def pv(self, instance_id: str) -> PvPhysics: + return self._pv[instance_id] + + def evse(self, instance_id: str) -> EvsePhysics: + return self._evse[instance_id] + + def all_circuits(self) -> dict[str, CircuitPhysics]: + return dict(self._circuits) + + def all_lugs(self) -> dict[str, LugsPhysics]: + return dict(self._lugs) + + def all_bess(self) -> dict[str, BessPhysics]: + return dict(self._bess) + + def all_pv(self) -> dict[str, PvPhysics]: + return dict(self._pv) + + def all_evse(self) -> dict[str, EvsePhysics]: + return dict(self._evse) + + +# --------------------------------------------------------------------------- +# Parsers — one per entity_class. +# --------------------------------------------------------------------------- + + +def _require(md: dict[str, str], key: str) -> str: + if key not in md: + raise ManifestValidationError(f"missing required metadata key {key!r}") + return md[key] + + +def _opt_float(md: dict[str, str], key: str, default: float) -> float: + if key not in md: + return default + try: + return float(md[key]) + except ValueError as exc: + raise ManifestValidationError(f"key {key!r}: not a float ({md[key]!r})") from exc + + +def _opt_int(md: dict[str, str], key: str, default: int) -> int: + if key not in md: + return default + try: + return int(md[key]) + except ValueError as exc: + raise ManifestValidationError(f"key {key!r}: not an int ({md[key]!r})") from exc + + +def _opt_bool(md: dict[str, str], key: str, default: bool) -> bool: + if key not in md: + return default + raw = md[key].strip().lower() + if raw in ("true", "1", "yes"): + return True + if raw in ("false", "0", "no"): + return False + raise ManifestValidationError(f"key {key!r}: not a bool ({md[key]!r})") + + +def _opt_str(md: dict[str, str], key: str) -> str | None: + value = md.get(key) + return value if value not in (None, "") else None + + +def _req_float(md: dict[str, str], key: str) -> float: + raw = _require(md, key) + try: + return float(raw) + except ValueError as exc: + raise ManifestValidationError(f"key {key!r}: not a float ({raw!r})") from exc + + +def _req_int(md: dict[str, str], key: str) -> int: + raw = _require(md, key) + try: + return int(raw) + except ValueError as exc: + raise ManifestValidationError(f"key {key!r}: not an int ({raw!r})") from exc + + +def _parse_panel(inst: DeviceInstance) -> PanelPhysics: + md = inst.metadata + topology_raw = md.get("schema-topology", "flat") + if topology_raw not in _VALID_TOPOLOGIES: + raise ManifestValidationError( + f"key 'schema-topology': must be one of {sorted(_VALID_TOPOLOGIES)}, " + f"got {topology_raw!r}" + ) + # ``cast`` would also work here, but a direct comparison keeps the Literal + # narrow without an explicit import-only typing helper. + topology: Literal["flat", "parent-child"] = ( + "parent-child" if topology_raw == "parent-child" else "flat" + ) + return PanelPhysics( + serial_number=_require(md, "serial-number"), + vendor_name=_require(md, "vendor-name"), + firmware_version=_opt_str(md, "firmware-version") or _require(md, "software-version"), + hardware_version=_require(md, "hardware-version"), + panel_size=_req_int(md, "panel-size"), + main_breaker_rating_a=_req_int(md, "main-breaker-rating-a"), + panel_model=_require(md, "panel-model"), + postal_code=_require(md, "postal-code"), + time_zone=_require(md, "time-zone"), + service_voltage_v=_opt_float(md, "service-voltage-v", 240.0), + line_voltage_v=_opt_float(md, "line-voltage-v", 120.0), + islandable=_opt_bool(md, "islandable", False), + topology=topology, + ) + + +def _parse_lugs(inst: DeviceInstance) -> LugsPhysics: + direction = _require(inst.metadata, "direction") + if direction not in _VALID_LUGS_DIRECTIONS: + raise ManifestValidationError( + f"key 'direction': must be one of {sorted(_VALID_LUGS_DIRECTIONS)}, got {direction!r}" + ) + return LugsPhysics(direction=direction) + + +def _parse_circuit(inst: DeviceInstance) -> CircuitPhysics: + md = inst.metadata + raw_tabs = _require(md, "tab-numbers") + try: + tabs = tuple(int(t.strip()) for t in raw_tabs.split(",") if t.strip()) + except ValueError as exc: + raise ManifestValidationError( + f"key 'tab-numbers': not a comma-separated int list ({raw_tabs!r})" + ) from exc + if not tabs: + raise ManifestValidationError("key 'tab-numbers': must list at least one tab") + try: + legs = legs_for_tabs(tabs) + except ValueError as exc: + raise ManifestValidationError(f"key 'tab-numbers': {exc}") from exc + dipole_declared = _opt_bool(md, "dipole", default=len(tabs) > 1) + # NOTE: ``dipole`` + leg-spanning is not strictly validated. Real SPAN panels + # use slot numberings where two adjacent breaker positions on the same leg + # can still be ganged as a "240 V" feed (e.g. tabs 20+22). The convention + # in ``conventions/tab_legs.py`` is informational for per-leg current + # calculation; producers are trusted to declare dipole correctly. + if not dipole_declared and len(tabs) > 1: + raise ManifestValidationError( + f"dipole=false but {len(tabs)} tabs declared; single-tab circuits only" + ) + + priority = _require(md, "default-priority") + if priority not in _VALID_PRIORITIES: + raise ManifestValidationError( + f"key 'default-priority': must be one of {sorted(_VALID_PRIORITIES)}, got {priority!r}" + ) + + relay_behavior = _require(md, "relay-behavior") + if relay_behavior not in _VALID_RELAY_BEHAVIORS: + raise ManifestValidationError( + f"key 'relay-behavior': must be one of {sorted(_VALID_RELAY_BEHAVIORS)}, " + f"got {relay_behavior!r}" + ) + + placement = _require(md, "placement") + if placement not in _VALID_PLACEMENTS: + raise ManifestValidationError( + f"key 'placement': must be one of {sorted(_VALID_PLACEMENTS)}, got {placement!r}" + ) + + always_on = _opt_bool(md, "always-on", default=relay_behavior == "always-on") + + return CircuitPhysics( + tabs=tabs, + legs=legs, + dipole=dipole_declared, + breaker_rating_a=_req_float(md, "breaker-rating-a"), + default_priority=priority, + relay_behavior=relay_behavior, + placement=placement, + always_on=always_on, + pcs_priority=_opt_int(md, "pcs-priority", 0), + initial_consumed_wh=_opt_float(md, "initial-consumed-wh", 0.0), + initial_produced_wh=_opt_float(md, "initial-produced-wh", 0.0), + ) + + +def _parse_bess(inst: DeviceInstance) -> BessPhysics: + md = inst.metadata + initial_soe: float | None = None + if "initial-soe-kwh" in md: + initial_soe = _opt_float(md, "initial-soe-kwh", 0.0) + return BessPhysics( + vendor_name=_require(md, "vendor-name"), + nameplate_capacity_kwh=_req_float(md, "nameplate-capacity-kwh"), + initial_soe_kwh=initial_soe, + product_name=_opt_str(md, "product-name"), + model=_opt_str(md, "model"), + serial_number=_opt_str(md, "serial-number"), + firmware_version=_opt_str(md, "firmware-version") or _opt_str(md, "software-version"), + relative_position=_opt_str(md, "relative-position") or "UPSTREAM", + feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"), + ) + + +def _parse_pv(inst: DeviceInstance) -> PvPhysics: + md = inst.metadata + inverter_type = _require(md, "inverter-type") + if inverter_type not in _VALID_INVERTER_TYPES: + raise ManifestValidationError( + f"key 'inverter-type': must be one of {sorted(_VALID_INVERTER_TYPES)}, " + f"got {inverter_type!r}" + ) + return PvPhysics( + vendor_name=_require(md, "vendor-name"), + nameplate_capacity_w=_req_float(md, "nameplate-capacity-w"), + inverter_type=inverter_type, + product_name=_opt_str(md, "product-name"), + serial_number=_opt_str(md, "serial-number"), + firmware_version=_opt_str(md, "firmware-version") or _opt_str(md, "software-version"), + relative_position=_opt_str(md, "relative-position") or "IN_PANEL", + feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"), + ) + + +def _parse_evse(inst: DeviceInstance) -> EvsePhysics: + md = inst.metadata + return EvsePhysics( + vendor_name=_require(md, "vendor-name"), + product_name=_require(md, "product-name"), + part_number=_require(md, "part-number"), + serial_number=_require(md, "serial-number"), + firmware_version=_opt_str(md, "firmware-version") or _require(md, "software-version"), + max_current_a=_req_float(md, "max-current-a"), + feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"), + ) diff --git a/src/span_panel_simulator/flat_emitter/native_devices/__init__.py b/src/span_panel_simulator/flat_emitter/native_devices/__init__.py new file mode 100644 index 0000000..1aeeac4 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/native_devices/__init__.py @@ -0,0 +1,35 @@ +"""Emitter-native devices. + +A native device is a configured-and-self-driving entity that lives inside the emitter +and computes its per-tick state from its configuration plus per-tick context pushed by +the producer (simulator). Reflector-class devices (circuits, PV, EVSE, lugs) are +producer-driven and do not have a corresponding NativeDevice. + +Today's native devices: BESS, LoadShedding. MID + PCS are future when the parent-child +schema lands. The list is intentionally small — circuits stay 100% producer-driven +even when the producer's view of them is HVAC-shaped or recorder-replayed. +""" + +from span_panel_simulator.flat_emitter.native_devices.bess import ( + BESSConfig, + BESSDevice, + ChargeMode, +) +from span_panel_simulator.flat_emitter.native_devices.load_shedding import ( + LoadSheddingConfig, + LoadSheddingDevice, +) +from span_panel_simulator.flat_emitter.native_devices.protocol import ( + NativeDevice, + NativeTickContext, +) + +__all__ = [ + "BESSConfig", + "BESSDevice", + "ChargeMode", + "LoadSheddingConfig", + "LoadSheddingDevice", + "NativeDevice", + "NativeTickContext", +] diff --git a/src/span_panel_simulator/flat_emitter/native_devices/bess.py b/src/span_panel_simulator/flat_emitter/native_devices/bess.py new file mode 100644 index 0000000..4933a46 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/native_devices/bess.py @@ -0,0 +1,185 @@ +"""Native BESS device — configured-and-self-driving. + +Configuration is supplied at construction (via ``Emitter(bess_configs=...)``) and +mutable mid-run via ``Emitter.update_bess_config``. Per-tick context — grid +state, instantaneous load demand, instantaneous PV available, current_time — +is pushed by the producer via ``Emitter.publish_tick``; the emitter calls +``BESSDevice.tick`` and writes the resulting ``EbusBatterySnapshot`` into the +internal snapshot. + +Subclassable for vendor-variant behaviour (Powerwall vs Enphase IQ etc.).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from span_panel_simulator.flat_emitter.native_devices.protocol import NativeTickContext +from span_panel_simulator.flat_emitter.snapshot import EbusBatterySnapshot + +ChargeMode = Literal["self-consumption", "backup-only"] +DispatchState = Literal["charging", "discharging", "idle"] + + +@dataclass(slots=True) +class BESSConfig: + """BESS device configuration. Producer supplies at construction; mutation through + ``BESSDevice.update_config`` takes effect on the next tick.""" + + instance_id: str + nameplate_capacity_kwh: float + max_charge_w: float + max_discharge_w: float + charge_efficiency: float = 0.95 + discharge_efficiency: float = 0.95 + backup_reserve_pct: float = 20.0 + charge_mode: ChargeMode = "self-consumption" + charge_hours: tuple[int, ...] = () + discharge_hours: tuple[int, ...] = () + initial_soc_pct: float = 50.0 + + +@dataclass(slots=True) +class BESSDevice: + """Per-tick BESS state machine. State (SOC/SOE) accumulates across ticks; config + can be replaced live without restart.""" + + config: BESSConfig + _soc_pct: float = field(init=False) + _soe_kwh: float = field(init=False) + _last_tick_time: float | None = field(init=False, default=None) + _state: DispatchState = field(init=False, default="idle") + + def __post_init__(self) -> None: + self._soc_pct = self.config.initial_soc_pct + self._soe_kwh = self.config.nameplate_capacity_kwh * (self.config.initial_soc_pct / 100.0) + + @property + def instance_id(self) -> str: + """Stable identifier for this device — delegates to ``config.instance_id``. + + Exposed as a property so ``BESSDevice`` structurally satisfies the + ``NativeDevice[EbusBatterySnapshot]`` Protocol.""" + return self.config.instance_id + + def update_config(self, config: BESSConfig) -> None: + """Replace configuration; SOC/SOE persist.""" + self.config = config + + def set_soe(self, soe_kwh: float) -> None: + """Overwrite stored SOE/SOC. Used by ``Emitter.seed_bess_soe`` to restore + battery state across emitter restarts.""" + capped = max(0.0, min(self.config.nameplate_capacity_kwh, soe_kwh)) + self._soe_kwh = capped + if self.config.nameplate_capacity_kwh > 0: + self._soc_pct = (capped / self.config.nameplate_capacity_kwh) * 100.0 + else: + self._soc_pct = 0.0 + + def tick(self, ctx: NativeTickContext) -> EbusBatterySnapshot: + """Run one dispatch step and return a fresh ``EbusBatterySnapshot``. + + Given the current grid state, instantaneous load demand, and PV + availability for this tick, decide whether to charge, discharge, or + idle; integrate energy in/out of the cell since the previous tick; + update internal SOC/SOE; and return a snapshot describing the result. + Pure with respect to the panel snapshot — does not read or mutate it.""" + dispatch_w = self._decide_dispatch( + current_time=ctx.current_time, + grid_online=ctx.grid_online, + load_demand_w=ctx.load_demand_w, + pv_available_w=ctx.pv_available_w, + ) + + if self._last_tick_time is not None: + dt_seconds = max(0.0, ctx.current_time - self._last_tick_time) + dt_hours = dt_seconds / 3600.0 + if dispatch_w > 0: + self._soe_kwh -= ( + dispatch_w * dt_hours / 1000.0 + ) / self.config.discharge_efficiency + elif dispatch_w < 0: + self._soe_kwh += ( + abs(dispatch_w) * dt_hours / 1000.0 + ) * self.config.charge_efficiency + self._soe_kwh = max(0.0, min(self.config.nameplate_capacity_kwh, self._soe_kwh)) + self._soc_pct = (self._soe_kwh / self.config.nameplate_capacity_kwh) * 100.0 + self._last_tick_time = ctx.current_time + + if dispatch_w > 0: + self._state = "discharging" + elif dispatch_w < 0: + self._state = "charging" + else: + self._state = "idle" + + return EbusBatterySnapshot( + soe_percentage=self._soc_pct, + soe_kwh=self._soe_kwh, + active_power_w=dispatch_w, + nameplate_capacity_kwh=self.config.nameplate_capacity_kwh, + communication="OK", + ) + + @property + def state(self) -> DispatchState: + return self._state + + def _decide_dispatch( + self, + *, + current_time: float, + grid_online: bool, + load_demand_w: float, + pv_available_w: float, + ) -> float: + """Return signed dispatch power in watts. Positive = discharge, negative = charge.""" + # Reserve floor: do not discharge below backup reserve when grid is online. + backup_floor_kwh = ( + self.config.nameplate_capacity_kwh * self.config.backup_reserve_pct / 100.0 + ) + + if not grid_online: + # Off-grid: discharge to meet load demand minus PV (down to empty). + deficit_w = max(0.0, load_demand_w - pv_available_w) + if self._soe_kwh <= 0: + return 0.0 + return min(deficit_w, self.config.max_discharge_w) + + # Grid online — apply mode-specific behavior. + pv_surplus_w = max(0.0, pv_available_w - load_demand_w) + if self.config.charge_mode == "backup-only": + # Keep reserve while on-grid. Charge only from PV surplus; never + # charge from utility grid and never discharge in backup-only mode. + if pv_surplus_w > 0 and self._soe_kwh < self.config.nameplate_capacity_kwh: + return -min( + self.config.max_charge_w, + pv_surplus_w, + (self.config.nameplate_capacity_kwh - self._soe_kwh) * 1000.0, + ) + return 0.0 + + # self-consumption mode — always reactive: charge from PV surplus, + # discharge to cover load deficit. Hour-of-day windows are NOT applied + # (those belong to a TOU/custom dispatch mode, not modelled here). + # ``backup_floor_kwh`` still gates discharge so the reserve isn't + # consumed during normal operation. + del current_time # not used in self-consumption + load_deficit_w = max(0.0, load_demand_w - pv_available_w) + + if pv_surplus_w > 0 and self._soe_kwh < self.config.nameplate_capacity_kwh: + return -min( + self.config.max_charge_w, + pv_surplus_w, + (self.config.nameplate_capacity_kwh - self._soe_kwh) * 1000.0, + ) + + if load_deficit_w > 0 and self._soe_kwh > backup_floor_kwh: + available_kwh = self._soe_kwh - backup_floor_kwh + return min( + load_deficit_w, + self.config.max_discharge_w, + available_kwh * 1000.0, + ) + + return 0.0 diff --git a/src/span_panel_simulator/flat_emitter/native_devices/load_shedding.py b/src/span_panel_simulator/flat_emitter/native_devices/load_shedding.py new file mode 100644 index 0000000..287dbd1 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/native_devices/load_shedding.py @@ -0,0 +1,60 @@ +"""Native load-shedding controller. + +When the grid goes offline, circuits with shedding priorities below the active +SOC threshold are forcibly opened. The shedding decisions are emitter-domain +because they're a deterministic configured response: given (grid_state, BESS +SOC, per-circuit priority), the shed set is computable without producer +intervention. + +``decide_shed`` is a pure function used by ``Emitter.publish_tick`` to drive +``RelayResolver``. When the operator has a /set override on a sheddable +circuit, the ``RelayResolver`` honors operator intent (per the v0.3.0 +precedence rule: always-on > /set > load-shed > default-CLOSED). Load-shed +only takes effect when there's no operator override.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + + +@dataclass(slots=True) +class LoadSheddingConfig: + """When grid is offline, circuits with these priorities are shed in order: + first OFF_GRID, then SOC_THRESHOLD when BESS SOC drops below soc_threshold_pct. + NEVER-priority circuits are never shed.""" + + soc_threshold_pct: float = 20.0 + + +@dataclass(slots=True) +class LoadSheddingDevice: + """Per-tick load-shedding policy.""" + + config: LoadSheddingConfig + + def update_config(self, config: LoadSheddingConfig) -> None: + self.config = config + + def decide_shed( + self, + *, + grid_online: bool, + bess_soc_pct: float | None, + priorities: Mapping[str, str], + ) -> set[str]: + """Return the set of circuit instance_ids the policy wants OPEN. + + - On-grid: always empty (nothing is shed when grid is up). + - Off-grid: ``OFF_GRID`` priority always shed; ``SOC_THRESHOLD`` shed + when BESS SOC is below ``soc_threshold_pct`` (or when no BESS exists). + - ``NEVER`` priority is never shed.""" + if grid_online: + return set() + soc_low = bess_soc_pct is None or bess_soc_pct < self.config.soc_threshold_pct + shed: set[str] = set() + for cid, priority in priorities.items(): + p = (priority or "").upper() + if p == "OFF_GRID" or (p == "SOC_THRESHOLD" and soc_low): + shed.add(cid) + return shed diff --git a/src/span_panel_simulator/flat_emitter/native_devices/protocol.py b/src/span_panel_simulator/flat_emitter/native_devices/protocol.py new file mode 100644 index 0000000..0c5f7e5 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/native_devices/protocol.py @@ -0,0 +1,38 @@ +"""Native-device tick contract — shared by BESS and future natives. + +Future natives include MID and vendor-specific BESS variants.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, TypeVar, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class NativeTickContext: + """Per-tick driving signal handed to a native device's ``tick()``. + + All natives consume the same primitive signals; per-device behaviour lives + in the device implementation, not in the context shape.""" + + current_time: float + grid_online: bool + load_demand_w: float + pv_available_w: float + + +SnapT = TypeVar("SnapT", covariant=True) + + +@runtime_checkable +class NativeDevice(Protocol[SnapT]): + """Common contract for emitter-resident, configured-and-self-driving devices. + + Today: ``BESSDevice``. Future: ``MidDevice`` (when MID becomes a separate + device per the upcoming eBus migration), vendor-specific BESS variants + (Powerwall vs Enphase IQ specifics).""" + + @property + def instance_id(self) -> str: ... + + def tick(self, ctx: NativeTickContext) -> SnapT: ... diff --git a/src/span_panel_simulator/flat_emitter/panel_meter.py b/src/span_panel_simulator/flat_emitter/panel_meter.py new file mode 100644 index 0000000..3ba93f8 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/panel_meter.py @@ -0,0 +1,218 @@ +"""Panel-level aggregator. Pure function: takes resolved per-circuit gated powers ++ battery dispatch + grid online + manifest physics, returns the panel-level +fields that go into ``EbusPanelSnapshot``. + +Stateless — all integration / accumulation lives in ``EnergyIntegrator``. This +module is just arithmetic over the current tick's inputs. + +Sign conventions (consistent across the emitter): +- 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. +- ``upstream_active_power_w``: net power through the panel-side upstream lugs, + before any upstream BESS contribution is removed. +- ``feedthrough_power_w``: net power flowing through the lugs to downstream + loads (panel-side meter perspective). + +Off-grid: when ``grid_online`` is False, ``instant_grid_power_w`` is 0 by +definition (grid is electrically disconnected); battery and PV cover load.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg + +if TYPE_CHECKING: + from span_panel_simulator.flat_emitter.manifest_physics import CircuitPhysics, PanelPhysics + + +# --------------------------------------------------------------------------- +# Per-tick output bundle +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class PanelMeterReading: + """Resolved panel-level values for a single tick.""" + + instant_grid_power_w: float + upstream_active_power_w: float + feedthrough_power_w: float + upstream_l1_current_a: float + upstream_l2_current_a: float + downstream_l1_current_a: float + downstream_l2_current_a: float + line_voltage_v: float + main_relay_state: str # "OPEN" | "CLOSED" + grid_state: str | None # "ON_GRID" | "OFF_GRID" + dsm_state: str + current_run_config: str + dominant_power_source: str | None # "GRID" | "BATTERY" | None + grid_islandable: bool + power_flow_pv: float + power_flow_battery: float + power_flow_grid: float + power_flow_site: float + + +# --------------------------------------------------------------------------- +# Per-circuit derived current — published per circuit, also folded into panel +# upstream/downstream legs. +# --------------------------------------------------------------------------- + + +def circuit_current_a(power_w: float, dipole: bool, line_voltage_v: float) -> float: + """Magnitude of current draw. Dipole circuits use line-to-line voltage + (2 * line_voltage_v); single-tab circuits use line-to-neutral.""" + if line_voltage_v <= 0: + return 0.0 + voltage = 2 * line_voltage_v if dipole else line_voltage_v + return abs(power_w) / voltage + + +# --------------------------------------------------------------------------- +# Panel state strings — derived from grid + battery presence +# --------------------------------------------------------------------------- + + +_DSM_ON = "DSM_ON_GRID" +_DSM_OFF = "DSM_OFF_GRID" +_RUN_ON = "PANEL_ON_GRID" +_RUN_OFF = "PANEL_OFF_GRID" + + +# --------------------------------------------------------------------------- +# Resolver +# --------------------------------------------------------------------------- + + +def resolve( + *, + panel: PanelPhysics, + circuits: dict[str, CircuitPhysics], + gated_powers: dict[str, float], # circuit_id -> post-relay-gate signed power + battery_w: float, # signed; positive = discharging + grid_online: bool, + has_battery: bool, +) -> PanelMeterReading: + """Build the panel-level reading from per-circuit gated powers + battery + grid.""" + + load_demand_w = sum(p for p in gated_powers.values() if p > 0) + pv_available_w = -sum(p for p in gated_powers.values() if p < 0) # positive magnitude + + upstream_active_w = load_demand_w - pv_available_w + + 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. + grid_w = _grid_power_from_lugs_and_bess(upstream_active_w, battery_w) + grid_state: str | None = "ON_GRID" + dsm_state = _DSM_ON + current_run_config = _RUN_ON + main_relay_state = "CLOSED" + line_voltage_v = panel.line_voltage_v + dominant_power_source: str | None = "GRID" + else: + grid_w = 0.0 + grid_state = "OFF_GRID" + dsm_state = _DSM_OFF + current_run_config = _RUN_OFF + main_relay_state = "OPEN" + line_voltage_v = panel.line_voltage_v if has_battery else 0.0 + dominant_power_source = "BATTERY" if has_battery else None + + # Per-leg upstream current = panel-side current routed through the lugs. + # With an upstream BESS, this can differ from utility grid current. + if line_voltage_v > 0: + upstream_l1_a, upstream_l2_a = _per_leg_current( + gated_powers, + circuits, + line_voltage_v=line_voltage_v, + ) + else: + upstream_l1_a = upstream_l2_a = 0.0 + + # Feedthrough = signed power across downstream-of-lugs circuits only. + feedthrough_w = sum( + p for cid, p in gated_powers.items() if circuits[cid].placement == "downstream-of-lugs" + ) + if line_voltage_v > 0: + downstream_l1_a, downstream_l2_a = _per_leg_current( + { + cid: p + for cid, p in gated_powers.items() + if circuits[cid].placement == "downstream-of-lugs" + }, + circuits, + line_voltage_v=line_voltage_v, + ) + else: + downstream_l1_a = downstream_l2_a = 0.0 + + return PanelMeterReading( + instant_grid_power_w=grid_w, + upstream_active_power_w=upstream_active_w if grid_online else 0.0, + feedthrough_power_w=feedthrough_w, + upstream_l1_current_a=upstream_l1_a, + upstream_l2_current_a=upstream_l2_a, + downstream_l1_current_a=downstream_l1_a, + downstream_l2_current_a=downstream_l2_a, + line_voltage_v=line_voltage_v, + main_relay_state=main_relay_state, + grid_state=grid_state, + dsm_state=dsm_state, + 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, + 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. + """ + 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 + + +def _per_leg_current( + powers: dict[str, float], + circuits: dict[str, CircuitPhysics], + *, + line_voltage_v: float, +) -> tuple[float, float]: + """Sum per-circuit current contribution onto L1 and L2. + + Single-tab circuit on tab N → all current to legs_for_tabs((N,))[0]. + Dipole circuit (one tab per leg) → equal current on both legs at line-to-line + voltage (2 * line_voltage_v).""" + l1_a = 0.0 + l2_a = 0.0 + for cid, power in powers.items(): + cphys = circuits[cid] + if cphys.dipole: + i = abs(power) / (2 * line_voltage_v) + # Dipole spans both legs — same current on each leg. + l1_a += i + l2_a += i + else: + i = abs(power) / line_voltage_v + leg = cphys.legs[0] + if leg == Leg.L1: + l1_a += i + else: + l2_a += i + return l1_a, l2_a diff --git a/src/span_panel_simulator/flat_emitter/relay_resolver.py b/src/span_panel_simulator/flat_emitter/relay_resolver.py new file mode 100644 index 0000000..39b443e --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/relay_resolver.py @@ -0,0 +1,110 @@ +"""Per-circuit relay state, with strict precedence over commands from multiple sources. + +The emitter now owns relay state across ticks. Commands arrive from three sources: + +1. **Manifest declaration** — ``relay-behavior == "always-on"`` or ``always-on=true`` + metadata. Absolute: the relay can never be opened, regardless of /set or + load-shedding decisions. +2. **/set commands** — operator-driven via Homie ``circuit/.../switch/relay/set`` + topic. Authoritative for non-always-on circuits, no debounce. +3. **Load shedding** — emitter's ``LoadSheddingDevice`` decisions. Applies only + when there's no /set override. + +Precedence (highest wins): + + always-on > /set override > load-shed > default-CLOSED + +``relay_requester`` reflects the source of the active decision: +- ``NEVER`` for always-on (the relay is physically incapable of opening) +- ``USER`` for /set +- ``BACKUP`` for load-shed +- ``UNKNOWN`` for the default-CLOSED state + +The producer never sees /set commands. ``Emitter`` registers internal handlers +for ``circuit.switch/relay``, ``circuit.priority/shed-priority``, and +``circuit.info/name``; those handlers call ``RelayResolver.set_user_override`` +(and the priority equivalent on a sibling state map).""" + +from __future__ import annotations + +from enum import StrEnum + + +class RelayState(StrEnum): + OPEN = "OPEN" + CLOSED = "CLOSED" + + +class RelayRequester(StrEnum): + NEVER = "NEVER" # always-on circuit; cannot open + USER = "USER" # /set override active + BACKUP = "BACKUP" # load-shed in effect + UNKNOWN = "UNKNOWN" # default-CLOSED, no decision-maker + + +class RelayResolver: + """Maintains relay state per circuit instance. + + Construct empty, register each circuit with its always-on flag, then update + overrides and shed decisions; query ``state()`` for the resolved final + state.""" + + def __init__(self) -> None: + # always_on map: instance_id -> bool (manifest declaration; immutable post-register) + self._always_on: dict[str, bool] = {} + # /set override map: instance_id -> RelayState | None (None = no override) + self._user_overrides: dict[str, RelayState | None] = {} + # load-shed decision map: instance_id -> bool (True = wants OPEN) + self._shed: dict[str, bool] = {} + + def register(self, instance_id: str, *, always_on: bool) -> None: + """Idempotent — re-registering with a different always_on value updates + the manifest declaration (typical use: emitter restart with edited manifest).""" + self._always_on[instance_id] = always_on + self._user_overrides.setdefault(instance_id, None) + self._shed.setdefault(instance_id, False) + + def set_user_override(self, instance_id: str, state: RelayState | None) -> None: + """Operator /set or explicit clear. ``state=None`` clears the override + and lets load-shed (or default-CLOSED) take effect. + + Always-on circuits silently drop the override — operator cannot open them.""" + if instance_id not in self._always_on: + raise KeyError(f"set_user_override for unregistered instance_id={instance_id!r}") + if self._always_on[instance_id]: + return # absolute: always-on ignores /set + self._user_overrides[instance_id] = state + + def clear_user_override(self, instance_id: str) -> None: + self.set_user_override(instance_id, None) + + def set_shed(self, instance_id: str, *, open_relay: bool) -> None: + """Load-shedding decision. ``open_relay=True`` means the load-shedding + policy wants this circuit OPEN. + + Always-on circuits silently drop the request.""" + if instance_id not in self._always_on: + raise KeyError(f"set_shed for unregistered instance_id={instance_id!r}") + if self._always_on[instance_id]: + return + self._shed[instance_id] = open_relay + + def clear_all_shed(self) -> None: + """Reset every shed decision to False. Called by the emitter at the + start of each tick before re-running ``LoadSheddingDevice``.""" + for k in self._shed: + self._shed[k] = False + + def state(self, instance_id: str) -> tuple[RelayState, RelayRequester]: + """Resolve the final state for ``instance_id``.""" + if self._always_on.get(instance_id, False): + return RelayState.CLOSED, RelayRequester.NEVER + override = self._user_overrides.get(instance_id) + if override is not None: + return override, RelayRequester.USER + if self._shed.get(instance_id, False): + return RelayState.OPEN, RelayRequester.BACKUP + return RelayState.CLOSED, RelayRequester.UNKNOWN + + def known(self, instance_id: str) -> bool: + return instance_id in self._always_on diff --git a/src/span_panel_simulator/flat_emitter/snapshot.py b/src/span_panel_simulator/flat_emitter/snapshot.py new file mode 100644 index 0000000..7b0880f --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/snapshot.py @@ -0,0 +1,241 @@ +"""Per-tick snapshot dataclasses — emitter-internal data model. + +The emitter constructs these inside ``publish_tick`` from ``TickInputs`` plus +manifest physics, native device state, and integrated accumulators. Producers +do not build snapshots directly; they push ``TickInputs`` and read state back +via ``Emitter.last_snapshot``. The snapshot is the cache key for the wire-layer +property differ: only fields that change between ticks are republished. The +``Ebus-`` prefix reflects the producer-side data model for the eBus convention; +the shape is residential-energy-panel-generic (panel + circuits + battery + PV ++ EVSE + PCS) and is decoupled from any specific transport profile. + +Phase 2 reshape: panel-level state is split into capability sub-dataclasses +(``info``, ``door``, ``meter``, ``status``, ``pcs``, ``power_flows``) that +mirror the wire profile's capability nodes. BESS and PV are pluralized into +``dict[str, ...]`` keyed by ``instance_id``. ``EbusLugsSnapshot`` is added so +the lugs profile can be populated cleanly. ``EbusPcsSnapshot`` is folded into +``EbusPanelPcs`` and removed.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass(slots=True) +class EbusCircuitSnapshot: + """Transport-agnostic circuit state.""" + + circuit_id: str + name: str + + relay_state: str # OPEN | CLOSED | UNKNOWN + instant_power_w: float # Positive = consumption, negative = production + produced_energy_wh: float + consumed_energy_wh: float + + tabs: list[int] + priority: str # MUST_HAVE | NICE_TO_HAVE | NON_ESSENTIAL | NEVER | SOC_THRESHOLD | OFF_GRID + is_user_controllable: bool + is_sheddable: bool + is_never_backup: bool + + device_type: str = "circuit" + is_240v: bool = False + current_a: float | None = None + breaker_rating_a: float | None = None + always_on: bool = False + pcs_managed: bool = True + pcs_priority: int = 0 + relay_requester: str = "UNKNOWN" + energy_accum_update_time_s: int = 0 + instant_power_update_time_s: int = 0 + + +@dataclass(slots=True) +class EbusPvSnapshot: + """PV inverter metadata.""" + + node_id: str = "" + feed_circuit_id: str | None = "" + vendor_name: str | None = None + product_name: str | None = None + nameplate_capacity_w: float | None = None + firmware_version: str | None = None + serial_number: str | None = None + relative_position: str | None = None + + +@dataclass(slots=True) +class EbusEvseSnapshot: + """EV Charger (EVSE) state.""" + + node_id: str + feed_circuit_id: str | None + status: str = "UNKNOWN" + lock_state: str = "UNKNOWN" + advertised_current_a: float | None = None + + vendor_name: str | None = None + product_name: str | None = None + part_number: str | None = None + serial_number: str | None = None + firmware_version: str | None = None + + +@dataclass(slots=True) +class EbusBatterySnapshot: + """Battery state.""" + + instance_id: str = "" + soe_percentage: float | None = None + soe_kwh: float | None = None + active_power_w: float = 0.0 # Positive = discharging, negative = charging + + vendor_name: str | None = None + product_name: str | None = None + model: str | None = None + serial_number: str | None = None + firmware_version: str | None = None + relative_position: str | None = None + feed_circuit_id: str | None = None + nameplate_capacity_kwh: float | None = None + connected: bool | None = None + grid_state: str | None = None + communication: Literal["OK", "LOST", "DEGRADED"] | None = None + + +@dataclass(slots=True) +class EbusLugsSnapshot: + """Lugs (upstream / downstream) — ``info`` + ``meter`` capability subset.""" + + instance_id: str + direction: Literal["upstream", "downstream"] + feed: str | None = None + l1_current_a: float | None = None + l2_current_a: float | None = None + active_power_w: float = 0.0 + imported_energy_wh: float = 0.0 + exported_energy_wh: float = 0.0 + + +# --------------------------------------------------------------------------- +# Panel capability sub-dataclasses — one per capability node on the panel +# device profile (panel.json). Each sub-dataclass owns the fields that map to +# its capability's properties, so the bag builder can iterate mechanically. +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class EbusPanelInfo: + """Identity + topology — ``info`` capability node on the panel device.""" + + serial_number: str + firmware_version: str + vendor_name: str | None = None + hardware_version: str | None = None + panel_size: int = 0 + panel_model: str | None = None + schema_topology: Literal["flat", "parent-child"] = "flat" + + +@dataclass(slots=True) +class EbusPanelDoor: + """``door`` capability node.""" + + state: str = "CLOSED" + proximity_proven: bool = True + + +@dataclass(slots=True) +class EbusPanelMeter: + """``meter`` capability node — voltage + grid power + main-meter energies.""" + + instant_grid_power_w: float = 0.0 + main_meter_energy_consumed_wh: float = 0.0 + main_meter_energy_produced_wh: float = 0.0 + feedthrough_power_w: float = 0.0 + feedthrough_energy_consumed_wh: float = 0.0 + feedthrough_energy_produced_wh: float = 0.0 + l1_voltage: float | None = None + l2_voltage: float | None = None + upstream_l1_current_a: float | None = None + upstream_l2_current_a: float | None = None + downstream_l1_current_a: float | None = None + downstream_l2_current_a: float | None = None + + +@dataclass(slots=True) +class EbusPanelStatus: + """``status`` capability node — networking, cloud, location, relay state.""" + + main_relay_state: str = "CLOSED" + eth0_link: bool = True + wlan_link: bool = True + wwan_link: bool = False + wifi_ssid: str | None = None + cloud_connection: str = "CONNECTED" + postal_code: str | None = None + time_zone: str | None = None + uptime_s: int = 0 + + +@dataclass(slots=True) +class EbusPanelPcs: + """``pcs`` capability node — power-control system + grid-topology flags. + + Folds in everything that lived on the (now-removed) standalone + ``EbusPcsSnapshot`` so panel PCS state has exactly one home.""" + + main_breaker_rating_a: int | None = None + grid_islandable: bool | None = None + dominant_power_source: str | None = None + grid_state: str | None = None + dsm_state: str = "DSM_ON_GRID" + current_run_config: str = "" + enabled: bool = False + active: bool = False + import_limit_a: float = 0.0 + feed_import_limit_a: float = 0.0 + feed_import_limit_enablement: str = "UNCONFIGURED" + feed_import_limit_active: bool = False + grid_import_limit_a: float = 0.0 + grid_import_limit_enablement: str = "UNCONFIGURED" + grid_import_limit_active: bool = False + off_grid_import_limit_a: float = 0.0 + off_grid_import_limit_enablement: str = "UNCONFIGURED" + off_grid_import_limit_active: bool = False + requested_import_limit_a: float = 0.0 + requested_import_limit_enablement: str = "UNCONFIGURED" + requested_import_limit_active: bool = False + + +@dataclass(slots=True) +class EbusPanelPowerFlows: + """``power-flows`` capability node.""" + + pv: float | None = None + battery: float | None = None + grid: float | None = None + site: float | None = None + + +@dataclass(slots=True) +class EbusPanelSnapshot: + """Complete panel state — single point-in-time view. + + Top-level fields hold capability sub-dataclasses that mirror the panel + wire profile's capability nodes. Per-instance children (circuits, + batteries, PV, EVSE, lugs) live in dicts keyed by ``instance_id``.""" + + info: EbusPanelInfo + door: EbusPanelDoor = field(default_factory=EbusPanelDoor) + meter: EbusPanelMeter = field(default_factory=EbusPanelMeter) + status: EbusPanelStatus = field(default_factory=EbusPanelStatus) + pcs: EbusPanelPcs = field(default_factory=EbusPanelPcs) + power_flows: EbusPanelPowerFlows = field(default_factory=EbusPanelPowerFlows) + circuits: dict[str, EbusCircuitSnapshot] = field(default_factory=dict) + battery: dict[str, EbusBatterySnapshot] = field(default_factory=dict) + pv: dict[str, EbusPvSnapshot] = field(default_factory=dict) + evse: dict[str, EbusEvseSnapshot] = field(default_factory=dict) + lugs: dict[str, EbusLugsSnapshot] = field(default_factory=dict) diff --git a/src/span_panel_simulator/flat_emitter/tick_inputs.py b/src/span_panel_simulator/flat_emitter/tick_inputs.py new file mode 100644 index 0000000..2f0e259 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/tick_inputs.py @@ -0,0 +1,66 @@ +"""``TickInputs`` — the v0.3.0 producer/emitter per-tick contract. + +The producer's job each tick is reduced to: collect the signed power for each +known circuit (and EVSE) from the modelled world, fill in a small panel +envelope, and call ``Emitter.publish_tick(tick_inputs)``. The emitter does the +rest: BESS dispatch, load shedding, relay state resolution, energy integration, +per-leg currents, panel meter aggregation, and Homie-diff publication. + +Sign convention for circuit / EVSE powers: + power_w > 0 → consume (load) + power_w < 0 → produce (PV / V2G) + power_w == 0 → idle + +The emitter does NOT consult ``power_w`` to discover what kind of device an +instance is; it learns that from the manifest's ``entity_class``. The sign +purely tells direction within that class.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class PanelEnvelopeTick: + """Producer-supplied panel envelope facts that aren't derivable from + circuit-level state. Most have sensible defaults; the producer overrides + only what its model represents.""" + + door_state: str = "CLOSED" + proximity_proven: bool = True + eth0_link: bool = True + wlan_link: bool = True + wwan_link: bool = False + uptime_s: int = 0 + wifi_ssid: str | None = None + cloud_connection: str = "CONNECTED" + + +@dataclass(slots=True) +class TickInputs: + """Single-tick driving signal handed to ``Emitter.publish_tick``. + + Fields: + current_time: UNIX epoch seconds. Used by BESS for charge/discharge + window evaluation, by EnergyIntegrator for ``dt``, and + by per-property update timestamps. The producer is + responsible for picking a clock (real-time vs sim-time); + the emitter only requires monotonic-ish progression. + grid_online: Whether the utility grid is electrically connected. False + triggers BESS islanding behaviour, opens the main relay, + zeros published grid power, and may activate load + shedding. + circuits: Mapping of circuit ``instance_id`` → signed instant + power in watts. Every circuit in the manifest should + appear; missing entries are treated as 0 W. + evse: Mapping of EVSE ``instance_id`` → signed instant power + in watts. Status (``CHARGING``/``AVAILABLE``) is + derived from this signal in the emitter. + envelope: Panel envelope facts; defaults are sensible for most + producers.""" + + current_time: float + grid_online: bool + circuits: dict[str, float] + evse: dict[str, float] = field(default_factory=dict) + envelope: PanelEnvelopeTick = field(default_factory=PanelEnvelopeTick) diff --git a/src/span_panel_simulator/flat_emitter/wire/__init__.py b/src/span_panel_simulator/flat_emitter/wire/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/span_panel_simulator/flat_emitter/wire/_sdk_seam.py b/src/span_panel_simulator/flat_emitter/wire/_sdk_seam.py new file mode 100644 index 0000000..82246a8 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/_sdk_seam.py @@ -0,0 +1,57 @@ +"""Internal seam over ebus_sdk.property. + +Localises every property-construction and property-mutation call so that future SDK +changes to property.py touch one file. NOT an abstraction layer — other modules pass +ebus_sdk.Property instances around directly. The seam only owns construction and +mutation. +""" + +from __future__ import annotations + +from typing import Any + +import ebus_sdk +from ebus_sdk import PropertyDatatype, Unit + + +def make_property( + *, + node: ebus_sdk.Node, + key: str, + name: str, + datatype: PropertyDatatype, + unit: Unit | None, + format_str: str | None, + settable: bool, +) -> ebus_sdk.Property: + """Construct an ebus_sdk.Property and attach it to a node.""" + spec: dict[str, Any] = { + "id": key, + "name": name, + "datatype": datatype, + } + if unit is not None: + spec["unit"] = unit + if format_str is not None: + spec["format"] = format_str + if settable: + spec["settable"] = True + return node.add_property_from_dict(spec) + + +async def set_property_value(prop: ebus_sdk.Property, value: object) -> None: + """Set a property value. Async-only signature — forward-compat hedge against any + future SDK change to make set_value async.""" + # The current SDK exposes value via the Property's set/get; use coerced_value as + # the assignment target consistent with as_dict round-trip semantics. + if hasattr(prop, "set_value"): + prop.set_value(value) + else: + prop.coerced_value = value + + +def settable_handler_signature(prop: ebus_sdk.Property) -> tuple[type, ...]: + """SDK introspection used by set_router for handler validation. The current SDK + delivers /set values as strings; set_router decodes per profile datatype.""" + _ = prop + return (str,) diff --git a/src/span_panel_simulator/flat_emitter/wire/bag_builder.py b/src/span_panel_simulator/flat_emitter/wire/bag_builder.py new file mode 100644 index 0000000..6933b3e --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/bag_builder.py @@ -0,0 +1,384 @@ +"""Profile-driven snapshot → ``PropertyBag`` translator. + +The wire-layer graph + mapping table together declare every property the +emitter can publish. The bag builder walks that declared set and pulls each +value out of an ``EbusPanelSnapshot`` using a static lookup table that maps +``(entity_class, capability/property)`` → snapshot accessor. + +Two-purpose design: + +1. **Mechanical and complete.** Every profile-declared property is considered + on every tick; if its snapshot value is non-None, it goes into the bag. + This eliminates the silent-drop bug where the old hand-rolled + ``_snapshot_to_bag`` published only the ~25% of properties someone happened + to remember. +2. **Fail loud on schema drift.** If the profile declares a property the + builder has no source for, construction raises ``EmitterStateError``. + Adding a new profile property without a corresponding snapshot field is a + loud build failure rather than a silent missing topic. + +Property values that resolve to ``None`` are skipped (Homie 5 allows missing +properties — the property's retained topic just isn't updated this tick).""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from span_panel_simulator.flat_emitter.exceptions import EmitterStateError +from span_panel_simulator.flat_emitter.snapshot import EbusPanelSnapshot +from span_panel_simulator.flat_emitter.wire.graph_builder import BuiltGraph +from span_panel_simulator.flat_emitter.wire.mapping_loader import MappingTable +from span_panel_simulator.flat_emitter.wire.profile_loader import ProfileTable +from span_panel_simulator.flat_emitter.wire.property_bag import PropertyBag + +# A ``Resolver`` is a function that pulls a single property's value from the +# snapshot. It receives the snapshot and the per-instance id and returns the +# value (or ``None`` to skip publication this tick). Resolvers are pure +# functions over the snapshot; no side effects. +Resolver = Callable[[EbusPanelSnapshot, str], object] + + +def _panel_resolver(getter: Callable[[EbusPanelSnapshot], object]) -> Resolver: + """Wrap a panel-level getter so it ignores the per-instance id.""" + + def _resolve(snapshot: EbusPanelSnapshot, _instance_id: str) -> object: + return getter(snapshot) + + return _resolve + + +# --------------------------------------------------------------------------- +# Static resolver table — one entry per profile-declared property. +# +# Adding a new profile property: add it here AND add a snapshot field. The +# constructor's coverage check raises if you only do one half. +# --------------------------------------------------------------------------- + + +def _circuit_field(field: str) -> Resolver: + def _resolve(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + return getattr(circuit, field) + + return _resolve + + +def _bess_field(field: str) -> Resolver: + def _resolve(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + bess = snapshot.battery.get(instance_id) + if bess is None: + return None + return getattr(bess, field) + + return _resolve + + +def _pv_field(field: str) -> Resolver: + def _resolve(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + pv = snapshot.pv.get(instance_id) + if pv is None: + return None + return getattr(pv, field) + + return _resolve + + +def _evse_field(field: str) -> Resolver: + def _resolve(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + evse = snapshot.evse.get(instance_id) + if evse is None: + return None + return getattr(evse, field) + + return _resolve + + +def _lugs_field(field: str) -> Resolver: + def _resolve(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + lugs = snapshot.lugs.get(instance_id) + if lugs is None: + return None + return getattr(lugs, field) + + return _resolve + + +def _circuit_space(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + circuit = snapshot.circuits.get(instance_id) + if circuit is None or not circuit.tabs: + return None + return circuit.tabs[0] + + +def _circuit_breaker(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + circuit = snapshot.circuits.get(instance_id) + if circuit is None or circuit.breaker_rating_a is None: + return None + return int(circuit.breaker_rating_a) + + +def _circuit_shed_priority(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + if circuit.priority in ("OFF_GRID", "SOC_THRESHOLD", "NEVER"): + return circuit.priority + return "UNKNOWN" + + +def _circuit_relay_requester(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + return "NONE" if circuit.relay_requester == "UNKNOWN" else circuit.relay_requester + + +def _circuit_wire_active_power(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + """Circuit ``active-power`` in the enclosure reference frame. + + The snapshot's ``instant_power_w`` is device-frame (positive = the circuit is + consuming). The wire is enclosure-frame: positive means power flowing *into* + the enclosure busbar (a circuit backfeeding, e.g. a PV inverter), negative + means power flowing *out* of the busbar to a load. Hence the negation. + + The energy accumulators below must be relabelled for the same reason — see + ``_circuit_wire_imported_energy``.""" + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + return 0.0 if circuit.instant_power_w == 0 else -circuit.instant_power_w + + +def _circuit_wire_imported_energy(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + """Circuit ``imported-energy`` — energy imported *by the enclosure* from the + circuit, i.e. the circuit backfeeding the busbar. That is the snapshot's + ``produced_energy_wh``.""" + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + return circuit.produced_energy_wh + + +def _circuit_wire_exported_energy(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + """Circuit ``exported-energy`` — energy exported *by the enclosure* to the + circuit, i.e. normal load consumption. That is the snapshot's + ``consumed_energy_wh``.""" + circuit = snapshot.circuits.get(instance_id) + if circuit is None: + return None + return circuit.consumed_energy_wh + + +def _upper_lugs_direction(snapshot: EbusPanelSnapshot, instance_id: str) -> object: + lugs = snapshot.lugs.get(instance_id) + if lugs is None: + return None + return lugs.direction.upper() + + +# Wire property path ``"/"`` → resolver. +# Keyed by (entity_class, "/"). One entry per profile-declared +# property; the constructor verifies coverage at startup. +_RESOLVERS: dict[tuple[str, str], Resolver] = { + # ---- panel ---------------------------------------------------------- + ("panel", "core/vendor-name"): _panel_resolver(lambda s: s.info.vendor_name), + ("panel", "core/model"): _panel_resolver(lambda s: s.info.panel_model), + ("panel", "core/serial-number"): _panel_resolver(lambda s: s.info.serial_number), + ("panel", "core/hardware-version"): _panel_resolver(lambda s: s.info.hardware_version), + ("panel", "core/software-version"): _panel_resolver( + lambda s: s.info.firmware_version, + ), + ("panel", "core/door"): _panel_resolver(lambda s: s.door.state), + ("panel", "core/grid-islandable"): _panel_resolver(lambda s: s.pcs.grid_islandable), + ("panel", "core/dominant-power-source"): _panel_resolver( + lambda s: s.pcs.dominant_power_source, + ), + ("panel", "core/relay"): _panel_resolver(lambda s: s.status.main_relay_state), + ("panel", "core/l1-voltage"): _panel_resolver(lambda s: s.meter.l1_voltage), + ("panel", "core/l2-voltage"): _panel_resolver(lambda s: s.meter.l2_voltage), + ("panel", "core/breaker-rating"): _panel_resolver(lambda s: s.pcs.main_breaker_rating_a), + ("panel", "core/ethernet"): _panel_resolver(lambda s: s.status.eth0_link), + ("panel", "core/wifi"): _panel_resolver(lambda s: s.status.wlan_link), + ("panel", "core/wifi-ssid"): _panel_resolver(lambda s: s.status.wifi_ssid), + ("panel", "core/vendor-cloud"): _panel_resolver(lambda s: s.status.cloud_connection), + ("panel", "core/postal-code"): _panel_resolver(lambda s: s.status.postal_code), + ("panel", "core/time-zone"): _panel_resolver(lambda s: s.status.time_zone), + ("panel", "pcs/enabled"): _panel_resolver(lambda s: s.pcs.enabled), + ("panel", "pcs/active"): _panel_resolver(lambda s: s.pcs.active), + ("panel", "pcs/import-limit"): _panel_resolver(lambda s: s.pcs.import_limit_a), + ("panel", "pcs/feed-import-limit"): _panel_resolver(lambda s: s.pcs.feed_import_limit_a), + ("panel", "pcs/feed-import-limit-enablement"): _panel_resolver( + lambda s: s.pcs.feed_import_limit_enablement, + ), + ("panel", "pcs/feed-import-limit-active"): _panel_resolver( + lambda s: s.pcs.feed_import_limit_active, + ), + ("panel", "pcs/grid-import-limit"): _panel_resolver(lambda s: s.pcs.grid_import_limit_a), + ("panel", "pcs/grid-import-limit-enablement"): _panel_resolver( + lambda s: s.pcs.grid_import_limit_enablement, + ), + ("panel", "pcs/grid-import-limit-active"): _panel_resolver( + lambda s: s.pcs.grid_import_limit_active, + ), + ("panel", "pcs/off-grid-import-limit"): _panel_resolver( + lambda s: s.pcs.off_grid_import_limit_a, + ), + ("panel", "pcs/off-grid-import-limit-enablement"): _panel_resolver( + lambda s: s.pcs.off_grid_import_limit_enablement, + ), + ("panel", "pcs/off-grid-import-limit-active"): _panel_resolver( + lambda s: s.pcs.off_grid_import_limit_active, + ), + ("panel", "pcs/requested-import-limit"): _panel_resolver( + lambda s: s.pcs.requested_import_limit_a, + ), + ("panel", "pcs/requested-import-limit-enablement"): _panel_resolver( + lambda s: s.pcs.requested_import_limit_enablement, + ), + ("panel", "pcs/requested-import-limit-active"): _panel_resolver( + lambda s: s.pcs.requested_import_limit_active, + ), + ("panel", "power-flows/pv"): _panel_resolver(lambda s: s.power_flows.pv), + ("panel", "power-flows/battery"): _panel_resolver(lambda s: s.power_flows.battery), + ("panel", "power-flows/grid"): _panel_resolver(lambda s: s.power_flows.grid), + ("panel", "power-flows/site"): _panel_resolver(lambda s: s.power_flows.site), + ("panel", "meter/active-power"): _panel_resolver( + lambda s: s.meter.instant_grid_power_w, + ), + # ---- circuit -------------------------------------------------------- + ("circuit", "circuit/name"): _circuit_field("name"), + ("circuit", "circuit/relay"): _circuit_field("relay_state"), + ("circuit", "circuit/relay-requester"): _circuit_relay_requester, + ("circuit", "circuit/breaker-rating"): _circuit_breaker, + ("circuit", "circuit/current"): _circuit_field("current_a"), + ("circuit", "circuit/active-power"): _circuit_wire_active_power, + ("circuit", "circuit/imported-energy"): _circuit_wire_imported_energy, + ("circuit", "circuit/exported-energy"): _circuit_wire_exported_energy, + ("circuit", "circuit/space"): _circuit_space, + ("circuit", "circuit/dipole"): _circuit_field("is_240v"), + ("circuit", "circuit/shed-priority"): _circuit_shed_priority, + ("circuit", "circuit/pcs-managed"): _circuit_field("pcs_managed"), + ("circuit", "circuit/pcs-priority"): _circuit_field("pcs_priority"), + ("circuit", "circuit/sheddable"): _circuit_field("is_sheddable"), + ("circuit", "circuit/never-backup"): _circuit_field("is_never_backup"), + ("circuit", "circuit/always-on"): _circuit_field("always_on"), + # ---- bess ----------------------------------------------------------- + ("bess", "bess/vendor-name"): _bess_field("vendor_name"), + ("bess", "bess/product-name"): _bess_field("product_name"), + ("bess", "bess/model"): _bess_field("model"), + ("bess", "bess/serial-number"): _bess_field("serial_number"), + ("bess", "bess/software-version"): _bess_field("firmware_version"), + ("bess", "bess/nameplate-capacity"): _bess_field("nameplate_capacity_kwh"), + ("bess", "bess/relative-position"): _bess_field("relative_position"), + ("bess", "bess/feed"): _bess_field("feed_circuit_id"), + ("bess", "bess/soc"): _bess_field("soe_percentage"), + ("bess", "bess/soe"): _bess_field("soe_kwh"), + ("bess", "bess/connected"): _bess_field("connected"), + ("bess", "bess/grid-state"): _bess_field("grid_state"), + # ---- pv ------------------------------------------------------------- + ("pv", "pv/vendor-name"): _pv_field("vendor_name"), + ("pv", "pv/product-name"): _pv_field("product_name"), + ("pv", "pv/serial-number"): _pv_field("serial_number"), + ("pv", "pv/software-version"): _pv_field("firmware_version"), + ("pv", "pv/nameplate-capacity"): _pv_field("nameplate_capacity_w"), + ("pv", "pv/relative-position"): _pv_field("relative_position"), + ("pv", "pv/feed"): _pv_field("feed_circuit_id"), + # ---- evse ----------------------------------------------------------- + ("evse", "evse/vendor-name"): _evse_field("vendor_name"), + ("evse", "evse/product-name"): _evse_field("product_name"), + ("evse", "evse/part-number"): _evse_field("part_number"), + ("evse", "evse/serial-number"): _evse_field("serial_number"), + ("evse", "evse/software-version"): _evse_field("firmware_version"), + ("evse", "evse/feed"): _evse_field("feed_circuit_id"), + ("evse", "evse/lock-state"): _evse_field("lock_state"), + ("evse", "evse/status"): _evse_field("status"), + ("evse", "evse/advertised-current"): _evse_field("advertised_current_a"), + # ---- lugs ----------------------------------------------------------- + ("lugs", "lugs/direction"): _upper_lugs_direction, + ("lugs", "lugs/feed"): _lugs_field("feed"), + ("lugs", "lugs/active-power"): _lugs_field("active_power_w"), + ("lugs", "lugs/l1-current"): _lugs_field("l1_current_a"), + ("lugs", "lugs/l2-current"): _lugs_field("l2_current_a"), + ("lugs", "lugs/imported-energy"): _lugs_field("imported_energy_wh"), + ("lugs", "lugs/exported-energy"): _lugs_field("exported_energy_wh"), +} + + +# --------------------------------------------------------------------------- +# Bag builder +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class _BoundProperty: + """Resolved property binding ready for per-tick evaluation.""" + + entity_class: str + instance_id: str + property_path: str + resolver: Resolver + + +class BagBuilder: + """Walk the wire graph + profiles to populate a ``PropertyBag`` per tick. + + Construction validates that every profile-declared property has a + resolver; this is the structural check that catches silent-drop drift + between profile JSONs and the snapshot dataclasses.""" + + def __init__( + self, + graph: BuiltGraph, + mapping: MappingTable, + profiles: ProfileTable, + ) -> None: + del mapping # accepted for API symmetry; not consulted today. + self._bound: list[_BoundProperty] = [] + + # First pass: structural coverage check. Every profile property the + # graph references must have a resolver entry. A missing resolver is a + # programmer error (profile JSON updated without bag-builder follow-up) + # and we want to fail at construction rather than silently drop topics. + missing: list[tuple[str, str]] = [] + for ec, profile in profiles.items(): + for cap_name, cap in profile.capabilities.items(): + for prop_key in cap.properties: + full = f"{cap_name}/{prop_key}" + if (ec, full) not in _RESOLVERS: + missing.append((ec, full)) + if missing: + joined = ", ".join(f"{ec}.{p}" for ec, p in missing) + raise EmitterStateError( + f"BagBuilder: profile-declared properties have no snapshot " + f"resolver: {joined}. Snapshot dataclasses and wire profiles are " + f"out of sync.", + ) + + # Second pass: bind resolvers to the (entity_class, instance_id, + # property_path) keys actually present in the graph. The graph already + # encodes which instances exist for each entity_class. + for entity_class, instance_id, property_path in graph.properties: + resolver = _RESOLVERS[(entity_class, property_path)] + self._bound.append( + _BoundProperty( + entity_class=entity_class, + instance_id=instance_id, + property_path=property_path, + resolver=resolver, + ), + ) + + def build(self, snapshot: EbusPanelSnapshot) -> PropertyBag: + """Pull a value for every bound property; skip ``None`` values + (Homie 5 lets a property's retained topic stay unchanged when the + emitter has nothing fresh to say).""" + bag = PropertyBag(values={}) + for bound in self._bound: + value = bound.resolver(snapshot, bound.instance_id) + if value is None: + continue + bag.set(bound.entity_class, bound.instance_id, bound.property_path, value) + return bag diff --git a/src/span_panel_simulator/flat_emitter/wire/graph_builder.py b/src/span_panel_simulator/flat_emitter/wire/graph_builder.py new file mode 100644 index 0000000..d53290e --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/graph_builder.py @@ -0,0 +1,313 @@ +"""Walk the manifest + mapping descriptors + profiles, build the ebus-sdk Device graph.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from typing import Any + +import ebus_sdk + +from span_panel_simulator.flat_emitter.exceptions import ( + ManifestValidationError, + ProfileValidationError, +) +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.wire._sdk_seam import make_property +from span_panel_simulator.flat_emitter.wire.mapping_loader import MappingDescriptor, MappingTable +from span_panel_simulator.flat_emitter.wire.profile_loader import Profile, ProfileTable + +PropertyKey = tuple[str, str, str] + + +@dataclass(slots=True) +class BuiltGraph: + devices: dict[str, ebus_sdk.Device] = field(default_factory=dict) + properties: dict[PropertyKey, ebus_sdk.Property] = field(default_factory=dict) + description_payloads: dict[str, dict[str, Any]] = field(default_factory=dict) + children_of: dict[str, tuple[str, ...]] = field(default_factory=dict) + node_types: dict[str, str] = field(default_factory=dict) + + +def build_graph( + manifest: DeviceManifest, + mapping: MappingTable, + profiles: ProfileTable, +) -> BuiltGraph: + graph = BuiltGraph() + + root_descriptors = [m for m in mapping.values() if m.placement.kind == "root-device"] + if len(root_descriptors) != 1: + raise ManifestValidationError( + f"Expected exactly one root-device descriptor, got {len(root_descriptors)}" + ) + root_class = root_descriptors[0].entity_class + + root_instances = manifest.of_class(root_class) + if len(root_instances) != 1: + raise ManifestValidationError( + f"Expected exactly one {root_class!r} instance in manifest, got {len(root_instances)}" + ) + root_instance = root_instances[0] + + root_device = ebus_sdk.Device( + root_instance.instance_id, + name=root_instance.display_name, + type=profiles[root_class].type, + ) + graph.devices[root_instance.instance_id] = root_device + + _attach_profile( + root_device, + profiles[root_class], + root_instance, + graph, + entity_class=root_class, + parent_for_path=None, + node_id_template=None, + ) + + # Children indexed by parent device id (instance_id) — populated as + # ``child-of-parent`` descriptors are processed below. + children_acc: dict[str, list[str]] = {} + + # Topologically order non-root descriptors so that any descriptor whose + # ``child-of-parent`` placement names a parent_entity_class is processed + # AFTER that parent's descriptor. ``node-on-parent`` descriptors also + # participate in the sort but their parent edge is already implicitly the + # root device — they only become predecessors when something is parented + # under them, which is not currently expressible (their target is always + # the root). The DAG edges therefore come solely from + # ``child-of-parent.parent_entity_class`` references. + ordered = _topo_sort_descriptors(mapping, root_class) + + for descriptor in ordered: + ec = descriptor.entity_class + for inst in manifest.of_class(ec): + if descriptor.placement.kind == "node-on-parent": + _attach_profile( + root_device, + profiles[ec], + inst, + graph, + entity_class=ec, + parent_for_path=root_instance, + node_id_template=descriptor.placement.node_id_template, + ) + elif descriptor.placement.kind == "child-of-parent": + parent_ec = descriptor.placement.parent_entity_class + if parent_ec is None: + raise ProfileValidationError( + f"mapping {ec!r} placement.kind='child-of-parent' requires " + "parent_entity_class to be set" + ) + + # Resolve the parent SDK device. If parent_ec is the root, it's the + # single root device; otherwise we must find the specific parent + # instance built earlier by topo order. + parent_instance: DeviceInstance + if parent_ec == root_class: + parent_instance = root_instance + else: + candidates = manifest.of_class(parent_ec) + if len(candidates) != 1: + raise ManifestValidationError( + f"Cannot place {ec!r} child instance {inst.instance_id!r}: " + f"expected exactly one {parent_ec!r} parent instance in " + f"manifest, got {len(candidates)}" + ) + parent_instance = candidates[0] + + parent_device = graph.devices.get(parent_instance.instance_id) + if parent_device is None: + raise ManifestValidationError( + f"Cannot place {ec!r} child instance {inst.instance_id!r}: " + f"parent device {parent_instance.instance_id!r} not yet built " + f"(topology bug — should have been ordered before this descriptor)" + ) + + child = ebus_sdk.Device( + inst.instance_id, + name=inst.display_name, + type=profiles[ec].type, + parent_id=parent_instance.instance_id, + root_id=root_instance.instance_id, + ) + parent_device.add_child(inst.instance_id) + graph.devices[inst.instance_id] = child + children_acc.setdefault(parent_instance.instance_id, []).append(inst.instance_id) + _attach_profile( + child, + profiles[ec], + inst, + graph, + entity_class=ec, + parent_for_path=None, + node_id_template=descriptor.placement.node_id_template, + ) + + graph.children_of = {pid: tuple(kids) for pid, kids in children_acc.items()} + + 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: + graph.description_payloads[device_id] = { + "homie": "5.0", + "version": profiles[root_class].version, + "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()) + }, + } + else: + graph.description_payloads[device_id] = { + "name": name, + "id": device_id, + } + + return graph + + +def _topo_sort_descriptors(mapping: MappingTable, root_class: str) -> list[MappingDescriptor]: + """Return non-root mapping descriptors in topological order. + + Edges are derived from ``child-of-parent.parent_entity_class``: a child + descriptor depends on its parent descriptor and must therefore be processed + after it. Within a topological level, descriptors are tie-broken by + ``placement.kind`` (``node-on-parent`` before ``child-of-parent``) and then + by ``entity_class`` for determinism. + + Raises ``ProfileValidationError`` on cycles.""" + nodes: dict[str, MappingDescriptor] = { + ec: m for ec, m in mapping.items() if m.placement.kind != "root-device" + } + + # Build adjacency: edge parent_ec -> child_ec when child has parent_ec set + # and parent_ec is itself a non-root descriptor (root parent contributes + # no edge — the root device is built unconditionally first). + in_degree: dict[str, int] = {ec: 0 for ec in nodes} + successors: dict[str, list[str]] = {ec: [] for ec in nodes} + for ec, descriptor in nodes.items(): + parent_ec = descriptor.placement.parent_entity_class + if ( + descriptor.placement.kind == "child-of-parent" + and parent_ec is not None + and parent_ec != root_class + and parent_ec in nodes + ): + successors[parent_ec].append(ec) + in_degree[ec] += 1 + + def _kind_rank(ec: str) -> int: + return 0 if nodes[ec].placement.kind == "node-on-parent" else 1 + + ready: deque[str] = deque( + sorted( + (ec for ec, deg in in_degree.items() if deg == 0), + key=lambda ec: (_kind_rank(ec), ec), + ) + ) + ordered: list[MappingDescriptor] = [] + while ready: + ec = ready.popleft() + ordered.append(nodes[ec]) + newly_ready: list[str] = [] + for succ in successors[ec]: + in_degree[succ] -= 1 + if in_degree[succ] == 0: + newly_ready.append(succ) + for succ in sorted(newly_ready, key=lambda e: (_kind_rank(e), e)): + ready.append(succ) + + if len(ordered) != len(nodes): + unresolved = sorted(ec for ec, deg in in_degree.items() if deg > 0) + raise ProfileValidationError( + "cycle detected in mapping descriptor parent_entity_class graph involving: " + + ", ".join(unresolved) + ) + return ordered + + +def _attach_profile( + device: ebus_sdk.Device, + profile: Profile, + instance: DeviceInstance, + graph: BuiltGraph, + *, + entity_class: str, + parent_for_path: DeviceInstance | None, + node_id_template: str | None, +) -> None: + """Attach the profile's capabilities + properties to the given device. + + For root entities (parent_for_path is None) capability nodes use plain capability + names. For node-on-parent entities, capability nodes are namespaced with the instance + ID so multiple circuits/lugs/etc. coexist on the parent without collision. + """ + single_capability = len(profile.capabilities) == 1 + for cap_name, cap in profile.capabilities.items(): + if parent_for_path is None: + node_id = cap_name + else: + node_prefix = _render_node_id(node_id_template or "{instance_id}", instance) + node_id = node_prefix if single_capability else f"{node_prefix}-{cap_name}" + graph.node_types[node_id] = cap.type + node = device.add_node_from_dict( + { + "id": node_id, + "name": cap_name, + "type": cap.type, + } + ) + for prop_key, prop in cap.properties.items(): + sdk_prop = make_property( + node=node, + key=prop_key, + name=prop.name, + datatype=_to_sdk_datatype(prop.datatype), + unit=_to_sdk_unit(prop.unit), + format_str=prop.format, + settable=prop.settable, + ) + graph.properties[(entity_class, instance.instance_id, f"{cap_name}/{prop_key}")] = ( + sdk_prop + ) + + +def _render_node_id(template: str, instance: DeviceInstance) -> str: + return template.format( + instance_id=instance.instance_id, + instance_id_short=instance.instance_id[:8], + display_name=instance.display_name, + ) + + +def _to_sdk_datatype(dt: str) -> ebus_sdk.PropertyDatatype: + mapping = { + "string": ebus_sdk.PropertyDatatype.STRING, + "integer": ebus_sdk.PropertyDatatype.INTEGER, + "float": ebus_sdk.PropertyDatatype.FLOAT, + "boolean": ebus_sdk.PropertyDatatype.BOOLEAN, + "enum": ebus_sdk.PropertyDatatype.ENUM, + } + return mapping.get(dt.lower(), ebus_sdk.PropertyDatatype.STRING) + + +def _to_sdk_unit(unit: str | None) -> ebus_sdk.Unit | None: + if unit is None: + return None + table = { + "W": "WATT", + "A": "AMPERE", + "V": "VOLT", + "kWh": "KILOWATT_HOUR", + "Wh": "WATT_HOUR", + "%": "PERCENT", + "kW": "KILOWATT", + "Hz": "HERTZ", + } + name = table.get(unit) or unit.upper().replace("-", "_") + return getattr(ebus_sdk.Unit, name, None) diff --git a/src/span_panel_simulator/flat_emitter/wire/lifecycle.py b/src/span_panel_simulator/flat_emitter/wire/lifecycle.py new file mode 100644 index 0000000..2d71853 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/lifecycle.py @@ -0,0 +1,134 @@ +"""Lifecycle controller — owns $state, $description, /set subscription, LWT. + +v1_flat behaviour only; v2_children adds child-device cascade in a future major release. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from span_panel_simulator.flat_emitter.exceptions import EmitterStateError +from span_panel_simulator.flat_emitter.manifest import DeviceManifest +from span_panel_simulator.flat_emitter.wire.graph_builder import BuiltGraph +from span_panel_simulator.flat_emitter.wire.mapping_loader import MappingTable +from span_panel_simulator.flat_emitter.wire.profile_loader import ProfileTable +from span_panel_simulator.flat_emitter.wire.set_router import SetSubscription +from span_panel_simulator.flat_emitter.wire.wire_paths import ( + device_description_topic, + device_state_topic, + root_state_topic, +) + + +@runtime_checkable +class _MqttClientLike(Protocol): + def is_connected(self) -> bool: ... + async def publish( + self, topic: str, payload: bytes, qos: int = 0, retain: bool = False + ) -> None: ... + async def subscribe(self, topic: str) -> None: ... + + +def lwt_settings( + manifest: DeviceManifest, + *, + domain: str, + bus_version: str, + root_entity_class: str, +) -> tuple[str, bytes, int, bool]: + root = manifest.of_class(root_entity_class)[0] + return root_state_topic(domain, bus_version, root.instance_id), b"lost", 1, True + + +@dataclass(slots=True) +class LifecycleController: + manifest: DeviceManifest + mapping: MappingTable + profiles: ProfileTable + graph: BuiltGraph + mqtt: _MqttClientLike + domain: str = "ebus" + bus_version: str = "5" + subscriptions: list[SetSubscription] = field(default_factory=list) + _started: bool = False + _stopped: bool = False + _root_id: str = "" + + def __post_init__(self) -> None: + root_ec = self.mapping.root_entity_class() + self._root_id = self.manifest.of_class(root_ec)[0].instance_id + + async def start(self) -> None: + if not self.mqtt.is_connected(): + raise EmitterStateError("mqtt_client must be connected before start()") + + await self.mqtt.publish( + root_state_topic(self.domain, self.bus_version, self._root_id), + b"init", + qos=1, + retain=True, + ) + + for device_id, payload in self.graph.description_payloads.items(): + await self.mqtt.publish( + device_description_topic(self.domain, self.bus_version, device_id), + json.dumps(payload).encode(), + qos=1, + retain=True, + ) + + for sub in self.subscriptions: + await self.mqtt.subscribe(sub.topic_pattern) + + await self.mqtt.publish( + root_state_topic(self.domain, self.bus_version, self._root_id), + b"ready", + qos=1, + retain=True, + ) + self._started = True + + async def stop(self, *, graceful: bool, clear_retained: bool = False) -> None: + if not graceful: + return + for device_id in self.graph.devices: + if device_id == self._root_id: + continue + await self.mqtt.publish( + device_state_topic(self.domain, self.bus_version, device_id), + b"disconnected", + qos=1, + retain=True, + ) + await self.mqtt.publish( + root_state_topic(self.domain, self.bus_version, self._root_id), + b"disconnected", + qos=1, + retain=True, + ) + if clear_retained: + for topic in self._retained_topics(): + await self.mqtt.publish(topic, b"", qos=1, retain=True) + self._stopped = True + + def _retained_topics(self) -> list[str]: + topics = [ + root_state_topic(self.domain, self.bus_version, self._root_id), + *( + device_state_topic(self.domain, self.bus_version, device_id) + for device_id in self.graph.devices + if device_id != self._root_id + ), + *( + device_description_topic(self.domain, self.bus_version, device_id) + for device_id in self.graph.description_payloads + ), + ] + topics.extend( + f"{self.domain}/{self.bus_version}/{prop.get_device_id()}/" + f"{prop.get_node_id()}/{prop.id()}" + for prop in self.graph.properties.values() + ) + return sorted(set(topics)) diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/.gitkeep b/src/span_panel_simulator/flat_emitter/wire/mapping/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/bess.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/bess.yaml new file mode 100644 index 0000000..abc2deb --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/bess.yaml @@ -0,0 +1,16 @@ +entity_class: bess +profile: bess.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: panel + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "BESS {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/circuit.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/circuit.yaml new file mode 100644 index 0000000..7965c42 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/circuit.yaml @@ -0,0 +1,16 @@ +entity_class: circuit +profile: circuit.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: panel + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "Circuit {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/evse.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/evse.yaml new file mode 100644 index 0000000..bf2186b --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/evse.yaml @@ -0,0 +1,16 @@ +entity_class: evse +profile: evse.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: panel + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "EVSE {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/lugs.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/lugs.yaml new file mode 100644 index 0000000..c99bdd7 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/lugs.yaml @@ -0,0 +1,16 @@ +entity_class: lugs +profile: lugs.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: panel + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "Lugs {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/panel.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/panel.yaml new file mode 100644 index 0000000..9270a0c --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/panel.yaml @@ -0,0 +1,15 @@ +entity_class: panel +profile: panel.json +profile_version: 1 +placement: + kind: root-device + device_id_template: "{instance_id}" +wire: + device_id_source: self + property_path_template: "{capability}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "Panel {instance_id_short}" +discovery: + $description_owner: self + state_owner: self diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping/pv.yaml b/src/span_panel_simulator/flat_emitter/wire/mapping/pv.yaml new file mode 100644 index 0000000..f1deddd --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping/pv.yaml @@ -0,0 +1,16 @@ +entity_class: pv +profile: pv.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: panel + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "PV {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent diff --git a/src/span_panel_simulator/flat_emitter/wire/mapping_loader.py b/src/span_panel_simulator/flat_emitter/wire/mapping_loader.py new file mode 100644 index 0000000..7147e1e --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/mapping_loader.py @@ -0,0 +1,144 @@ +"""Load and parse vendored mapping descriptor YAMLs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, cast + +import yaml + +from span_panel_simulator.flat_emitter.exceptions import ProfileValidationError +from span_panel_simulator.flat_emitter.wire.profile_loader import ProfileTable + +_DEFAULT_DIR = Path(__file__).parent / "mapping" + +PlacementKind = Literal["root-device", "node-on-parent", "child-of-parent"] + + +@dataclass(frozen=True, slots=True) +class Placement: + kind: PlacementKind + parent_entity_class: str | None = None + node_id_template: str | None = None + device_id_template: str | None = None + + +@dataclass(frozen=True, slots=True) +class WireConfig: + device_id_source: Literal["self", "parent"] + property_path_template: str + + +@dataclass(frozen=True, slots=True) +class DisplayConfig: + name_template: str + fallback_name_template: str + + +@dataclass(frozen=True, slots=True) +class DiscoveryConfig: + description_owner: Literal["self", "parent"] + state_owner: Literal["self", "parent"] + parent_back_reference: str | None = None + + +@dataclass(frozen=True, slots=True) +class MappingDescriptor: + entity_class: str + profile: str + profile_version: int + placement: Placement + wire: WireConfig + display: DisplayConfig + discovery: DiscoveryConfig + + +class MappingTable(dict[str, MappingDescriptor]): + """Mapping of entity_class → MappingDescriptor.""" + + def root_entity_class(self) -> str: + """Return the entity_class whose placement is the root device. + + Raises ``ProfileValidationError`` if the table does not contain + exactly one root-device descriptor (also enforced by + ``validate_against`` at load time).""" + roots = [m.entity_class for m in self.values() if m.placement.kind == "root-device"] + if len(roots) != 1: + raise ProfileValidationError( + f"mapping table must have exactly one root-device descriptor; got {len(roots)}" + ) + return roots[0] + + def validate_against(self, profiles: ProfileTable) -> None: + roots = [m for m in self.values() if m.placement.kind == "root-device"] + if len(roots) != 1: + raise ProfileValidationError( + f"mapping table must have exactly one root-device descriptor; got {len(roots)}" + ) + for ec, m in self.items(): + if ec not in profiles: + raise ProfileValidationError( + f"mapping {ec} references missing profile {m.profile}" + ) + if profiles[ec].version != m.profile_version: + raise ProfileValidationError( + f"mapping {ec} expects profile_version {m.profile_version}, " + f"profile is {profiles[ec].version}" + ) + if ( + m.placement.parent_entity_class is not None + and m.placement.parent_entity_class not in self + ): + raise ProfileValidationError( + f"mapping {ec} references unknown parent_entity_class " + f"{m.placement.parent_entity_class!r}" + ) + if ( + m.discovery.parent_back_reference is not None + and m.discovery.parent_back_reference not in self + ): + raise ProfileValidationError( + f"mapping {ec} references unknown parent_back_reference " + f"{m.discovery.parent_back_reference!r}" + ) + + +def load_mapping_table(directory: Path = _DEFAULT_DIR) -> MappingTable: + table = MappingTable() + for path in sorted(directory.glob("*.yaml")): + raw = yaml.safe_load(path.read_text()) + table[raw["entity_class"]] = MappingDescriptor( + entity_class=raw["entity_class"], + profile=raw["profile"], + profile_version=raw["profile_version"], + placement=Placement( + kind=cast("PlacementKind", raw["placement"]["kind"]), + parent_entity_class=raw["placement"].get("parent_entity_class"), + node_id_template=raw["placement"].get("node_id_template"), + device_id_template=raw["placement"].get("device_id_template"), + ), + wire=WireConfig( + device_id_source=cast( + "Literal['self', 'parent']", + raw["wire"]["device_id_source"], + ), + property_path_template=raw["wire"]["property_path_template"], + ), + display=DisplayConfig( + name_template=raw["display"]["name_template"], + fallback_name_template=raw["display"]["fallback_name_template"], + ), + discovery=DiscoveryConfig( + description_owner=cast( + "Literal['self', 'parent']", + raw["discovery"]["$description_owner"], + ), + state_owner=cast( + "Literal['self', 'parent']", + raw["discovery"]["state_owner"], + ), + parent_back_reference=raw["discovery"].get("parent_back_reference"), + ), + ) + return table diff --git a/src/span_panel_simulator/flat_emitter/wire/profile_loader.py b/src/span_panel_simulator/flat_emitter/wire/profile_loader.py new file mode 100644 index 0000000..6e396d4 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profile_loader.py @@ -0,0 +1,79 @@ +"""Load and parse vendored Homie 5 device profile JSONs.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from span_panel_simulator.flat_emitter.exceptions import ProfileValidationError + +_DEFAULT_DIR = Path(__file__).parent / "profiles" + + +@dataclass(frozen=True, slots=True) +class ProfileProperty: + name: str + datatype: str + unit: str | None + format: str | None + settable: bool + + +@dataclass(frozen=True, slots=True) +class ProfileCapability: + type: str + properties: dict[str, ProfileProperty] + + +@dataclass(frozen=True, slots=True) +class Profile: + entity_class: str + version: int + type: str + capabilities: dict[str, ProfileCapability] + + def settable_properties(self) -> list[tuple[str, str]]: + """Return [(capability, property_key), ...] for every settable=True property.""" + out: list[tuple[str, str]] = [] + for cap_name, cap in self.capabilities.items(): + for prop_key, prop in cap.properties.items(): + if prop.settable: + out.append((cap_name, prop_key)) + return out + + +class ProfileTable(dict[str, Profile]): + """Mapping of entity_class → Profile.""" + + +def load_profiles(directory: Path = _DEFAULT_DIR) -> ProfileTable: + table = ProfileTable() + for path in sorted(directory.glob("*.json")): + entity_class = path.stem + raw = json.loads(path.read_text()) + if "$version" not in raw or "type" not in raw or "capabilities" not in raw: + raise ProfileValidationError(f"profile {path} missing required top-level keys") + capabilities = { + cap_name: ProfileCapability( + type=cap["type"], + properties={ + prop_key: ProfileProperty( + name=prop["name"], + datatype=prop["datatype"], + unit=prop.get("unit"), + format=prop.get("format"), + settable=prop.get("settable", False), + ) + for prop_key, prop in cap["properties"].items() + }, + ) + for cap_name, cap in raw["capabilities"].items() + } + table[entity_class] = Profile( + entity_class=entity_class, + version=raw["$version"], + type=raw["type"], + capabilities=capabilities, + ) + return table diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/.gitkeep b/src/span_panel_simulator/flat_emitter/wire/profiles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/bess.json b/src/span_panel_simulator/flat_emitter/wire/profiles/bess.json new file mode 100644 index 0000000..bafaabc --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/bess.json @@ -0,0 +1,23 @@ +{ + "$version": 1, + "type": "energy.ebus.device.bess", + "capabilities": { + "bess": { + "type": "energy.ebus.device.bess", + "properties": { + "vendor-name": {"name": "Vendor name", "datatype": "string"}, + "product-name": {"name": "Product name", "datatype": "string"}, + "model": {"name": "Model", "datatype": "string"}, + "serial-number": {"name": "Serial number", "datatype": "string"}, + "software-version": {"name": "Software version", "datatype": "string"}, + "nameplate-capacity": {"name": "Nameplate capacity", "datatype": "float", "unit": "kWh"}, + "relative-position": {"name": "Relative position of the commissioned backup system WRT the distribution enclosure", "datatype": "enum", "format": "UPSTREAM,DOWNSTREAM,IN_PANEL"}, + "feed": {"name": "Circuit ID upon which the commissioned backup system is landed", "datatype": "enum"}, + "soc": {"name": "State of charge", "datatype": "float", "unit": "%"}, + "soe": {"name": "State of energy", "datatype": "float", "unit": "kWh"}, + "connected": {"name": "Connected to backup system?", "datatype": "boolean"}, + "grid-state": {"name": "Grid connection state", "datatype": "enum", "format": "UNKNOWN,ON_GRID,OFF_GRID"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/circuit.json b/src/span_panel_simulator/flat_emitter/wire/profiles/circuit.json new file mode 100644 index 0000000..89f1595 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/circuit.json @@ -0,0 +1,27 @@ +{ + "$version": 1, + "type": "energy.ebus.device.circuit", + "capabilities": { + "circuit": { + "type": "energy.ebus.device.circuit", + "properties": { + "name": {"name": "Circuit name", "datatype": "string"}, + "relay": {"name": "Circuit relay state", "datatype": "enum", "format": "UNKNOWN,OPEN,CLOSED", "settable": true}, + "relay-requester": {"name": "Actor requesting the relay state", "datatype": "enum", "format": "UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT"}, + "breaker-rating": {"name": "Circuit breaker rating", "datatype": "integer", "unit": "A"}, + "current": {"name": "Measured current", "datatype": "float", "unit": "A"}, + "active-power": {"name": "Measured active power", "datatype": "float", "unit": "W"}, + "imported-energy": {"name": "Measured energy imported", "datatype": "float", "unit": "Wh"}, + "exported-energy": {"name": "Measured energy exported", "datatype": "float", "unit": "Wh"}, + "space": {"name": "Circuit breaker space number within load center", "datatype": "integer", "format": "1:40:1"}, + "dipole": {"name": "Does circuit land on a two-pole breaker?", "datatype": "boolean"}, + "shed-priority": {"name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", "datatype": "enum", "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", "settable": true}, + "pcs-managed": {"name": "Is circuit managed by PCS?", "datatype": "boolean"}, + "pcs-priority": {"name": "Circuit PCS priority ranking", "datatype": "integer"}, + "sheddable": {"name": "Is circuit configured to be sheddable?", "datatype": "boolean"}, + "never-backup": {"name": "Is circuit configured to be never-backup?", "datatype": "boolean"}, + "always-on": {"name": "Is circuit configured to be always on?", "datatype": "boolean"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/evse.json b/src/span_panel_simulator/flat_emitter/wire/profiles/evse.json new file mode 100644 index 0000000..3ba2680 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/evse.json @@ -0,0 +1,20 @@ +{ + "$version": 1, + "type": "energy.ebus.device.evse", + "capabilities": { + "evse": { + "type": "energy.ebus.device.evse", + "properties": { + "vendor-name": {"name": "Vendor name", "datatype": "string"}, + "product-name": {"name": "Product name", "datatype": "string"}, + "part-number": {"name": "Part number", "datatype": "string"}, + "serial-number": {"name": "Serial number", "datatype": "string"}, + "software-version": {"name": "Software version", "datatype": "string"}, + "feed": {"name": "Circuit ID upon which the commissioned EVSE is landed", "datatype": "enum"}, + "lock-state": {"name": "Lock state", "datatype": "enum", "format": "UNKNOWN,LOCKED,UNLOCKED"}, + "status": {"name": "Status", "datatype": "enum", "format": "UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE"}, + "advertised-current": {"name": "Current EVSE is advertising to the EV", "datatype": "float", "unit": "A"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/lugs.json b/src/span_panel_simulator/flat_emitter/wire/profiles/lugs.json new file mode 100644 index 0000000..239fcad --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/lugs.json @@ -0,0 +1,18 @@ +{ + "$version": 1, + "type": "energy.ebus.device.lugs", + "capabilities": { + "lugs": { + "type": "energy.ebus.device.lugs", + "properties": { + "direction": {"name": "Lugs feed direction: upstream or downstream", "datatype": "enum", "format": "UPSTREAM,DOWNSTREAM"}, + "feed": {"name": "Device the lugs are connected to, if known", "datatype": "string"}, + "l1-current": {"name": "L1 current", "datatype": "float", "unit": "A"}, + "l2-current": {"name": "L2 current", "datatype": "float", "unit": "A"}, + "active-power": {"name": "Active power", "datatype": "float", "unit": "W"}, + "imported-energy": {"name": "Imported energy", "datatype": "float", "unit": "Wh"}, + "exported-energy": {"name": "Exported energy", "datatype": "float", "unit": "Wh"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/panel.json b/src/span_panel_simulator/flat_emitter/wire/profiles/panel.json new file mode 100644 index 0000000..0e20a61 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/panel.json @@ -0,0 +1,58 @@ +{ + "$version": 1, + "type": "energy.ebus.device.distribution-enclosure", + "capabilities": { + "core": { + "type": "energy.ebus.device.distribution-enclosure.core", + "properties": { + "vendor-name": {"name": "Vendor name", "datatype": "string"}, + "model": {"name": "Model", "datatype": "enum", "format": "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48"}, + "serial-number": {"name": "Serial number", "datatype": "string"}, + "hardware-version": {"name": "Hardware version", "datatype": "string"}, + "software-version": {"name": "Software version", "datatype": "string"}, + "door": {"name": "Door state", "datatype": "enum", "format": "UNKNOWN,OPEN,CLOSED"}, + "grid-islandable": {"name": "Capable of operating with power while disconnected from the grid", "datatype": "boolean"}, + "dominant-power-source": {"name": "Current dominant power source, load-shedding trigger", "datatype": "enum", "format": "GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN", "settable": true}, + "relay": {"name": "Main relay", "datatype": "enum", "format": "UNKNOWN,OPEN,CLOSED"}, + "l1-voltage": {"name": "L1 voltage", "datatype": "float", "unit": "V"}, + "l2-voltage": {"name": "L2 voltage", "datatype": "float", "unit": "V"}, + "breaker-rating": {"name": "Main breaker rating", "datatype": "integer", "unit": "A"}, + "ethernet": {"name": "Is Ethernet network interface operational?", "datatype": "boolean"}, + "wifi": {"name": "Is Wi-Fi network interface operational?", "datatype": "boolean"}, + "wifi-ssid": {"name": "SSID to which Wi-Fi network interface is connected", "datatype": "string"}, + "vendor-cloud": {"name": "Device connected to vendor cloud?", "datatype": "enum", "format": "UNKNOWN,UNCONNECTED,CONNECTED"}, + "postal-code": {"name": "Postal (Zip) code", "datatype": "string"}, + "time-zone": {"name": "Time zone", "datatype": "string"} + } + }, + "pcs": { + "type": "energy.ebus.device.pcs", + "properties": { + "enabled": {"name": "PCS system enabled", "datatype": "boolean"}, + "active": {"name": "PCS system actively controlling one (or more) loads", "datatype": "boolean"}, + "import-limit": {"name": "The power import limit currently being managed to", "datatype": "float", "unit": "A"}, + "feed-import-limit": {"name": "Limit of maximum power feeding the distribution enclosure", "datatype": "float", "unit": "A"}, + "feed-import-limit-enablement": {"name": "Enablement status of the feed-import-limit", "datatype": "enum", "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"}, + "feed-import-limit-active": {"name": "Is feed-import-limit currently being enforced?", "datatype": "boolean"}, + "grid-import-limit": {"name": "Grid limit maximum import power", "datatype": "float", "unit": "A"}, + "grid-import-limit-enablement": {"name": "Enablement status of the grid-import-limit", "datatype": "enum", "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"}, + "grid-import-limit-active": {"name": "Is grid-import-limit currently being enforced?", "datatype": "boolean"}, + "off-grid-import-limit": {"name": "Off-Grid limit maximum import power", "datatype": "float", "unit": "A"}, + "off-grid-import-limit-enablement": {"name": "Enablement status of the off-grid-import-limit", "datatype": "enum", "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"}, + "off-grid-import-limit-active": {"name": "Is off-grid-import-limit currently being enforced?", "datatype": "boolean"}, + "requested-import-limit": {"name": "Requested limit maximum import power", "datatype": "float", "unit": "A"}, + "requested-import-limit-enablement": {"name": "Enablement status of the requested-import-limit", "datatype": "enum", "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"}, + "requested-import-limit-active": {"name": "Is requested-import-limit currently being enforced?", "datatype": "boolean"} + } + }, + "power-flows": { + "type": "energy.ebus.device.power-flows", + "properties": { + "pv": {"name": "PV power flow", "datatype": "float", "unit": "W"}, + "battery": {"name": "Battery/BESS power flow", "datatype": "float", "unit": "W"}, + "grid": {"name": "Grid power flow", "datatype": "float", "unit": "W"}, + "site": {"name": "Site power flow", "datatype": "float", "unit": "W"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/profiles/pv.json b/src/span_panel_simulator/flat_emitter/wire/profiles/pv.json new file mode 100644 index 0000000..2f9de5d --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/profiles/pv.json @@ -0,0 +1,18 @@ +{ + "$version": 1, + "type": "energy.ebus.device.pv", + "capabilities": { + "pv": { + "type": "energy.ebus.device.pv", + "properties": { + "vendor-name": {"name": "Vendor name", "datatype": "string"}, + "product-name": {"name": "Product name", "datatype": "string"}, + "serial-number": {"name": "Serial number", "datatype": "string"}, + "software-version": {"name": "Software version", "datatype": "string"}, + "nameplate-capacity": {"name": "Nameplate capacity", "datatype": "float", "unit": "W"}, + "relative-position": {"name": "Relative position of the commissioned PV system WRT the distribution enclosure", "datatype": "enum", "format": "UPSTREAM,DOWNSTREAM,IN_PANEL"}, + "feed": {"name": "Circuit ID upon which the commissioned PV system is landed", "datatype": "enum"} + } + } + } +} diff --git a/src/span_panel_simulator/flat_emitter/wire/property_bag.py b/src/span_panel_simulator/flat_emitter/wire/property_bag.py new file mode 100644 index 0000000..2c4cd86 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/property_bag.py @@ -0,0 +1,56 @@ +"""Per-tick property values + diff cache.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +PropertyKey = tuple[str, str, str] # (entity_class, instance_id, property_path) + + +@dataclass(slots=True) +class PropertyBag: + values: dict[PropertyKey, object] + + def set( + self, + entity_class: str, + instance_id: str, + property_path: str, + value: object, + ) -> None: + self.values[(entity_class, instance_id, property_path)] = value + + def get(self, key: PropertyKey) -> object | None: + return self.values.get(key) + + def __len__(self) -> int: + return len(self.values) + + +@dataclass(slots=True) +class PropertyDiffer: + all_keys: tuple[PropertyKey, ...] = field(default_factory=tuple) + last_published: dict[PropertyKey, object] = field(default_factory=dict) + pending_initial: set[PropertyKey] = field(default_factory=set) + + def __init__(self, all_keys: Iterable[PropertyKey]) -> None: + self.all_keys = tuple(all_keys) + self.last_published = {} + self.pending_initial = set(self.all_keys) + + def diff(self, bag: PropertyBag) -> list[tuple[PropertyKey, object]]: + changes: list[tuple[PropertyKey, object]] = [] + for key in self.all_keys: + if key not in bag.values: + continue + value = bag.values[key] + if key in self.pending_initial or self.last_published.get(key) != value: + changes.append((key, value)) + changes.sort(key=lambda kv: kv[0]) + return changes + + def commit(self, published: list[tuple[PropertyKey, object]]) -> None: + for key, value in published: + self.last_published[key] = value + self.pending_initial.discard(key) diff --git a/src/span_panel_simulator/flat_emitter/wire/publisher.py b/src/span_panel_simulator/flat_emitter/wire/publisher.py new file mode 100644 index 0000000..6033b7f --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/publisher.py @@ -0,0 +1,73 @@ +"""Wire publisher — owns the per-tick diff/publish loop. + +The emitter hands the publisher a ``PropertyBag`` representing the current +tick's full property values. The publisher diffs against the previous tick's +state, encodes each changed property's value, and publishes via the MQTT +client seam. + +This is the public seam between the emitter facade and the wire layer for +publishing: ``Emitter`` no longer reaches into ``PropertyDiffer`` directly.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +import ebus_sdk + +from span_panel_simulator.flat_emitter.wire.graph_builder import BuiltGraph +from span_panel_simulator.flat_emitter.wire.property_bag import PropertyBag, PropertyDiffer + + +@runtime_checkable +class _MqttClientLike(Protocol): + def is_connected(self) -> bool: ... + async def publish( + self, + topic: str, + payload: bytes, + qos: int = 0, + retain: bool = False, + ) -> None: ... + async def subscribe(self, topic: str) -> None: ... + + +class Publisher: + """Owns the diff/publish loop. Consumers hand it a ``PropertyBag``; it + computes the delta against the prior tick and publishes changes via the + SDK seam.""" + + def __init__( + self, + graph: BuiltGraph, + mqtt: _MqttClientLike, + *, + domain: str, + bus_version: str, + ) -> None: + self._graph = graph + self._mqtt = mqtt + self._domain = domain + self._bus_version = bus_version + self._differ = PropertyDiffer(all_keys=tuple(graph.properties.keys())) + + async def publish(self, bag: PropertyBag) -> None: + """Publish all changed properties since the last call.""" + changes = self._differ.diff(bag) + for key, value in changes: + sdk_prop = self._graph.properties[key] + topic = self._topic_for(sdk_prop) + await self._mqtt.publish(topic, _encode_payload(value), qos=1, retain=True) + self._differ.commit(changes) + + def _topic_for(self, sdk_prop: ebus_sdk.Property) -> str: + device_id = sdk_prop.get_device_id() + node_id = sdk_prop.get_node_id() + return f"{self._domain}/{self._bus_version}/{device_id}/{node_id}/{sdk_prop.id()}" + + +def _encode_payload(value: object) -> bytes: + if isinstance(value, bool): + return b"true" if value else b"false" + if isinstance(value, (list, tuple)): + return ",".join(str(v) for v in value).encode() + return str(value).encode() diff --git a/src/span_panel_simulator/flat_emitter/wire/set_router.py b/src/span_panel_simulator/flat_emitter/wire/set_router.py new file mode 100644 index 0000000..be7c635 --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/set_router.py @@ -0,0 +1,146 @@ +"""Setter registry, /set subscription computation, and dispatch.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from span_panel_simulator.flat_emitter.exceptions import MissingSetterError + +_LOG = logging.getLogger(__name__) + +SetterHandler = Callable[[str, str, str, object], Awaitable[None]] + + +@dataclass(slots=True) +class SetSubscription: + topic_pattern: str + entity_class: str + instance_id: str + property_path: str + datatype: str + handler: SetterHandler + + +class SetterRegistry: + def __init__(self) -> None: + self._handlers: dict[tuple[str, str], SetterHandler] = {} + + def register( + self, + entity_class: str, + property_path: str, + handler: SetterHandler, + ) -> None: + self._handlers[(entity_class, property_path)] = handler + + def get(self, entity_class: str, property_path: str) -> SetterHandler | None: + return self._handlers.get((entity_class, property_path)) + + +def compute_subscriptions( + *, + instances: list[tuple[str, str]], + settables_by_class: dict[str, list[tuple[str, str]]], + registry: SetterRegistry, + domain: str, + bus_version: str, + device_id_for: Callable[[str, str], str], + node_id_for: Callable[[str, str, str], str] | None = None, + datatype_for: Callable[[str, str, str], str] | None = None, +) -> list[SetSubscription]: + if datatype_for is None: + + def datatype_for_default(_ec: str, _cap: str, _key: str) -> str: + return "string" + + datatype_for = datatype_for_default + if node_id_for is None: + + def node_id_for_default(_ec: str, iid: str, cap: str) -> str: + del _ec, iid + return cap + + node_id_for = node_id_for_default + + missing: list[tuple[str, str]] = [] + declared_classes: set[str] = set() + for ec, _iid in instances: + declared_classes.add(ec) + for ec in declared_classes: + for cap, key in settables_by_class.get(ec, []): + prop_path = f"{cap}/{key}" + if registry.get(ec, prop_path) is None: + missing.append((ec, prop_path)) + + if missing: + raise MissingSetterError(missing=sorted(set(missing))) + + subs: list[SetSubscription] = [] + for ec, iid in instances: + device_id = device_id_for(ec, iid) + for cap, key in settables_by_class.get(ec, []): + node_id = node_id_for(ec, iid, cap) + prop_path = f"{cap}/{key}" + handler = registry.get(ec, prop_path) + assert handler is not None + subs.append( + SetSubscription( + topic_pattern=f"{domain}/{bus_version}/{device_id}/{node_id}/{key}/set", + entity_class=ec, + instance_id=iid, + property_path=prop_path, + datatype=datatype_for(ec, cap, key), + handler=handler, + ) + ) + return subs + + +async def dispatch( + topic: str, + payload: bytes, + subscriptions: list[SetSubscription], +) -> None: + """Topic miss → log + drop. Decode failure → log + drop. Handler raises → log at + ERROR with full context, then re-raise (fail-fast).""" + for sub in subscriptions: + if sub.topic_pattern != topic: + continue + try: + value = _decode(payload, sub.datatype) + except Exception: + _LOG.warning( + "set decode failed for topic=%s payload=%r datatype=%s", + topic, + payload, + sub.datatype, + ) + return + try: + await sub.handler(sub.entity_class, sub.instance_id, sub.property_path, value) + except Exception: + _LOG.exception( + "setter handler raised: entity_class=%s instance_id=%s property_path=%s value=%r", + sub.entity_class, + sub.instance_id, + sub.property_path, + value, + ) + raise + return + _LOG.warning("/set topic miss: %s", topic) + + +def _decode(payload: bytes, datatype: str) -> object: + text = payload.decode("utf-8") + match datatype: + case "float": + return float(text) + case "integer": + return int(text) + case "boolean": + return text.lower() in ("true", "1") + case _: + return text diff --git a/src/span_panel_simulator/flat_emitter/wire/wire_paths.py b/src/span_panel_simulator/flat_emitter/wire/wire_paths.py new file mode 100644 index 0000000..c1f03ae --- /dev/null +++ b/src/span_panel_simulator/flat_emitter/wire/wire_paths.py @@ -0,0 +1,43 @@ +"""Pure topic-template functions for Homie wire paths the SDK does not own.""" + +from __future__ import annotations + + +def root_state_topic(domain: str, bus_version: str, root_device_id: str) -> str: + return f"{domain}/{bus_version}/{root_device_id}/$state" + + +def device_state_topic(domain: str, bus_version: str, device_id: str) -> str: + return f"{domain}/{bus_version}/{device_id}/$state" + + +def device_description_topic(domain: str, bus_version: str, device_id: str) -> str: + return f"{domain}/{bus_version}/{device_id}/$description" + + +def set_topic_for( + domain: str, + bus_version: str, + device_id: str, + capability: str, + property_key: str, +) -> str: + return f"{domain}/{bus_version}/{device_id}/{capability}/{property_key}/set" + + +def parse_set_topic( + topic: str, + domain: str, + bus_version: str, +) -> tuple[str, str, str] | None: + prefix = f"{domain}/{bus_version}/" + if not topic.startswith(prefix) or not topic.endswith("/set"): + return None + rest = topic[len(prefix) : -len("/set")] + parts = rest.split("/") + if len(parts) != 3: + return None + device_id, capability, property_key = parts + if capability.startswith("$") or property_key.startswith("$"): + return None + return device_id, capability, property_key diff --git a/src/span_panel_simulator/panel.py b/src/span_panel_simulator/panel.py index 9dc69d3..4b439b1 100644 --- a/src/span_panel_simulator/panel.py +++ b/src/span_panel_simulator/panel.py @@ -18,10 +18,9 @@ from pathlib import Path from typing import Any - from ebus_emitter import EbusBatterySnapshot - from span_panel_simulator.config_types import BESSConfigYAML from span_panel_simulator.emitter_adapter.runtime import BrokerConnection, CloneRuntime + from span_panel_simulator.flat_emitter import EbusBatterySnapshot from span_panel_simulator.recorder import RecorderDataSource _LOGGER = logging.getLogger(__name__) diff --git a/tests/flat_emitter/__init__.py b/tests/flat_emitter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flat_emitter/conftest.py b/tests/flat_emitter/conftest.py new file mode 100644 index 0000000..31d3819 --- /dev/null +++ b/tests/flat_emitter/conftest.py @@ -0,0 +1,5 @@ +"""Shared pytest fixtures for the emitter test suite. + +The mosquitto fixture used by lifecycle and integration tests is implemented in +tests/integration/conftest.py once the broker harness lands (Phase A6.4). +""" diff --git a/tests/flat_emitter/conventions/__init__.py b/tests/flat_emitter/conventions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flat_emitter/conventions/test_tab_legs.py b/tests/flat_emitter/conventions/test_tab_legs.py new file mode 100644 index 0000000..c85359e --- /dev/null +++ b/tests/flat_emitter/conventions/test_tab_legs.py @@ -0,0 +1,40 @@ +import pytest + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg, legs_for_tabs + + +def test_odd_tab_lands_on_l1() -> None: + assert legs_for_tabs((1,)) == (Leg.L1,) + assert legs_for_tabs((3,)) == (Leg.L1,) + assert legs_for_tabs((39,)) == (Leg.L1,) + + +def test_even_tab_lands_on_l2() -> None: + assert legs_for_tabs((2,)) == (Leg.L2,) + assert legs_for_tabs((4,)) == (Leg.L2,) + assert legs_for_tabs((40,)) == (Leg.L2,) + + +def test_standard_dipole_spans_both_legs() -> None: + assert legs_for_tabs((1, 2)) == (Leg.L1, Leg.L2) + assert legs_for_tabs((39, 40)) == (Leg.L1, Leg.L2) + + +def test_non_adjacent_dipole_does_not_span_both_legs() -> None: + # Convention is mechanical; ManifestPhysicsView is responsible for catching + # mis-declared dipoles. legs_for_tabs just reports the truth. + assert legs_for_tabs((1, 3)) == (Leg.L1, Leg.L1) + assert legs_for_tabs((2, 4)) == (Leg.L2, Leg.L2) + + +def test_empty_tabs_returns_empty() -> None: + assert legs_for_tabs(()) == () + + +def test_zero_or_negative_tab_raises() -> None: + with pytest.raises(ValueError, match="must be >= 1"): + legs_for_tabs((0,)) + with pytest.raises(ValueError, match="must be >= 1"): + legs_for_tabs((-1,)) + with pytest.raises(ValueError, match="must be >= 1"): + legs_for_tabs((1, 0)) diff --git a/tests/flat_emitter/test_emitter_public_surface.py b/tests/flat_emitter/test_emitter_public_surface.py new file mode 100644 index 0000000..ab5ae87 --- /dev/null +++ b/tests/flat_emitter/test_emitter_public_surface.py @@ -0,0 +1,171 @@ +"""Public surface smoke tests — verify exports are present and Emitter +constructs + publishes against an in-memory FakeMqttClient via publish_tick. + +The full publish_tick coverage (BESS, load shedding, /set internal handlers, +seed APIs, etc.) lives in test_publish_tick.py.""" + +from __future__ import annotations + +import pytest + +from span_panel_simulator.flat_emitter import ( + BESSConfig, + BessPhysics, + CircuitPhysics, + DeviceInstance, + DeviceManifest, + EbusBatterySnapshot, + EbusCircuitSnapshot, + EbusEvseSnapshot, + EbusLugsSnapshot, + EbusPanelSnapshot, + EbusPvSnapshot, + Emitter, + EmitterError, + EmitterStateError, + EvsePhysics, + Leg, + LoadSheddingConfig, + LugsPhysics, + ManifestPhysicsView, + ManifestValidationError, + MissingSetterError, + PanelEnvelopeTick, + PanelPhysics, + PvPhysics, + RelayRequester, + RelayResolver, + RelayState, + SetterRegistry, + TickInputs, + legs_for_tabs, +) + + +class FakeMqttClient: + def __init__(self) -> None: + self.published: list[tuple[str, bytes, int, bool]] = [] + self.subscribed: list[str] = [] + + def is_connected(self) -> bool: + return True + + async def publish( + self, + topic: str, + payload: bytes, + qos: int = 0, + retain: bool = False, + ) -> None: + self.published.append((topic, payload, qos, retain)) + + async def subscribe(self, topic: str) -> None: + self.subscribed.append(topic) + + +def _manifest() -> DeviceManifest: + return DeviceManifest( + instances=( + DeviceInstance( + "panel", + "p1", + "Span", + metadata={ + "vendor-name": "Span", + "serial-number": "p1", + "firmware-version": "r2026", + "hardware-version": "rev2", + "panel-size": "32", + "main-breaker-rating-a": "200", + "panel-model": "MAIN_32", + "postal-code": "94103", + "time-zone": "America/Los_Angeles", + }, + ), + DeviceInstance( + "circuit", + "c1", + "Kitchen", + metadata={ + "tab-numbers": "1", + "breaker-rating-a": "20", + "default-priority": "NICE_TO_HAVE", + "relay-behavior": "controllable", + "placement": "downstream-of-lugs", + }, + ), + ) + ) + + +def test_imports_succeed() -> None: + """Smoke check that every public name resolves and types are usable.""" + for klass in ( + DeviceInstance, + DeviceManifest, + Emitter, + SetterRegistry, + BESSConfig, + LoadSheddingConfig, + TickInputs, + PanelEnvelopeTick, + ManifestPhysicsView, + PanelPhysics, + CircuitPhysics, + BessPhysics, + PvPhysics, + EvsePhysics, + LugsPhysics, + RelayResolver, + RelayState, + RelayRequester, + Leg, + ): + assert callable(klass) or isinstance(klass, type) + assert callable(legs_for_tabs) + for snap_cls in ( + EbusPanelSnapshot, + EbusCircuitSnapshot, + EbusBatterySnapshot, + EbusPvSnapshot, + EbusEvseSnapshot, + EbusLugsSnapshot, + ): + assert callable(snap_cls) + for exc in (EmitterError, EmitterStateError, ManifestValidationError, MissingSetterError): + assert issubclass(exc, Exception) + + +def test_emitter_init_fills_in_default_setter_handlers() -> None: + """v0.3.0 contract: emitter registers internal default handlers for the + four settable properties when the producer hasn't supplied one. An empty + SetterRegistry no longer triggers MissingSetterError.""" + setters = SetterRegistry() + Emitter(_manifest(), setters, FakeMqttClient()) + assert setters.get("circuit", "circuit/relay") is not None + assert setters.get("circuit", "circuit/shed-priority") is not None + assert setters.get("circuit", "circuit/name") is not None + assert setters.get("panel", "core/dominant-power-source") is not None + + +@pytest.mark.asyncio +async def test_emitter_lifecycle_start_publish_stop() -> None: + mqtt = FakeMqttClient() + emitter = Emitter(_manifest(), SetterRegistry(), mqtt) + await emitter.start() + snapshot = await emitter.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"c1": 200.0}), + ) + assert snapshot.info.serial_number == "p1" + assert any(t.endswith("$state") for (t, _, _, _) in mqtt.published) + assert emitter.last_snapshot is snapshot + await emitter.stop(graceful=True) + + +@pytest.mark.asyncio +async def test_publish_tick_before_start_raises() -> None: + emitter = Emitter(_manifest(), SetterRegistry(), FakeMqttClient()) + with pytest.raises(EmitterStateError): + await emitter.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"c1": 0.0}), + ) diff --git a/tests/flat_emitter/test_energy_integrator.py b/tests/flat_emitter/test_energy_integrator.py new file mode 100644 index 0000000..4f5d104 --- /dev/null +++ b/tests/flat_emitter/test_energy_integrator.py @@ -0,0 +1,125 @@ +import pytest + +from span_panel_simulator.flat_emitter.energy_integrator import EnergyIntegrator + + +def test_register_initializes_at_zero() -> None: + ei = EnergyIntegrator() + ei.register("c1") + st = ei.state("c1") + assert st.consumed_wh == 0.0 + assert st.produced_wh == 0.0 + assert st.last_tick_time_s is None + + +def test_register_is_idempotent() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 1000.0, 0.0) + ei.observe("c1", 1000.0, 3600.0) + assert ei.state("c1").consumed_wh == 1000.0 + ei.register("c1") # should not reset + assert ei.state("c1").consumed_wh == 1000.0 + + +def test_first_observe_does_not_integrate() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 1000.0, 0.0) + st = ei.state("c1") + assert st.consumed_wh == 0.0 + assert st.last_tick_time_s == 0.0 + + +def test_consumption_integration_one_hour_at_1000w() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 1000.0, 0.0) + ei.observe("c1", 1000.0, 3600.0) + assert ei.state("c1").consumed_wh == pytest.approx(1000.0) + assert ei.state("c1").produced_wh == 0.0 + + +def test_production_integration_negative_power() -> None: + ei = EnergyIntegrator() + ei.register("pv1") + ei.observe("pv1", -2000.0, 0.0) + ei.observe("pv1", -2000.0, 1800.0) # half hour + assert ei.state("pv1").produced_wh == pytest.approx(1000.0) + assert ei.state("pv1").consumed_wh == 0.0 + + +def test_zero_power_does_not_change_accumulators() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 0.0, 0.0) + ei.observe("c1", 0.0, 3600.0) + assert ei.state("c1").consumed_wh == 0.0 + assert ei.state("c1").produced_wh == 0.0 + + +def test_backwards_dt_is_no_op() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 1000.0, 100.0) + ei.observe("c1", 1000.0, 50.0) # clock went backwards + assert ei.state("c1").consumed_wh == 0.0 + # last_tick_time_s should still update so subsequent ticks integrate from + # the new (earlier) baseline. + assert ei.state("c1").last_tick_time_s == 50.0 + + +def test_seed_overwrites_accumulators() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.seed("c1", consumed_wh=10000.0, produced_wh=500.0) + assert ei.state("c1").consumed_wh == 10000.0 + assert ei.state("c1").produced_wh == 500.0 + + +def test_seed_preserves_time_bookkeeping() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.observe("c1", 1000.0, 100.0) # establishes last_tick_time_s = 100 + ei.seed("c1", consumed_wh=99999.0) + assert ei.state("c1").last_tick_time_s == 100.0 + + +def test_seed_unknown_id_raises() -> None: + ei = EnergyIntegrator() + with pytest.raises(KeyError, match="unknown instance_id"): + ei.seed("ghost", consumed_wh=1.0) + + +def test_observe_unknown_id_raises() -> None: + ei = EnergyIntegrator() + with pytest.raises(KeyError, match="unknown instance_id"): + ei.observe("ghost", 100.0, 0.0) + + +def test_independent_instances_dont_cross_contaminate() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.register("c2") + ei.observe("c1", 1000.0, 0.0) + ei.observe("c2", 500.0, 0.0) + ei.observe("c1", 1000.0, 3600.0) + ei.observe("c2", 500.0, 3600.0) + assert ei.state("c1").consumed_wh == pytest.approx(1000.0) + assert ei.state("c2").consumed_wh == pytest.approx(500.0) + + +def test_known_returns_true_after_register() -> None: + ei = EnergyIntegrator() + assert ei.known("c1") is False + ei.register("c1") + assert ei.known("c1") is True + + +def test_seed_after_initial_observe_then_continued_ticks_carry_seed_baseline() -> None: + ei = EnergyIntegrator() + ei.register("c1") + ei.seed("c1", consumed_wh=5000.0) + ei.observe("c1", 1000.0, 0.0) + ei.observe("c1", 1000.0, 3600.0) + assert ei.state("c1").consumed_wh == pytest.approx(6000.0) diff --git a/tests/flat_emitter/test_exceptions.py b/tests/flat_emitter/test_exceptions.py new file mode 100644 index 0000000..72c79aa --- /dev/null +++ b/tests/flat_emitter/test_exceptions.py @@ -0,0 +1,27 @@ +from span_panel_simulator.flat_emitter.exceptions import ( + EmitterError, + EmitterStateError, + ManifestValidationError, + MissingSetterError, + ProfileValidationError, + RuntimeSpecValidationError, +) + + +def test_all_exceptions_subclass_emitter_error() -> None: + for exc in ( + ManifestValidationError, + RuntimeSpecValidationError, + MissingSetterError, + ProfileValidationError, + EmitterStateError, + ): + assert issubclass(exc, EmitterError) + + +def test_missing_setter_error_carries_pairs() -> None: + pairs = [("circuit", "circuit/relay"), ("panel", "core/dominant-power-source")] + err = MissingSetterError(missing=pairs) + assert err.missing == pairs + assert "circuit" in str(err) + assert "circuit/relay" in str(err) diff --git a/tests/flat_emitter/test_manifest.py b/tests/flat_emitter/test_manifest.py new file mode 100644 index 0000000..937ca05 --- /dev/null +++ b/tests/flat_emitter/test_manifest.py @@ -0,0 +1,48 @@ +import pytest + +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest + + +def _instance( + entity_class: str = "circuit", + instance_id: str = "c1", + display_name: str = "Kitchen", + **metadata: str, +) -> DeviceInstance: + return DeviceInstance( + entity_class=entity_class, + instance_id=instance_id, + display_name=display_name, + metadata=metadata, + ) + + +def test_device_instance_is_frozen() -> None: + inst = _instance() + with pytest.raises(AttributeError): + inst.entity_class = "panel" # type: ignore[misc] + + +def test_manifest_get_returns_matching_instance() -> None: + a = _instance(instance_id="c1") + b = _instance(entity_class="panel", instance_id="p1", display_name="Span") + manifest = DeviceManifest(instances=(a, b)) + assert manifest.get("circuit", "c1") is a + assert manifest.get("panel", "p1") is b + + +def test_manifest_get_raises_on_unknown() -> None: + manifest = DeviceManifest(instances=(_instance(),)) + with pytest.raises(KeyError): + manifest.get("circuit", "missing") + + +def test_manifest_of_class_returns_all_matching() -> None: + a = _instance(instance_id="c1") + b = _instance(instance_id="c2") + p = _instance(entity_class="panel", instance_id="p1") + manifest = DeviceManifest(instances=(a, b, p)) + circuits = manifest.of_class("circuit") + assert len(circuits) == 2 + assert {c.instance_id for c in circuits} == {"c1", "c2"} + assert manifest.of_class("missing") == () diff --git a/tests/flat_emitter/test_manifest_physics.py b/tests/flat_emitter/test_manifest_physics.py new file mode 100644 index 0000000..6702ad0 --- /dev/null +++ b/tests/flat_emitter/test_manifest_physics.py @@ -0,0 +1,289 @@ +import pytest + +from span_panel_simulator.flat_emitter.conventions.tab_legs import Leg +from span_panel_simulator.flat_emitter.exceptions import ManifestValidationError +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.manifest_physics import ManifestPhysicsView + + +def _panel(**md: str) -> DeviceInstance: + base = { + "serial-number": "abc-123", + "vendor-name": "Span", + "firmware-version": "sim/v0.1.0", + "hardware-version": "rev2", + "panel-size": "40", + "main-breaker-rating-a": "200", + "panel-model": "MAIN_40", + "postal-code": "94103", + "time-zone": "America/Los_Angeles", + } + base.update(md) + return DeviceInstance( + entity_class="panel", + instance_id="abc-123", + display_name="Panel", + metadata=base, + ) + + +def _circuit(instance_id: str = "kitchen", **md: str) -> DeviceInstance: + base = { + "tab-numbers": "1", + "breaker-rating-a": "20", + "default-priority": "NICE_TO_HAVE", + "relay-behavior": "controllable", + "placement": "downstream-of-lugs", + } + base.update(md) + return DeviceInstance( + entity_class="circuit", + instance_id=instance_id, + display_name=instance_id, + metadata=base, + ) + + +def test_panel_view_with_defaults() -> None: + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(),))) + assert view.panel.serial_number == "abc-123" + assert view.panel.service_voltage_v == 240.0 + assert view.panel.line_voltage_v == 120.0 + assert view.panel.islandable is False + + +def test_panel_voltage_overrides_apply() -> None: + view = ManifestPhysicsView( + DeviceManifest( + instances=( + _panel( + **{"service-voltage-v": "400", "line-voltage-v": "230", "islandable": "true"} + ), + ) + ) + ) + assert view.panel.service_voltage_v == 400.0 + assert view.panel.line_voltage_v == 230.0 + assert view.panel.islandable is True + + +def test_missing_panel_raises() -> None: + with pytest.raises(ManifestValidationError, match="no panel instance"): + ManifestPhysicsView(DeviceManifest(instances=())) + + +def test_missing_required_panel_key_raises() -> None: + bad = DeviceInstance( + entity_class="panel", + instance_id="x", + display_name="x", + metadata={}, + ) + with pytest.raises(ManifestValidationError, match="serial-number"): + ManifestPhysicsView(DeviceManifest(instances=(bad,))) + + +def test_circuit_single_tab_l1() -> None: + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), _circuit()))) + c = view.circuit("kitchen") + assert c.tabs == (1,) + assert c.legs == (Leg.L1,) + assert c.dipole is False + assert c.always_on is False + + +def test_circuit_dipole_spans_legs() -> None: + view = ManifestPhysicsView( + DeviceManifest( + instances=( + _panel(), + _circuit("hvac", **{"tab-numbers": "1,2", "breaker-rating-a": "40"}), + ) + ) + ) + c = view.circuit("hvac") + assert c.tabs == (1, 2) + assert c.legs == (Leg.L1, Leg.L2) + assert c.dipole is True + + +def test_circuit_dipole_on_same_leg_is_allowed() -> None: + """Real SPAN panels gang two adjacent same-leg tabs as dipole feeds, so we + don't enforce the spans-both-legs rule on the dipole flag. The legs tuple + just reports the truth.""" + inst = _circuit("hvac", **{"tab-numbers": "1,3", "dipole": "true"}) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), inst))) + c = view.circuit("hvac") + assert c.tabs == (1, 3) + assert c.dipole is True + assert c.legs == (Leg.L1, Leg.L1) + + +def test_circuit_multi_tab_without_dipole_flag_raises() -> None: + bad = _circuit("hvac", **{"tab-numbers": "1,2", "dipole": "false"}) + with pytest.raises(ManifestValidationError, match="single-tab circuits only"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_circuit_invalid_priority_raises() -> None: + bad = _circuit(**{"default-priority": "BOGUS"}) + with pytest.raises(ManifestValidationError, match="default-priority"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_circuit_invalid_placement_raises() -> None: + bad = _circuit(**{"placement": "side-of-lugs"}) + with pytest.raises(ManifestValidationError, match="placement"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_circuit_always_on_default_from_relay_behavior() -> None: + view = ManifestPhysicsView( + DeviceManifest( + instances=( + _panel(), + _circuit("dryer", **{"relay-behavior": "always-on"}), + ) + ) + ) + assert view.circuit("dryer").always_on is True + + +def test_circuit_initial_energy_seeds() -> None: + view = ManifestPhysicsView( + DeviceManifest( + instances=( + _panel(), + _circuit( + "kitchen", **{"initial-consumed-wh": "12345.0", "initial-produced-wh": "67.5"} + ), + ) + ) + ) + c = view.circuit("kitchen") + assert c.initial_consumed_wh == 12345.0 + assert c.initial_produced_wh == 67.5 + + +def test_circuit_zero_tab_raises() -> None: + bad = _circuit(**{"tab-numbers": "0"}) + with pytest.raises(ManifestValidationError, match="must be >= 1"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_lugs_direction_validated() -> None: + good_up = DeviceInstance( + entity_class="lugs", + instance_id="up", + display_name="up", + metadata={"direction": "upstream"}, + ) + good_dn = DeviceInstance( + entity_class="lugs", + instance_id="dn", + display_name="dn", + metadata={"direction": "downstream"}, + ) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), good_up, good_dn))) + assert view.lugs("up").direction == "upstream" + assert view.lugs("dn").direction == "downstream" + + bad = DeviceInstance( + entity_class="lugs", + instance_id="x", + display_name="x", + metadata={"direction": "sideways"}, + ) + with pytest.raises(ManifestValidationError, match="direction"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_bess_with_initial_soe() -> None: + bess = DeviceInstance( + entity_class="bess", + instance_id="b1", + display_name="Battery", + metadata={ + "vendor-name": "Span", + "nameplate-capacity-kwh": "13.5", + "initial-soe-kwh": "6.75", + }, + ) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), bess))) + b = view.bess("b1") + assert b.nameplate_capacity_kwh == 13.5 + assert b.initial_soe_kwh == 6.75 + + +def test_bess_without_initial_soe_returns_none() -> None: + bess = DeviceInstance( + entity_class="bess", + instance_id="b1", + display_name="Battery", + metadata={"vendor-name": "Span", "nameplate-capacity-kwh": "13.5"}, + ) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), bess))) + assert view.bess("b1").initial_soe_kwh is None + + +def test_pv_inverter_type_validated() -> None: + pv = DeviceInstance( + entity_class="pv", + instance_id="pv1", + display_name="Solar", + metadata={ + "vendor-name": "Enphase", + "nameplate-capacity-w": "5000", + "inverter-type": "hybrid", + }, + ) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), pv))) + assert view.pv("pv1").inverter_type == "hybrid" + + bad = DeviceInstance( + entity_class="pv", + instance_id="pv2", + display_name="Solar", + metadata={ + "vendor-name": "Enphase", + "nameplate-capacity-w": "5000", + "inverter-type": "string", + }, + ) + with pytest.raises(ManifestValidationError, match="inverter-type"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) + + +def test_evse_required_fields() -> None: + evse = DeviceInstance( + entity_class="evse", + instance_id="ev1", + display_name="EV", + metadata={ + "vendor-name": "SPAN", + "product-name": "SPAN Drive", + "part-number": "SPN-DRV-001", + "serial-number": "SIM-EVSE-1", + "firmware-version": "1.0", + "max-current-a": "32", + }, + ) + view = ManifestPhysicsView(DeviceManifest(instances=(_panel(), evse))) + assert view.evse("ev1").max_current_a == 32.0 + + +def test_multiple_panels_raises() -> None: + p2 = DeviceInstance( + entity_class="panel", + instance_id="def-456", + display_name="P2", + metadata={**_panel().metadata, "serial-number": "def-456"}, + ) + with pytest.raises(ManifestValidationError, match="Multiple panel"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), p2))) + + +def test_error_message_includes_offending_instance_id() -> None: + bad = _circuit("microwave", **{"default-priority": "BOGUS"}) + with pytest.raises(ManifestValidationError, match="circuit/microwave"): + ManifestPhysicsView(DeviceManifest(instances=(_panel(), bad))) diff --git a/tests/flat_emitter/test_panel_meter.py b/tests/flat_emitter/test_panel_meter.py new file mode 100644 index 0000000..d2124fe --- /dev/null +++ b/tests/flat_emitter/test_panel_meter.py @@ -0,0 +1,369 @@ +import pytest + +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 + + +def _panel(**overrides: object) -> PanelPhysics: + base = dict( + serial_number="abc-123", + vendor_name="Span", + firmware_version="sim/v0.1.0", + hardware_version="rev2", + panel_size=40, + main_breaker_rating_a=200, + panel_model="MAIN_40", + postal_code="94103", + time_zone="America/Los_Angeles", + service_voltage_v=240.0, + line_voltage_v=120.0, + islandable=False, + ) + base.update(overrides) + return PanelPhysics(**base) # type: ignore[arg-type] + + +def _circuit( + *, + tabs: tuple[int, ...] = (1,), + legs: tuple[Leg, ...] | None = None, + placement: str = "downstream-of-lugs", + always_on: bool = False, +) -> CircuitPhysics: + if legs is None: + from span_panel_simulator.flat_emitter.conventions.tab_legs import legs_for_tabs + + legs = legs_for_tabs(tabs) + return CircuitPhysics( + tabs=tabs, + legs=legs, + dipole=len(tabs) > 1, + breaker_rating_a=20.0, + default_priority="NICE_TO_HAVE", + relay_behavior="always-on" if always_on else "controllable", + placement=placement, + always_on=always_on, + initial_consumed_wh=0.0, + initial_produced_wh=0.0, + ) + + +# -- circuit_current_a ------------------------------------------------------- + + +def test_single_tab_current_uses_line_voltage() -> None: + assert circuit_current_a(1200.0, dipole=False, line_voltage_v=120.0) == pytest.approx(10.0) + + +def test_dipole_current_uses_line_to_line() -> None: + assert circuit_current_a(2400.0, dipole=True, line_voltage_v=120.0) == pytest.approx(10.0) + + +def test_negative_power_returns_positive_current() -> None: + assert circuit_current_a(-1200.0, dipole=False, line_voltage_v=120.0) == pytest.approx(10.0) + + +def test_zero_voltage_returns_zero_current() -> None: + assert circuit_current_a(1200.0, dipole=False, line_voltage_v=0.0) == 0.0 + + +# -- resolve: grid online, simple consumer ----------------------------------- + + +def test_on_grid_consumer_only() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 1000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + 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 + assert r.power_flow_site == 1000.0 + assert r.grid_state == "ON_GRID" + assert r.dominant_power_source == "GRID" + assert r.main_relay_state == "CLOSED" + assert r.line_voltage_v == 120.0 + + +def test_on_grid_with_pv_export() -> None: + panel = _panel() + circuits = { + "kitchen": _circuit(tabs=(1,)), + "solar": _circuit(tabs=(3,)), + } + powers = {"kitchen": 500.0, "solar": -2000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + # 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 + + +def test_on_grid_with_battery_discharging() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 3000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=2000.0, + grid_online=True, + has_battery=True, + ) + # 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 + + +def test_on_grid_with_battery_charging_from_pv_surplus() -> None: + panel = _panel() + circuits = { + "kitchen": _circuit(tabs=(1,)), + "solar": _circuit(tabs=(3,)), + } + powers = {"kitchen": 500.0, "solar": -2000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=-1500.0, + grid_online=True, + has_battery=True, + ) + # PV surplus charges the BESS without creating utility grid import. + assert r.instant_grid_power_w == 0.0 + assert r.upstream_active_power_w == -1500.0 + assert r.power_flow_battery == -1500.0 + + +def test_pv_surplus_exports_when_battery_charges_less_than_surplus() -> None: + panel = _panel() + circuits = { + "kitchen": _circuit(tabs=(1,)), + "solar": _circuit(tabs=(3,)), + } + powers = {"kitchen": 500.0, "solar": -2000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=-500.0, + grid_online=True, + has_battery=True, + ) + assert r.upstream_active_power_w == -1500.0 + assert r.instant_grid_power_w == -1000.0 + + +def test_battery_charging_never_adds_grid_import() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 500.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=-1500.0, + grid_online=True, + has_battery=True, + ) + assert r.upstream_active_power_w == 500.0 + assert r.instant_grid_power_w == 500.0 + + +def test_without_bess_upstream_lug_power_is_grid_power() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 1000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + assert r.upstream_active_power_w == r.instant_grid_power_w + + +# -- resolve: off-grid ------------------------------------------------------- + + +def test_off_grid_no_battery_zeros_voltage() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 1000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=False, + has_battery=False, + ) + assert r.instant_grid_power_w == 0.0 + assert r.line_voltage_v == 0.0 + assert r.main_relay_state == "OPEN" + assert r.grid_state == "OFF_GRID" + assert r.dominant_power_source is None + + +def test_off_grid_with_battery_keeps_voltage() -> None: + panel = _panel() + circuits = {"kitchen": _circuit(tabs=(1,))} + powers = {"kitchen": 1000.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=1000.0, + grid_online=False, + has_battery=True, + ) + assert r.instant_grid_power_w == 0.0 + assert r.line_voltage_v == 120.0 + assert r.main_relay_state == "OPEN" + assert r.dominant_power_source == "BATTERY" + + +# -- per-leg currents -------------------------------------------------------- + + +def test_per_leg_currents_single_tab_split_l1_l2() -> None: + panel = _panel() + circuits = { + "a": _circuit(tabs=(1,)), # L1 + "b": _circuit(tabs=(2,)), # L2 + } + powers = {"a": 1200.0, "b": 2400.0} # 10 A on L1, 20 A on L2 + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + assert r.upstream_l1_current_a == pytest.approx(10.0) + assert r.upstream_l2_current_a == pytest.approx(20.0) + + +def test_per_leg_currents_dipole_appears_on_both() -> None: + panel = _panel() + circuits = {"hvac": _circuit(tabs=(1, 2))} + powers = {"hvac": 4800.0} # 4800 W / 240 V = 20 A on each leg + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + assert r.upstream_l1_current_a == pytest.approx(20.0) + assert r.upstream_l2_current_a == pytest.approx(20.0) + + +# -- feedthrough ------------------------------------------------------------- + + +def test_feedthrough_is_downstream_only() -> None: + panel = _panel() + circuits = { + "main_breaker_load": _circuit(tabs=(1,), placement="upstream-of-lugs"), + "subpanel_a": _circuit(tabs=(3,), placement="downstream-of-lugs"), + "subpanel_b": _circuit(tabs=(5,), placement="downstream-of-lugs"), + } + powers = {"main_breaker_load": 500.0, "subpanel_a": 1000.0, "subpanel_b": 1500.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + # Feedthrough only counts downstream circuits. + 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 + + +def test_feedthrough_per_leg_currents() -> None: + panel = _panel() + circuits = { + "upstream": _circuit(tabs=(1,), placement="upstream-of-lugs"), + "down_l1": _circuit(tabs=(3,), placement="downstream-of-lugs"), + "down_l2": _circuit(tabs=(4,), placement="downstream-of-lugs"), + } + powers = {"upstream": 1200.0, "down_l1": 600.0, "down_l2": 1200.0} + r = resolve( + panel=panel, + circuits=circuits, + gated_powers=powers, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + # downstream L1: 600/120 = 5; downstream L2: 1200/120 = 10 + assert r.downstream_l1_current_a == pytest.approx(5.0) + assert r.downstream_l2_current_a == pytest.approx(10.0) + # upstream sees ALL circuits: L1 = (1200 + 600)/120 = 15; L2 = 1200/120 = 10 + assert r.upstream_l1_current_a == pytest.approx(15.0) + assert r.upstream_l2_current_a == pytest.approx(10.0) + + +def test_islandable_passed_through_from_panel() -> None: + panel = _panel(islandable=True) + r = resolve( + panel=panel, + circuits={}, + gated_powers={}, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + assert r.grid_islandable is True + + +def test_dsm_and_run_config_track_grid_state() -> None: + panel = _panel() + on = resolve( + panel=panel, + circuits={}, + gated_powers={}, + battery_w=0.0, + grid_online=True, + has_battery=False, + ) + off = resolve( + panel=panel, + circuits={}, + gated_powers={}, + battery_w=0.0, + grid_online=False, + has_battery=True, + ) + assert on.dsm_state == "DSM_ON_GRID" + assert on.current_run_config == "PANEL_ON_GRID" + assert off.dsm_state == "DSM_OFF_GRID" + assert off.current_run_config == "PANEL_OFF_GRID" diff --git a/tests/flat_emitter/test_publish_tick.py b/tests/flat_emitter/test_publish_tick.py new file mode 100644 index 0000000..3472d63 --- /dev/null +++ b/tests/flat_emitter/test_publish_tick.py @@ -0,0 +1,726 @@ +"""Integration tests for ``Emitter.publish_tick``. + +Uses an in-memory FakeMqttClient (same pattern as test_emitter_public_surface) +to assert the wire output is well-formed without a real broker.""" + +from __future__ import annotations + +import json + +import pytest + +from span_panel_simulator.flat_emitter import ( + BESSConfig, + DeviceInstance, + DeviceManifest, + Emitter, + EmitterStateError, + LoadSheddingConfig, + PanelEnvelopeTick, + RelayState, + SetterRegistry, + TickInputs, +) + + +class FakeMqttClient: + def __init__(self) -> None: + self.published: list[tuple[str, bytes, int, bool]] = [] + self.subscribed: list[str] = [] + + def is_connected(self) -> bool: + return True + + async def publish( + self, + topic: str, + payload: bytes, + qos: int = 0, + retain: bool = False, + ) -> None: + self.published.append((topic, payload, qos, retain)) + + async def subscribe(self, topic: str) -> None: + self.subscribed.append(topic) + + +def _panel_inst() -> DeviceInstance: + return DeviceInstance( + "panel", + "abc-123", + "Span Panel", + metadata={ + "vendor-name": "Span", + "serial-number": "abc-123", + "firmware-version": "sim/v0.1.0", + "hardware-version": "rev2", + "panel-size": "40", + "main-breaker-rating-a": "200", + "panel-model": "MAIN_40", + "postal-code": "94103", + "time-zone": "America/Los_Angeles", + }, + ) + + +def _circuit_inst( + cid: str = "kitchen", + *, + tabs: str = "1", + priority: str = "NICE_TO_HAVE", + relay_behavior: str = "controllable", + placement: str = "downstream-of-lugs", +) -> DeviceInstance: + return DeviceInstance( + "circuit", + cid, + cid.title(), + metadata={ + "tab-numbers": tabs, + "breaker-rating-a": "20", + "default-priority": priority, + "relay-behavior": relay_behavior, + "placement": placement, + }, + ) + + +def _bess_inst(instance_id: str = "abc-123-bess") -> DeviceInstance: + return DeviceInstance( + "bess", + instance_id, + "Battery", + metadata={ + "vendor-name": "Span", + "nameplate-capacity-kwh": "13.5", + }, + ) + + +def _registry() -> SetterRegistry: + """Stub setter registry — handlers do nothing. Phase 1 doesn't yet route + /set into RelayResolver internally; that's Phase 2.""" + setters = SetterRegistry() + + async def _noop(entity_class: str, instance_id: str, prop: str, value: object) -> None: + del entity_class, instance_id, prop, value + + setters.register("circuit", "circuit/relay", _noop) + setters.register("circuit", "circuit/shed-priority", _noop) + setters.register("circuit", "circuit/name", _noop) + setters.register("panel", "core/dominant-power-source", _noop) + return setters + + +@pytest.fixture +def emitter_no_bess() -> Emitter: + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + return Emitter(manifest, _registry(), FakeMqttClient()) + + +@pytest.fixture +def emitter_with_bess() -> Emitter: + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst(), _bess_inst())) + bess_cfg = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + ) + return Emitter(manifest, _registry(), FakeMqttClient(), bess_configs=(bess_cfg,)) + + +@pytest.mark.asyncio +async def test_publish_tick_before_start_raises(emitter_no_bess: Emitter) -> None: + with pytest.raises(EmitterStateError, match="before start"): + await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 500.0}), + ) + + +@pytest.mark.asyncio +async def test_publish_tick_emits_circuit_power(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + snap = await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 500.0}), + ) + assert "kitchen" in snap.circuits + assert snap.circuits["kitchen"].instant_power_w == 500.0 + 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 + assert snap.pcs.grid_state == "ON_GRID" + + +@pytest.mark.asyncio +async def test_publish_tick_uses_live_panel_flat_topic_shape(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + fake = emitter_no_bess._publisher._mqtt + assert isinstance(fake, FakeMqttClient) + + description_payload = next( + payload + for topic, payload, _qos, _retain in fake.published + if topic == "ebus/5/abc-123/$description" + ) + description = json.loads(description_payload) + assert description["nodes"]["core"]["type"] == ( + "energy.ebus.device.distribution-enclosure.core" + ) + assert description["nodes"]["kitchen"]["type"] == "energy.ebus.device.circuit" + + await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 500.0}), + ) + retained = {topic: payload.decode() for topic, payload, _qos, _retain in fake.published} + assert retained["ebus/5/abc-123/core/software-version"] == "sim/v0.1.0" + assert retained["ebus/5/abc-123/core/grid-islandable"] == "false" + assert retained["ebus/5/abc-123/kitchen/active-power"] == "-500.0" + assert retained["ebus/5/abc-123/kitchen/space"] == "1" + assert retained["ebus/5/abc-123/kitchen/relay-requester"] == "NONE" + + +@pytest.mark.asyncio +async def test_publish_tick_integrates_energy_across_ticks(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + await emitter_no_bess.publish_tick( + TickInputs(current_time=3600.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + snap = emitter_no_bess.last_snapshot + assert snap is not None + # 1000 W for 1 hour = 1000 Wh + assert snap.circuits["kitchen"].consumed_energy_wh == pytest.approx(1000.0) + assert snap.meter.main_meter_energy_consumed_wh == pytest.approx(1000.0) + + +@pytest.mark.asyncio +async def test_publish_tick_relay_open_zeros_power(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + emitter_no_bess.relays.set_user_override("kitchen", RelayState.OPEN) + snap = await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + assert snap.circuits["kitchen"].instant_power_w == 0.0 + assert snap.circuits["kitchen"].relay_state == "OPEN" + assert snap.circuits["kitchen"].relay_requester == "USER" + # Grid power follows the gated value, not the producer's reported value. + assert snap.meter.instant_grid_power_w == 0.0 + + +@pytest.mark.asyncio +async def test_publish_tick_off_grid_zeros_grid_power(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + snap = await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"kitchen": 1000.0}), + ) + assert snap.meter.instant_grid_power_w == 0.0 + assert snap.pcs.grid_state == "OFF_GRID" + assert snap.meter.l1_voltage == 0.0 + assert snap.meter.l2_voltage == 0.0 + assert snap.status.main_relay_state == "OPEN" + + +@pytest.mark.asyncio +async def test_publish_tick_with_bess_reports_battery(emitter_with_bess: Emitter) -> None: + await emitter_with_bess.start() + snap = await emitter_with_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + bess = snap.battery["abc-123-bess"] + assert bess.communication == "OK" + assert bess.nameplate_capacity_kwh == 13.5 + # First tick establishes baseline; SOE reflects initial 50%. + assert bess.soe_percentage == pytest.approx(50.0) + + +@pytest.mark.asyncio +async def test_publish_tick_diff_only_publishes_changes(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + fake = emitter_no_bess._publisher._mqtt + assert isinstance(fake, FakeMqttClient) + + await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 500.0}), + ) + after_first = len(fake.published) + + # Identical second tick → no new publishes (only retained values changed by + # update_time_s timestamps still flow if mapped). + await emitter_no_bess.publish_tick( + TickInputs(current_time=1.0, grid_online=True, circuits={"kitchen": 500.0}), + ) + after_second = len(fake.published) + # Energy accumulators advance, so consumed-energy will publish; but most + # property values are unchanged. Assert second publish set is much smaller. + assert after_second - after_first < after_first + + +@pytest.mark.asyncio +async def test_publish_tick_pv_export_drives_grid_negative(emitter_no_bess: Emitter) -> None: + # Add a PV-feed circuit by replacing the manifest. Easier: build a fresh + # emitter with both circuits. + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("kitchen", tabs="1"), + _circuit_inst("solar", tabs="3"), + ) + ) + em = Emitter(manifest, _registry(), FakeMqttClient()) + await em.start() + snap = await em.publish_tick( + TickInputs( + current_time=0.0, grid_online=True, circuits={"kitchen": 500.0, "solar": -2000.0} + ), + ) + # load - pv = 500 - 2000 = -1500 (exporting) + assert snap.meter.instant_grid_power_w == -1500.0 + assert snap.power_flows.pv == 2000.0 + + +@pytest.mark.asyncio +async def test_circuit_active_power_wire_sign_is_inverse_of_internal_model() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("kitchen", tabs="1"), + _circuit_inst("solar", tabs="3"), + ) + ) + fake = FakeMqttClient() + em = Emitter(manifest, _registry(), fake) + await em.start() + snap = await em.publish_tick( + TickInputs( + current_time=0.0, + grid_online=True, + circuits={"kitchen": 500.0, "solar": -2000.0}, + ), + ) + + retained = {topic: payload.decode() for topic, payload, _qos, _retain in fake.published} + assert snap.circuits["kitchen"].instant_power_w == 500.0 + assert snap.circuits["solar"].instant_power_w == -2000.0 + assert retained["ebus/5/abc-123/kitchen/active-power"] == "-500.0" + assert retained["ebus/5/abc-123/solar/active-power"] == "2000.0" + + +@pytest.mark.asyncio +async def test_seed_energy_carries_into_first_tick(emitter_no_bess: Emitter) -> None: + emitter_no_bess.seed_energy("kitchen", consumed_wh=5000.0, produced_wh=100.0) + await emitter_no_bess.start() + await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + await emitter_no_bess.publish_tick( + TickInputs(current_time=3600.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + snap = emitter_no_bess.last_snapshot + assert snap is not None + assert snap.circuits["kitchen"].consumed_energy_wh == pytest.approx(6000.0) + assert snap.circuits["kitchen"].produced_energy_wh == pytest.approx(100.0) + + +@pytest.mark.asyncio +async def test_seed_energy_unknown_id_raises(emitter_no_bess: Emitter) -> None: + with pytest.raises(KeyError): + emitter_no_bess.seed_energy("ghost", consumed_wh=1.0) + + +@pytest.mark.asyncio +async def test_seed_bess_soe_overwrites(emitter_with_bess: Emitter) -> None: + emitter_with_bess.seed_bess_soe("abc-123-bess", soe_kwh=10.0) + await emitter_with_bess.start() + snap = await emitter_with_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + # SOE / nameplate * 100 = 10/13.5 * 100 = ~74.07 + bess = snap.battery["abc-123-bess"] + assert bess.soe_kwh == pytest.approx(10.0) + assert bess.soe_percentage == pytest.approx(10.0 / 13.5 * 100.0) + + +@pytest.mark.asyncio +async def test_seed_bess_soe_no_bess_raises(emitter_no_bess: Emitter) -> None: + with pytest.raises(EmitterStateError, match="no BESS"): + emitter_no_bess.seed_bess_soe("anything", soe_kwh=1.0) + + +@pytest.mark.asyncio +async def test_seed_bess_soe_wrong_id_raises(emitter_with_bess: Emitter) -> None: + with pytest.raises(EmitterStateError, match="not among configured"): + emitter_with_bess.seed_bess_soe("wrong-id", soe_kwh=1.0) + + +@pytest.mark.asyncio +async def test_envelope_overrides_propagate(emitter_no_bess: Emitter) -> None: + await emitter_no_bess.start() + env = PanelEnvelopeTick( + door_state="OPEN", + proximity_proven=False, + wifi_ssid="MyHouse", + eth0_link=False, + cloud_connection="DISCONNECTED", + ) + snap = await emitter_no_bess.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 500.0}, envelope=env), + ) + assert snap.door.state == "OPEN" + assert snap.door.proximity_proven is False + assert snap.status.wifi_ssid == "MyHouse" + assert snap.status.eth0_link is False + assert snap.status.cloud_connection == "DISCONNECTED" + + +@pytest.mark.asyncio +async def test_load_shed_off_grid_opens_off_grid_priority_circuit() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("hot_tub", priority="OFF_GRID"), + _circuit_inst("fridge", tabs="3", priority="MUST_HAVE"), + _bess_inst(), + ) + ) + bess = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + initial_soc_pct=80.0, + ) + em = Emitter( + manifest, + _registry(), + FakeMqttClient(), + bess_configs=(bess,), + load_shedding_config=LoadSheddingConfig(soc_threshold_pct=20.0), + ) + await em.start() + snap = await em.publish_tick( + TickInputs( + current_time=0.0, grid_online=False, circuits={"hot_tub": 3000.0, "fridge": 200.0} + ), + ) + # OFF_GRID priority shed regardless of SOC. + assert snap.circuits["hot_tub"].relay_state == "OPEN" + assert snap.circuits["hot_tub"].relay_requester == "BACKUP" + assert snap.circuits["hot_tub"].instant_power_w == 0.0 + # MUST_HAVE not shed. + assert snap.circuits["fridge"].relay_state == "CLOSED" + assert snap.circuits["fridge"].instant_power_w == 200.0 + + +@pytest.mark.asyncio +async def test_load_shed_soc_threshold_only_when_soc_low() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("ev", priority="SOC_THRESHOLD"), + _bess_inst(), + ) + ) + bess = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + initial_soc_pct=50.0, + ) + em = Emitter( + manifest, + _registry(), + FakeMqttClient(), + bess_configs=(bess,), + load_shedding_config=LoadSheddingConfig(soc_threshold_pct=20.0), + ) + await em.start() + # SOC=50%, threshold=20% → NOT shed. + snap_high = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"ev": 7000.0}), + ) + assert snap_high.circuits["ev"].relay_state == "CLOSED" + + # Drop SOC well below threshold by seeding. + em.seed_bess_soe("abc-123-bess", soe_kwh=1.0) # ~7.4% + snap_low = await em.publish_tick( + TickInputs(current_time=1.0, grid_online=False, circuits={"ev": 7000.0}), + ) + assert snap_low.circuits["ev"].relay_state == "OPEN" + assert snap_low.circuits["ev"].relay_requester == "BACKUP" + + +@pytest.mark.asyncio +async def test_user_override_beats_load_shed() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("hot_tub", priority="OFF_GRID"), + _bess_inst(), + ) + ) + bess = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + initial_soc_pct=10.0, + ) + em = Emitter( + manifest, + _registry(), + FakeMqttClient(), + bess_configs=(bess,), + load_shedding_config=LoadSheddingConfig(soc_threshold_pct=20.0), + ) + await em.start() + em.relays.set_user_override("hot_tub", RelayState.CLOSED) + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"hot_tub": 3000.0}), + ) + # Operator commanded CLOSED; load-shed wants OPEN; operator wins. + assert snap.circuits["hot_tub"].relay_state == "CLOSED" + assert snap.circuits["hot_tub"].relay_requester == "USER" + assert snap.circuits["hot_tub"].instant_power_w == 3000.0 + + +@pytest.mark.asyncio +async def test_always_on_beats_load_shed() -> None: + # Mark the circuit as always-on via relay-behavior. + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("smoke_alarm", priority="OFF_GRID", relay_behavior="always-on"), + _bess_inst(), + ) + ) + bess = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + initial_soc_pct=5.0, + ) + em = Emitter( + manifest, + _registry(), + FakeMqttClient(), + bess_configs=(bess,), + load_shedding_config=LoadSheddingConfig(soc_threshold_pct=20.0), + ) + await em.start() + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"smoke_alarm": 50.0}), + ) + # Always-on cannot open regardless. + assert snap.circuits["smoke_alarm"].relay_state == "CLOSED" + assert snap.circuits["smoke_alarm"].relay_requester == "NEVER" + assert snap.circuits["smoke_alarm"].instant_power_w == 50.0 + + +@pytest.mark.asyncio +async def test_shed_clears_when_grid_recovers() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("hot_tub", priority="OFF_GRID"), + _bess_inst(), + ) + ) + bess = BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + ) + em = Emitter( + manifest, + _registry(), + FakeMqttClient(), + bess_configs=(bess,), + load_shedding_config=LoadSheddingConfig(), + ) + await em.start() + snap_off = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"hot_tub": 3000.0}), + ) + assert snap_off.circuits["hot_tub"].relay_state == "OPEN" + + snap_back = await em.publish_tick( + TickInputs(current_time=1.0, grid_online=True, circuits={"hot_tub": 3000.0}), + ) + # Grid restored, shed cleared, circuit back online. + assert snap_back.circuits["hot_tub"].relay_state == "CLOSED" + assert snap_back.circuits["hot_tub"].instant_power_w == 3000.0 + + +@pytest.mark.asyncio +async def test_internal_setters_registered_when_no_producer_handler() -> None: + """Producer can pass an empty SetterRegistry — Emitter fills in defaults + for the four settable properties from its own internal state.""" + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + setters = SetterRegistry() + em = Emitter(manifest, setters, FakeMqttClient()) + # All four required handlers should now be present. + assert setters.get("circuit", "circuit/relay") is not None + assert setters.get("circuit", "circuit/shed-priority") is not None + assert setters.get("circuit", "circuit/name") is not None + assert setters.get("panel", "core/dominant-power-source") is not None + del em # silence unused + + +@pytest.mark.asyncio +async def test_internal_relay_setter_routes_to_relay_resolver() -> None: + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + setters = SetterRegistry() + em = Emitter(manifest, setters, FakeMqttClient()) + await em.start() + + # Simulate /set circuit/relay = false (open). + handler = setters.get("circuit", "circuit/relay") + assert handler is not None + await handler("circuit", "kitchen", "circuit/relay", False) + + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + assert snap.circuits["kitchen"].relay_state == "OPEN" + assert snap.circuits["kitchen"].instant_power_w == 0.0 + + +@pytest.mark.asyncio +async def test_internal_name_setter_overrides_display_name() -> None: + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + setters = SetterRegistry() + em = Emitter(manifest, setters, FakeMqttClient()) + await em.start() + + handler = setters.get("circuit", "circuit/name") + assert handler is not None + await handler("circuit", "kitchen", "circuit/name", "Kitchen Lights") + + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 1000.0}), + ) + assert snap.circuits["kitchen"].name == "Kitchen Lights" + + +@pytest.mark.asyncio +async def test_internal_priority_setter_changes_shed_decision() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("ev", priority="MUST_HAVE"), + _bess_inst(), + ) + ) + setters = SetterRegistry() + em = Emitter( + manifest, + setters, + FakeMqttClient(), + bess_configs=( + BESSConfig( + instance_id="abc-123-bess", + nameplate_capacity_kwh=13.5, + max_charge_w=3500.0, + max_discharge_w=3500.0, + ), + ), + load_shedding_config=LoadSheddingConfig(), + ) + await em.start() + + # Initially MUST_HAVE: not shed off-grid. + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=False, circuits={"ev": 7000.0}), + ) + assert snap.circuits["ev"].relay_state == "CLOSED" + + # Operator changes priority to OFF_GRID. + handler = setters.get("circuit", "circuit/shed-priority") + assert handler is not None + await handler("circuit", "ev", "circuit/shed-priority", "OFF_GRID") + + snap2 = await em.publish_tick( + TickInputs(current_time=1.0, grid_online=False, circuits={"ev": 7000.0}), + ) + assert snap2.circuits["ev"].relay_state == "OPEN" + assert snap2.circuits["ev"].priority == "OFF_GRID" + + +@pytest.mark.asyncio +async def test_internal_dom_power_source_setter_overrides_meter() -> None: + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + setters = SetterRegistry() + em = Emitter(manifest, setters, FakeMqttClient()) + await em.start() + + # Default on-grid → GRID + snap1 = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"kitchen": 100.0}), + ) + assert snap1.pcs.dominant_power_source == "GRID" + + handler = setters.get("panel", "core/dominant-power-source") + assert handler is not None + await handler("panel", "abc-123", "core/dominant-power-source", "BATTERY") + + snap2 = await em.publish_tick( + TickInputs(current_time=1.0, grid_online=True, circuits={"kitchen": 100.0}), + ) + assert snap2.pcs.dominant_power_source == "BATTERY" + + +@pytest.mark.asyncio +async def test_producer_handler_takes_precedence_over_internal() -> None: + """If the producer registered its own handler, the emitter does NOT clobber it.""" + captured: list[str] = [] + + async def producer_handler( + entity_class: str, + instance_id: str, + prop_path: str, + value: object, + ) -> None: + del entity_class, instance_id, prop_path + captured.append(str(value)) + + manifest = DeviceManifest(instances=(_panel_inst(), _circuit_inst())) + setters = SetterRegistry() + setters.register("circuit", "circuit/relay", producer_handler) + em = Emitter(manifest, setters, FakeMqttClient()) + await em.start() + + handler = setters.get("circuit", "circuit/relay") + assert handler is producer_handler + await handler("circuit", "kitchen", "circuit/relay", True) + assert captured == ["True"] + # Internal RelayResolver was NOT updated since producer handler ran instead. + relay_state, _req = em.relays.state("kitchen") + assert relay_state == RelayState.CLOSED # default + + +@pytest.mark.asyncio +async def test_dipole_circuit_per_leg_currents() -> None: + manifest = DeviceManifest( + instances=( + _panel_inst(), + _circuit_inst("hvac", tabs="1,2"), + ) + ) + em = Emitter(manifest, _registry(), FakeMqttClient()) + await em.start() + snap = await em.publish_tick( + TickInputs(current_time=0.0, grid_online=True, circuits={"hvac": 4800.0}), + ) + # Dipole 4800W / 240V = 20A on each leg + assert snap.meter.upstream_l1_current_a == pytest.approx(20.0) + assert snap.meter.upstream_l2_current_a == pytest.approx(20.0) + # 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 diff --git a/tests/flat_emitter/test_relay_resolver.py b/tests/flat_emitter/test_relay_resolver.py new file mode 100644 index 0000000..b690044 --- /dev/null +++ b/tests/flat_emitter/test_relay_resolver.py @@ -0,0 +1,127 @@ +import pytest + +from span_panel_simulator.flat_emitter.relay_resolver import ( + RelayRequester, + RelayResolver, + RelayState, +) + + +def test_default_state_is_closed_unknown() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + assert rr.state("c1") == (RelayState.CLOSED, RelayRequester.UNKNOWN) + + +def test_always_on_resolves_closed_never() -> None: + rr = RelayResolver() + rr.register("dryer", always_on=True) + assert rr.state("dryer") == (RelayState.CLOSED, RelayRequester.NEVER) + + +def test_user_override_open_wins_over_default() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_user_override("c1", RelayState.OPEN) + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.USER) + + +def test_user_override_closed_wins_over_shed() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_shed("c1", open_relay=True) + rr.set_user_override("c1", RelayState.CLOSED) + # Operator commanded CLOSED while shed wants OPEN: operator wins. + assert rr.state("c1") == (RelayState.CLOSED, RelayRequester.USER) + + +def test_user_override_open_persists_across_shed_clear() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_user_override("c1", RelayState.OPEN) + rr.set_shed("c1", open_relay=True) + rr.clear_all_shed() + # /set persists; only shed was cleared + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.USER) + + +def test_clear_user_override_falls_back_to_shed() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_shed("c1", open_relay=True) + rr.set_user_override("c1", RelayState.CLOSED) + rr.clear_user_override("c1") + # Without /set override, shed re-asserts. + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.BACKUP) + + +def test_shed_only_no_override_resolves_open_backup() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_shed("c1", open_relay=True) + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.BACKUP) + + +def test_always_on_ignores_user_override_open() -> None: + rr = RelayResolver() + rr.register("dryer", always_on=True) + rr.set_user_override("dryer", RelayState.OPEN) + # Silently dropped. Always-on remains CLOSED with NEVER requester. + assert rr.state("dryer") == (RelayState.CLOSED, RelayRequester.NEVER) + + +def test_always_on_ignores_shed() -> None: + rr = RelayResolver() + rr.register("dryer", always_on=True) + rr.set_shed("dryer", open_relay=True) + assert rr.state("dryer") == (RelayState.CLOSED, RelayRequester.NEVER) + + +def test_clear_all_shed_resets_only_shed() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.register("c2", always_on=False) + rr.set_shed("c1", open_relay=True) + rr.set_shed("c2", open_relay=True) + rr.set_user_override("c1", RelayState.OPEN) + rr.clear_all_shed() + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.USER) + assert rr.state("c2") == (RelayState.CLOSED, RelayRequester.UNKNOWN) + + +def test_unregistered_instance_set_user_override_raises() -> None: + rr = RelayResolver() + with pytest.raises(KeyError, match="unregistered"): + rr.set_user_override("ghost", RelayState.OPEN) + + +def test_unregistered_instance_set_shed_raises() -> None: + rr = RelayResolver() + with pytest.raises(KeyError, match="unregistered"): + rr.set_shed("ghost", open_relay=True) + + +def test_register_idempotent_keeps_state() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_user_override("c1", RelayState.OPEN) + rr.register("c1", always_on=False) # re-register + # /set override should persist. + assert rr.state("c1") == (RelayState.OPEN, RelayRequester.USER) + + +def test_register_can_change_always_on() -> None: + rr = RelayResolver() + rr.register("c1", always_on=False) + rr.set_user_override("c1", RelayState.OPEN) + rr.register("c1", always_on=True) + # Newly always-on: /set is silently dropped on next set; existing override + # remains in the map but state() honors always-on precedence. + assert rr.state("c1") == (RelayState.CLOSED, RelayRequester.NEVER) + + +def test_known_returns_true_after_register() -> None: + rr = RelayResolver() + assert rr.known("c1") is False + rr.register("c1", always_on=False) + assert rr.known("c1") is True diff --git a/tests/flat_emitter/test_tick_inputs.py b/tests/flat_emitter/test_tick_inputs.py new file mode 100644 index 0000000..ca77b38 --- /dev/null +++ b/tests/flat_emitter/test_tick_inputs.py @@ -0,0 +1,43 @@ +from span_panel_simulator.flat_emitter.tick_inputs import PanelEnvelopeTick, TickInputs + + +def test_tick_inputs_defaults() -> None: + t = TickInputs(current_time=100.0, grid_online=True, circuits={"c1": 500.0}) + assert t.current_time == 100.0 + assert t.grid_online is True + assert t.circuits == {"c1": 500.0} + assert t.evse == {} + assert isinstance(t.envelope, PanelEnvelopeTick) + assert t.envelope.door_state == "CLOSED" + + +def test_panel_envelope_tick_defaults() -> None: + e = PanelEnvelopeTick() + assert e.door_state == "CLOSED" + assert e.proximity_proven is True + assert e.eth0_link is True + assert e.wlan_link is True + assert e.wwan_link is False + assert e.uptime_s == 0 + assert e.wifi_ssid is None + assert e.cloud_connection == "CONNECTED" + + +def test_tick_inputs_is_mutable() -> None: + t = TickInputs(current_time=0.0, grid_online=True, circuits={}) + t.circuits["c1"] = 500.0 + t.evse["ev1"] = 7000.0 + assert t.circuits == {"c1": 500.0} + assert t.evse == {"ev1": 7000.0} + + +def test_envelope_overrides_apply() -> None: + env = PanelEnvelopeTick( + door_state="OPEN", + proximity_proven=False, + wifi_ssid="MyHouse", + ) + t = TickInputs(current_time=0.0, grid_online=True, circuits={}, envelope=env) + assert t.envelope.door_state == "OPEN" + assert t.envelope.proximity_proven is False + assert t.envelope.wifi_ssid == "MyHouse" diff --git a/tests/flat_emitter/wire/__init__.py b/tests/flat_emitter/wire/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flat_emitter/wire/test_circuit_energy_frame.py b/tests/flat_emitter/wire/test_circuit_energy_frame.py new file mode 100644 index 0000000..1eaea0a --- /dev/null +++ b/tests/flat_emitter/wire/test_circuit_energy_frame.py @@ -0,0 +1,119 @@ +"""Circuit power/energy reference-frame tests. + +The snapshot is device-frame (``instant_power_w`` positive = the circuit is +consuming; ``consumed_energy_wh`` accumulates that consumption). The Homie wire +is **enclosure-frame**: values describe flow relative to the enclosure busbar. + + imported-energy = energy imported BY THE ENCLOSURE from the circuit (backfeed) + exported-energy = energy exported BY THE ENCLOSURE to the circuit (load) + active-power > 0 = flowing into the enclosure (backfeed) + active-power < 0 = flowing out of the enclosure to a load + +These tests exist because the energy accumulators were previously published +un-relabelled while ``active-power`` was correctly negated, so a pure load +published a *rising* ``imported-energy`` and a flat ``exported-energy`` — +internally inconsistent with its own power sign, and inverted relative to real +SPAN panel firmware. Consumers reading the wire saw every load circuit as +producing energy. +""" + +from __future__ import annotations + +from span_panel_simulator.flat_emitter.snapshot import ( + EbusCircuitSnapshot, + EbusPanelInfo, + EbusPanelSnapshot, +) +from span_panel_simulator.flat_emitter.wire.bag_builder import _RESOLVERS + +_IMPORTED = _RESOLVERS[("circuit", "circuit/imported-energy")] +_EXPORTED = _RESOLVERS[("circuit", "circuit/exported-energy")] +_ACTIVE_POWER = _RESOLVERS[("circuit", "circuit/active-power")] + + +def _snapshot(circuit: EbusCircuitSnapshot) -> EbusPanelSnapshot: + """A panel snapshot carrying a single circuit — the only field these + resolvers read.""" + return EbusPanelSnapshot( + info=EbusPanelInfo(serial_number="test-panel", firmware_version="test/v0"), + circuits={circuit.circuit_id: circuit}, + ) + + +def _wh(value: object) -> float: + """Narrow a resolver's ``object`` return to a float for comparison.""" + assert isinstance(value, float) + return value + + +def _circuit( + *, + instant_power_w: float, + consumed_energy_wh: float, + produced_energy_wh: float, +) -> EbusCircuitSnapshot: + return EbusCircuitSnapshot( + circuit_id="c1", + name="Test Circuit", + relay_state="CLOSED", + instant_power_w=instant_power_w, + produced_energy_wh=produced_energy_wh, + consumed_energy_wh=consumed_energy_wh, + tabs=[1], + priority="NEVER", + is_user_controllable=True, + is_sheddable=False, + is_never_backup=False, + ) + + +def test_load_circuit_exports_energy_and_reads_negative_power() -> None: + """A pure load: the enclosure is *exporting* energy to it.""" + snap = _snapshot( + _circuit(instant_power_w=300.0, consumed_energy_wh=4430.0, produced_energy_wh=0.0) + ) + + assert _ACTIVE_POWER(snap, "c1") == -300.0 + assert _EXPORTED(snap, "c1") == 4430.0 + assert _IMPORTED(snap, "c1") == 0.0 + + +def test_backfeeding_circuit_imports_energy_and_reads_positive_power() -> None: + """A PV inverter on a breaker: the enclosure is *importing* energy from it.""" + snap = _snapshot( + _circuit(instant_power_w=-8500.0, consumed_energy_wh=0.0, produced_energy_wh=141.6) + ) + + assert _ACTIVE_POWER(snap, "c1") == 8500.0 + assert _IMPORTED(snap, "c1") == 141.6 + assert _EXPORTED(snap, "c1") == 0.0 + + +def test_power_sign_agrees_with_the_accumulator_that_grows() -> None: + """The regression guard: integrating published power must agree with the + published accumulator. Negative power (load) must pair with exported-energy; + positive power (backfeed) must pair with imported-energy.""" + load = _snapshot( + _circuit(instant_power_w=300.0, consumed_energy_wh=4430.0, produced_energy_wh=0.0) + ) + backfeed = _snapshot( + _circuit(instant_power_w=-8500.0, consumed_energy_wh=0.0, produced_energy_wh=141.6) + ) + + assert _wh(_ACTIVE_POWER(load, "c1")) < 0 + assert _wh(_EXPORTED(load, "c1")) > 0, "negative power must grow exported-energy" + assert _wh(_IMPORTED(load, "c1")) == 0 + + assert _wh(_ACTIVE_POWER(backfeed, "c1")) > 0 + assert _wh(_IMPORTED(backfeed, "c1")) > 0, "positive power must grow imported-energy" + assert _wh(_EXPORTED(backfeed, "c1")) == 0 + + +def test_resolvers_return_none_for_unknown_circuit() -> None: + snap = _snapshot( + _circuit(instant_power_w=300.0, consumed_energy_wh=1.0, produced_energy_wh=0.0) + ) + + assert _IMPORTED(snap, "missing") is None + assert _EXPORTED(snap, "missing") is None + assert _ACTIVE_POWER(snap, "missing") is None diff --git a/tests/flat_emitter/wire/test_graph_builder.py b/tests/flat_emitter/wire/test_graph_builder.py new file mode 100644 index 0000000..d376d66 --- /dev/null +++ b/tests/flat_emitter/wire/test_graph_builder.py @@ -0,0 +1,41 @@ +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.wire.graph_builder import build_graph +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 + + +def _manifest_panel_with_one_circuit() -> DeviceManifest: + return DeviceManifest( + instances=( + DeviceInstance(entity_class="panel", instance_id="p1", display_name="Span"), + DeviceInstance(entity_class="circuit", instance_id="c1", display_name="Kitchen"), + ) + ) + + +def test_build_graph_for_panel_and_one_circuit() -> None: + profiles = load_profiles() + mapping = load_mapping_table() + g = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) + # Panel is the only Device under v1_flat node-on-parent layout. + assert "p1" in g.devices + assert "c1" not in g.devices + # Circuit's properties are present, attached to the panel device under namespaced nodes. + assert ("circuit", "c1", "circuit/active-power") in g.properties + assert ("circuit", "c1", "circuit/relay") in g.properties + + +def test_build_graph_is_deterministic() -> None: + profiles = load_profiles() + mapping = load_mapping_table() + 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 + + +def test_build_graph_includes_panel_settable_property() -> None: + profiles = load_profiles() + mapping = load_mapping_table() + g = build_graph(_manifest_panel_with_one_circuit(), mapping, profiles) + assert ("panel", "p1", "core/dominant-power-source") in g.properties diff --git a/tests/flat_emitter/wire/test_graph_builder_topology.py b/tests/flat_emitter/wire/test_graph_builder_topology.py new file mode 100644 index 0000000..a59ef56 --- /dev/null +++ b/tests/flat_emitter/wire/test_graph_builder_topology.py @@ -0,0 +1,199 @@ +"""Topology tests for ``build_graph`` — verify ``parent_entity_class`` is honoured +for ``child-of-parent`` placements (MID-inside-BESS readiness, etc.) using +synthetic mapping descriptors + placeholder profiles. Production mapping/profile +JSONs are NOT modified — fixtures live entirely in this test module.""" + +from __future__ import annotations + +import pytest + +from span_panel_simulator.flat_emitter.exceptions import ProfileValidationError +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.wire.graph_builder import build_graph +from span_panel_simulator.flat_emitter.wire.mapping_loader import ( + DiscoveryConfig, + DisplayConfig, + MappingDescriptor, + MappingTable, + Placement, + WireConfig, +) +from span_panel_simulator.flat_emitter.wire.profile_loader import ( + Profile, + ProfileCapability, + ProfileProperty, + ProfileTable, +) + + +def _minimal_profile(entity_class: str, type_str: str) -> Profile: + """Single-property profile sufficient for graph construction.""" + return Profile( + entity_class=entity_class, + version=1, + type=type_str, + capabilities={ + "info": ProfileCapability( + type="generic", + properties={ + "id": ProfileProperty( + name="ID", + datatype="string", + unit=None, + format=None, + settable=False, + ) + }, + ) + }, + ) + + +def _descriptor( + entity_class: str, + *, + placement: Placement, + profile_filename: str, +) -> MappingDescriptor: + return MappingDescriptor( + entity_class=entity_class, + profile=profile_filename, + profile_version=1, + placement=placement, + wire=WireConfig( + device_id_source="self", + property_path_template="{capability}/{property_key}", + ), + display=DisplayConfig( + name_template="{display_name}", + fallback_name_template=f"{entity_class} {{instance_id_short}}", + ), + discovery=DiscoveryConfig( + description_owner="self", + state_owner="self", + ), + ) + + +def _three_level_chain() -> tuple[DeviceManifest, MappingTable, ProfileTable]: + """Synthetic panel -> bess -> mid topology. + + panel: root device. + bess: child-of-parent (parent_entity_class=panel). + mid: child-of-parent (parent_entity_class=bess) — the MID-inside-BESS + topology that the upcoming eBus migration requires. + """ + mapping = MappingTable() + mapping["panel"] = _descriptor( + "panel", + placement=Placement(kind="root-device", device_id_template="{instance_id}"), + profile_filename="panel.json", + ) + mapping["bess"] = _descriptor( + "bess", + placement=Placement( + kind="child-of-parent", + parent_entity_class="panel", + device_id_template="{instance_id}", + ), + profile_filename="bess.json", + ) + mapping["mid"] = _descriptor( + "mid", + placement=Placement( + kind="child-of-parent", + parent_entity_class="bess", + device_id_template="{instance_id}", + ), + profile_filename="mid.json", + ) + + profiles = ProfileTable() + profiles["panel"] = _minimal_profile("panel", "ebus.panel") + profiles["bess"] = _minimal_profile("bess", "ebus.bess") + profiles["mid"] = _minimal_profile("mid", "ebus.mid") + + manifest = DeviceManifest( + instances=( + DeviceInstance(entity_class="panel", instance_id="p1", display_name="Span"), + DeviceInstance(entity_class="bess", instance_id="b1", display_name="Powerwall"), + DeviceInstance(entity_class="mid", instance_id="m1", display_name="MID"), + ) + ) + return manifest, mapping, profiles + + +def test_three_level_chain_parents_mid_under_bess() -> None: + manifest, mapping, profiles = _three_level_chain() + g = build_graph(manifest, mapping, profiles) + + # All three devices were created. + assert set(g.devices.keys()) == {"p1", "b1", "m1"} + + # children_of records the parent->children topology. + assert g.children_of["p1"] == ("b1",) + assert g.children_of["b1"] == ("m1",) + assert "m1" not in g.children_of # leaf + + # MID was parented under BESS, not under the root panel. + mid_device = g.devices["m1"] + bess_device = g.devices["b1"] + assert mid_device.parent_id() == "b1" + assert mid_device.root_id() == "p1" + assert bess_device.parent_id() == "p1" + assert bess_device.root_id() == "p1" + + # SDK child registration. + assert "m1" in bess_device.children_ids() + assert "b1" in g.devices["p1"].children_ids() + + +def test_descriptor_order_does_not_affect_result() -> None: + """Topo sort must process bess before mid even if mapping order is reversed.""" + manifest, mapping, profiles = _three_level_chain() + + # Reverse insertion order: mid first, then bess, then panel. + reordered = MappingTable() + reordered["mid"] = mapping["mid"] + reordered["bess"] = mapping["bess"] + reordered["panel"] = mapping["panel"] + + g = build_graph(manifest, reordered, profiles) + assert g.children_of["b1"] == ("m1",) + assert g.devices["m1"].parent_id() == "b1" + + +def test_cycle_in_parent_entity_class_raises() -> None: + """Two non-root descriptors that name each other as parent must raise.""" + profiles = ProfileTable() + profiles["panel"] = _minimal_profile("panel", "ebus.panel") + profiles["a"] = _minimal_profile("a", "ebus.a") + profiles["b"] = _minimal_profile("b", "ebus.b") + + mapping = MappingTable() + mapping["panel"] = _descriptor( + "panel", + placement=Placement(kind="root-device", device_id_template="{instance_id}"), + profile_filename="panel.json", + ) + mapping["a"] = _descriptor( + "a", + placement=Placement(kind="child-of-parent", parent_entity_class="b"), + profile_filename="a.json", + ) + mapping["b"] = _descriptor( + "b", + placement=Placement(kind="child-of-parent", parent_entity_class="a"), + profile_filename="b.json", + ) + + manifest = DeviceManifest( + instances=( + DeviceInstance(entity_class="panel", instance_id="p1", display_name="Span"), + DeviceInstance(entity_class="a", instance_id="a1", display_name="A"), + DeviceInstance(entity_class="b", instance_id="b1", display_name="B"), + ) + ) + + with pytest.raises(ProfileValidationError, match="cycle"): + build_graph(manifest, mapping, profiles) diff --git a/tests/flat_emitter/wire/test_lifecycle.py b/tests/flat_emitter/wire/test_lifecycle.py new file mode 100644 index 0000000..bee9f2e --- /dev/null +++ b/tests/flat_emitter/wire/test_lifecycle.py @@ -0,0 +1,118 @@ +import pytest + +from span_panel_simulator.flat_emitter.manifest import DeviceInstance, DeviceManifest +from span_panel_simulator.flat_emitter.wire.graph_builder import build_graph +from span_panel_simulator.flat_emitter.wire.lifecycle import LifecycleController, lwt_settings +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 + + +class FakeMqttClient: + def __init__(self) -> None: + self.published: list[tuple[str, bytes, int, bool]] = [] + self.subscribed: list[str] = [] + self._connected = True + + def is_connected(self) -> bool: + return self._connected + + async def publish( + self, + topic: str, + payload: bytes, + qos: int = 0, + retain: bool = False, + ) -> None: + self.published.append((topic, payload, qos, retain)) + + async def subscribe(self, topic: str) -> None: + self.subscribed.append(topic) + + +def _manifest() -> DeviceManifest: + return DeviceManifest( + instances=( + DeviceInstance("panel", "p1", "Span"), + DeviceInstance("circuit", "c1", "Kitchen"), + ) + ) + + +def _build(mqtt: FakeMqttClient) -> LifecycleController: + profiles = load_profiles() + mapping = load_mapping_table() + graph = build_graph(_manifest(), mapping, profiles) + return LifecycleController( + _manifest(), + mapping, + profiles, + graph, + mqtt, + domain="ebus", + bus_version="5", + subscriptions=[], + ) + + +@pytest.mark.asyncio +async def test_v1_cold_start_publishes_init_description_ready_in_order() -> None: + mqtt = FakeMqttClient() + lc = _build(mqtt) + await lc.start() + + # First publish must be $state=init on the root. + assert mqtt.published[0] == ("ebus/5/p1/$state", b"init", 1, True) + # Last publish must be $state=ready on the root. + assert mqtt.published[-1] == ("ebus/5/p1/$state", b"ready", 1, True) + # At least one $description publish in between. + description_topics = [t for (t, _, _, _) in mqtt.published if t.endswith("$description")] + assert "ebus/5/p1/$description" in description_topics + + +def test_lwt_settings_returns_root_state_topic_with_lost_payload() -> None: + topic, payload, qos, retain = lwt_settings( + _manifest(), + domain="ebus", + bus_version="5", + root_entity_class="panel", + ) + assert topic == "ebus/5/p1/$state" + assert payload == b"lost" + assert qos == 1 + assert retain is True + + +@pytest.mark.asyncio +async def test_graceful_stop_publishes_disconnected_on_root() -> None: + mqtt = FakeMqttClient() + lc = _build(mqtt) + await lc.start() + mqtt.published.clear() + await lc.stop(graceful=True) + assert ("ebus/5/p1/$state", b"disconnected", 1, True) in mqtt.published + + +@pytest.mark.asyncio +async def test_graceful_stop_can_clear_retained_topics() -> None: + mqtt = FakeMqttClient() + lc = _build(mqtt) + await lc.start() + mqtt.published.clear() + await lc.stop(graceful=True, clear_retained=True) + + tombstones = { + topic for topic, payload, _qos, retain in mqtt.published if payload == b"" and retain + } + assert "ebus/5/p1/$state" in tombstones + assert "ebus/5/p1/$description" in tombstones + assert "ebus/5/p1/c1/active-power" in tombstones + + +@pytest.mark.asyncio +async def test_non_graceful_stop_publishes_nothing() -> None: + mqtt = FakeMqttClient() + lc = _build(mqtt) + await lc.start() + mqtt.published.clear() + await lc.stop(graceful=False) + assert mqtt.published == [] diff --git a/tests/flat_emitter/wire/test_profile_mapping_validation.py b/tests/flat_emitter/wire/test_profile_mapping_validation.py new file mode 100644 index 0000000..e51c096 --- /dev/null +++ b/tests/flat_emitter/wire/test_profile_mapping_validation.py @@ -0,0 +1,84 @@ +from pathlib import Path + +import pytest + +from span_panel_simulator.flat_emitter.exceptions import ProfileValidationError +from span_panel_simulator.flat_emitter.wire.mapping_loader import MappingTable, load_mapping_table +from span_panel_simulator.flat_emitter.wire.profile_loader import ProfileTable, load_profiles + + +def test_load_profiles_returns_all_vendored() -> None: + profiles = load_profiles() + assert isinstance(profiles, ProfileTable) + for cls in ("panel", "circuit", "lugs", "bess", "pv", "evse"): + assert cls in profiles, f"profile {cls} missing" + + +def test_load_mapping_returns_all_vendored() -> None: + mapping = load_mapping_table() + assert isinstance(mapping, MappingTable) + for cls in ("panel", "circuit", "lugs", "bess", "pv", "evse"): + assert cls in mapping, f"mapping {cls} missing" + + +def test_mapping_cross_check_passes_against_vendored_profiles() -> None: + profiles = load_profiles() + mapping = load_mapping_table() + mapping.validate_against(profiles) + + +def test_mapping_cross_check_rejects_unknown_parent_entity_class(tmp_path: Path) -> None: + panel_yaml = """\ +entity_class: panel +profile: panel.json +profile_version: 1 +placement: + kind: root-device + device_id_template: "{instance_id}" +wire: + device_id_source: self + property_path_template: "{capability}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "Panel {instance_id_short}" +discovery: + $description_owner: self + state_owner: self +""" + bad_yaml = """\ +entity_class: circuit +profile: circuit.json +profile_version: 1 +placement: + kind: node-on-parent + parent_entity_class: nonexistent + node_id_template: "{instance_id}" +wire: + device_id_source: parent + property_path_template: "{node_id}/{property_key}" +display: + name_template: "{display_name}" + fallback_name_template: "Circuit {instance_id_short}" +discovery: + $description_owner: parent + state_owner: parent +""" + (tmp_path / "panel.yaml").write_text(panel_yaml) + (tmp_path / "circuit.yaml").write_text(bad_yaml) + profiles = load_profiles() + mapping = load_mapping_table(directory=tmp_path) + with pytest.raises(ProfileValidationError, match="parent_entity_class"): + mapping.validate_against(profiles) + + +def test_settable_properties_circuit_includes_relay_and_priority() -> None: + profiles = load_profiles() + settables = profiles["circuit"].settable_properties() + assert ("circuit", "relay") in settables + assert ("circuit", "shed-priority") in settables + + +def test_settable_properties_panel_includes_dominant_power_source() -> None: + profiles = load_profiles() + settables = profiles["panel"].settable_properties() + assert ("core", "dominant-power-source") in settables diff --git a/tests/flat_emitter/wire/test_property_bag.py b/tests/flat_emitter/wire/test_property_bag.py new file mode 100644 index 0000000..7f9dfbf --- /dev/null +++ b/tests/flat_emitter/wire/test_property_bag.py @@ -0,0 +1,80 @@ +from span_panel_simulator.flat_emitter.wire.property_bag import PropertyBag, PropertyDiffer + + +def _bag(*items: tuple[str, str, str, object]) -> PropertyBag: + bag = PropertyBag(values={}) + for ec, iid, pp, value in items: + bag.set(ec, iid, pp, value) + return bag + + +def test_first_diff_emits_all_keys() -> None: + differ = PropertyDiffer( + all_keys=[ + ("circuit", "c1", "circuit/active-power"), + ("circuit", "c1", "circuit/relay"), + ] + ) + bag = _bag( + ("circuit", "c1", "circuit/active-power", 200.0), + ("circuit", "c1", "circuit/relay", "CLOSED"), + ) + changes = differ.diff(bag) + assert len(changes) == 2 + differ.commit(changes) + + +def test_unchanged_diff_returns_empty() -> None: + differ = PropertyDiffer(all_keys=[("circuit", "c1", "circuit/active-power")]) + bag = _bag(("circuit", "c1", "circuit/active-power", 200.0)) + differ.commit(differ.diff(bag)) + assert differ.diff(bag) == [] + + +def test_changed_value_returns_only_changed_key() -> None: + differ = PropertyDiffer( + all_keys=[ + ("circuit", "c1", "circuit/active-power"), + ("circuit", "c1", "circuit/relay"), + ] + ) + differ.commit( + differ.diff( + _bag( + ("circuit", "c1", "circuit/active-power", 200.0), + ("circuit", "c1", "circuit/relay", "CLOSED"), + ) + ) + ) + bag2 = _bag( + ("circuit", "c1", "circuit/active-power", 205.0), + ("circuit", "c1", "circuit/relay", "CLOSED"), + ) + changes = differ.diff(bag2) + assert len(changes) == 1 + assert changes[0][0] == ("circuit", "c1", "circuit/active-power") + + +def test_sparse_bag_does_not_clear_absent_keys() -> None: + differ = PropertyDiffer(all_keys=[("circuit", "c1", "circuit/active-power")]) + differ.commit(differ.diff(_bag(("circuit", "c1", "circuit/active-power", 200.0)))) + sparse = PropertyBag(values={}) + assert differ.diff(sparse) == [] + + +def test_change_set_is_sorted() -> None: + differ = PropertyDiffer( + all_keys=[ + ("circuit", "c2", "circuit/active-power"), + ("circuit", "c1", "circuit/active-power"), + ("panel", "p1", "core/software-version"), + ] + ) + bag = _bag( + ("circuit", "c2", "circuit/active-power", 2.0), + ("circuit", "c1", "circuit/active-power", 1.0), + ("panel", "p1", "core/software-version", "r2026"), + ) + changes = differ.diff(bag) + keys = [k for k, _ in changes] + assert keys == sorted(keys) diff --git a/tests/flat_emitter/wire/test_sdk_seam.py b/tests/flat_emitter/wire/test_sdk_seam.py new file mode 100644 index 0000000..eaf8199 --- /dev/null +++ b/tests/flat_emitter/wire/test_sdk_seam.py @@ -0,0 +1,41 @@ +"""Smoke test for the SDK seam — exercises real ebus_sdk Property construction.""" + +from __future__ import annotations + +import ebus_sdk +import pytest + +from span_panel_simulator.flat_emitter.wire._sdk_seam import make_property, set_property_value + + +def test_make_property_attaches_to_node() -> None: + device = ebus_sdk.Device("d1", name="Test") + node = device.add_node_from_dict({"id": "meter", "name": "Meter", "type": "meter"}) + prop = make_property( + node=node, + key="active-power", + name="Active Power", + datatype=ebus_sdk.PropertyDatatype.FLOAT, + unit=ebus_sdk.Unit.WATT, + format_str=None, + settable=False, + ) + assert prop is not None + assert prop.id() == "active-power" + + +@pytest.mark.asyncio +async def test_set_property_value_does_not_raise() -> None: + device = ebus_sdk.Device("d1", name="Test") + node = device.add_node_from_dict({"id": "meter", "name": "Meter", "type": "meter"}) + prop = make_property( + node=node, + key="active-power", + name="Active Power", + datatype=ebus_sdk.PropertyDatatype.FLOAT, + unit=ebus_sdk.Unit.WATT, + format_str=None, + settable=False, + ) + # Without an attached MQTT client this is a no-op; we just want the seam to not raise. + await set_property_value(prop, 1234.5) diff --git a/tests/flat_emitter/wire/test_set_router.py b/tests/flat_emitter/wire/test_set_router.py new file mode 100644 index 0000000..2d0fe7d --- /dev/null +++ b/tests/flat_emitter/wire/test_set_router.py @@ -0,0 +1,113 @@ +import pytest + +from span_panel_simulator.flat_emitter.exceptions import MissingSetterError +from span_panel_simulator.flat_emitter.wire.set_router import ( + SetSubscription, + SetterRegistry, + compute_subscriptions, + dispatch, +) + + +def _settables() -> dict[str, list[tuple[str, str]]]: + return { + "circuit": [("circuit", "relay"), ("circuit", "shed-priority")], + "panel": [("core", "dominant-power-source")], + } + + +def _instances() -> list[tuple[str, str]]: + return [("circuit", "c1"), ("circuit", "c2"), ("panel", "p1")] + + +async def _noop(*_a: object, **_kw: object) -> None: + return None + + +def test_compute_subscriptions_produces_one_per_settable_per_instance() -> None: + reg = SetterRegistry() + reg.register("circuit", "circuit/relay", _noop) + reg.register("circuit", "circuit/shed-priority", _noop) + reg.register("panel", "core/dominant-power-source", _noop) + + subs = compute_subscriptions( + instances=_instances(), + settables_by_class=_settables(), + registry=reg, + domain="ebus", + bus_version="5", + device_id_for=lambda ec, iid: "p1" if ec == "circuit" else iid, + node_id_for=lambda _ec, iid, cap: iid if cap == "circuit" else cap, + ) + assert len(subs) == 5 + topics = {s.topic_pattern for s in subs} + assert "ebus/5/p1/c1/relay/set" in topics + assert "ebus/5/p1/core/dominant-power-source/set" in topics + + +def test_compute_subscriptions_raises_on_missing_handler() -> None: + reg = SetterRegistry() + reg.register("circuit", "circuit/relay", _noop) + + with pytest.raises(MissingSetterError) as excinfo: + compute_subscriptions( + instances=_instances(), + settables_by_class=_settables(), + registry=reg, + domain="ebus", + bus_version="5", + device_id_for=lambda ec, iid: "p1" if ec == "circuit" else iid, + node_id_for=lambda _ec, iid, cap: iid if cap == "circuit" else cap, + ) + assert ("circuit", "circuit/shed-priority") in excinfo.value.missing + assert ("panel", "core/dominant-power-source") in excinfo.value.missing + + +@pytest.mark.asyncio +async def test_dispatch_invokes_handler_with_decoded_value() -> None: + invoked: list[tuple[str, str, str, object]] = [] + + async def handler(ec: str, iid: str, pp: str, value: object) -> None: + invoked.append((ec, iid, pp, value)) + + sub = SetSubscription( + topic_pattern="ebus/5/p1/c1/relay/set", + entity_class="circuit", + instance_id="c1", + property_path="circuit/relay", + datatype="enum", + handler=handler, + ) + await dispatch("ebus/5/p1/c1/relay/set", b"CLOSED", [sub]) + assert invoked == [("circuit", "c1", "circuit/relay", "CLOSED")] + + +@pytest.mark.asyncio +async def test_dispatch_drops_topic_miss() -> None: + invoked: list[tuple[object, ...]] = [] + + async def handler(*a: object) -> None: + invoked.append(a) + + sub = SetSubscription( + "ebus/5/p1/c1/relay/set", "circuit", "c1", "circuit/relay", "string", handler + ) + await dispatch("unrelated/topic", b"x", [sub]) + assert invoked == [] + + +@pytest.mark.asyncio +async def test_dispatch_re_raises_handler_exception() -> None: + async def boom(*_a: object, **_kw: object) -> None: + raise RuntimeError("handler bug") + + sub = SetSubscription( + topic_pattern="ebus/5/p1/c1/relay/set", + entity_class="circuit", + instance_id="c1", + property_path="circuit/relay", + datatype="enum", + handler=boom, + ) + with pytest.raises(RuntimeError, match="handler bug"): + await dispatch("ebus/5/p1/c1/relay/set", b"CLOSED", [sub]) diff --git a/tests/flat_emitter/wire/test_wire_paths.py b/tests/flat_emitter/wire/test_wire_paths.py new file mode 100644 index 0000000..c498eee --- /dev/null +++ b/tests/flat_emitter/wire/test_wire_paths.py @@ -0,0 +1,34 @@ +from span_panel_simulator.flat_emitter.wire.wire_paths import ( + device_description_topic, + device_state_topic, + parse_set_topic, + root_state_topic, + set_topic_for, +) + + +def test_root_state_topic() -> None: + assert root_state_topic("ebus", "5", "panel-1") == "ebus/5/panel-1/$state" + + +def test_device_state_and_description() -> None: + assert device_state_topic("ebus", "5", "panel-1") == "ebus/5/panel-1/$state" + assert device_description_topic("ebus", "5", "panel-1") == "ebus/5/panel-1/$description" + + +def test_set_topic_for() -> None: + assert ( + set_topic_for("ebus", "5", "panel-1", "switch", "relay") + == "ebus/5/panel-1/switch/relay/set" + ) + + +def test_parse_set_topic_matches_expected_shape() -> None: + parsed = parse_set_topic("ebus/5/panel-1/switch/relay/set", "ebus", "5") + assert parsed == ("panel-1", "switch", "relay") + + +def test_parse_set_topic_returns_none_on_mismatch() -> None: + assert parse_set_topic("nope", "ebus", "5") is None + assert parse_set_topic("ebus/5/panel-1/$state", "ebus", "5") is None + assert parse_set_topic("ebus/5/panel-1/x", "ebus", "5") is None diff --git a/tests/test_panel.py b/tests/test_panel.py index bc05d1d..b5e00c6 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -97,7 +97,8 @@ async def test_serial_before_start_raises(self, simple_config: Path) -> None: # Engine-only tests removed post-cutover — the legacy DynamicSimulationEngine and its -# in-memory snapshot/total_tabs accessors were lifted into ebus_emitter.scheduleRunner. +# in-memory snapshot/total_tabs accessors were lifted into +# span_panel_simulator.flat_emitter.scheduleRunner. # Equivalent behaviour is exercised by: # - tests/emitter_adapter/test_spec_generator.py (manifest + runtime spec construction) # - the emitter package's own scheduleRunner test suite diff --git a/uv.lock b/uv.lock index 00f86f9..bacb587 100644 --- a/uv.lock +++ b/uv.lock @@ -299,6 +299,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "ebus-mqtt-client" +version = "0.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/3b/8c80258871abe09f90e3a11218485294ebed0fe88f80546d07fd58c62158/ebus_mqtt_client-0.1.8.tar.gz", hash = "sha256:282347fbdca2b2baa395a03b677eb78bb320a9a17dadd1b0d73862b8da736833", size = 17238, upload-time = "2026-07-20T18:08:32.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/42/f8e4565adf3e13f52690172bd6e8f925a8adfbbea12c4b9eb9d1004061bf/ebus_mqtt_client-0.1.8-py3-none-any.whl", hash = "sha256:7a2876a148e7c31be13fc7dc4a0f819f7ee51949bf2588c6996a5e00889a105e", size = 10538, upload-time = "2026-07-20T18:08:30.851Z" }, +] + +[[package]] +name = "ebus-sdk" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ebus-mqtt-client" }, + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/95/ef27d5bff409527824a47e05ca1eb39b988f932fbd740bacf5ec2c4f1512/ebus_sdk-0.1.5.tar.gz", hash = "sha256:bba3151f0e1809ea44d39c476ebd23d6e46d7eb963009da91f787d755fb3a3da", size = 36924, upload-time = "2026-04-18T01:19:30.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/15/aa037947096072ab28ef1e8133ea584f78ff53ad6208dc719fac6f45858a/ebus_sdk-0.1.5-py3-none-any.whl", hash = "sha256:a077b12e3edc4b9a57b371f90691747eb7c98b223ea57f7217fcb88120c9ca0f", size = 25252, upload-time = "2026-04-18T01:19:29.204Z" }, +] + [[package]] name = "filelock" version = "3.25.2" @@ -895,7 +920,9 @@ dependencies = [ { name = "aiohttp-jinja2" }, { name = "aiomqtt" }, { name = "cryptography" }, + { name = "ebus-sdk" }, { name = "jinja2" }, + { name = "paho-mqtt" }, { name = "pyyaml" }, { name = "timezonefinder" }, { name = "zeroconf" }, @@ -919,7 +946,9 @@ requires-dist = [ { name = "aiohttp-jinja2", specifier = ">=1.6" }, { name = "aiomqtt", specifier = ">=2.0.0" }, { name = "cryptography", specifier = ">=42.0.0" }, + { name = "ebus-sdk", specifier = "==0.1.5" }, { name = "jinja2", specifier = ">=3.1.0" }, + { name = "paho-mqtt", specifier = ">=2.0.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "timezonefinder", specifier = ">=6.0" }, { name = "zeroconf", specifier = ">=0.131.0" },