Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 43 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]
Expand All @@ -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"
Expand All @@ -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
Expand Down
50 changes: 8 additions & 42 deletions scripts/dev-setup.sh
Original file line number Diff line number Diff line change
@@ -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 <path>` 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__)'"
7 changes: 3 additions & 4 deletions scripts/run-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 6 additions & 5 deletions src/span_panel_simulator/emitter_adapter/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")),
Expand Down
3 changes: 1 addition & 2 deletions src/span_panel_simulator/emitter_adapter/spec_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/span_panel_simulator/energy/__init__.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/energy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
2 changes: 1 addition & 1 deletion src/span_panel_simulator/energy/system.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/span_panel_simulator/energy/types.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
11 changes: 5 additions & 6 deletions src/span_panel_simulator/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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(
Expand Down
Loading