ebus-panel-sim 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ebus_panel_sim-0.3.0/.ebus-spec.json +36 -0
- ebus_panel_sim-0.3.0/.github/CODEOWNERS +2 -0
- ebus_panel_sim-0.3.0/.github/workflows/ci.yaml +40 -0
- ebus_panel_sim-0.3.0/.github/workflows/publish.yml +105 -0
- ebus_panel_sim-0.3.0/.gitignore +17 -0
- ebus_panel_sim-0.3.0/.pre-commit-config.yaml +31 -0
- ebus_panel_sim-0.3.0/.python-version +1 -0
- ebus_panel_sim-0.3.0/AUTHORS +7 -0
- ebus_panel_sim-0.3.0/CHANGELOG.md +55 -0
- ebus_panel_sim-0.3.0/CONTRIBUTING.md +69 -0
- ebus_panel_sim-0.3.0/DESIGN.md +89 -0
- ebus_panel_sim-0.3.0/DEVELOPER.md +145 -0
- ebus_panel_sim-0.3.0/LICENSE +21 -0
- ebus_panel_sim-0.3.0/PKG-INFO +14 -0
- ebus_panel_sim-0.3.0/README.md +176 -0
- ebus_panel_sim-0.3.0/examples/forty_tab_minimal.yaml +83 -0
- ebus_panel_sim-0.3.0/examples/run_forty_tab_minimal.py +432 -0
- ebus_panel_sim-0.3.0/pyproject.toml +77 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/__init__.py +119 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/conventions/__init__.py +11 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/conventions/tab_legs.py +47 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/emitter.py +750 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/energy_integrator.py +89 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/exceptions.py +38 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/manifest.py +35 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/manifest_physics.py +451 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/__init__.py +24 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/bess.py +185 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/load_shedding.py +60 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/protocol.py +38 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/panel_meter.py +216 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/py.typed +0 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/relay_resolver.py +113 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/snapshot.py +301 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/tick_inputs.py +66 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/__init__.py +0 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/_sdk_seam.py +55 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/bag_builder.py +429 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/breaker.json +52 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/charge-limit.json +37 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/connection.json +72 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/door.json +17 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/grid.json +38 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/info.json +52 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/load-shed.json +18 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/meter.json +201 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/pcs.json +111 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/power-flows.json +35 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +41 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/shed.json +24 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/soc.json +35 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/status.json +28 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/switch.json +29 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/graph_builder.py +309 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/bess.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/circuit.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/evse.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/lugs.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/mid.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/panel.yaml +15 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/pv.yaml +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping_loader.py +144 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profile_loader.py +215 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/bess.json +58 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/circuit.json +88 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/evse.json +26 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/lugs.json +52 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/mid.json +40 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/panel.json +160 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/pv.json +28 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/circuit.json +19 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/evse.json +52 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/lugs.json +16 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/panel.json +77 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/property_bag.py +56 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/publisher.py +36 -0
- ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/set_router.py +122 -0
- ebus_panel_sim-0.3.0/tests/__init__.py +0 -0
- ebus_panel_sim-0.3.0/tests/conftest.py +71 -0
- ebus_panel_sim-0.3.0/tests/conventions/__init__.py +0 -0
- ebus_panel_sim-0.3.0/tests/conventions/test_tab_legs.py +40 -0
- ebus_panel_sim-0.3.0/tests/test_catalog_drift.py +67 -0
- ebus_panel_sim-0.3.0/tests/test_connection.py +150 -0
- ebus_panel_sim-0.3.0/tests/test_emitter_public_surface.py +148 -0
- ebus_panel_sim-0.3.0/tests/test_energy_integrator.py +125 -0
- ebus_panel_sim-0.3.0/tests/test_exceptions.py +27 -0
- ebus_panel_sim-0.3.0/tests/test_manifest.py +48 -0
- ebus_panel_sim-0.3.0/tests/test_manifest_physics.py +289 -0
- ebus_panel_sim-0.3.0/tests/test_mid_placement.py +91 -0
- ebus_panel_sim-0.3.0/tests/test_panel_meter.py +356 -0
- ebus_panel_sim-0.3.0/tests/test_publish_tick.py +636 -0
- ebus_panel_sim-0.3.0/tests/test_relay_resolver.py +123 -0
- ebus_panel_sim-0.3.0/tests/test_shed_forecast.py +94 -0
- ebus_panel_sim-0.3.0/tests/test_tick_inputs.py +43 -0
- ebus_panel_sim-0.3.0/tests/test_variant.py +119 -0
- ebus_panel_sim-0.3.0/tests/test_wire_units.py +80 -0
- ebus_panel_sim-0.3.0/tests/wire/__init__.py +0 -0
- ebus_panel_sim-0.3.0/tests/wire/test_circuit_energy_frame.py +119 -0
- ebus_panel_sim-0.3.0/tests/wire/test_graph_builder.py +50 -0
- ebus_panel_sim-0.3.0/tests/wire/test_graph_builder_topology.py +199 -0
- ebus_panel_sim-0.3.0/tests/wire/test_profile_mapping_validation.py +84 -0
- ebus_panel_sim-0.3.0/tests/wire/test_property_bag.py +80 -0
- ebus_panel_sim-0.3.0/tests/wire/test_sdk_seam.py +37 -0
- ebus_panel_sim-0.3.0/tests/wire/test_set_router.py +136 -0
- ebus_panel_sim-0.3.0/uv.lock +350 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ebus.energy/schemas/ebus-spec.json",
|
|
3
|
+
"spec_repo": "https://github.com/electrification-bus/specification",
|
|
4
|
+
"synced_commit": "6e582c994fff4c77853a79d8bab26ef9924e22c7",
|
|
5
|
+
"synced_date": "2026-08-01",
|
|
6
|
+
"role": "publisher",
|
|
7
|
+
"framework": "0.7",
|
|
8
|
+
"implements": {
|
|
9
|
+
"capabilities": {
|
|
10
|
+
"info": "0.2",
|
|
11
|
+
"meter": "0.2",
|
|
12
|
+
"switch": "0.1",
|
|
13
|
+
"breaker": "0.1",
|
|
14
|
+
"connection": "0.1",
|
|
15
|
+
"load-shed": "0.3",
|
|
16
|
+
"pcs": "0.3",
|
|
17
|
+
"shed": "0.2",
|
|
18
|
+
"shed-forecast": "0.1",
|
|
19
|
+
"grid": "0.1",
|
|
20
|
+
"power-flows": "0.1",
|
|
21
|
+
"door": "0.1",
|
|
22
|
+
"status": "0.1",
|
|
23
|
+
"soc": "0.1"
|
|
24
|
+
},
|
|
25
|
+
"devices": {
|
|
26
|
+
"distribution-enclosure": "0.12",
|
|
27
|
+
"circuit": "0.3",
|
|
28
|
+
"bess": "0.14"
|
|
29
|
+
},
|
|
30
|
+
"registries": {
|
|
31
|
+
"capability-types": "0.19",
|
|
32
|
+
"device-types": "0.4"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"notes": "role=publisher: ebus-panel-sim publishes a full Homie 5 distribution-enclosure tree (enclosure + circuits + upstream/downstream lugs + bess/pv/evse/mid children) as a producer-side reference/test fixture for the eBus convention. The wire type contract is single-sourced from the vendored spec capability catalogs (src/ebus_panel_sim/wire/catalogs/, byte copies of the spec capabilities/ at synced_commit); base profiles are light selections hydrated against them, guarded by tests/test_catalog_drift.py (lockfile-vs-catalog versions always; vendored-vs-source content when a ../specification checkout is present). ebus-panel-sim ships two variants: 'reference' publishes the spec-conformant surface; 'span' (the default) deep-merges a SPAN-vendor overlay (src/ebus_panel_sim/wire/profiles/span/) and is the SPAN-faithful fixture. Capabilities are pinned to what the profiles publish AND that exist in the current spec catalog. Deliberate SPAN-variant deviations, carried in the overlay and absent from 'reference': panel status diagnostics (relay/ethernet/wifi/wifi-ssid/cloud-connection/postal-code/time-zone); shed/policy published read-only (settable=false), because a SPAN panel does not accept policy writes, whereas the spec catalog and the 'reference' variant make it settable and the Emitter accepts policy writes there; the legacy evse `config` grab-bag (the spec `charge-limit` catalog is vendored but not yet composed into the evse profile, DESIM-a5p.15.1, hence `charge-limit` is not pinned); circuit info/name + info/spaces; lugs info/direction. Remaining spec reconciliation: pv omits `meter` (DESIM-a5p.15.4). Device coverage: pv/evse/mid/lugs have no standalone versioned device model yet (pv gated on SPEC-730, evse on SPEC-a8e, mid Planned on SPEC-9jz, lugs unmodeled) and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned. The property-JSON shape contract (conventions/property-json, property-schema-v1 v0.1) is NOT pinned here because spec-provenance's implements schema has only capabilities/devices/registries slots (no conventions slot); to be added once the provenance schema gains implements.conventions."
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint-and-test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.14"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v3
|
|
26
|
+
|
|
27
|
+
- name: Install dependencies
|
|
28
|
+
run: uv sync
|
|
29
|
+
|
|
30
|
+
- name: Ruff check
|
|
31
|
+
run: uv run ruff check src tests
|
|
32
|
+
|
|
33
|
+
- name: Ruff format
|
|
34
|
+
run: uv run ruff format --check src tests
|
|
35
|
+
|
|
36
|
+
- name: Mypy
|
|
37
|
+
run: uv run mypy --strict src tests
|
|
38
|
+
|
|
39
|
+
- name: Pytest
|
|
40
|
+
run: uv run pytest tests --tb=short
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
# Re-run the full gate set against the tag. A tag can point anywhere, so a green
|
|
13
|
+
# CI run on the branch is not evidence about the commit being released.
|
|
14
|
+
test:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
strategy:
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.11", "3.14"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
- uses: astral-sh/setup-uv@v3
|
|
25
|
+
- run: uv sync --group dev
|
|
26
|
+
- run: uv run ruff check src tests
|
|
27
|
+
- run: uv run ruff format --check src tests
|
|
28
|
+
- run: uv run mypy --strict src tests
|
|
29
|
+
- run: uv run pytest tests --tb=short
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: test
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
# Names the PyPI trusted publisher's environment. PyPI checks this claim on the
|
|
35
|
+
# OIDC token, so it must match the "Environment name" registered there.
|
|
36
|
+
environment: pypi
|
|
37
|
+
permissions:
|
|
38
|
+
# Mints the OIDC token trusted publishing exchanges for an upload token. No
|
|
39
|
+
# PyPI API token is stored anywhere.
|
|
40
|
+
id-token: write
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
- uses: actions/setup-python@v5
|
|
44
|
+
with:
|
|
45
|
+
python-version: "3.12"
|
|
46
|
+
- uses: astral-sh/setup-uv@v3
|
|
47
|
+
|
|
48
|
+
# The version lives in one place, but the tag is typed by hand. Refuse to
|
|
49
|
+
# publish an artifact whose version disagrees with the tag that triggered it,
|
|
50
|
+
# rather than burning a version number on PyPI (uploads are not replaceable).
|
|
51
|
+
- name: Verify the tag matches the package version
|
|
52
|
+
run: |
|
|
53
|
+
pkg="v$(uv run python -c 'import re,pathlib; print(re.search(r"__version__ = \"([^\"]+)\"", pathlib.Path("src/ebus_panel_sim/__init__.py").read_text()).group(1))')"
|
|
54
|
+
echo "tag=${GITHUB_REF_NAME} package=${pkg}"
|
|
55
|
+
test "${pkg}" = "${GITHUB_REF_NAME}"
|
|
56
|
+
|
|
57
|
+
- name: Build the sdist and wheel
|
|
58
|
+
run: uv build
|
|
59
|
+
|
|
60
|
+
# Guards the packaging fault that kept this package unbuildable until #8: the
|
|
61
|
+
# wheel must actually carry the YAML/JSON wire data, since every profile and
|
|
62
|
+
# mapping lookup resolves from it at runtime and the test suite reads the
|
|
63
|
+
# source tree instead.
|
|
64
|
+
- name: Verify the wheel carries its wire data
|
|
65
|
+
run: |
|
|
66
|
+
python - <<'PY'
|
|
67
|
+
import glob, zipfile, sys
|
|
68
|
+
names = zipfile.ZipFile(sorted(glob.glob("dist/*.whl"))[0]).namelist()
|
|
69
|
+
missing = [d for d in ("wire/profiles", "wire/mapping", "wire/catalogs")
|
|
70
|
+
if not any(d in n for n in names)]
|
|
71
|
+
dupes = len(names) - len(set(names))
|
|
72
|
+
if missing or dupes:
|
|
73
|
+
sys.exit(f"missing={missing} duplicate_entries={dupes}")
|
|
74
|
+
print(f"ok: {len(names)} entries, no duplicates, all wire data present")
|
|
75
|
+
PY
|
|
76
|
+
|
|
77
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
78
|
+
with:
|
|
79
|
+
print-hash: true
|
|
80
|
+
|
|
81
|
+
# Mirror the release to GitHub Releases, using the tag's CHANGELOG section as the
|
|
82
|
+
# notes. Idempotent (create, else edit).
|
|
83
|
+
github-release:
|
|
84
|
+
needs: publish
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
permissions:
|
|
87
|
+
contents: write
|
|
88
|
+
steps:
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
- name: Create the GitHub Release from the CHANGELOG section
|
|
91
|
+
env:
|
|
92
|
+
GH_TOKEN: ${{ github.token }}
|
|
93
|
+
run: |
|
|
94
|
+
version="${GITHUB_REF_NAME#v}"
|
|
95
|
+
python3 - "$version" > notes.md <<'PY'
|
|
96
|
+
import re, sys, pathlib
|
|
97
|
+
v = re.escape(sys.argv[1])
|
|
98
|
+
text = pathlib.Path("CHANGELOG.md").read_text()
|
|
99
|
+
m = re.search(rf"^## \[{v}\][^\n]*\n(.*?)(?=^## \[|\Z)", text, re.S | re.M)
|
|
100
|
+
body = (m.group(1).strip() if m else "") or "See CHANGELOG.md."
|
|
101
|
+
sys.stdout.write(body + "\n")
|
|
102
|
+
PY
|
|
103
|
+
gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" \
|
|
104
|
+
--notes-file notes.md --verify-tag \
|
|
105
|
+
|| gh release edit "${GITHUB_REF_NAME}" --notes-file notes.md
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.15.12
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff-check
|
|
6
|
+
args: [--fix, --exit-non-zero-on-fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
|
|
9
|
+
# Run mypy through the project venv rather than a pre-commit-managed one. The
|
|
10
|
+
# isolated env would need `ebus-sdk` restated in `additional_dependencies`, and
|
|
11
|
+
# a copy of the pin that nothing keeps in sync silently drifts from pyproject.
|
|
12
|
+
# Typing the ebus-sdk seam correctly depends on resolving the SDK's real types
|
|
13
|
+
# (it has shipped `py.typed` since 0.13.0), so this hook now runs exactly what
|
|
14
|
+
# CI runs, against exactly the pinned SDK. It also checks `src`, not just
|
|
15
|
+
# `src/ebus_panel_sim`, matching CI.
|
|
16
|
+
- repo: local
|
|
17
|
+
hooks:
|
|
18
|
+
- id: mypy
|
|
19
|
+
name: mypy --strict (project venv)
|
|
20
|
+
language: system
|
|
21
|
+
types: [python]
|
|
22
|
+
pass_filenames: false
|
|
23
|
+
entry: uv run mypy --strict src tests
|
|
24
|
+
|
|
25
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
26
|
+
rev: v5.0.0
|
|
27
|
+
hooks:
|
|
28
|
+
- id: trailing-whitespace
|
|
29
|
+
- id: end-of-file-fixer
|
|
30
|
+
- id: check-yaml
|
|
31
|
+
- id: check-added-large-files
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14.4
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Authors
|
|
2
|
+
|
|
3
|
+
This project is a fork of, and builds on, the original simulator created by Bill Flood.
|
|
4
|
+
|
|
5
|
+
Bill Flood (@cayossarian): creator and principal developer of the original work.
|
|
6
|
+
|
|
7
|
+
Clark Communications Corporation (@dcj): fork maintainer; updates to track the latest eBus specification.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.3.0] - 2026-08-07
|
|
6
|
+
|
|
7
|
+
First release published to PyPI, as **`ebus-panel-sim`**.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Published to PyPI.** Releases are tag-triggered and use PyPI trusted publishing (OIDC), so no API token is stored anywhere. The workflow re-runs the full gate set against the tag rather than trusting a green branch run, since a tag can point at any commit, and it refuses to publish when the tag disagrees with `__version__` or when the built wheel is missing its wire data. Consumers pinning by git URL can now `pip install ebus-panel-sim` instead. (#10)
|
|
12
|
+
- **`py.typed`.** The package is `mypy --strict` throughout but shipped no PEP 561 marker, so none of its annotations reached consumers: an installed `panel_sim` resolved to `Any`. This is the same class of gap that let the `Device.mqttc` teardown bug below sit unnoticed here until `ebus-sdk` shipped its own marker. Verified from outside: a consumer installing the wheel now types `Emitter.publish_tick` as `(TickInputs) -> EbusPanelSnapshot`. (#10)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **BREAKING (package): renamed to `ebus-panel-sim`, importing as `ebus_panel_sim`.** Was `panel-sim`/`panel_sim`. Update imports and any git-URL or path pin. Across the eBus family the repository name and the distribution name differ freely, but the distribution name always equals the import package under an `ebus` prefix (`ebus-sdk`/`ebus_sdk`, `ebus-tools`/`ebus_tools`, `ebus-service-discovery`/`ebus_service_discovery`), and this package was the outlier. The unprefixed name was also actively misleading: PyPI's `panel` is HoloViz's dashboard framework, with an established `panel-*` plugin ecosystem, so `panel-sim` read as a plugin for it. Done now because it is free before the first release and a breaking change for every consumer after it. (#8)
|
|
17
|
+
- `ebus-sdk` pin moved from `>=0.18,<0.19` to `>=0.19,<0.20`. The motivating fix is in **0.18.1**, which the old range already permitted but `uv.lock` had never picked up: `Device.refresh_tree()` published a device's own `$state` before recursing to its children, so a device could announce `ready` while the children it vouches for had published nothing ([python-sdk#31](https://github.com/electrification-bus/python-sdk/issues/31)). That matters here specifically because this package publishes a parent/child tree, which is the shape that exhibits it. **0.19.0** additionally makes the `refresh_tree()` cascade best-effort per child, so one raising descendant can no longer abort the rest of a reconnect republish; with an enclosure plus a device per circuit, lugs pair and DER, this tree has many descendants to abort. Its other changes do not reach us: `Controller.is_tree_complete()`/`on_tree_ready` is consumer-side, and the `Node.delete_property()` `$description` fix applies to an API this package never calls. No source changes. The emitted wire surface is unchanged between `0.18.0` and `0.19.0`: identical publish order, identical subscriptions, and identical retained payloads once the `$description` `version` wall-clock stamp is normalised (it differs run to run regardless of SDK version). Suite is `158 passed` under both.
|
|
18
|
+
- `ebus-sdk` pin moved from `>=0.12,<0.13` to `>=0.18,<0.19`. The old range excluded the release carrying the [python-sdk#27](https://github.com/electrification-bus/python-sdk/issues/27) fixes — the `battery` capability key removed in favour of `soc`, and `energy` → `energy_storage` / `total_increasing` → `measurement` for `soe`, `total-energy-storage` and `loadup-headroom` — so a downstream needing those could not stay inside the pin. No source changes: the published tree is byte-identical on `0.12.0` and `0.18.0` (197 retained topics, identical topic sets, zero payload differences once the `$description` `version` timestamp is normalised), and the suite is `158 passed` under both. `uv.lock` also moves `ebus-mqtt-client` 0.1.8 → 0.4.0, which `ebus-sdk` 0.18.0 requires. (#4, closes #3)
|
|
19
|
+
- The package version is now single-sourced from `ebus_panel_sim.__version__` and read by `[tool.hatch.version]`, rather than being restated in `pyproject.toml`. Note this is the *package* version, which is distinct from the producer-contract version the module docstrings refer to. (#10)
|
|
20
|
+
- `pre-commit` runs mypy from the project venv instead of a pre-commit-managed one. The isolated environment could never see `ebus-sdk` at all, so the hook silently checked less than CI did; restating the pin in `additional_dependencies` would have put a second copy of it somewhere nothing keeps in sync. (#7)
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **The package could not be built at all.** `packages` already carries everything under the package directory, so the `force-include` table naming the profiles/mapping/catalogs trees re-added each file at a path the wheel already held, which hatchling treats as fatal. Every `uv build` failed, on every commit this project has ever had. Nothing caught it because the test suite exercises the source tree rather than the built artifact, so `publish.yml` now asserts the wheel's contents directly. The sdist additionally excludes the agent and issue-tracker symlinks, which are untracked and absent from a clean checkout but break a local build on their absolute link targets. (#8)
|
|
25
|
+
- `Emitter.stop(graceful=False)` did not type-check, leaving `main` red from the 0.18 pin bump onward. `ebus-sdk` 0.18 ships a `py.typed` marker, so mypy stopped resolving the SDK to `Any` and started reading its real types, and `Device.mqttc` is typed `MqttDeviceTransport`, which deliberately omits `start`/`stop`: that omission *is* the SDK's no-start/no-stop guarantee, expressed as a type. `stop` resolves only on the concrete client the SDK builds and owns. Now narrowed at runtime in the SDK seam. Thanks to [@cayossarian](https://github.com/cayossarian), who found this independently and contributed the fix. (#7)
|
|
26
|
+
|
|
27
|
+
## [0.2.0] - 2026-08-01
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **BREAKING (wire):** circuit `meter/imported-energy` and `meter/exported-energy` are now published in the enclosure reference frame, matching the already-enclosure-framed `meter/active-power` and real panel firmware. Previously a load circuit published a rising `imported-energy` while its `active-power` was negative, so integrating the published power grew the opposite accumulator (every load looked like it produced energy). Consumers that compensated for the old inverted behaviour must drop the workaround; consumers written against real panel firmware need no change. Lugs metering is unchanged (the frames coincide there). (#2, fixes #1)
|
|
32
|
+
|
|
33
|
+
## [0.1.0] — 2026-05-02
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Initial scaffolding of the `ebus-panel-sim` package: wire layer (manifest/mapping/profiles,
|
|
38
|
+
graph builder, lifecycle, set router, SDK seam, property bag diff) and schedule runner
|
|
39
|
+
(clock, energy package, simulated circuits, solar curve evaluation, override store,
|
|
40
|
+
tick orchestration).
|
|
41
|
+
- Public `Emitter` API: `start()`, `tick()`, `stop()`, `set_property_override()`,
|
|
42
|
+
`clear_property_override()`, `force_grid_state()`, `last_snapshot`, `topology_version`,
|
|
43
|
+
static `lwt_settings()`.
|
|
44
|
+
- Vendored Homie 5 device profiles for panel, circuit, lugs, BESS, PV, EVSE (v1_flat).
|
|
45
|
+
- Four canonical example manifest + runtime-spec pairs.
|
|
46
|
+
- End-to-end mosquitto integration test.
|
|
47
|
+
- 132 passing tests across `wire/`, `scheduleRunner/`, and integration suites.
|
|
48
|
+
|
|
49
|
+
### Deferred
|
|
50
|
+
|
|
51
|
+
- Full lift of the simulator's `RealisticBehaviorEngine` with cycling state, smart-load
|
|
52
|
+
noise, and HVAC seasonal modulation. v0.1.0 ships a stub that applies the runtime-
|
|
53
|
+
spec's pre-baked hour/monthly factors directly with deterministic noise.
|
|
54
|
+
- v2_children topology (parent-child Homie schema). Pending the upstream `ebus-sdk` adding
|
|
55
|
+
parent/child support.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Contributing to distribution-enclosure-simulator
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing! This project (`ebus-panel-sim`) is a producer-side [Homie 5](https://homieiot.github.io) publisher and a fully-loaded distribution-enclosure simulator for the [Electrification Bus (eBus)](https://ebus.energy) convention. It publishes a complete eBus Homie device tree (the enclosure plus a device for every circuit, lugs pair, and integrated DER: BESS, PV, EVSE, and MID) so consumers can build and test against a realistic SPAN-like panel without beta firmware or a live installation. It began as a fork of Bill Flood's original simulator and now tracks the latest eBus specification.
|
|
4
|
+
|
|
5
|
+
## How to contribute
|
|
6
|
+
|
|
7
|
+
### Discussions
|
|
8
|
+
|
|
9
|
+
Use [Discussions](https://github.com/electrification-bus/distribution-enclosure-simulator/discussions) for:
|
|
10
|
+
|
|
11
|
+
- Open-ended questions about the simulator's design, the producer/emitter split, or the wire model ("how should the producer drive circuit X?")
|
|
12
|
+
- Integration questions ("I'm building a consumer / dashboard / Home Assistant integration against the published tree, what's the recommended pattern?")
|
|
13
|
+
- Proposed new device types, native behaviours, or profile/mapping changes worth aligning on before writing the code
|
|
14
|
+
- Questions about the relationship between this simulator and the [Electrification Bus specification](https://github.com/electrification-bus/specification) (this repo aims to be a faithful producer of the spec; spec-level questions belong in the spec repo's Discussions)
|
|
15
|
+
- Thinking out loud about a proposed change before scoping it
|
|
16
|
+
|
|
17
|
+
Discussions are open-ended: a good place to align on direction before something becomes a concrete change. Aligned outcomes often turn into one or more Issues or pull requests.
|
|
18
|
+
|
|
19
|
+
### Issues
|
|
20
|
+
|
|
21
|
+
Use [Issues](https://github.com/electrification-bus/distribution-enclosure-simulator/issues) for actionable changes:
|
|
22
|
+
|
|
23
|
+
- Bug reports with reproduction steps (the YAML device definition, broker, and a code snippet or the published topics you saw versus expected)
|
|
24
|
+
- Spec-conformance gaps where the published tree diverges from the [Electrification Bus specification](https://github.com/electrification-bus/specification) (note which spec document, capability catalog, and section)
|
|
25
|
+
- Concrete feature requests with a clear scope and a use case
|
|
26
|
+
- Documentation gaps where a specific README, example, or docstring change is intended
|
|
27
|
+
- Discussion outcomes that have alignment and a clear scope
|
|
28
|
+
|
|
29
|
+
If you're not sure whether something is an Issue or a Discussion, start with a Discussion: we can convert it later.
|
|
30
|
+
|
|
31
|
+
### Pull requests
|
|
32
|
+
|
|
33
|
+
Pull requests are welcome.
|
|
34
|
+
|
|
35
|
+
- For small fixes (typos, docstring tweaks, version bumps, low-risk bug fixes with a test), open a PR directly.
|
|
36
|
+
- For substantive changes (new public API surface, changes to `TickInputs` or the manifest contract, new device types, changes that alter discovery/topic structure or property semantics), open a Discussion or Issue first so we can align on scope before you invest the effort.
|
|
37
|
+
- **Spec conformance is the north star.** This simulator exists to publish a spec-conformant eBus Homie tree. When a PR's behaviour is normative (device states, property contracts, topic structure, capability surface), point to the spec section it implements. The wire type contract is single-sourced from the vendored spec capability catalogs and guarded by `tests/test_catalog_drift.py`; if you touch a capability, keep the catalog and the lockfile (`.ebus-spec.json`) in sync. If the spec is ambiguous or wrong, file an Issue against the spec repo first and reference it from the PR here.
|
|
38
|
+
- **Respect the producer/emitter boundary.** The producer computes generation-side physics and hands the emitter a per-tick driving signal via `TickInputs`; the emitter derives wire-facing telemetry and publishes it. Keep generation models out of the emitter (see `DESIGN.md` and `DEVELOPER.md`).
|
|
39
|
+
- **Keep comments to a minimum.** The project style is self-explanatory code, with comments reserved for the non-obvious *why* (a spec quirk, a Homie nuance, a SPAN-variant deviation). Don't add comments that just restate the code.
|
|
40
|
+
- One commit per logical change is fine; we don't require squash or any particular branch naming.
|
|
41
|
+
|
|
42
|
+
## Local development
|
|
43
|
+
|
|
44
|
+
Python >= 3.11 (developed and CI-tested on 3.11 and 3.14), managed with [uv](https://docs.astral.sh/uv/). See [DEVELOPER.md](DEVELOPER.md) for the full guide.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv sync --group dev # create .venv, install runtime + dev deps
|
|
48
|
+
uv run pre-commit install # install the pre-commit hooks
|
|
49
|
+
uv run pytest # tests
|
|
50
|
+
uv run ruff check --fix src/ tests/ # lint
|
|
51
|
+
uv run ruff format src/ tests/ # format
|
|
52
|
+
uv run mypy --strict src/ebus_panel_sim/ # type check (strict)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Every commit is validated by pre-commit and by the [`ci.yaml`](.github/workflows/ci.yaml) workflow (ruff, ruff-format, mypy `--strict`, pytest). Run the gates locally before pushing; new behaviour needs a test, and bug fixes need a regression test.
|
|
56
|
+
|
|
57
|
+
## Releases
|
|
58
|
+
|
|
59
|
+
`ebus-panel-sim` is published to [PyPI](https://pypi.org/project/ebus-panel-sim/). Releases are tag-triggered: pushing a `v*` tag runs `.github/workflows/publish.yml`, which re-runs the gates against the tag, builds, uploads via PyPI trusted publishing (OIDC, no stored token), and mirrors the release to GitHub Releases using the tag's `CHANGELOG.md` section.
|
|
60
|
+
|
|
61
|
+
A release-worthy change bumps `__version__` in `src/ebus_panel_sim/__init__.py`, the single source of truth, and adds a `CHANGELOG.md` entry under a matching `## [x.y.z]` heading. Do not restate the version in `pyproject.toml`; `[tool.hatch.version]` reads it. The publish workflow refuses to upload when the tag and `__version__` disagree, because a PyPI upload can never be replaced.
|
|
62
|
+
|
|
63
|
+
## Code of conduct
|
|
64
|
+
|
|
65
|
+
Be respectful and constructive. We appreciate everyone who takes the time to file an issue, start a discussion, or send a pull request.
|
|
66
|
+
|
|
67
|
+
## Maintenance posture
|
|
68
|
+
|
|
69
|
+
This is an active alpha project. Updates and maintenance, including responses to issues filed on GitHub, happen on an "as time and resources permit" basis. It is maintained alongside the [Electrification Bus specification](https://github.com/electrification-bus/specification) and the eBus SDK.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# ebus-panel-sim design
|
|
2
|
+
|
|
3
|
+
Internals of the emitter. For what it is and how to run/configure it, see [README.md](README.md).
|
|
4
|
+
|
|
5
|
+
## The per-tick pipeline
|
|
6
|
+
|
|
7
|
+
The producer builds a `DeviceManifest` (identity plus physics keys per device) at startup and hands it to `Emitter` together with a `SetterRegistry`, an `mqtt_cfg` (the broker coordinates ebus-sdk connects with), zero or more `BESSConfig`s, and an optional `LoadSheddingConfig`. Each tick the producer builds a `TickInputs` (signed power per circuit, current time, grid-online flag, panel envelope) and calls `emitter.publish_tick(tick_inputs)`. Inside, the emitter:
|
|
8
|
+
|
|
9
|
+
1. Resolves BESS dispatch (charge/discharge/idle) for every native BESS.
|
|
10
|
+
2. Decides load-shedding (which circuits open when off-grid).
|
|
11
|
+
3. Applies relay-state precedence and gates per-circuit power.
|
|
12
|
+
4. Integrates energy per circuit/EVSE via `EnergyIntegrator`.
|
|
13
|
+
5. Computes per-leg currents and panel meter aggregates via `PanelMeter`.
|
|
14
|
+
6. Assembles an `EbusPanelSnapshot` and publishes the Homie diff to MQTT (only changed properties are retransmitted).
|
|
15
|
+
|
|
16
|
+
## Wire model
|
|
17
|
+
|
|
18
|
+
The enclosure is a Homie root device (`energy.ebus.device.distribution-enclosure`); every circuit, lugs pair, and integrated DER (BESS, PV, EVSE, MID) is a separate child Homie device with `root` and `parent` back-references to the enclosure. Each device's properties are grouped into capability-typed nodes (`info`, `meter`, `switch`, `breaker`, `load-shed`, `pcs`, `connection`, `status`, `door`, `soc`, `shed`, `shed-forecast`, `grid`, `config`, `power-flows`), whose node `$type` is `energy.ebus.capability.<capability>`. A child device therefore publishes under its own topic root, for example `ebus/5/<circuit-id>/switch/relay` and `ebus/5/<bess-id>-mid/grid/islanding-state`.
|
|
19
|
+
|
|
20
|
+
Placement is declarative. Each `wire/mapping/*.yaml` descriptor says whether its device class is the `root-device` or a `child-of-parent`; `graph_builder` walks the manifest, mappings, and profiles to build the SDK device graph, and the SDK's `Device` owns the `$state` cascade: `graph_builder` wraps each device's node/property build in a `state_transition()` that coalesces the description republish into a single `init` then `ready` cycle, while `Emitter.start()`/`stop()` drive connect and disconnect. A graceful `stop()` publishes only the root's `$state=disconnected` (by Homie's effective-state rule that covers every child); an ungraceful drop leaves the broker LWT to fire `$state=lost`; retained topics are cleared only when `stop(clear_retained=True)` is passed. The vendored `wire/profiles/*.json` are the schema (capabilities, properties, datatypes, units, `$format`, settability); `bag_builder` maps each profile-declared property to a snapshot accessor and fails loud at construction if any declared property has no source.
|
|
21
|
+
|
|
22
|
+
## Native devices
|
|
23
|
+
|
|
24
|
+
Two device classes are not pure publishers: their behaviour runs inside the emitter.
|
|
25
|
+
|
|
26
|
+
### BESS (`ebus_panel_sim.native_devices.bess`)
|
|
27
|
+
|
|
28
|
+
Owns the dispatch decision, SOC/SOE accumulation, mode behaviour (self-consumption / backup-only), and the backup-reserve floor. (The `charge_hours` / `discharge_hours` config fields exist but are inert: the dispatch logic never reads them, and hour-of-day / TOU windows are explicitly not modelled.) Instantiated when `Emitter` is constructed with `bess_configs`, a tuple of `BESSConfig` (default empty). One `BESSDevice` is created per config, keyed by `BESSConfig.instance_id`; duplicate instance IDs raise `EmitterStateError`.
|
|
29
|
+
|
|
30
|
+
Per-tick inputs (from `TickInputs`): `current_time` (received but not consulted by the current dispatch logic; hour-of-day windows are not modelled), `grid_online` (when false the BESS discharges to meet `load_demand - pv_available`), and the derived `load_demand_w` (sum of positive circuit powers) and `pv_available_w` (magnitude of the negative circuit powers).
|
|
31
|
+
|
|
32
|
+
Per-tick outputs (into `snapshot.battery`): `soe_percentage`, `soe_kwh`, and `active_power_w` (positive = discharging, negative = charging).
|
|
33
|
+
|
|
34
|
+
Mid-run config changes: `emitter.update_bess_config(new_config)` swaps the `BESSConfig` reference while SOC/SOE state persists (the path for dashboard edits to mode and max charge/discharge rates; the charge/discharge hour-window fields are carried but not yet applied by the dispatch logic). Persistence across restart: call `emitter.seed_bess_soe(instance_id, soe_kwh)` between `__init__` and `start()`, or declare `initial-soe-kwh` in the manifest. Subclassing `BESSDevice` is supported for vendor-variant behaviour without a plugin framework.
|
|
35
|
+
|
|
36
|
+
### Load shedding (`ebus_panel_sim.native_devices.load_shedding`)
|
|
37
|
+
|
|
38
|
+
When the grid is offline the policy returns the circuit instance-ids whose priority is `OFF_GRID`, plus those with `SOC_THRESHOLD` priority once the live SOC falls below `soc_threshold_pct`. The emitter writes that decision into the `RelayResolver` shed map; final relay state is then resolved by the precedence rules below. Mid-run config: `emitter.update_load_shedding_config(new_config)`.
|
|
39
|
+
|
|
40
|
+
## Settable properties (`/set`)
|
|
41
|
+
|
|
42
|
+
`/set` commands arrive on the child device's settable-property topics (for example `ebus/5/<circuit-id>/switch/relay/set`) and are dispatched through the `SetterRegistry`. The emitter installs internal default handlers for the settable properties when the producer has not supplied one; producer-supplied handlers always win.
|
|
43
|
+
|
|
44
|
+
| Entity class | Property | Effect |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| circuit | `switch/relay` | Updates the `RelayResolver` user override |
|
|
47
|
+
| circuit | `load-shed/priority` | Updates the emitter's per-circuit priority override |
|
|
48
|
+
| panel | `shed/asserted-islanding-state` | Updates the consumer-asserted islanding override |
|
|
49
|
+
| evse | `config/user-max-charge-current` | Updates the per-EVSE user charge-current ceiling |
|
|
50
|
+
|
|
51
|
+
### Relay state precedence
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
always-on > /set override > load-shed > default-CLOSED
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `always-on` circuits (`relay-behavior=always-on`) ignore both `/set` and load-shed; the relay is permanently CLOSED. `switch/relay-requester` reports `CONFIGURATION`.
|
|
58
|
+
- A `/set` override takes effect on the next tick, with no debounce, and can override a safety-shed. `switch/relay-requester` reports `USER`.
|
|
59
|
+
- Load-shed applies only when there is no `/set` override. `switch/relay-requester` reports `LOAD_SHED`.
|
|
60
|
+
- Default-CLOSED is the resting state when no decision-maker has spoken. `switch/relay-requester` reports `NONE`.
|
|
61
|
+
|
|
62
|
+
Relay changes reach the wire on the next `publish_tick`, bounded by the producer's tick cadence (typically 1.0 s).
|
|
63
|
+
|
|
64
|
+
## Energy integration
|
|
65
|
+
|
|
66
|
+
`EnergyIntegrator` accumulates per-circuit (and per-EVSE) `consumed_wh` / `produced_wh` across ticks using `dt = current_time - last_tick_time` per instance. Seed values at startup with `emitter.seed_energy("kitchen", consumed_wh=12345.0, produced_wh=0.0)`, or via the manifest `initial-consumed-wh` / `initial-produced-wh` keys. Either path adds a new circuit to a running deployment without zeroing existing accumulators.
|
|
67
|
+
|
|
68
|
+
## Tab-to-leg convention
|
|
69
|
+
|
|
70
|
+
`legs_for_tabs((tab, ...)) -> tuple[Leg, ...]` in `ebus_panel_sim.conventions.tab_legs` is the single source of truth for the US residential split-phase convention: odd-numbered tabs land on L1, even-numbered on L2, and a 240 V circuit occupies tabs on both legs. It is isolated so non-US / 3-phase support can land there later without touching `PanelMeter` or the per-leg current calculations.
|
|
71
|
+
|
|
72
|
+
## Snapshot read-back
|
|
73
|
+
|
|
74
|
+
`emitter.last_snapshot` returns the most recently published `EbusPanelSnapshot`. Consumers (dashboards, HA-API endpoints) read aggregated state through this property; they do not construct snapshots.
|
|
75
|
+
|
|
76
|
+
## What lives where
|
|
77
|
+
|
|
78
|
+
| Concern | Owner |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Homie wire mechanics (`$description`/`$state`, parent-child arrays, retained topics + value encoding, LWT) | ebus-sdk (emitter builds the `Device` tree + capability nodes and drives the start/stop lifecycle) |
|
|
81
|
+
| Device profiles + property graph + diff publishing | emitter |
|
|
82
|
+
| Settable-property routing (`/set` to internal state) | emitter |
|
|
83
|
+
| Relay state machine (always-on > /set > shed > default-CLOSED) | emitter |
|
|
84
|
+
| BESS dispatch + SOC/SOE integration | emitter |
|
|
85
|
+
| Load-shedding policy (SOC threshold, off-grid priority) | emitter |
|
|
86
|
+
| Energy integration + per-leg current + panel meter aggregation | emitter |
|
|
87
|
+
| Device identity + static attributes (vendor, serial, ratings, tabs) | producer (via manifest) |
|
|
88
|
+
| Per-circuit / per-EVSE signed power, `current_time`, `grid_online`, envelope | producer (per tick) |
|
|
89
|
+
| Weather, schedules, rates, modelling, recorder/replay history | producer |
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Developer Guide
|
|
2
|
+
|
|
3
|
+
## Prerequisites
|
|
4
|
+
|
|
5
|
+
- Python >= 3.11 (`pyproject.toml` sets `requires-python = ">=3.11"`); developed and CI-tested on 3.11 and 3.14
|
|
6
|
+
- [uv](https://docs.astral.sh/uv/) (`brew install uv` on macOS)
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
# Clone and enter the repo
|
|
12
|
+
git clone https://github.com/electrification-bus/distribution-enclosure-simulator.git
|
|
13
|
+
cd distribution-enclosure-simulator
|
|
14
|
+
|
|
15
|
+
# Create venv and install all dependencies (runtime + dev)
|
|
16
|
+
# uv reads pyproject.toml and uv.lock, creates .venv/ automatically
|
|
17
|
+
uv sync --group dev
|
|
18
|
+
|
|
19
|
+
# Install pre-commit hooks
|
|
20
|
+
uv run pre-commit install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's it. The `.venv/` directory is created in the project root and `uv.lock` pins exact versions for reproducible installs.
|
|
24
|
+
|
|
25
|
+
## Common Commands
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Run the standalone example (a producer driving the emitter over a broker)
|
|
29
|
+
uv run python examples/run_forty_tab_minimal.py
|
|
30
|
+
|
|
31
|
+
# Run tests
|
|
32
|
+
uv run pytest
|
|
33
|
+
|
|
34
|
+
# Lint + format
|
|
35
|
+
uv run ruff check --fix src tests
|
|
36
|
+
uv run ruff format src tests
|
|
37
|
+
|
|
38
|
+
# Type check (strict; matches the pre-commit hook)
|
|
39
|
+
uv run mypy --strict src/ebus_panel_sim tests
|
|
40
|
+
|
|
41
|
+
# Add a new dependency
|
|
42
|
+
uv add <package> # runtime
|
|
43
|
+
uv add --group dev <pkg> # dev only
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This package has no console entry point (`pyproject.toml` defines no `[project.scripts]`): it is a producer library. The `examples/` directory is the runnable demonstration of correct output.
|
|
47
|
+
|
|
48
|
+
## Pre-commit Hooks
|
|
49
|
+
|
|
50
|
+
Every commit is validated by:
|
|
51
|
+
|
|
52
|
+
| Hook | What it checks |
|
|
53
|
+
|---|---|
|
|
54
|
+
| **ruff** | Lint rules (E, F, W, I, UP, B, SIM, TCH, RUF) with auto-fix |
|
|
55
|
+
| **ruff-format** | Consistent formatting |
|
|
56
|
+
| **mypy --strict** | Full strict type checking across `src/ebus_panel_sim` and `tests` |
|
|
57
|
+
| **trailing-whitespace** | No trailing whitespace |
|
|
58
|
+
| **end-of-file-fixer** | Files end with a newline |
|
|
59
|
+
| **check-yaml** | Valid YAML syntax |
|
|
60
|
+
| **check-added-large-files** | Prevents accidental large file commits |
|
|
61
|
+
|
|
62
|
+
## Emitter Internals
|
|
63
|
+
|
|
64
|
+
The emitter is the **publisher** half of the eBus producer/emitter split: the producer (a simulator, a real gateway, or a modelling agent) computes the generation-side physics (solar curves, HVAC modulation, weather, battery schedules) and hands the emitter a per-tick driving signal via `TickInputs`. The emitter derives wire-facing telemetry from that signal and publishes it. It does **not** run the generation models itself.
|
|
65
|
+
|
|
66
|
+
### Per-tick flow
|
|
67
|
+
|
|
68
|
+
Each tick, given `TickInputs` (signed power per circuit, current time, grid-online flag):
|
|
69
|
+
|
|
70
|
+
1. Resolve per-circuit relay state, applying strict precedence across command sources (`relay_resolver.py`).
|
|
71
|
+
2. Run native-device behaviours that own their own state (`native_devices/`): BESS dispatch (`bess.py`) and load shedding (`load_shedding.py`).
|
|
72
|
+
3. Derive gated per-circuit power, then aggregate panel-level meter values (`panel_meter.py`, a pure function).
|
|
73
|
+
4. Integrate energy in watt-hours per instance (`energy_integrator.py`).
|
|
74
|
+
5. Translate the resulting snapshot into a `PropertyBag` and diff-publish (`wire/bag_builder.py`, `wire/property_bag.py`, `wire/publisher.py`).
|
|
75
|
+
|
|
76
|
+
Device identity and static attributes come from the producer once at startup via the `DeviceManifest` (`manifest.py`); dynamic telemetry is derived here. The split is **identity = manifest, telemetry = derived from TickInputs**.
|
|
77
|
+
|
|
78
|
+
### Energy Accumulation
|
|
79
|
+
|
|
80
|
+
Energy integrates over time in watt-hours:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
delta_energy = power_watts * delta_seconds / 3600
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Consumed and produced energy are tracked separately per circuit, seeded from producer-supplied starting values.
|
|
87
|
+
|
|
88
|
+
### Diffing
|
|
89
|
+
|
|
90
|
+
Only changed property values are republished each tick (`wire/property_bag.py` holds the diff cache; `wire/publisher.py` owns the loop). Unchanged values are not retransmitted.
|
|
91
|
+
|
|
92
|
+
## Directory Layout
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
distribution-enclosure-simulator/
|
|
96
|
+
pyproject.toml # Package metadata, deps, ruff/mypy/pytest config
|
|
97
|
+
uv.lock # Pinned dependency versions
|
|
98
|
+
README.md # Overview and usage
|
|
99
|
+
DEVELOPER.md # This guide
|
|
100
|
+
CHANGELOG.md
|
|
101
|
+
LICENSE
|
|
102
|
+
AGENTS.md # Agent rules (no AI attribution in commits)
|
|
103
|
+
.pre-commit-config.yaml
|
|
104
|
+
.python-version
|
|
105
|
+
.github/workflows/
|
|
106
|
+
ci.yaml # CI: ruff, ruff-format, mypy --strict, pytest
|
|
107
|
+
examples/
|
|
108
|
+
forty_tab_minimal.yaml # Example device manifest
|
|
109
|
+
run_forty_tab_minimal.py # Minimal standalone producer + emitter demo
|
|
110
|
+
src/ebus_panel_sim/
|
|
111
|
+
__init__.py # Public surface
|
|
112
|
+
emitter.py # Public Emitter facade (wire publisher + native runtime)
|
|
113
|
+
tick_inputs.py # TickInputs: the producer/emitter per-tick contract
|
|
114
|
+
manifest.py # Producer-supplied device identity manifest
|
|
115
|
+
manifest_physics.py # Typed accessor over device metadata (physics fields)
|
|
116
|
+
relay_resolver.py # Per-circuit relay state with strict command precedence
|
|
117
|
+
energy_integrator.py # Per-instance watt-hour energy accumulator
|
|
118
|
+
panel_meter.py # Panel-level aggregator (pure function)
|
|
119
|
+
snapshot.py # Per-tick snapshot dataclasses (internal model)
|
|
120
|
+
exceptions.py # Public exception hierarchy
|
|
121
|
+
conventions/
|
|
122
|
+
tab_legs.py # Tab-to-leg convention for split-phase panels
|
|
123
|
+
native_devices/ # Emitter-native device behaviours
|
|
124
|
+
bess.py # Native BESS (configured, self-driving)
|
|
125
|
+
load_shedding.py # Native load-shedding controller
|
|
126
|
+
protocol.py # Native-device tick contract
|
|
127
|
+
wire/ # Homie 5 wire production over ebus-sdk
|
|
128
|
+
graph_builder.py # Manifest + mappings + profiles -> SDK Device graph
|
|
129
|
+
profile_loader.py # Load vendored Homie 5 profile JSONs
|
|
130
|
+
mapping_loader.py # Load vendored mapping descriptor YAMLs
|
|
131
|
+
bag_builder.py # Snapshot -> PropertyBag translator
|
|
132
|
+
property_bag.py # Per-tick property values + diff cache
|
|
133
|
+
publisher.py # Per-tick diff/publish loop
|
|
134
|
+
lifecycle.py # $state, $description, /set subscription, LWT
|
|
135
|
+
set_router.py # Setter registry and /set dispatch
|
|
136
|
+
wire_paths.py # Homie topic-template helpers
|
|
137
|
+
_sdk_seam.py # Internal seam over ebus_sdk.property
|
|
138
|
+
profiles/ # Vendored Homie 5 device profiles (JSON), per device type
|
|
139
|
+
mapping/ # Vendored mapping descriptors (YAML), per device type
|
|
140
|
+
tests/ # pytest suite (asyncio auto; in-process amqtt broker)
|
|
141
|
+
conftest.py
|
|
142
|
+
conventions/ # convention tests
|
|
143
|
+
wire/ # wire-layer tests
|
|
144
|
+
test_*.py # unit tests (manifest, energy, relay, panel meter, ...)
|
|
145
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bill Flood, Clark Communications Corporation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|