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,750 @@
1
+ """Public Emitter facade — wire-layer publisher with native-device runtime.
2
+
3
+ The producer hands the emitter a small per-tick driving signal via
4
+ ``publish_tick(TickInputs)``: signed power per circuit/EVSE, current_time,
5
+ grid_online, panel envelope. The emitter resolves BESS dispatch, gates circuit
6
+ power through ``RelayResolver``, integrates energy via ``EnergyIntegrator``,
7
+ aggregates panel-level fields via ``PanelMeter``, builds the internal snapshot,
8
+ and publishes the Homie diff to MQTT.
9
+
10
+ The internal snapshot type (``EbusPanelSnapshot`` and friends) is used for the
11
+ diff cache and read-back via ``last_snapshot``; producers do not construct it."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ from ebus_panel_sim.energy_integrator import EnergyIntegrator
21
+ from ebus_panel_sim.exceptions import EmitterStateError
22
+ from ebus_panel_sim.manifest import DeviceManifest
23
+ from ebus_panel_sim.manifest_physics import ManifestPhysicsView
24
+ from ebus_panel_sim.native_devices import (
25
+ BESSConfig,
26
+ BESSDevice,
27
+ LoadSheddingConfig,
28
+ LoadSheddingDevice,
29
+ NativeTickContext,
30
+ )
31
+ from ebus_panel_sim.panel_meter import circuit_current_a
32
+ from ebus_panel_sim.panel_meter import resolve as resolve_panel
33
+ from ebus_panel_sim.relay_resolver import RelayResolver, RelayState
34
+ from ebus_panel_sim.snapshot import (
35
+ EbusBatterySnapshot,
36
+ EbusCircuitSnapshot,
37
+ EbusEvseSnapshot,
38
+ EbusLugsSnapshot,
39
+ EbusMidSnapshot,
40
+ EbusPanelDoor,
41
+ EbusPanelInfo,
42
+ EbusPanelMeter,
43
+ EbusPanelPcs,
44
+ EbusPanelPowerFlows,
45
+ EbusPanelShed,
46
+ EbusPanelShedForecast,
47
+ EbusPanelSnapshot,
48
+ EbusPanelStatus,
49
+ EbusPvSnapshot,
50
+ )
51
+ from ebus_panel_sim.tick_inputs import TickInputs
52
+ from ebus_panel_sim.wire._sdk_seam import owned_client
53
+ from ebus_panel_sim.wire.bag_builder import BagBuilder
54
+ from ebus_panel_sim.wire.graph_builder import build_graph
55
+ from ebus_panel_sim.wire.mapping_loader import load_mapping_table
56
+ from ebus_panel_sim.wire.profile_loader import Variant, load_profiles
57
+ from ebus_panel_sim.wire.publisher import Publisher
58
+ from ebus_panel_sim.wire.set_router import (
59
+ SetterRegistry,
60
+ check_setter_coverage,
61
+ make_set_callback,
62
+ )
63
+
64
+ _LOG = logging.getLogger(__name__)
65
+ _DEFAULT_MQTT_CFG: dict[str, Any] = {"host": "127.0.0.1", "port": 1883}
66
+
67
+
68
+ class Emitter:
69
+ """One emitter per logical panel/clone."""
70
+
71
+ def __init__(
72
+ self,
73
+ manifest: DeviceManifest,
74
+ setter_registry: SetterRegistry,
75
+ *,
76
+ mqtt_cfg: dict[str, Any] | None = None,
77
+ bess_configs: tuple[BESSConfig, ...] = (),
78
+ load_shedding_config: LoadSheddingConfig | None = None,
79
+ variant: Variant = "span",
80
+ ) -> None:
81
+ self._manifest = manifest
82
+ self._mqtt_cfg = dict(mqtt_cfg) if mqtt_cfg is not None else dict(_DEFAULT_MQTT_CFG)
83
+
84
+ # variant="span" (default) publishes the SPAN-faithful surface (status
85
+ # diagnostics, read-only shed/policy, the legacy evse config); "reference"
86
+ # is the vendor-neutral spec-conformant tree.
87
+ self._profiles = load_profiles(variant=variant)
88
+ self._mapping = load_mapping_table()
89
+ self._mapping.validate_against(self._profiles)
90
+
91
+ # The root device owns the shared MQTT connection built from mqtt_cfg;
92
+ # children share it. Construction opens no socket (deferred to start()).
93
+ self._graph = build_graph(manifest, self._mapping, self._profiles, mqtt_cfg=self._mqtt_cfg)
94
+ self._root = self._graph.devices[self._graph.root_id]
95
+
96
+ # ---- native-device + physics state (must exist before internal /set
97
+ # handlers bind, which must happen before setter-coverage validation). ----
98
+ # BESS is pluralized: a panel can host multiple battery devices (e.g. a
99
+ # Powerwall plus an Enphase IQ, or two Powerwalls). Keyed by
100
+ # ``BESSConfig.instance_id``; duplicate IDs are a producer-side bug.
101
+ self._bess: dict[str, BESSDevice] = {}
102
+ for cfg in bess_configs:
103
+ if cfg.instance_id in self._bess:
104
+ raise EmitterStateError(f"duplicate bess_config instance_id={cfg.instance_id!r}")
105
+ self._bess[cfg.instance_id] = BESSDevice(config=cfg)
106
+ self._load_shedding: LoadSheddingDevice | None = (
107
+ LoadSheddingDevice(config=load_shedding_config)
108
+ if load_shedding_config is not None
109
+ else None
110
+ )
111
+ # ManifestPhysicsView raises ManifestValidationError if the manifest
112
+ # is missing required physics keys. publish_tick is the only publish
113
+ # path now, so a malformed manifest is a hard error at construction.
114
+ self._physics = ManifestPhysicsView(manifest)
115
+ self._relays = RelayResolver()
116
+ self._energy = EnergyIntegrator()
117
+ self._priority_overrides: dict[str, str] = {}
118
+ self._name_overrides: dict[str, str] = {}
119
+ self._dominant_power_source_override: str | None = None
120
+ self._asserted_islanding_override: str | None = None
121
+ self._shed_policy_override: str | None = None
122
+ self._evse_user_max_override: dict[str, int] = {}
123
+
124
+ for cid, cphys in self._physics.all_circuits().items():
125
+ self._relays.register(cid, always_on=cphys.always_on)
126
+ self._energy.register(cid)
127
+ if cphys.initial_consumed_wh or cphys.initial_produced_wh:
128
+ self._energy.seed(
129
+ cid,
130
+ consumed_wh=cphys.initial_consumed_wh,
131
+ produced_wh=cphys.initial_produced_wh,
132
+ )
133
+ for eid in self._physics.all_evse():
134
+ self._energy.register(eid)
135
+ # Seed any configured BESS whose manifest physics declares an initial SOE.
136
+ for bess_id, bphys in self._physics.all_bess().items():
137
+ if bphys.initial_soe_kwh is not None and bess_id in self._bess:
138
+ self._bess[bess_id].set_soe(bphys.initial_soe_kwh)
139
+
140
+ # Internal default /set handlers — registered BEFORE the setter-coverage
141
+ # check so it passes. Producer-supplied handlers always win (the helper
142
+ # checks .get() first).
143
+ self._register_internal_setters(setter_registry)
144
+
145
+ # ---- /set wiring: fail loud on any settable without a handler, then
146
+ # bind each settable Property's SDK set-callback to its registry handler.
147
+ # (ebus-sdk owns the /set subscription + payload decode.) ----
148
+ instances = [(i.entity_class, i.instance_id) for i in manifest.instances]
149
+ settables_by_class = {
150
+ ec: profile.settable_properties() for ec, profile in self._profiles.items()
151
+ }
152
+ check_setter_coverage(
153
+ instances=instances,
154
+ settables_by_class=settables_by_class,
155
+ registry=setter_registry,
156
+ )
157
+ self._wire_set_callbacks(setter_registry, instances, settables_by_class)
158
+
159
+ self._publisher = Publisher(self._graph)
160
+ self._bag_builder = BagBuilder(self._graph, self._mapping, self._profiles)
161
+ self._last_snapshot: EbusPanelSnapshot | None = None
162
+ self._started = False
163
+
164
+ def _wire_set_callbacks(
165
+ self,
166
+ registry: SetterRegistry,
167
+ instances: list[tuple[str, str]],
168
+ settables_by_class: dict[str, list[tuple[str, str]]],
169
+ ) -> None:
170
+ """Bind every settable property's ebus-sdk set-callback to its registry
171
+ handler. The callback coerces the ``/set`` payload per datatype and fans
172
+ in to the handler; ebus-sdk owns the subscription + decode."""
173
+ for ec, iid in instances:
174
+ for cap, key in settables_by_class.get(ec, []):
175
+ prop_path = f"{cap}/{key}"
176
+ handler = registry.get(ec, prop_path)
177
+ sdk_prop = self._graph.properties.get((ec, iid, prop_path))
178
+ if handler is None or sdk_prop is None:
179
+ continue
180
+ datatype = self._profiles[ec].capabilities[cap].properties[key].datatype
181
+ sdk_prop.set_set_callback(
182
+ make_set_callback(
183
+ handler,
184
+ entity_class=ec,
185
+ instance_id=iid,
186
+ property_path=prop_path,
187
+ datatype=datatype,
188
+ )
189
+ )
190
+
191
+ def start(self, *, connect_timeout_s: float = 5.0) -> None:
192
+ """Open the MQTT connection and publish the retained device tree.
193
+
194
+ ebus-sdk publishes each device's ``$description`` + ``$state`` and
195
+ subscribes the ``/set`` topics itself once the link comes up (on_connect
196
+ -> refresh_tree). We start the root's shared client and wait, bounded,
197
+ for the link so the first ``publish_tick`` lands live; publishing before
198
+ connect is still safe (values are retained and republished on connect)."""
199
+ self._root.start_mqtt_client()
200
+ deadline = time.monotonic() + connect_timeout_s
201
+ while not self._root.is_connected() and time.monotonic() < deadline:
202
+ time.sleep(0.02)
203
+ if not self._root.is_connected():
204
+ _LOG.warning(
205
+ "Emitter.start(): MQTT link not up after %.1fs; publishing anyway "
206
+ "(values are retained and republished on connect)",
207
+ connect_timeout_s,
208
+ )
209
+ self._started = True
210
+
211
+ def publish_tick(self, tick_inputs: TickInputs) -> EbusPanelSnapshot:
212
+ """The producer-facing publish path. Builds the snapshot from the tick,
213
+ publishes the Homie diff, and returns the snapshot for read-back."""
214
+ if not self._started:
215
+ raise EmitterStateError("Emitter.publish_tick() called before start()")
216
+ snapshot = self._build_snapshot_from_tick(tick_inputs)
217
+ self._publish_diff(snapshot)
218
+ return snapshot
219
+
220
+ def seed_energy(
221
+ self,
222
+ instance_id: str,
223
+ *,
224
+ consumed_wh: float = 0.0,
225
+ produced_wh: float = 0.0,
226
+ ) -> None:
227
+ """Overwrite an instance's energy accumulators. Typical use: producer
228
+ reads last-known values from persistent storage and seeds at startup
229
+ before the first ``publish_tick`` call. Raises ``KeyError`` for unknown
230
+ instance IDs (caught typos before they cause silent data loss)."""
231
+ self._energy.seed(instance_id, consumed_wh=consumed_wh, produced_wh=produced_wh)
232
+
233
+ def seed_bess_soe(self, instance_id: str, soe_kwh: float) -> None:
234
+ """Overwrite a BESS device's stored SOE. Raises ``EmitterStateError``
235
+ if no BESS is configured or if ``instance_id`` is not among the
236
+ configured BESS instances — both are producer-side programming
237
+ errors."""
238
+ if not self._bess:
239
+ raise EmitterStateError(
240
+ f"seed_bess_soe({instance_id!r}, ...): no BESS configured on this emitter"
241
+ )
242
+ if instance_id not in self._bess:
243
+ known = sorted(self._bess.keys())
244
+ raise EmitterStateError(
245
+ f"seed_bess_soe: instance_id={instance_id!r} not among configured "
246
+ f"BESS instances {known!r}"
247
+ )
248
+ self._bess[instance_id].set_soe(soe_kwh)
249
+
250
+ def stop(self, *, graceful: bool = True, clear_retained: bool = False) -> None:
251
+ """Tear down the MQTT connection.
252
+
253
+ Graceful (default): publish the root's ``$state=disconnected`` then stop
254
+ the shared client (ebus-sdk's bounded teardown; per Homie's
255
+ effective-state rule the root going disconnected covers every child).
256
+ Non-graceful: stop the client without the disconnected publish, leaving
257
+ the LWT to fire ``$state=lost``. ``clear_retained`` additionally clears
258
+ every device's retained values + ``$description`` before disconnecting,
259
+ for a clean-slate re-run."""
260
+ if not graceful:
261
+ client = owned_client(self._root.mqttc)
262
+ if client is not None:
263
+ client.stop()
264
+ return
265
+ if clear_retained:
266
+ for device in self._graph.devices.values():
267
+ device.delete_all_from_mqtt()
268
+ self._root.stop()
269
+
270
+ def update_bess_config(self, config: BESSConfig) -> None:
271
+ """Replace (or add) a BESS device's configuration keyed by
272
+ ``config.instance_id``. Takes effect on the next publish call.
273
+ SOC/SOE state persists across in-place config swaps; freshly added
274
+ BESS instances start from their config's ``initial_soc_pct``."""
275
+ existing = self._bess.get(config.instance_id)
276
+ if existing is None:
277
+ self._bess[config.instance_id] = BESSDevice(config=config)
278
+ else:
279
+ existing.update_config(config)
280
+
281
+ def update_load_shedding_config(self, config: LoadSheddingConfig) -> None:
282
+ if self._load_shedding is None:
283
+ self._load_shedding = LoadSheddingDevice(config=config)
284
+ else:
285
+ self._load_shedding.update_config(config)
286
+
287
+ @property
288
+ def last_snapshot(self) -> EbusPanelSnapshot | None:
289
+ return self._last_snapshot
290
+
291
+ @property
292
+ def topology_version(self) -> int:
293
+ return next(iter(self._mapping.values())).profile_version
294
+
295
+ @property
296
+ def relays(self) -> RelayResolver:
297
+ """Read-write access to the per-circuit relay resolver. Used by /set
298
+ handlers (registered by the emitter for ``circuit.switch/relay``) to
299
+ update operator overrides."""
300
+ return self._relays
301
+
302
+ @property
303
+ def dominant_power_source_override(self) -> str | None:
304
+ """Operator-set dominant power source override, or None if not set.
305
+ Set via /set ``panel.pcs/dominant-power-source`` topic."""
306
+ return self._dominant_power_source_override
307
+
308
+ # ---- internal --------------------------------------------------------
309
+
310
+ def _register_internal_setters(self, registry: SetterRegistry) -> None:
311
+ """Register default handlers for the settable properties when the
312
+ producer hasn't already supplied one. The handlers update emitter-
313
+ internal state (RelayResolver, the priority override map, the panel
314
+ asserted-islanding override). The next ``publish_tick`` call reflects
315
+ the change on the wire.
316
+
317
+ Producers needing custom routing register their own handler before
318
+ constructing the ``Emitter`` and the registry's existing entry wins."""
319
+
320
+ def on_circuit_relay(
321
+ entity_class: str,
322
+ instance_id: str,
323
+ prop_path: str,
324
+ value: object,
325
+ ) -> None:
326
+ del entity_class, prop_path
327
+ # Homie boolean: True = relay closed (energized), False = open.
328
+ closed = (
329
+ bool(value)
330
+ if isinstance(value, bool)
331
+ else (str(value).strip().lower() in ("true", "1", "closed", "on"))
332
+ )
333
+ new_state = RelayState.CLOSED if closed else RelayState.OPEN
334
+ if self._relays.known(instance_id):
335
+ self._relays.set_user_override(instance_id, new_state)
336
+
337
+ def on_shed_priority(
338
+ entity_class: str,
339
+ instance_id: str,
340
+ prop_path: str,
341
+ value: object,
342
+ ) -> None:
343
+ del entity_class, prop_path
344
+ self._priority_overrides[instance_id] = str(value).upper()
345
+
346
+ def on_asserted_islanding(
347
+ entity_class: str,
348
+ instance_id: str,
349
+ prop_path: str,
350
+ value: object,
351
+ ) -> None:
352
+ del entity_class, instance_id, prop_path
353
+ self._asserted_islanding_override = str(value).upper()
354
+
355
+ def on_shed_policy(
356
+ entity_class: str,
357
+ instance_id: str,
358
+ prop_path: str,
359
+ value: object,
360
+ ) -> None:
361
+ del entity_class, instance_id, prop_path
362
+ # A json-datatype /set delivers a parsed object; store the policy as JSON text.
363
+ self._shed_policy_override = value if isinstance(value, str) else json.dumps(value)
364
+
365
+ def on_evse_user_max(
366
+ entity_class: str,
367
+ instance_id: str,
368
+ prop_path: str,
369
+ value: object,
370
+ ) -> None:
371
+ del entity_class, prop_path
372
+ self._evse_user_max_override[instance_id] = int(float(str(value)))
373
+
374
+ if registry.get("circuit", "switch/relay") is None:
375
+ registry.register("circuit", "switch/relay", on_circuit_relay)
376
+ if registry.get("circuit", "load-shed/priority") is None:
377
+ registry.register("circuit", "load-shed/priority", on_shed_priority)
378
+ if registry.get("panel", "shed/asserted-islanding-state") is None:
379
+ registry.register("panel", "shed/asserted-islanding-state", on_asserted_islanding)
380
+ if registry.get("panel", "shed/policy") is None:
381
+ registry.register("panel", "shed/policy", on_shed_policy)
382
+ if registry.get("evse", "config/user-max-charge-current") is None:
383
+ registry.register("evse", "config/user-max-charge-current", on_evse_user_max)
384
+
385
+ def _publish_diff(self, snapshot: EbusPanelSnapshot) -> None:
386
+ bag = self._bag_builder.build(snapshot)
387
+ self._publisher.publish(bag)
388
+ self._last_snapshot = snapshot
389
+
390
+ def _build_snapshot_from_tick(self, tick: TickInputs) -> EbusPanelSnapshot:
391
+ panel_phys = self._physics.panel
392
+ circuits_phys = self._physics.all_circuits()
393
+
394
+ # Step 1: aggregate inputs for BESS dispatch (pre-shed: use raw producer
395
+ # power, not gated, since shedding decisions DEPEND on BESS SOC).
396
+ load_demand_w = sum(p for p in tick.circuits.values() if p > 0)
397
+ pv_available_w = -sum(p for p in tick.circuits.values() if p < 0)
398
+
399
+ # Step 2: BESS dispatch + battery snapshots — one per configured BESS.
400
+ # ``battery_w`` is the SUM of signed dispatch across all batteries; it
401
+ # feeds the panel meter aggregation as a single combined contribution.
402
+ # The min-SOC across batteries is what drives load-shedding decisions
403
+ # (the most-depleted BESS is the binding constraint).
404
+ battery_snapshots: dict[str, EbusBatterySnapshot] = {}
405
+ battery_w = 0.0
406
+ for bess_id, bess_dev in self._bess.items():
407
+ bphys = self._physics.bess(bess_id)
408
+ snap = bess_dev.tick(
409
+ NativeTickContext(
410
+ current_time=tick.current_time,
411
+ grid_online=tick.grid_online,
412
+ load_demand_w=load_demand_w,
413
+ pv_available_w=pv_available_w,
414
+ )
415
+ )
416
+ snap.instance_id = bess_id
417
+ snap.vendor_name = bphys.vendor_name
418
+ snap.part_number = bphys.part_number
419
+ snap.model = bphys.model
420
+ snap.serial_number = bphys.serial_number
421
+ snap.firmware_version = bphys.firmware_version
422
+ snap.relative_position = bphys.relative_position
423
+ snap.feed_circuit_id = bphys.feed
424
+ snap.connected = snap.communication == "OK"
425
+ snap.grid_state = "ON_GRID" if tick.grid_online else "OFF_GRID"
426
+ battery_snapshots[bess_id] = snap
427
+ battery_w += snap.active_power_w
428
+ has_battery = bool(battery_snapshots)
429
+ # ``min_soc`` is None when no BESS reports a SOE value (all uninitialised);
430
+ # ``decide_shed`` then treats SOC as unknown.
431
+ soc_values = [
432
+ s.soe_percentage for s in battery_snapshots.values() if s.soe_percentage is not None
433
+ ]
434
+ min_soc: float | None = min(soc_values) if soc_values else None
435
+
436
+ # Step 3: load-shedding decisions written into RelayResolver. Always
437
+ # cleared first so a previous tick's shed state doesn't linger when the
438
+ # grid comes back online or SOC recovers. Operator-set priority
439
+ # overrides take precedence over manifest defaults.
440
+ self._relays.clear_all_shed()
441
+ if self._load_shedding is not None:
442
+ effective_priorities = {
443
+ cid: self._priority_overrides.get(cid, cphys.default_priority)
444
+ for cid, cphys in circuits_phys.items()
445
+ }
446
+ shed_ids = self._load_shedding.decide_shed(
447
+ grid_online=tick.grid_online,
448
+ bess_soc_pct=min_soc,
449
+ priorities=effective_priorities,
450
+ )
451
+ for cid in shed_ids:
452
+ self._relays.set_shed(cid, open_relay=True)
453
+
454
+ # Step 4: resolve final relay state per circuit (always-on > /set > shed
455
+ # > default-CLOSED) and gate producer-reported power.
456
+ gated_powers: dict[str, float] = {}
457
+ for cid in circuits_phys:
458
+ raw_power = tick.circuits.get(cid, 0.0)
459
+ relay_state, _requester = self._relays.state(cid)
460
+ gated_powers[cid] = 0.0 if relay_state == RelayState.OPEN else raw_power
461
+
462
+ # Step 4: integrate energy per circuit using gated power (if relay open,
463
+ # no energy flows).
464
+ for cid, gated in gated_powers.items():
465
+ self._energy.observe(cid, gated, tick.current_time)
466
+ for eid, evse_power in tick.evse.items():
467
+ if self._energy.known(eid):
468
+ self._energy.observe(eid, evse_power, tick.current_time)
469
+
470
+ # Step 5: panel-level aggregation.
471
+ meter = resolve_panel(
472
+ panel=panel_phys,
473
+ circuits=circuits_phys,
474
+ gated_powers=gated_powers,
475
+ battery_w=battery_w,
476
+ grid_online=tick.grid_online,
477
+ has_battery=has_battery,
478
+ )
479
+
480
+ # Cross-device connection edges. Real SPAN owns the connection index on
481
+ # the panel-side device (the circuit or lugs that feeds a DER), never on
482
+ # the DER child: each DER's feed circuit publishes the feeds-* triple, and
483
+ # an upstream BESS's fed-by triple lands on the upstream lugs. Status is a
484
+ # link-health enum (OK/LOST/DEGRADED); PV/EVSE have no comms model so they
485
+ # report OK, a BESS reports its battery snapshot's communication health.
486
+ feeds_by_circuit: dict[str, tuple[str, str, str]] = {}
487
+ for pv_id, pv_phys in self._physics.all_pv().items():
488
+ if pv_phys.feed:
489
+ feeds_by_circuit[pv_phys.feed] = (pv_id, self._profiles["pv"].type, "OK")
490
+ for evse_id, evse_phys in self._physics.all_evse().items():
491
+ if evse_phys.feed:
492
+ feeds_by_circuit[evse_phys.feed] = (evse_id, self._profiles["evse"].type, "OK")
493
+ upstream_fed_by: tuple[str, str, str] | None = None
494
+ for bess_id, bess_phys in self._physics.all_bess().items():
495
+ bsnap = battery_snapshots.get(bess_id)
496
+ link = (bsnap.communication if bsnap else None) or "OK"
497
+ if bess_phys.relative_position == "UPSTREAM":
498
+ upstream_fed_by = (bess_id, self._profiles["bess"].type, link)
499
+ elif bess_phys.feed:
500
+ feeds_by_circuit[bess_phys.feed] = (bess_id, self._profiles["bess"].type, link)
501
+
502
+ # Step 6: build per-circuit snapshots — applying any operator name and
503
+ # priority overrides on top of manifest defaults.
504
+ circuit_snaps: dict[str, EbusCircuitSnapshot] = {}
505
+ for cid, cphys in circuits_phys.items():
506
+ relay_state, requester = self._relays.state(cid)
507
+ gated_p = gated_powers[cid]
508
+ estate = self._energy.state(cid)
509
+ effective_priority = self._priority_overrides.get(cid, cphys.default_priority)
510
+ effective_name = self._name_overrides.get(
511
+ cid,
512
+ self._manifest.get("circuit", cid).display_name,
513
+ )
514
+ edge = feeds_by_circuit.get(cid)
515
+ circuit_snaps[cid] = EbusCircuitSnapshot(
516
+ circuit_id=cid,
517
+ name=effective_name,
518
+ relay_state=str(relay_state),
519
+ instant_power_w=gated_p,
520
+ produced_energy_wh=estate.produced_wh,
521
+ consumed_energy_wh=estate.consumed_wh,
522
+ tabs=list(cphys.tabs),
523
+ priority=effective_priority,
524
+ is_user_controllable=cphys.relay_behavior == "controllable",
525
+ is_sheddable=effective_priority in ("OFF_GRID", "SOC_THRESHOLD"),
526
+ is_never_backup=effective_priority == "NEVER",
527
+ is_240v=cphys.dipole,
528
+ current_a=circuit_current_a(
529
+ gated_p,
530
+ dipole=cphys.dipole,
531
+ line_voltage_v=panel_phys.line_voltage_v,
532
+ ),
533
+ breaker_rating_a=cphys.breaker_rating_a,
534
+ always_on=cphys.always_on,
535
+ pcs_managed=cphys.relay_behavior == "controllable",
536
+ pcs_priority=cphys.pcs_priority,
537
+ relay_requester=str(requester),
538
+ energy_accum_update_time_s=int(tick.current_time),
539
+ instant_power_update_time_s=int(tick.current_time),
540
+ feeds_device_id=edge[0] if edge else None,
541
+ feeds_device_type=edge[1] if edge else None,
542
+ feeds_device_status=edge[2] if edge else None,
543
+ )
544
+
545
+ # Step 7: PV snapshots — one entry per PV instance in the manifest.
546
+ # Per-PV power telemetry comes from the producer's circuit feed; the
547
+ # snapshot here carries the static identity from manifest physics.
548
+ pv_snaps: dict[str, EbusPvSnapshot] = {}
549
+ for pv_id, pv_phys in self._physics.all_pv().items():
550
+ pv_snaps[pv_id] = EbusPvSnapshot(
551
+ node_id=pv_id,
552
+ feed_circuit_id=pv_phys.feed,
553
+ vendor_name=pv_phys.vendor_name,
554
+ model=pv_phys.model,
555
+ serial_number=pv_phys.serial_number,
556
+ nominal_power_w=pv_phys.nominal_power_w,
557
+ firmware_version=pv_phys.firmware_version,
558
+ relative_position=pv_phys.relative_position,
559
+ )
560
+
561
+ # Step 7b: Lugs snapshots — one per declared lugs instance. Per-leg
562
+ # currents and power/energy come from the panel meter aggregation;
563
+ # ``direction`` and ``feed`` come from manifest physics. Producers that
564
+ # only model a single lugs (most US split-phase setups) get a single
565
+ # entry here; OPNsense-fed multi-lugs panels get one per device.
566
+ lugs_snaps: dict[str, EbusLugsSnapshot] = {}
567
+ for lugs_id, lphys in self._physics.all_lugs().items():
568
+ if lphys.direction == "upstream":
569
+ l1 = meter.upstream_l1_current_a
570
+ l2 = meter.upstream_l2_current_a
571
+ # Upstream lugs are panel-side. With an upstream BESS, utility
572
+ # grid flow is computed beyond the BESS and can differ.
573
+ active_w = meter.upstream_active_power_w
574
+ imported_wh = sum(s.consumed_energy_wh for s in circuit_snaps.values())
575
+ exported_wh = sum(s.produced_energy_wh for s in circuit_snaps.values())
576
+ else: # downstream
577
+ l1 = meter.downstream_l1_current_a
578
+ l2 = meter.downstream_l2_current_a
579
+ active_w = meter.feedthrough_power_w
580
+ imported_wh = sum(
581
+ s.consumed_energy_wh
582
+ for cid, s in circuit_snaps.items()
583
+ if circuits_phys[cid].placement == "downstream-of-lugs"
584
+ )
585
+ exported_wh = sum(
586
+ s.produced_energy_wh
587
+ for cid, s in circuit_snaps.items()
588
+ if circuits_phys[cid].placement == "downstream-of-lugs"
589
+ )
590
+ fed_by = upstream_fed_by if lphys.direction == "upstream" else None
591
+ lugs_snaps[lugs_id] = EbusLugsSnapshot(
592
+ instance_id=lugs_id,
593
+ direction=("upstream" if lphys.direction == "upstream" else "downstream"),
594
+ feed=None,
595
+ l1_current_a=l1,
596
+ l2_current_a=l2,
597
+ active_power_w=active_w,
598
+ imported_energy_wh=imported_wh,
599
+ exported_energy_wh=exported_wh,
600
+ fed_by_device_id=fed_by[0] if fed_by else None,
601
+ fed_by_device_type=fed_by[1] if fed_by else None,
602
+ fed_by_device_status=fed_by[2] if fed_by else None,
603
+ )
604
+
605
+ # Step 8: EVSE snapshots derived from per-tick power.
606
+ evse_snaps: dict[str, EbusEvseSnapshot] = {}
607
+ for eid, ephys in self._physics.all_evse().items():
608
+ power = tick.evse.get(eid, 0.0)
609
+ charging = power > 100.0
610
+ evse_snaps[eid] = EbusEvseSnapshot(
611
+ node_id=eid,
612
+ feed_circuit_id=ephys.feed,
613
+ status="CHARGING" if charging else "AVAILABLE",
614
+ lock_state="LOCKED" if charging else "UNLOCKED",
615
+ advertised_current_a=ephys.max_current_a,
616
+ max_charge_current_a=int(ephys.max_current_a),
617
+ user_max_charge_current_a=self._evse_user_max_override.get(
618
+ eid, int(ephys.max_current_a)
619
+ ),
620
+ vendor_name=ephys.vendor_name,
621
+ model=ephys.model,
622
+ part_number=ephys.part_number,
623
+ serial_number=ephys.serial_number,
624
+ firmware_version=ephys.firmware_version,
625
+ )
626
+
627
+ # Step 8b: MID snapshots — the grid-forming interconnect device that a
628
+ # commissioned islanding BESS exposes. Identity is static; grid state is
629
+ # derived from the grid-online signal this tick.
630
+ mid_snaps: dict[str, EbusMidSnapshot] = {}
631
+ for mid_id, mphys in self._physics.all_mid().items():
632
+ mid_snaps[mid_id] = EbusMidSnapshot(
633
+ instance_id=mid_id,
634
+ vendor_name=mphys.vendor_name,
635
+ serial_number=mphys.serial_number,
636
+ model=mphys.model,
637
+ firmware_version=mphys.firmware_version,
638
+ hardware_version=mphys.hardware_version,
639
+ islanding_state="ON_GRID" if tick.grid_online else "OFF_GRID",
640
+ grid_state="UP" if tick.grid_online else "DOWN",
641
+ grid_forming_entity="GRID" if tick.grid_online else "BESS",
642
+ )
643
+
644
+ # Step 9: assemble the panel snapshot from capability sub-dataclasses.
645
+ info = EbusPanelInfo(
646
+ serial_number=panel_phys.serial_number,
647
+ firmware_version=panel_phys.firmware_version,
648
+ vendor_name=panel_phys.vendor_name,
649
+ hardware_version=panel_phys.hardware_version,
650
+ panel_size=panel_phys.panel_size,
651
+ panel_model=panel_phys.panel_model,
652
+ schema_topology=panel_phys.topology,
653
+ )
654
+ door = EbusPanelDoor(
655
+ state=tick.envelope.door_state,
656
+ proximity_proven=tick.envelope.proximity_proven,
657
+ )
658
+ consumed_total = sum(s.consumed_energy_wh for s in circuit_snaps.values())
659
+ produced_total = sum(s.produced_energy_wh for s in circuit_snaps.values())
660
+ feedthrough_consumed = sum(
661
+ s.consumed_energy_wh
662
+ for cid, s in circuit_snaps.items()
663
+ if circuits_phys[cid].placement == "downstream-of-lugs"
664
+ )
665
+ feedthrough_produced = sum(
666
+ s.produced_energy_wh
667
+ for cid, s in circuit_snaps.items()
668
+ if circuits_phys[cid].placement == "downstream-of-lugs"
669
+ )
670
+ meter_section = EbusPanelMeter(
671
+ instant_grid_power_w=meter.instant_grid_power_w,
672
+ main_meter_energy_consumed_wh=consumed_total,
673
+ main_meter_energy_produced_wh=produced_total,
674
+ feedthrough_power_w=meter.feedthrough_power_w,
675
+ feedthrough_energy_consumed_wh=feedthrough_consumed,
676
+ feedthrough_energy_produced_wh=feedthrough_produced,
677
+ l1_voltage=meter.line_voltage_v,
678
+ l2_voltage=meter.line_voltage_v,
679
+ upstream_l1_current_a=meter.upstream_l1_current_a,
680
+ upstream_l2_current_a=meter.upstream_l2_current_a,
681
+ downstream_l1_current_a=meter.downstream_l1_current_a,
682
+ downstream_l2_current_a=meter.downstream_l2_current_a,
683
+ )
684
+ status = EbusPanelStatus(
685
+ main_relay_state=meter.main_relay_state,
686
+ eth0_link=tick.envelope.eth0_link,
687
+ wlan_link=tick.envelope.wlan_link,
688
+ wwan_link=tick.envelope.wwan_link,
689
+ wifi_ssid=tick.envelope.wifi_ssid,
690
+ cloud_connection=tick.envelope.cloud_connection,
691
+ postal_code=panel_phys.postal_code,
692
+ time_zone=panel_phys.time_zone,
693
+ uptime_s=tick.envelope.uptime_s,
694
+ )
695
+ pcs = EbusPanelPcs(
696
+ main_breaker_rating_a=panel_phys.main_breaker_rating_a,
697
+ dominant_power_source=(
698
+ self._dominant_power_source_override
699
+ if self._dominant_power_source_override is not None
700
+ else meter.dominant_power_source
701
+ ),
702
+ grid_state=meter.grid_state,
703
+ dsm_state=meter.dsm_state,
704
+ current_run_config=meter.current_run_config,
705
+ )
706
+ power_flows = EbusPanelPowerFlows(
707
+ pv=meter.power_flow_pv,
708
+ battery=meter.power_flow_battery,
709
+ grid=meter.power_flow_grid,
710
+ site=meter.power_flow_site,
711
+ )
712
+ shed = EbusPanelShed(
713
+ asserted_islanding_state=self._asserted_islanding_override or "NONE",
714
+ policy=self._shed_policy_override
715
+ or (
716
+ '{"algorithm": "soc-priority.v1", '
717
+ '"parameters": {"soc-threshold-shed": 20, "soc-threshold-release": 30}}'
718
+ ),
719
+ )
720
+ # Battery Time Remaining forecast: representative values matching real SPAN
721
+ # (lc3 nt-2026-c192x), published only when a BESS is present. Minutes for the
722
+ # four time fields; confidence is a LOW/MEDIUM/HIGH enum.
723
+ shed_forecast = (
724
+ EbusPanelShedForecast(
725
+ total_time_remaining=4320,
726
+ time_to_priority_shed=3037,
727
+ full_charge_total_time_remaining=4320,
728
+ full_charge_time_to_priority_shed=3038,
729
+ confidence="HIGH",
730
+ )
731
+ if has_battery
732
+ else EbusPanelShedForecast()
733
+ )
734
+
735
+ return EbusPanelSnapshot(
736
+ info=info,
737
+ door=door,
738
+ meter=meter_section,
739
+ status=status,
740
+ pcs=pcs,
741
+ power_flows=power_flows,
742
+ shed=shed,
743
+ shed_forecast=shed_forecast,
744
+ circuits=circuit_snaps,
745
+ battery=battery_snapshots,
746
+ pv=pv_snaps,
747
+ evse=evse_snaps,
748
+ lugs=lugs_snaps,
749
+ mid=mid_snaps,
750
+ )