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 863fa43..3dade22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # 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.13 — 2026-07-31 — circuit energy reference frame ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 25271c3..b146bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/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 ecaec54..be679f1 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" },