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,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
|
+
]
|