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.
- ebus_panel_sim/__init__.py +119 -0
- ebus_panel_sim/conventions/__init__.py +11 -0
- ebus_panel_sim/conventions/tab_legs.py +47 -0
- ebus_panel_sim/emitter.py +750 -0
- ebus_panel_sim/energy_integrator.py +89 -0
- ebus_panel_sim/exceptions.py +38 -0
- ebus_panel_sim/manifest.py +35 -0
- ebus_panel_sim/manifest_physics.py +451 -0
- ebus_panel_sim/native_devices/__init__.py +24 -0
- ebus_panel_sim/native_devices/bess.py +185 -0
- ebus_panel_sim/native_devices/load_shedding.py +60 -0
- ebus_panel_sim/native_devices/protocol.py +38 -0
- ebus_panel_sim/panel_meter.py +216 -0
- ebus_panel_sim/py.typed +0 -0
- ebus_panel_sim/relay_resolver.py +113 -0
- ebus_panel_sim/snapshot.py +301 -0
- ebus_panel_sim/tick_inputs.py +66 -0
- ebus_panel_sim/wire/__init__.py +0 -0
- ebus_panel_sim/wire/_sdk_seam.py +55 -0
- ebus_panel_sim/wire/bag_builder.py +429 -0
- ebus_panel_sim/wire/catalogs/breaker.json +52 -0
- ebus_panel_sim/wire/catalogs/charge-limit.json +37 -0
- ebus_panel_sim/wire/catalogs/connection.json +72 -0
- ebus_panel_sim/wire/catalogs/door.json +17 -0
- ebus_panel_sim/wire/catalogs/grid.json +38 -0
- ebus_panel_sim/wire/catalogs/info.json +52 -0
- ebus_panel_sim/wire/catalogs/load-shed.json +18 -0
- ebus_panel_sim/wire/catalogs/meter.json +201 -0
- ebus_panel_sim/wire/catalogs/pcs.json +111 -0
- ebus_panel_sim/wire/catalogs/power-flows.json +35 -0
- ebus_panel_sim/wire/catalogs/shed-forecast.json +41 -0
- ebus_panel_sim/wire/catalogs/shed.json +24 -0
- ebus_panel_sim/wire/catalogs/soc.json +35 -0
- ebus_panel_sim/wire/catalogs/status.json +28 -0
- ebus_panel_sim/wire/catalogs/switch.json +29 -0
- ebus_panel_sim/wire/graph_builder.py +309 -0
- ebus_panel_sim/wire/mapping/.gitkeep +0 -0
- ebus_panel_sim/wire/mapping/bess.yaml +16 -0
- ebus_panel_sim/wire/mapping/circuit.yaml +16 -0
- ebus_panel_sim/wire/mapping/evse.yaml +16 -0
- ebus_panel_sim/wire/mapping/lugs.yaml +16 -0
- ebus_panel_sim/wire/mapping/mid.yaml +16 -0
- ebus_panel_sim/wire/mapping/panel.yaml +15 -0
- ebus_panel_sim/wire/mapping/pv.yaml +16 -0
- ebus_panel_sim/wire/mapping_loader.py +144 -0
- ebus_panel_sim/wire/profile_loader.py +215 -0
- ebus_panel_sim/wire/profiles/.gitkeep +0 -0
- ebus_panel_sim/wire/profiles/bess.json +58 -0
- ebus_panel_sim/wire/profiles/circuit.json +88 -0
- ebus_panel_sim/wire/profiles/evse.json +26 -0
- ebus_panel_sim/wire/profiles/lugs.json +52 -0
- ebus_panel_sim/wire/profiles/mid.json +40 -0
- ebus_panel_sim/wire/profiles/panel.json +160 -0
- ebus_panel_sim/wire/profiles/pv.json +28 -0
- ebus_panel_sim/wire/profiles/span/circuit.json +19 -0
- ebus_panel_sim/wire/profiles/span/evse.json +52 -0
- ebus_panel_sim/wire/profiles/span/lugs.json +16 -0
- ebus_panel_sim/wire/profiles/span/panel.json +77 -0
- ebus_panel_sim/wire/property_bag.py +56 -0
- ebus_panel_sim/wire/publisher.py +36 -0
- ebus_panel_sim/wire/set_router.py +122 -0
- ebus_panel_sim-0.3.0.dist-info/METADATA +14 -0
- ebus_panel_sim-0.3.0.dist-info/RECORD +66 -0
- ebus_panel_sim-0.3.0.dist-info/WHEEL +4 -0
- ebus_panel_sim-0.3.0.dist-info/licenses/AUTHORS +7 -0
- 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
|
+
)
|