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.
Files changed (107) hide show
  1. ebus_panel_sim-0.3.0/.ebus-spec.json +36 -0
  2. ebus_panel_sim-0.3.0/.github/CODEOWNERS +2 -0
  3. ebus_panel_sim-0.3.0/.github/workflows/ci.yaml +40 -0
  4. ebus_panel_sim-0.3.0/.github/workflows/publish.yml +105 -0
  5. ebus_panel_sim-0.3.0/.gitignore +17 -0
  6. ebus_panel_sim-0.3.0/.pre-commit-config.yaml +31 -0
  7. ebus_panel_sim-0.3.0/.python-version +1 -0
  8. ebus_panel_sim-0.3.0/AUTHORS +7 -0
  9. ebus_panel_sim-0.3.0/CHANGELOG.md +55 -0
  10. ebus_panel_sim-0.3.0/CONTRIBUTING.md +69 -0
  11. ebus_panel_sim-0.3.0/DESIGN.md +89 -0
  12. ebus_panel_sim-0.3.0/DEVELOPER.md +145 -0
  13. ebus_panel_sim-0.3.0/LICENSE +21 -0
  14. ebus_panel_sim-0.3.0/PKG-INFO +14 -0
  15. ebus_panel_sim-0.3.0/README.md +176 -0
  16. ebus_panel_sim-0.3.0/examples/forty_tab_minimal.yaml +83 -0
  17. ebus_panel_sim-0.3.0/examples/run_forty_tab_minimal.py +432 -0
  18. ebus_panel_sim-0.3.0/pyproject.toml +77 -0
  19. ebus_panel_sim-0.3.0/src/ebus_panel_sim/__init__.py +119 -0
  20. ebus_panel_sim-0.3.0/src/ebus_panel_sim/conventions/__init__.py +11 -0
  21. ebus_panel_sim-0.3.0/src/ebus_panel_sim/conventions/tab_legs.py +47 -0
  22. ebus_panel_sim-0.3.0/src/ebus_panel_sim/emitter.py +750 -0
  23. ebus_panel_sim-0.3.0/src/ebus_panel_sim/energy_integrator.py +89 -0
  24. ebus_panel_sim-0.3.0/src/ebus_panel_sim/exceptions.py +38 -0
  25. ebus_panel_sim-0.3.0/src/ebus_panel_sim/manifest.py +35 -0
  26. ebus_panel_sim-0.3.0/src/ebus_panel_sim/manifest_physics.py +451 -0
  27. ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/__init__.py +24 -0
  28. ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/bess.py +185 -0
  29. ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/load_shedding.py +60 -0
  30. ebus_panel_sim-0.3.0/src/ebus_panel_sim/native_devices/protocol.py +38 -0
  31. ebus_panel_sim-0.3.0/src/ebus_panel_sim/panel_meter.py +216 -0
  32. ebus_panel_sim-0.3.0/src/ebus_panel_sim/py.typed +0 -0
  33. ebus_panel_sim-0.3.0/src/ebus_panel_sim/relay_resolver.py +113 -0
  34. ebus_panel_sim-0.3.0/src/ebus_panel_sim/snapshot.py +301 -0
  35. ebus_panel_sim-0.3.0/src/ebus_panel_sim/tick_inputs.py +66 -0
  36. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/__init__.py +0 -0
  37. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/_sdk_seam.py +55 -0
  38. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/bag_builder.py +429 -0
  39. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/breaker.json +52 -0
  40. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/charge-limit.json +37 -0
  41. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/connection.json +72 -0
  42. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/door.json +17 -0
  43. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/grid.json +38 -0
  44. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/info.json +52 -0
  45. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/load-shed.json +18 -0
  46. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/meter.json +201 -0
  47. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/pcs.json +111 -0
  48. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/power-flows.json +35 -0
  49. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/shed-forecast.json +41 -0
  50. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/shed.json +24 -0
  51. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/soc.json +35 -0
  52. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/status.json +28 -0
  53. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/catalogs/switch.json +29 -0
  54. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/graph_builder.py +309 -0
  55. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  56. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/bess.yaml +16 -0
  57. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/circuit.yaml +16 -0
  58. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/evse.yaml +16 -0
  59. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/lugs.yaml +16 -0
  60. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/mid.yaml +16 -0
  61. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/panel.yaml +15 -0
  62. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping/pv.yaml +16 -0
  63. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/mapping_loader.py +144 -0
  64. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profile_loader.py +215 -0
  65. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  66. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/bess.json +58 -0
  67. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/circuit.json +88 -0
  68. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/evse.json +26 -0
  69. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/lugs.json +52 -0
  70. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/mid.json +40 -0
  71. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/panel.json +160 -0
  72. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/pv.json +28 -0
  73. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/circuit.json +19 -0
  74. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/evse.json +52 -0
  75. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/lugs.json +16 -0
  76. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/profiles/span/panel.json +77 -0
  77. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/property_bag.py +56 -0
  78. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/publisher.py +36 -0
  79. ebus_panel_sim-0.3.0/src/ebus_panel_sim/wire/set_router.py +122 -0
  80. ebus_panel_sim-0.3.0/tests/__init__.py +0 -0
  81. ebus_panel_sim-0.3.0/tests/conftest.py +71 -0
  82. ebus_panel_sim-0.3.0/tests/conventions/__init__.py +0 -0
  83. ebus_panel_sim-0.3.0/tests/conventions/test_tab_legs.py +40 -0
  84. ebus_panel_sim-0.3.0/tests/test_catalog_drift.py +67 -0
  85. ebus_panel_sim-0.3.0/tests/test_connection.py +150 -0
  86. ebus_panel_sim-0.3.0/tests/test_emitter_public_surface.py +148 -0
  87. ebus_panel_sim-0.3.0/tests/test_energy_integrator.py +125 -0
  88. ebus_panel_sim-0.3.0/tests/test_exceptions.py +27 -0
  89. ebus_panel_sim-0.3.0/tests/test_manifest.py +48 -0
  90. ebus_panel_sim-0.3.0/tests/test_manifest_physics.py +289 -0
  91. ebus_panel_sim-0.3.0/tests/test_mid_placement.py +91 -0
  92. ebus_panel_sim-0.3.0/tests/test_panel_meter.py +356 -0
  93. ebus_panel_sim-0.3.0/tests/test_publish_tick.py +636 -0
  94. ebus_panel_sim-0.3.0/tests/test_relay_resolver.py +123 -0
  95. ebus_panel_sim-0.3.0/tests/test_shed_forecast.py +94 -0
  96. ebus_panel_sim-0.3.0/tests/test_tick_inputs.py +43 -0
  97. ebus_panel_sim-0.3.0/tests/test_variant.py +119 -0
  98. ebus_panel_sim-0.3.0/tests/test_wire_units.py +80 -0
  99. ebus_panel_sim-0.3.0/tests/wire/__init__.py +0 -0
  100. ebus_panel_sim-0.3.0/tests/wire/test_circuit_energy_frame.py +119 -0
  101. ebus_panel_sim-0.3.0/tests/wire/test_graph_builder.py +50 -0
  102. ebus_panel_sim-0.3.0/tests/wire/test_graph_builder_topology.py +199 -0
  103. ebus_panel_sim-0.3.0/tests/wire/test_profile_mapping_validation.py +84 -0
  104. ebus_panel_sim-0.3.0/tests/wire/test_property_bag.py +80 -0
  105. ebus_panel_sim-0.3.0/tests/wire/test_sdk_seam.py +37 -0
  106. ebus_panel_sim-0.3.0/tests/wire/test_set_router.py +136 -0
  107. 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,2 @@
1
+ # Bill Flood (@cayossarian) created this simulator and is the default owner.
2
+ * @cayossarian @dcj
@@ -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,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ venv/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ /certs/
14
+ .local/
15
+ .claude/
16
+ .DS_Store
17
+ .envrc
@@ -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.