ebus-panel-sim 0.3.0__py3-none-any.whl

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 (66) hide show
  1. ebus_panel_sim/__init__.py +119 -0
  2. ebus_panel_sim/conventions/__init__.py +11 -0
  3. ebus_panel_sim/conventions/tab_legs.py +47 -0
  4. ebus_panel_sim/emitter.py +750 -0
  5. ebus_panel_sim/energy_integrator.py +89 -0
  6. ebus_panel_sim/exceptions.py +38 -0
  7. ebus_panel_sim/manifest.py +35 -0
  8. ebus_panel_sim/manifest_physics.py +451 -0
  9. ebus_panel_sim/native_devices/__init__.py +24 -0
  10. ebus_panel_sim/native_devices/bess.py +185 -0
  11. ebus_panel_sim/native_devices/load_shedding.py +60 -0
  12. ebus_panel_sim/native_devices/protocol.py +38 -0
  13. ebus_panel_sim/panel_meter.py +216 -0
  14. ebus_panel_sim/py.typed +0 -0
  15. ebus_panel_sim/relay_resolver.py +113 -0
  16. ebus_panel_sim/snapshot.py +301 -0
  17. ebus_panel_sim/tick_inputs.py +66 -0
  18. ebus_panel_sim/wire/__init__.py +0 -0
  19. ebus_panel_sim/wire/_sdk_seam.py +55 -0
  20. ebus_panel_sim/wire/bag_builder.py +429 -0
  21. ebus_panel_sim/wire/catalogs/breaker.json +52 -0
  22. ebus_panel_sim/wire/catalogs/charge-limit.json +37 -0
  23. ebus_panel_sim/wire/catalogs/connection.json +72 -0
  24. ebus_panel_sim/wire/catalogs/door.json +17 -0
  25. ebus_panel_sim/wire/catalogs/grid.json +38 -0
  26. ebus_panel_sim/wire/catalogs/info.json +52 -0
  27. ebus_panel_sim/wire/catalogs/load-shed.json +18 -0
  28. ebus_panel_sim/wire/catalogs/meter.json +201 -0
  29. ebus_panel_sim/wire/catalogs/pcs.json +111 -0
  30. ebus_panel_sim/wire/catalogs/power-flows.json +35 -0
  31. ebus_panel_sim/wire/catalogs/shed-forecast.json +41 -0
  32. ebus_panel_sim/wire/catalogs/shed.json +24 -0
  33. ebus_panel_sim/wire/catalogs/soc.json +35 -0
  34. ebus_panel_sim/wire/catalogs/status.json +28 -0
  35. ebus_panel_sim/wire/catalogs/switch.json +29 -0
  36. ebus_panel_sim/wire/graph_builder.py +309 -0
  37. ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  38. ebus_panel_sim/wire/mapping/bess.yaml +16 -0
  39. ebus_panel_sim/wire/mapping/circuit.yaml +16 -0
  40. ebus_panel_sim/wire/mapping/evse.yaml +16 -0
  41. ebus_panel_sim/wire/mapping/lugs.yaml +16 -0
  42. ebus_panel_sim/wire/mapping/mid.yaml +16 -0
  43. ebus_panel_sim/wire/mapping/panel.yaml +15 -0
  44. ebus_panel_sim/wire/mapping/pv.yaml +16 -0
  45. ebus_panel_sim/wire/mapping_loader.py +144 -0
  46. ebus_panel_sim/wire/profile_loader.py +215 -0
  47. ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  48. ebus_panel_sim/wire/profiles/bess.json +58 -0
  49. ebus_panel_sim/wire/profiles/circuit.json +88 -0
  50. ebus_panel_sim/wire/profiles/evse.json +26 -0
  51. ebus_panel_sim/wire/profiles/lugs.json +52 -0
  52. ebus_panel_sim/wire/profiles/mid.json +40 -0
  53. ebus_panel_sim/wire/profiles/panel.json +160 -0
  54. ebus_panel_sim/wire/profiles/pv.json +28 -0
  55. ebus_panel_sim/wire/profiles/span/circuit.json +19 -0
  56. ebus_panel_sim/wire/profiles/span/evse.json +52 -0
  57. ebus_panel_sim/wire/profiles/span/lugs.json +16 -0
  58. ebus_panel_sim/wire/profiles/span/panel.json +77 -0
  59. ebus_panel_sim/wire/property_bag.py +56 -0
  60. ebus_panel_sim/wire/publisher.py +36 -0
  61. ebus_panel_sim/wire/set_router.py +122 -0
  62. ebus_panel_sim-0.3.0.dist-info/METADATA +14 -0
  63. ebus_panel_sim-0.3.0.dist-info/RECORD +66 -0
  64. ebus_panel_sim-0.3.0.dist-info/WHEEL +4 -0
  65. ebus_panel_sim-0.3.0.dist-info/licenses/AUTHORS +7 -0
  66. ebus_panel_sim-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,89 @@
1
+ """Per-instance energy accumulator with producer-supplied seed values.
2
+
3
+ Each tick the producer reports an instantaneous power for an instance. The
4
+ integrator advances ``produced_wh`` and ``consumed_wh`` based on the elapsed
5
+ time since that instance's last tick, computed from ``current_time`` (epoch
6
+ seconds) the producer pushes. Power-sign convention matches the rest of the
7
+ emitter: positive = consumption (load), negative = production (PV/V2G).
8
+
9
+ Seeding:
10
+ - ``register(instance_id)`` initializes the integrator at zero.
11
+ - ``seed(instance_id, *, consumed_wh, produced_wh)`` overwrites the running
12
+ accumulators (typical use: producer reads last-known values from persistent
13
+ storage and seeds at startup before the first tick).
14
+ - The manifest's ``initial-consumed-wh`` / ``initial-produced-wh`` keys are a
15
+ declarative alternative; ``Emitter`` calls ``seed()`` from those at startup.
16
+
17
+ Time bookkeeping:
18
+ - The first observation for an instance establishes ``last_tick_time_s`` but
19
+ does NOT integrate (no prior interval). Subsequent observations integrate
20
+ ``power * (now - last)``.
21
+ - Backwards or zero ``dt`` is treated as a no-op (clock skew / duplicate tick)."""
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class EnergyState:
30
+ consumed_wh: float = 0.0
31
+ produced_wh: float = 0.0
32
+ last_tick_time_s: float | None = None
33
+
34
+
35
+ class EnergyIntegrator:
36
+ """Accumulators for many instances. One instance per circuit / PV / EVSE."""
37
+
38
+ def __init__(self) -> None:
39
+ self._states: dict[str, EnergyState] = {}
40
+
41
+ def register(self, instance_id: str) -> None:
42
+ """Idempotent — repeated calls leave existing state untouched."""
43
+ if instance_id not in self._states:
44
+ self._states[instance_id] = EnergyState()
45
+
46
+ def seed(
47
+ self,
48
+ instance_id: str,
49
+ *,
50
+ consumed_wh: float = 0.0,
51
+ produced_wh: float = 0.0,
52
+ ) -> None:
53
+ """Overwrite accumulators for ``instance_id``. Raises ``KeyError`` if the
54
+ instance was never registered (catches typos before the first tick)."""
55
+ if instance_id not in self._states:
56
+ raise KeyError(
57
+ f"seed() called for unknown instance_id={instance_id!r}; call register() first"
58
+ )
59
+ st = self._states[instance_id]
60
+ st.consumed_wh = consumed_wh
61
+ st.produced_wh = produced_wh
62
+ # Note: last_tick_time_s is intentionally NOT reset — seeding only
63
+ # overwrites the energy values, the time bookkeeping persists.
64
+
65
+ def observe(self, instance_id: str, power_w: float, current_time: float) -> None:
66
+ """Advance the integrator for ``instance_id`` with the producer's signed
67
+ ``power_w`` reported at ``current_time``. The first call for an instance
68
+ establishes ``last_tick_time_s`` without integrating."""
69
+ if instance_id not in self._states:
70
+ raise KeyError(f"observe() for unknown instance_id={instance_id!r}")
71
+ st = self._states[instance_id]
72
+ if st.last_tick_time_s is None:
73
+ st.last_tick_time_s = current_time
74
+ return
75
+ dt_s = current_time - st.last_tick_time_s
76
+ st.last_tick_time_s = current_time
77
+ if dt_s <= 0:
78
+ return
79
+ dt_h = dt_s / 3600.0
80
+ if power_w > 0:
81
+ st.consumed_wh += power_w * dt_h
82
+ elif power_w < 0:
83
+ st.produced_wh += -power_w * dt_h
84
+
85
+ def state(self, instance_id: str) -> EnergyState:
86
+ return self._states[instance_id]
87
+
88
+ def known(self, instance_id: str) -> bool:
89
+ return instance_id in self._states
@@ -0,0 +1,38 @@
1
+ """Public exception hierarchy for ebus-panel-sim."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class EmitterError(Exception):
7
+ """Base class for all emitter errors."""
8
+
9
+
10
+ class ManifestValidationError(EmitterError):
11
+ """A DeviceManifest references unknown entity_class, missing required parent, or
12
+ contains a duplicate (entity_class, instance_id) pair."""
13
+
14
+
15
+ class RuntimeSpecValidationError(EmitterError):
16
+ """A RuntimeSpec is structurally invalid or references manifest entities that do not
17
+ exist."""
18
+
19
+
20
+ class MissingSetterError(EmitterError):
21
+ """A settable property declared by a vendored profile has no corresponding handler in
22
+ the SetterRegistry passed to Emitter.__init__. ``missing`` carries the offending
23
+ (entity_class, property_path) pairs."""
24
+
25
+ def __init__(self, missing: list[tuple[str, str]]) -> None:
26
+ self.missing = missing
27
+ rendered = ", ".join(f"({c!r}, {p!r})" for c, p in missing)
28
+ super().__init__(f"Settable properties without registered handlers: {rendered}")
29
+
30
+
31
+ class ProfileValidationError(EmitterError):
32
+ """A vendored profile or mapping descriptor failed internal-consistency validation at
33
+ load time. Defensive — should never fire in shipped code."""
34
+
35
+
36
+ class EmitterStateError(EmitterError):
37
+ """Emitter operation called in the wrong state (e.g. tick() before start(), start()
38
+ against a disconnected MQTT client)."""
@@ -0,0 +1,35 @@
1
+ """Producer-supplied device identity manifest.
2
+
3
+ Frozen, immutable declaration of which entity instances exist on a panel. The producer
4
+ hands one to ``Emitter`` at construction; the emitter validates it against the vendored
5
+ mapping descriptors and profiles. Manifest mutations require emitter restart (no live
6
+ mutation API).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class DeviceInstance:
16
+ entity_class: str
17
+ instance_id: str
18
+ display_name: str
19
+ metadata: dict[str, str] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class DeviceManifest:
24
+ instances: tuple[DeviceInstance, ...]
25
+
26
+ def get(self, entity_class: str, instance_id: str) -> DeviceInstance:
27
+ for inst in self.instances:
28
+ if inst.entity_class == entity_class and inst.instance_id == instance_id:
29
+ return inst
30
+ raise KeyError(
31
+ f"No DeviceInstance with entity_class={entity_class!r}, instance_id={instance_id!r}"
32
+ )
33
+
34
+ def of_class(self, entity_class: str) -> tuple[DeviceInstance, ...]:
35
+ return tuple(i for i in self.instances if i.entity_class == entity_class)
@@ -0,0 +1,451 @@
1
+ """Typed accessor over ``DeviceInstance.metadata`` for physics-relevant fields.
2
+
3
+ The producer puts strings in ``DeviceInstance.metadata``; the emitter reads them
4
+ through this view. One central place to define every key the emitter consumes,
5
+ its parser, its default, and its validation rules. Adding a new physics field
6
+ is a one-line addition here plus a docs note in the README.
7
+
8
+ Validation runs once when ``ManifestPhysicsView`` is constructed (typically at
9
+ ``Emitter.__init__``). Missing required keys, malformed values, and contradictory
10
+ physics (e.g. ``dipole`` flag inconsistent with ``tab-numbers`` count) raise
11
+ ``ManifestValidationError`` with the offending instance_id."""
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Literal
17
+
18
+ from ebus_panel_sim.conventions.tab_legs import Leg, legs_for_tabs
19
+ from ebus_panel_sim.exceptions import ManifestValidationError
20
+
21
+ if TYPE_CHECKING:
22
+ from ebus_panel_sim.manifest import DeviceInstance, DeviceManifest
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Per-entity-class typed views — built once, queried many times.
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class PanelPhysics:
32
+ serial_number: str
33
+ vendor_name: str
34
+ firmware_version: str
35
+ hardware_version: str
36
+ panel_size: int
37
+ main_breaker_rating_a: int
38
+ panel_model: str
39
+ postal_code: str
40
+ time_zone: str
41
+ service_voltage_v: float
42
+ line_voltage_v: float
43
+ islandable: bool
44
+ # ``flat`` = legacy single-Homie-device shape (one device, many nodes).
45
+ # ``parent-child`` = post-migration shape where children become separate
46
+ # Homie devices. Producer-overridable via metadata key ``schema-topology``.
47
+ topology: Literal["flat", "parent-child"] = "flat"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class LugsPhysics:
52
+ direction: str # "upstream" | "downstream"
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class CircuitPhysics:
57
+ tabs: tuple[int, ...]
58
+ legs: tuple[Leg, ...]
59
+ dipole: bool
60
+ breaker_rating_a: float
61
+ default_priority: str
62
+ relay_behavior: str # "controllable" | "always-on" | "non-controllable"
63
+ placement: str # "upstream-of-lugs" | "downstream-of-lugs"
64
+ always_on: bool
65
+ initial_consumed_wh: float
66
+ initial_produced_wh: float
67
+ pcs_priority: int = 0
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class BessPhysics:
72
+ vendor_name: str
73
+ nameplate_capacity_kwh: float
74
+ initial_soe_kwh: float | None
75
+ part_number: str | None
76
+ model: str | None
77
+ serial_number: str | None
78
+ firmware_version: str | None
79
+ relative_position: str | None
80
+ feed: str | None
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class PvPhysics:
85
+ vendor_name: str
86
+ nominal_power_w: float
87
+ inverter_type: str # "hybrid" | "ac-coupled"
88
+ model: str | None
89
+ serial_number: str | None
90
+ firmware_version: str | None
91
+ relative_position: str | None
92
+ feed: str | None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class EvsePhysics:
97
+ vendor_name: str
98
+ model: str
99
+ part_number: str
100
+ serial_number: str
101
+ firmware_version: str
102
+ max_current_a: float
103
+ feed: str | None
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class MidPhysics:
108
+ """Microgrid Interconnect Device identity. Grid state (islanding-state /
109
+ grid-state / grid-forming-entity) is dynamic, derived per-tick from the
110
+ grid-online signal rather than parsed here."""
111
+
112
+ vendor_name: str | None
113
+ serial_number: str | None
114
+ model: str | None
115
+ firmware_version: str | None
116
+ hardware_version: str | None
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Top-level view
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ _VALID_PRIORITIES = frozenset(
125
+ {
126
+ "MUST_HAVE",
127
+ "NICE_TO_HAVE",
128
+ "NON_ESSENTIAL",
129
+ "NEVER",
130
+ "SOC_THRESHOLD",
131
+ "OFF_GRID",
132
+ }
133
+ )
134
+ _VALID_RELAY_BEHAVIORS = frozenset({"controllable", "always-on", "non-controllable"})
135
+ _VALID_PLACEMENTS = frozenset({"upstream-of-lugs", "downstream-of-lugs"})
136
+ _VALID_LUGS_DIRECTIONS = frozenset({"upstream", "downstream"})
137
+ _VALID_INVERTER_TYPES = frozenset({"hybrid", "ac-coupled"})
138
+ _VALID_TOPOLOGIES = frozenset({"flat", "parent-child"})
139
+
140
+
141
+ class ManifestPhysicsView:
142
+ """Validated, typed view over a ``DeviceManifest``'s metadata.
143
+
144
+ Built once at ``Emitter`` construction time. Holds parsed physics for every
145
+ instance keyed by ``instance_id``. Raises ``ManifestValidationError`` at
146
+ construction if any instance is missing required keys or has malformed
147
+ values; the emitter never sees a partially-validated manifest."""
148
+
149
+ def __init__(self, manifest: DeviceManifest) -> None:
150
+ self._panel: PanelPhysics | None = None
151
+ self._lugs: dict[str, LugsPhysics] = {}
152
+ self._circuits: dict[str, CircuitPhysics] = {}
153
+ self._bess: dict[str, BessPhysics] = {}
154
+ self._pv: dict[str, PvPhysics] = {}
155
+ self._evse: dict[str, EvsePhysics] = {}
156
+ self._mid: dict[str, MidPhysics] = {}
157
+
158
+ for inst in manifest.instances:
159
+ ec = inst.entity_class
160
+ try:
161
+ if ec == "panel":
162
+ if self._panel is not None:
163
+ raise ManifestValidationError(
164
+ "Multiple panel instances in manifest; expected exactly one",
165
+ )
166
+ self._panel = _parse_panel(inst)
167
+ elif ec == "lugs":
168
+ self._lugs[inst.instance_id] = _parse_lugs(inst)
169
+ elif ec == "circuit":
170
+ self._circuits[inst.instance_id] = _parse_circuit(inst)
171
+ elif ec == "bess":
172
+ self._bess[inst.instance_id] = _parse_bess(inst)
173
+ elif ec == "pv":
174
+ self._pv[inst.instance_id] = _parse_pv(inst)
175
+ elif ec == "evse":
176
+ self._evse[inst.instance_id] = _parse_evse(inst)
177
+ elif ec == "mid":
178
+ self._mid[inst.instance_id] = _parse_mid(inst)
179
+ # Unknown entity_class: leave to graph builder to reject.
180
+ except ManifestValidationError as exc:
181
+ raise ManifestValidationError(f"{ec}/{inst.instance_id}: {exc}") from exc
182
+
183
+ if self._panel is None:
184
+ raise ManifestValidationError("Manifest has no panel instance")
185
+
186
+ # -- accessors -----------------------------------------------------------
187
+
188
+ @property
189
+ def panel(self) -> PanelPhysics:
190
+ assert self._panel is not None # checked in __init__
191
+ return self._panel
192
+
193
+ def lugs(self, instance_id: str) -> LugsPhysics:
194
+ return self._lugs[instance_id]
195
+
196
+ def circuit(self, instance_id: str) -> CircuitPhysics:
197
+ return self._circuits[instance_id]
198
+
199
+ def bess(self, instance_id: str) -> BessPhysics:
200
+ return self._bess[instance_id]
201
+
202
+ def pv(self, instance_id: str) -> PvPhysics:
203
+ return self._pv[instance_id]
204
+
205
+ def evse(self, instance_id: str) -> EvsePhysics:
206
+ return self._evse[instance_id]
207
+
208
+ def all_circuits(self) -> dict[str, CircuitPhysics]:
209
+ return dict(self._circuits)
210
+
211
+ def all_lugs(self) -> dict[str, LugsPhysics]:
212
+ return dict(self._lugs)
213
+
214
+ def all_bess(self) -> dict[str, BessPhysics]:
215
+ return dict(self._bess)
216
+
217
+ def all_pv(self) -> dict[str, PvPhysics]:
218
+ return dict(self._pv)
219
+
220
+ def all_evse(self) -> dict[str, EvsePhysics]:
221
+ return dict(self._evse)
222
+
223
+ def mid(self, instance_id: str) -> MidPhysics:
224
+ return self._mid[instance_id]
225
+
226
+ def all_mid(self) -> dict[str, MidPhysics]:
227
+ return dict(self._mid)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Parsers — one per entity_class.
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def _require(md: dict[str, str], key: str) -> str:
236
+ if key not in md:
237
+ raise ManifestValidationError(f"missing required metadata key {key!r}")
238
+ return md[key]
239
+
240
+
241
+ def _opt_float(md: dict[str, str], key: str, default: float) -> float:
242
+ if key not in md:
243
+ return default
244
+ try:
245
+ return float(md[key])
246
+ except ValueError as exc:
247
+ raise ManifestValidationError(f"key {key!r}: not a float ({md[key]!r})") from exc
248
+
249
+
250
+ def _opt_int(md: dict[str, str], key: str, default: int) -> int:
251
+ if key not in md:
252
+ return default
253
+ try:
254
+ return int(md[key])
255
+ except ValueError as exc:
256
+ raise ManifestValidationError(f"key {key!r}: not an int ({md[key]!r})") from exc
257
+
258
+
259
+ def _opt_bool(md: dict[str, str], key: str, default: bool) -> bool:
260
+ if key not in md:
261
+ return default
262
+ raw = md[key].strip().lower()
263
+ if raw in ("true", "1", "yes"):
264
+ return True
265
+ if raw in ("false", "0", "no"):
266
+ return False
267
+ raise ManifestValidationError(f"key {key!r}: not a bool ({md[key]!r})")
268
+
269
+
270
+ def _opt_str(md: dict[str, str], key: str) -> str | None:
271
+ value = md.get(key)
272
+ return value if value not in (None, "") else None
273
+
274
+
275
+ def _req_float(md: dict[str, str], key: str) -> float:
276
+ raw = _require(md, key)
277
+ try:
278
+ return float(raw)
279
+ except ValueError as exc:
280
+ raise ManifestValidationError(f"key {key!r}: not a float ({raw!r})") from exc
281
+
282
+
283
+ def _req_int(md: dict[str, str], key: str) -> int:
284
+ raw = _require(md, key)
285
+ try:
286
+ return int(raw)
287
+ except ValueError as exc:
288
+ raise ManifestValidationError(f"key {key!r}: not an int ({raw!r})") from exc
289
+
290
+
291
+ def _parse_panel(inst: DeviceInstance) -> PanelPhysics:
292
+ md = inst.metadata
293
+ topology_raw = md.get("schema-topology", "flat")
294
+ if topology_raw not in _VALID_TOPOLOGIES:
295
+ raise ManifestValidationError(
296
+ f"key 'schema-topology': must be one of {sorted(_VALID_TOPOLOGIES)}, "
297
+ f"got {topology_raw!r}"
298
+ )
299
+ # ``cast`` would also work here, but a direct comparison keeps the Literal
300
+ # narrow without an explicit import-only typing helper.
301
+ topology: Literal["flat", "parent-child"] = (
302
+ "parent-child" if topology_raw == "parent-child" else "flat"
303
+ )
304
+ return PanelPhysics(
305
+ serial_number=_require(md, "serial-number"),
306
+ vendor_name=_require(md, "vendor-name"),
307
+ firmware_version=_opt_str(md, "firmware-version") or _require(md, "software-version"),
308
+ hardware_version=_require(md, "hardware-version"),
309
+ panel_size=_req_int(md, "panel-size"),
310
+ main_breaker_rating_a=_req_int(md, "main-breaker-rating-a"),
311
+ panel_model=_require(md, "panel-model"),
312
+ postal_code=_require(md, "postal-code"),
313
+ time_zone=_require(md, "time-zone"),
314
+ service_voltage_v=_opt_float(md, "service-voltage-v", 240.0),
315
+ line_voltage_v=_opt_float(md, "line-voltage-v", 120.0),
316
+ islandable=_opt_bool(md, "islandable", False),
317
+ topology=topology,
318
+ )
319
+
320
+
321
+ def _parse_lugs(inst: DeviceInstance) -> LugsPhysics:
322
+ direction = _require(inst.metadata, "direction")
323
+ if direction not in _VALID_LUGS_DIRECTIONS:
324
+ raise ManifestValidationError(
325
+ f"key 'direction': must be one of {sorted(_VALID_LUGS_DIRECTIONS)}, got {direction!r}"
326
+ )
327
+ return LugsPhysics(direction=direction)
328
+
329
+
330
+ def _parse_circuit(inst: DeviceInstance) -> CircuitPhysics:
331
+ md = inst.metadata
332
+ raw_tabs = _require(md, "tab-numbers")
333
+ try:
334
+ tabs = tuple(int(t.strip()) for t in raw_tabs.split(",") if t.strip())
335
+ except ValueError as exc:
336
+ raise ManifestValidationError(
337
+ f"key 'tab-numbers': not a comma-separated int list ({raw_tabs!r})"
338
+ ) from exc
339
+ if not tabs:
340
+ raise ManifestValidationError("key 'tab-numbers': must list at least one tab")
341
+ try:
342
+ legs = legs_for_tabs(tabs)
343
+ except ValueError as exc:
344
+ raise ManifestValidationError(f"key 'tab-numbers': {exc}") from exc
345
+ dipole_declared = _opt_bool(md, "dipole", default=len(tabs) > 1)
346
+ # NOTE: ``dipole`` + leg-spanning is not strictly validated. Real SPAN panels
347
+ # use slot numberings where two adjacent breaker positions on the same leg
348
+ # can still be ganged as a "240 V" feed (e.g. tabs 20+22). The convention
349
+ # in ``conventions/tab_legs.py`` is informational for per-leg current
350
+ # calculation; producers are trusted to declare dipole correctly.
351
+ if not dipole_declared and len(tabs) > 1:
352
+ raise ManifestValidationError(
353
+ f"dipole=false but {len(tabs)} tabs declared; single-tab circuits only"
354
+ )
355
+
356
+ priority = _require(md, "default-priority")
357
+ if priority not in _VALID_PRIORITIES:
358
+ raise ManifestValidationError(
359
+ f"key 'default-priority': must be one of {sorted(_VALID_PRIORITIES)}, got {priority!r}"
360
+ )
361
+
362
+ relay_behavior = _require(md, "relay-behavior")
363
+ if relay_behavior not in _VALID_RELAY_BEHAVIORS:
364
+ raise ManifestValidationError(
365
+ f"key 'relay-behavior': must be one of {sorted(_VALID_RELAY_BEHAVIORS)}, "
366
+ f"got {relay_behavior!r}"
367
+ )
368
+
369
+ placement = _require(md, "placement")
370
+ if placement not in _VALID_PLACEMENTS:
371
+ raise ManifestValidationError(
372
+ f"key 'placement': must be one of {sorted(_VALID_PLACEMENTS)}, got {placement!r}"
373
+ )
374
+
375
+ always_on = _opt_bool(md, "always-on", default=relay_behavior == "always-on")
376
+
377
+ return CircuitPhysics(
378
+ tabs=tabs,
379
+ legs=legs,
380
+ dipole=dipole_declared,
381
+ breaker_rating_a=_req_float(md, "breaker-rating-a"),
382
+ default_priority=priority,
383
+ relay_behavior=relay_behavior,
384
+ placement=placement,
385
+ always_on=always_on,
386
+ pcs_priority=_opt_int(md, "pcs-priority", 0),
387
+ initial_consumed_wh=_opt_float(md, "initial-consumed-wh", 0.0),
388
+ initial_produced_wh=_opt_float(md, "initial-produced-wh", 0.0),
389
+ )
390
+
391
+
392
+ def _parse_bess(inst: DeviceInstance) -> BessPhysics:
393
+ md = inst.metadata
394
+ initial_soe: float | None = None
395
+ if "initial-soe-kwh" in md:
396
+ initial_soe = _opt_float(md, "initial-soe-kwh", 0.0)
397
+ return BessPhysics(
398
+ vendor_name=_require(md, "vendor-name"),
399
+ nameplate_capacity_kwh=_req_float(md, "nameplate-capacity-kwh"),
400
+ initial_soe_kwh=initial_soe,
401
+ part_number=_opt_str(md, "part-number"),
402
+ model=_opt_str(md, "model"),
403
+ serial_number=_opt_str(md, "serial-number"),
404
+ firmware_version=_opt_str(md, "firmware-version") or _opt_str(md, "software-version"),
405
+ relative_position=_opt_str(md, "relative-position") or "UPSTREAM",
406
+ feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"),
407
+ )
408
+
409
+
410
+ def _parse_pv(inst: DeviceInstance) -> PvPhysics:
411
+ md = inst.metadata
412
+ inverter_type = _require(md, "inverter-type")
413
+ if inverter_type not in _VALID_INVERTER_TYPES:
414
+ raise ManifestValidationError(
415
+ f"key 'inverter-type': must be one of {sorted(_VALID_INVERTER_TYPES)}, "
416
+ f"got {inverter_type!r}"
417
+ )
418
+ return PvPhysics(
419
+ vendor_name=_require(md, "vendor-name"),
420
+ nominal_power_w=_req_float(md, "nominal-power-w"),
421
+ inverter_type=inverter_type,
422
+ model=_opt_str(md, "model"),
423
+ serial_number=_opt_str(md, "serial-number"),
424
+ firmware_version=_opt_str(md, "firmware-version") or _opt_str(md, "software-version"),
425
+ relative_position=_opt_str(md, "relative-position") or "IN_PANEL",
426
+ feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"),
427
+ )
428
+
429
+
430
+ def _parse_evse(inst: DeviceInstance) -> EvsePhysics:
431
+ md = inst.metadata
432
+ return EvsePhysics(
433
+ vendor_name=_require(md, "vendor-name"),
434
+ model=_require(md, "model"),
435
+ part_number=_require(md, "part-number"),
436
+ serial_number=_require(md, "serial-number"),
437
+ firmware_version=_opt_str(md, "firmware-version") or _require(md, "software-version"),
438
+ max_current_a=_req_float(md, "max-current-a"),
439
+ feed=_opt_str(md, "feed") or _opt_str(md, "feed-circuit-id"),
440
+ )
441
+
442
+
443
+ def _parse_mid(inst: DeviceInstance) -> MidPhysics:
444
+ md = inst.metadata
445
+ return MidPhysics(
446
+ vendor_name=_opt_str(md, "vendor-name"),
447
+ serial_number=_opt_str(md, "serial-number"),
448
+ model=_opt_str(md, "model"),
449
+ firmware_version=_opt_str(md, "firmware-version") or _opt_str(md, "software-version"),
450
+ hardware_version=_opt_str(md, "hardware-version"),
451
+ )
@@ -0,0 +1,24 @@
1
+ """Emitter-native devices.
2
+
3
+ A native device is a configured-and-self-driving entity that lives inside the emitter
4
+ and computes its per-tick state from its configuration plus per-tick context pushed by
5
+ the producer (simulator). Reflector-class devices (circuits, PV, EVSE, lugs) are
6
+ producer-driven and do not have a corresponding NativeDevice.
7
+
8
+ Today's native devices: BESS, LoadShedding. MID + PCS are future when the parent-child
9
+ schema lands. The list is intentionally small — circuits stay 100% producer-driven
10
+ even when the producer's view of them is HVAC-shaped or recorder-replayed.
11
+ """
12
+
13
+ from ebus_panel_sim.native_devices.bess import BESSConfig, BESSDevice
14
+ from ebus_panel_sim.native_devices.load_shedding import LoadSheddingConfig, LoadSheddingDevice
15
+ from ebus_panel_sim.native_devices.protocol import NativeDevice, NativeTickContext
16
+
17
+ __all__ = [
18
+ "BESSConfig",
19
+ "BESSDevice",
20
+ "LoadSheddingConfig",
21
+ "LoadSheddingDevice",
22
+ "NativeDevice",
23
+ "NativeTickContext",
24
+ ]