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,185 @@
|
|
|
1
|
+
"""Native BESS device — configured-and-self-driving.
|
|
2
|
+
|
|
3
|
+
Configuration is supplied at construction (via ``Emitter(bess_configs=...)``) and
|
|
4
|
+
mutable mid-run via ``Emitter.update_bess_config``. Per-tick context — grid
|
|
5
|
+
state, instantaneous load demand, instantaneous PV available, current_time —
|
|
6
|
+
is pushed by the producer via ``Emitter.publish_tick``; the emitter calls
|
|
7
|
+
``BESSDevice.tick`` and writes the resulting ``EbusBatterySnapshot`` into the
|
|
8
|
+
internal snapshot.
|
|
9
|
+
|
|
10
|
+
Subclassable for vendor-variant behaviour (Powerwall vs Enphase IQ etc.)."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from ebus_panel_sim.native_devices.protocol import NativeTickContext
|
|
18
|
+
from ebus_panel_sim.snapshot import EbusBatterySnapshot
|
|
19
|
+
|
|
20
|
+
ChargeMode = Literal["self-consumption", "backup-only"]
|
|
21
|
+
DispatchState = Literal["charging", "discharging", "idle"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class BESSConfig:
|
|
26
|
+
"""BESS device configuration. Producer supplies at construction; mutation through
|
|
27
|
+
``BESSDevice.update_config`` takes effect on the next tick."""
|
|
28
|
+
|
|
29
|
+
instance_id: str
|
|
30
|
+
nameplate_capacity_kwh: float
|
|
31
|
+
max_charge_w: float
|
|
32
|
+
max_discharge_w: float
|
|
33
|
+
charge_efficiency: float = 0.95
|
|
34
|
+
discharge_efficiency: float = 0.95
|
|
35
|
+
backup_reserve_pct: float = 20.0
|
|
36
|
+
charge_mode: ChargeMode = "self-consumption"
|
|
37
|
+
charge_hours: tuple[int, ...] = ()
|
|
38
|
+
discharge_hours: tuple[int, ...] = ()
|
|
39
|
+
initial_soc_pct: float = 50.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class BESSDevice:
|
|
44
|
+
"""Per-tick BESS state machine. State (SOC/SOE) accumulates across ticks; config
|
|
45
|
+
can be replaced live without restart."""
|
|
46
|
+
|
|
47
|
+
config: BESSConfig
|
|
48
|
+
_soc_pct: float = field(init=False)
|
|
49
|
+
_soe_kwh: float = field(init=False)
|
|
50
|
+
_last_tick_time: float | None = field(init=False, default=None)
|
|
51
|
+
_state: DispatchState = field(init=False, default="idle")
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
self._soc_pct = self.config.initial_soc_pct
|
|
55
|
+
self._soe_kwh = self.config.nameplate_capacity_kwh * (self.config.initial_soc_pct / 100.0)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def instance_id(self) -> str:
|
|
59
|
+
"""Stable identifier for this device — delegates to ``config.instance_id``.
|
|
60
|
+
|
|
61
|
+
Exposed as a property so ``BESSDevice`` structurally satisfies the
|
|
62
|
+
``NativeDevice[EbusBatterySnapshot]`` Protocol."""
|
|
63
|
+
return self.config.instance_id
|
|
64
|
+
|
|
65
|
+
def update_config(self, config: BESSConfig) -> None:
|
|
66
|
+
"""Replace configuration; SOC/SOE persist."""
|
|
67
|
+
self.config = config
|
|
68
|
+
|
|
69
|
+
def set_soe(self, soe_kwh: float) -> None:
|
|
70
|
+
"""Overwrite stored SOE/SOC. Used by ``Emitter.seed_bess_soe`` to restore
|
|
71
|
+
battery state across emitter restarts."""
|
|
72
|
+
capped = max(0.0, min(self.config.nameplate_capacity_kwh, soe_kwh))
|
|
73
|
+
self._soe_kwh = capped
|
|
74
|
+
if self.config.nameplate_capacity_kwh > 0:
|
|
75
|
+
self._soc_pct = (capped / self.config.nameplate_capacity_kwh) * 100.0
|
|
76
|
+
else:
|
|
77
|
+
self._soc_pct = 0.0
|
|
78
|
+
|
|
79
|
+
def tick(self, ctx: NativeTickContext) -> EbusBatterySnapshot:
|
|
80
|
+
"""Run one dispatch step and return a fresh ``EbusBatterySnapshot``.
|
|
81
|
+
|
|
82
|
+
Given the current grid state, instantaneous load demand, and PV
|
|
83
|
+
availability for this tick, decide whether to charge, discharge, or
|
|
84
|
+
idle; integrate energy in/out of the cell since the previous tick;
|
|
85
|
+
update internal SOC/SOE; and return a snapshot describing the result.
|
|
86
|
+
Pure with respect to the panel snapshot — does not read or mutate it."""
|
|
87
|
+
dispatch_w = self._decide_dispatch(
|
|
88
|
+
current_time=ctx.current_time,
|
|
89
|
+
grid_online=ctx.grid_online,
|
|
90
|
+
load_demand_w=ctx.load_demand_w,
|
|
91
|
+
pv_available_w=ctx.pv_available_w,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if self._last_tick_time is not None:
|
|
95
|
+
dt_seconds = max(0.0, ctx.current_time - self._last_tick_time)
|
|
96
|
+
dt_hours = dt_seconds / 3600.0
|
|
97
|
+
if dispatch_w > 0:
|
|
98
|
+
self._soe_kwh -= (
|
|
99
|
+
dispatch_w * dt_hours / 1000.0
|
|
100
|
+
) / self.config.discharge_efficiency
|
|
101
|
+
elif dispatch_w < 0:
|
|
102
|
+
self._soe_kwh += (
|
|
103
|
+
abs(dispatch_w) * dt_hours / 1000.0
|
|
104
|
+
) * self.config.charge_efficiency
|
|
105
|
+
self._soe_kwh = max(0.0, min(self.config.nameplate_capacity_kwh, self._soe_kwh))
|
|
106
|
+
self._soc_pct = (self._soe_kwh / self.config.nameplate_capacity_kwh) * 100.0
|
|
107
|
+
self._last_tick_time = ctx.current_time
|
|
108
|
+
|
|
109
|
+
if dispatch_w > 0:
|
|
110
|
+
self._state = "discharging"
|
|
111
|
+
elif dispatch_w < 0:
|
|
112
|
+
self._state = "charging"
|
|
113
|
+
else:
|
|
114
|
+
self._state = "idle"
|
|
115
|
+
|
|
116
|
+
return EbusBatterySnapshot(
|
|
117
|
+
soe_percentage=self._soc_pct,
|
|
118
|
+
soe_kwh=self._soe_kwh,
|
|
119
|
+
active_power_w=dispatch_w,
|
|
120
|
+
nameplate_capacity_kwh=self.config.nameplate_capacity_kwh,
|
|
121
|
+
communication="OK",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def state(self) -> DispatchState:
|
|
126
|
+
return self._state
|
|
127
|
+
|
|
128
|
+
def _decide_dispatch(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
current_time: float,
|
|
132
|
+
grid_online: bool,
|
|
133
|
+
load_demand_w: float,
|
|
134
|
+
pv_available_w: float,
|
|
135
|
+
) -> float:
|
|
136
|
+
"""Return signed dispatch power in watts. Positive = discharge, negative = charge."""
|
|
137
|
+
# Reserve floor: do not discharge below backup reserve when grid is online.
|
|
138
|
+
backup_floor_kwh = (
|
|
139
|
+
self.config.nameplate_capacity_kwh * self.config.backup_reserve_pct / 100.0
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not grid_online:
|
|
143
|
+
# Off-grid: discharge to meet load demand minus PV (down to empty).
|
|
144
|
+
deficit_w = max(0.0, load_demand_w - pv_available_w)
|
|
145
|
+
if self._soe_kwh <= 0:
|
|
146
|
+
return 0.0
|
|
147
|
+
return min(deficit_w, self.config.max_discharge_w)
|
|
148
|
+
|
|
149
|
+
# Grid online — apply mode-specific behavior.
|
|
150
|
+
pv_surplus_w = max(0.0, pv_available_w - load_demand_w)
|
|
151
|
+
if self.config.charge_mode == "backup-only":
|
|
152
|
+
# Keep reserve while on-grid. Charge only from PV surplus; never
|
|
153
|
+
# charge from utility grid and never discharge in backup-only mode.
|
|
154
|
+
if pv_surplus_w > 0 and self._soe_kwh < self.config.nameplate_capacity_kwh:
|
|
155
|
+
return -min(
|
|
156
|
+
self.config.max_charge_w,
|
|
157
|
+
pv_surplus_w,
|
|
158
|
+
(self.config.nameplate_capacity_kwh - self._soe_kwh) * 1000.0,
|
|
159
|
+
)
|
|
160
|
+
return 0.0
|
|
161
|
+
|
|
162
|
+
# self-consumption mode — always reactive: charge from PV surplus,
|
|
163
|
+
# discharge to cover load deficit. Hour-of-day windows are NOT applied
|
|
164
|
+
# (those belong to a TOU/custom dispatch mode, not modelled here).
|
|
165
|
+
# ``backup_floor_kwh`` still gates discharge so the reserve isn't
|
|
166
|
+
# consumed during normal operation.
|
|
167
|
+
del current_time # not used in self-consumption
|
|
168
|
+
load_deficit_w = max(0.0, load_demand_w - pv_available_w)
|
|
169
|
+
|
|
170
|
+
if pv_surplus_w > 0 and self._soe_kwh < self.config.nameplate_capacity_kwh:
|
|
171
|
+
return -min(
|
|
172
|
+
self.config.max_charge_w,
|
|
173
|
+
pv_surplus_w,
|
|
174
|
+
(self.config.nameplate_capacity_kwh - self._soe_kwh) * 1000.0,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if load_deficit_w > 0 and self._soe_kwh > backup_floor_kwh:
|
|
178
|
+
available_kwh = self._soe_kwh - backup_floor_kwh
|
|
179
|
+
return min(
|
|
180
|
+
load_deficit_w,
|
|
181
|
+
self.config.max_discharge_w,
|
|
182
|
+
available_kwh * 1000.0,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return 0.0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Native load-shedding controller.
|
|
2
|
+
|
|
3
|
+
When the grid goes offline, circuits with shedding priorities below the active
|
|
4
|
+
SOC threshold are forcibly opened. The shedding decisions are emitter-domain
|
|
5
|
+
because they're a deterministic configured response: given (grid_state, BESS
|
|
6
|
+
SOC, per-circuit priority), the shed set is computable without producer
|
|
7
|
+
intervention.
|
|
8
|
+
|
|
9
|
+
``decide_shed`` is a pure function used by ``Emitter.publish_tick`` to drive
|
|
10
|
+
``RelayResolver``. When the operator has a /set override on a sheddable
|
|
11
|
+
circuit, the ``RelayResolver`` honors operator intent (per the v0.3.0
|
|
12
|
+
precedence rule: always-on > /set > load-shed > default-CLOSED). Load-shed
|
|
13
|
+
only takes effect when there's no operator override."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class LoadSheddingConfig:
|
|
23
|
+
"""When grid is offline, circuits with these priorities are shed in order:
|
|
24
|
+
first OFF_GRID, then SOC_THRESHOLD when BESS SOC drops below soc_threshold_pct.
|
|
25
|
+
NEVER-priority circuits are never shed."""
|
|
26
|
+
|
|
27
|
+
soc_threshold_pct: float = 20.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class LoadSheddingDevice:
|
|
32
|
+
"""Per-tick load-shedding policy."""
|
|
33
|
+
|
|
34
|
+
config: LoadSheddingConfig
|
|
35
|
+
|
|
36
|
+
def update_config(self, config: LoadSheddingConfig) -> None:
|
|
37
|
+
self.config = config
|
|
38
|
+
|
|
39
|
+
def decide_shed(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
grid_online: bool,
|
|
43
|
+
bess_soc_pct: float | None,
|
|
44
|
+
priorities: Mapping[str, str],
|
|
45
|
+
) -> set[str]:
|
|
46
|
+
"""Return the set of circuit instance_ids the policy wants OPEN.
|
|
47
|
+
|
|
48
|
+
- On-grid: always empty (nothing is shed when grid is up).
|
|
49
|
+
- Off-grid: ``OFF_GRID`` priority always shed; ``SOC_THRESHOLD`` shed
|
|
50
|
+
when BESS SOC is below ``soc_threshold_pct`` (or when no BESS exists).
|
|
51
|
+
- ``NEVER`` priority is never shed."""
|
|
52
|
+
if grid_online:
|
|
53
|
+
return set()
|
|
54
|
+
soc_low = bess_soc_pct is None or bess_soc_pct < self.config.soc_threshold_pct
|
|
55
|
+
shed: set[str] = set()
|
|
56
|
+
for cid, priority in priorities.items():
|
|
57
|
+
p = (priority or "").upper()
|
|
58
|
+
if p == "OFF_GRID" or (p == "SOC_THRESHOLD" and soc_low):
|
|
59
|
+
shed.add(cid)
|
|
60
|
+
return shed
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Native-device tick contract — shared by BESS and future natives.
|
|
2
|
+
|
|
3
|
+
Future natives include MID and vendor-specific BESS variants."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class NativeTickContext:
|
|
13
|
+
"""Per-tick driving signal handed to a native device's ``tick()``.
|
|
14
|
+
|
|
15
|
+
All natives consume the same primitive signals; per-device behaviour lives
|
|
16
|
+
in the device implementation, not in the context shape."""
|
|
17
|
+
|
|
18
|
+
current_time: float
|
|
19
|
+
grid_online: bool
|
|
20
|
+
load_demand_w: float
|
|
21
|
+
pv_available_w: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SnapT = TypeVar("SnapT", covariant=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class NativeDevice(Protocol[SnapT]):
|
|
29
|
+
"""Common contract for emitter-resident, configured-and-self-driving devices.
|
|
30
|
+
|
|
31
|
+
Today: ``BESSDevice``. Future: ``MidDevice`` (when MID becomes a separate
|
|
32
|
+
device per the upcoming eBus migration), vendor-specific BESS variants
|
|
33
|
+
(Powerwall vs Enphase IQ specifics)."""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def instance_id(self) -> str: ...
|
|
37
|
+
|
|
38
|
+
def tick(self, ctx: NativeTickContext) -> SnapT: ...
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Panel-level aggregator. Pure function: takes resolved per-circuit gated powers
|
|
2
|
+
+ battery dispatch + grid online + manifest physics, returns the panel-level
|
|
3
|
+
fields that go into ``EbusPanelSnapshot``.
|
|
4
|
+
|
|
5
|
+
Stateless — all integration / accumulation lives in ``EnergyIntegrator``. This
|
|
6
|
+
module is just arithmetic over the current tick's inputs.
|
|
7
|
+
|
|
8
|
+
Sign conventions (consistent across the emitter):
|
|
9
|
+
- Per-circuit ``power_w``: positive = consume, negative = produce (PV/V2G).
|
|
10
|
+
- ``battery_w``: positive = discharging (battery → panel), negative = charging.
|
|
11
|
+
- ``instant_grid_power_w``: positive = importing from grid, negative = exporting.
|
|
12
|
+
- ``upstream_active_power_w``: net power through the panel-side upstream lugs,
|
|
13
|
+
before any upstream BESS contribution is removed.
|
|
14
|
+
- ``feedthrough_power_w``: net power flowing through the lugs to downstream
|
|
15
|
+
loads (panel-side meter perspective).
|
|
16
|
+
|
|
17
|
+
Off-grid: when ``grid_online`` is False, ``instant_grid_power_w`` is 0 by
|
|
18
|
+
definition (grid is electrically disconnected); battery and PV cover load."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from ebus_panel_sim.conventions.tab_legs import Leg
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from ebus_panel_sim.manifest_physics import CircuitPhysics, PanelPhysics
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Per-tick output bundle
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class PanelMeterReading:
|
|
38
|
+
"""Resolved panel-level values for a single tick."""
|
|
39
|
+
|
|
40
|
+
instant_grid_power_w: float
|
|
41
|
+
upstream_active_power_w: float
|
|
42
|
+
feedthrough_power_w: float
|
|
43
|
+
upstream_l1_current_a: float
|
|
44
|
+
upstream_l2_current_a: float
|
|
45
|
+
downstream_l1_current_a: float
|
|
46
|
+
downstream_l2_current_a: float
|
|
47
|
+
line_voltage_v: float
|
|
48
|
+
main_relay_state: str # "OPEN" | "CLOSED"
|
|
49
|
+
grid_state: str | None # "ON_GRID" | "OFF_GRID"
|
|
50
|
+
dsm_state: str
|
|
51
|
+
current_run_config: str
|
|
52
|
+
dominant_power_source: str | None # "GRID" | "BATTERY" | None
|
|
53
|
+
power_flow_pv: float
|
|
54
|
+
power_flow_battery: float
|
|
55
|
+
power_flow_grid: float
|
|
56
|
+
power_flow_site: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Per-circuit derived current — published per circuit, also folded into panel
|
|
61
|
+
# upstream/downstream legs.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def circuit_current_a(power_w: float, dipole: bool, line_voltage_v: float) -> float:
|
|
66
|
+
"""Magnitude of current draw. Dipole circuits use line-to-line voltage
|
|
67
|
+
(2 * line_voltage_v); single-tab circuits use line-to-neutral."""
|
|
68
|
+
if line_voltage_v <= 0:
|
|
69
|
+
return 0.0
|
|
70
|
+
voltage = 2 * line_voltage_v if dipole else line_voltage_v
|
|
71
|
+
return abs(power_w) / voltage
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Panel state strings — derived from grid + battery presence
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_DSM_ON = "DSM_ON_GRID"
|
|
80
|
+
_DSM_OFF = "DSM_OFF_GRID"
|
|
81
|
+
_RUN_ON = "PANEL_ON_GRID"
|
|
82
|
+
_RUN_OFF = "PANEL_OFF_GRID"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Resolver
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve(
|
|
91
|
+
*,
|
|
92
|
+
panel: PanelPhysics,
|
|
93
|
+
circuits: dict[str, CircuitPhysics],
|
|
94
|
+
gated_powers: dict[str, float], # circuit_id -> post-relay-gate signed power
|
|
95
|
+
battery_w: float, # signed; positive = discharging
|
|
96
|
+
grid_online: bool,
|
|
97
|
+
has_battery: bool,
|
|
98
|
+
) -> PanelMeterReading:
|
|
99
|
+
"""Build the panel-level reading from per-circuit gated powers + battery + grid."""
|
|
100
|
+
|
|
101
|
+
load_demand_w = sum(p for p in gated_powers.values() if p > 0)
|
|
102
|
+
pv_available_w = -sum(p for p in gated_powers.values() if p < 0) # positive magnitude
|
|
103
|
+
|
|
104
|
+
upstream_active_w = load_demand_w - pv_available_w
|
|
105
|
+
|
|
106
|
+
if grid_online:
|
|
107
|
+
# Upstream lugs see the panel-side net flow. Utility grid flow is on the
|
|
108
|
+
# other side of an upstream BESS, so subtract BESS discharge. Charging is
|
|
109
|
+
# limited to PV surplus; a BESS must not turn load into extra grid import.
|
|
110
|
+
grid_w = _grid_power_from_lugs_and_bess(upstream_active_w, battery_w)
|
|
111
|
+
grid_state: str | None = "ON_GRID"
|
|
112
|
+
dsm_state = _DSM_ON
|
|
113
|
+
current_run_config = _RUN_ON
|
|
114
|
+
main_relay_state = "CLOSED"
|
|
115
|
+
line_voltage_v = panel.line_voltage_v
|
|
116
|
+
dominant_power_source: str | None = "GRID"
|
|
117
|
+
else:
|
|
118
|
+
grid_w = 0.0
|
|
119
|
+
grid_state = "OFF_GRID"
|
|
120
|
+
dsm_state = _DSM_OFF
|
|
121
|
+
current_run_config = _RUN_OFF
|
|
122
|
+
main_relay_state = "OPEN"
|
|
123
|
+
line_voltage_v = panel.line_voltage_v if has_battery else 0.0
|
|
124
|
+
dominant_power_source = "BATTERY" if has_battery else None
|
|
125
|
+
|
|
126
|
+
# Per-leg upstream current = panel-side current routed through the lugs.
|
|
127
|
+
# With an upstream BESS, this can differ from utility grid current.
|
|
128
|
+
if line_voltage_v > 0:
|
|
129
|
+
upstream_l1_a, upstream_l2_a = _per_leg_current(
|
|
130
|
+
gated_powers,
|
|
131
|
+
circuits,
|
|
132
|
+
line_voltage_v=line_voltage_v,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
upstream_l1_a = upstream_l2_a = 0.0
|
|
136
|
+
|
|
137
|
+
# Feedthrough = signed power across downstream-of-lugs circuits only.
|
|
138
|
+
feedthrough_w = sum(
|
|
139
|
+
p for cid, p in gated_powers.items() if circuits[cid].placement == "downstream-of-lugs"
|
|
140
|
+
)
|
|
141
|
+
if line_voltage_v > 0:
|
|
142
|
+
downstream_l1_a, downstream_l2_a = _per_leg_current(
|
|
143
|
+
{
|
|
144
|
+
cid: p
|
|
145
|
+
for cid, p in gated_powers.items()
|
|
146
|
+
if circuits[cid].placement == "downstream-of-lugs"
|
|
147
|
+
},
|
|
148
|
+
circuits,
|
|
149
|
+
line_voltage_v=line_voltage_v,
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
downstream_l1_a = downstream_l2_a = 0.0
|
|
153
|
+
|
|
154
|
+
return PanelMeterReading(
|
|
155
|
+
instant_grid_power_w=grid_w,
|
|
156
|
+
upstream_active_power_w=upstream_active_w if grid_online else 0.0,
|
|
157
|
+
feedthrough_power_w=feedthrough_w,
|
|
158
|
+
upstream_l1_current_a=upstream_l1_a,
|
|
159
|
+
upstream_l2_current_a=upstream_l2_a,
|
|
160
|
+
downstream_l1_current_a=downstream_l1_a,
|
|
161
|
+
downstream_l2_current_a=downstream_l2_a,
|
|
162
|
+
line_voltage_v=line_voltage_v,
|
|
163
|
+
main_relay_state=main_relay_state,
|
|
164
|
+
grid_state=grid_state,
|
|
165
|
+
dsm_state=dsm_state,
|
|
166
|
+
current_run_config=current_run_config,
|
|
167
|
+
dominant_power_source=dominant_power_source,
|
|
168
|
+
power_flow_pv=pv_available_w,
|
|
169
|
+
power_flow_battery=battery_w,
|
|
170
|
+
power_flow_grid=grid_w,
|
|
171
|
+
power_flow_site=load_demand_w,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _grid_power_from_lugs_and_bess(upstream_active_w: float, battery_w: float) -> float:
|
|
176
|
+
"""Return utility-side grid power from panel-side lugs and BESS power.
|
|
177
|
+
|
|
178
|
+
BESS sign convention is positive=discharging, negative=charging. Charging is
|
|
179
|
+
only credited against PV surplus visible at the lugs; it never creates extra
|
|
180
|
+
grid import.
|
|
181
|
+
"""
|
|
182
|
+
if battery_w >= 0:
|
|
183
|
+
return upstream_active_w - battery_w
|
|
184
|
+
pv_surplus_w = max(0.0, -upstream_active_w)
|
|
185
|
+
pv_charge_w = min(abs(battery_w), pv_surplus_w)
|
|
186
|
+
return upstream_active_w + pv_charge_w
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _per_leg_current(
|
|
190
|
+
powers: dict[str, float],
|
|
191
|
+
circuits: dict[str, CircuitPhysics],
|
|
192
|
+
*,
|
|
193
|
+
line_voltage_v: float,
|
|
194
|
+
) -> tuple[float, float]:
|
|
195
|
+
"""Sum per-circuit current contribution onto L1 and L2.
|
|
196
|
+
|
|
197
|
+
Single-tab circuit on tab N → all current to legs_for_tabs((N,))[0].
|
|
198
|
+
Dipole circuit (one tab per leg) → equal current on both legs at line-to-line
|
|
199
|
+
voltage (2 * line_voltage_v)."""
|
|
200
|
+
l1_a = 0.0
|
|
201
|
+
l2_a = 0.0
|
|
202
|
+
for cid, power in powers.items():
|
|
203
|
+
cphys = circuits[cid]
|
|
204
|
+
if cphys.dipole:
|
|
205
|
+
i = abs(power) / (2 * line_voltage_v)
|
|
206
|
+
# Dipole spans both legs — same current on each leg.
|
|
207
|
+
l1_a += i
|
|
208
|
+
l2_a += i
|
|
209
|
+
else:
|
|
210
|
+
i = abs(power) / line_voltage_v
|
|
211
|
+
leg = cphys.legs[0]
|
|
212
|
+
if leg == Leg.L1:
|
|
213
|
+
l1_a += i
|
|
214
|
+
else:
|
|
215
|
+
l2_a += i
|
|
216
|
+
return l1_a, l2_a
|
ebus_panel_sim/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Per-circuit relay state, with strict precedence over commands from multiple sources.
|
|
2
|
+
|
|
3
|
+
The emitter now owns relay state across ticks. Commands arrive from three sources:
|
|
4
|
+
|
|
5
|
+
1. **Manifest declaration** — ``relay-behavior == "always-on"`` or ``always-on=true``
|
|
6
|
+
metadata. Absolute: the relay can never be opened, regardless of /set or
|
|
7
|
+
load-shedding decisions.
|
|
8
|
+
2. **/set commands** — operator-driven via Homie ``circuit/.../switch/relay/set``
|
|
9
|
+
topic. Authoritative for non-always-on circuits, no debounce.
|
|
10
|
+
3. **Load shedding** — emitter's ``LoadSheddingDevice`` decisions. Applies only
|
|
11
|
+
when there's no /set override.
|
|
12
|
+
|
|
13
|
+
Precedence (highest wins):
|
|
14
|
+
|
|
15
|
+
always-on > /set override > load-shed > default-CLOSED
|
|
16
|
+
|
|
17
|
+
``relay_requester`` reflects the source of the active decision, using the
|
|
18
|
+
canonical eBus ``switch/relay-requester`` domain:
|
|
19
|
+
- ``CONFIGURATION`` for always-on (commissioned; the relay cannot open)
|
|
20
|
+
- ``USER`` for /set
|
|
21
|
+
- ``LOAD_SHED`` for load-shed
|
|
22
|
+
- ``NONE`` for the default-CLOSED state
|
|
23
|
+
|
|
24
|
+
The producer never sees /set commands. ``Emitter`` registers internal handlers
|
|
25
|
+
for ``circuit.switch/relay`` and ``circuit.load-shed/priority``; those handlers
|
|
26
|
+
call ``RelayResolver.set_user_override`` (and the priority equivalent on a
|
|
27
|
+
sibling state map)."""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RelayState(StrEnum):
|
|
35
|
+
OPEN = "OPEN"
|
|
36
|
+
CLOSED = "CLOSED"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RelayRequester(StrEnum):
|
|
40
|
+
# Canonical eBus ``switch/relay-requester`` domain.
|
|
41
|
+
CONFIGURATION = "CONFIGURATION" # always-on circuit; commissioned, cannot open
|
|
42
|
+
USER = "USER" # /set override active
|
|
43
|
+
LOAD_SHED = "LOAD_SHED" # load-shed in effect
|
|
44
|
+
NONE = "NONE" # default-CLOSED, no active requester
|
|
45
|
+
UNKNOWN = "UNKNOWN" # unregistered / indeterminate
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RelayResolver:
|
|
49
|
+
"""Maintains relay state per circuit instance.
|
|
50
|
+
|
|
51
|
+
Construct empty, register each circuit with its always-on flag, then update
|
|
52
|
+
overrides and shed decisions; query ``state()`` for the resolved final
|
|
53
|
+
state."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
# always_on map: instance_id -> bool (manifest declaration; immutable post-register)
|
|
57
|
+
self._always_on: dict[str, bool] = {}
|
|
58
|
+
# /set override map: instance_id -> RelayState | None (None = no override)
|
|
59
|
+
self._user_overrides: dict[str, RelayState | None] = {}
|
|
60
|
+
# load-shed decision map: instance_id -> bool (True = wants OPEN)
|
|
61
|
+
self._shed: dict[str, bool] = {}
|
|
62
|
+
|
|
63
|
+
def register(self, instance_id: str, *, always_on: bool) -> None:
|
|
64
|
+
"""Idempotent — re-registering with a different always_on value updates
|
|
65
|
+
the manifest declaration (typical use: emitter restart with edited manifest)."""
|
|
66
|
+
self._always_on[instance_id] = always_on
|
|
67
|
+
self._user_overrides.setdefault(instance_id, None)
|
|
68
|
+
self._shed.setdefault(instance_id, False)
|
|
69
|
+
|
|
70
|
+
def set_user_override(self, instance_id: str, state: RelayState | None) -> None:
|
|
71
|
+
"""Operator /set or explicit clear. ``state=None`` clears the override
|
|
72
|
+
and lets load-shed (or default-CLOSED) take effect.
|
|
73
|
+
|
|
74
|
+
Always-on circuits silently drop the override — operator cannot open them."""
|
|
75
|
+
if instance_id not in self._always_on:
|
|
76
|
+
raise KeyError(f"set_user_override for unregistered instance_id={instance_id!r}")
|
|
77
|
+
if self._always_on[instance_id]:
|
|
78
|
+
return # absolute: always-on ignores /set
|
|
79
|
+
self._user_overrides[instance_id] = state
|
|
80
|
+
|
|
81
|
+
def clear_user_override(self, instance_id: str) -> None:
|
|
82
|
+
self.set_user_override(instance_id, None)
|
|
83
|
+
|
|
84
|
+
def set_shed(self, instance_id: str, *, open_relay: bool) -> None:
|
|
85
|
+
"""Load-shedding decision. ``open_relay=True`` means the load-shedding
|
|
86
|
+
policy wants this circuit OPEN.
|
|
87
|
+
|
|
88
|
+
Always-on circuits silently drop the request."""
|
|
89
|
+
if instance_id not in self._always_on:
|
|
90
|
+
raise KeyError(f"set_shed for unregistered instance_id={instance_id!r}")
|
|
91
|
+
if self._always_on[instance_id]:
|
|
92
|
+
return
|
|
93
|
+
self._shed[instance_id] = open_relay
|
|
94
|
+
|
|
95
|
+
def clear_all_shed(self) -> None:
|
|
96
|
+
"""Reset every shed decision to False. Called by the emitter at the
|
|
97
|
+
start of each tick before re-running ``LoadSheddingDevice``."""
|
|
98
|
+
for k in self._shed:
|
|
99
|
+
self._shed[k] = False
|
|
100
|
+
|
|
101
|
+
def state(self, instance_id: str) -> tuple[RelayState, RelayRequester]:
|
|
102
|
+
"""Resolve the final state for ``instance_id``."""
|
|
103
|
+
if self._always_on.get(instance_id, False):
|
|
104
|
+
return RelayState.CLOSED, RelayRequester.CONFIGURATION
|
|
105
|
+
override = self._user_overrides.get(instance_id)
|
|
106
|
+
if override is not None:
|
|
107
|
+
return override, RelayRequester.USER
|
|
108
|
+
if self._shed.get(instance_id, False):
|
|
109
|
+
return RelayState.OPEN, RelayRequester.LOAD_SHED
|
|
110
|
+
return RelayState.CLOSED, RelayRequester.NONE
|
|
111
|
+
|
|
112
|
+
def known(self, instance_id: str) -> bool:
|
|
113
|
+
return instance_id in self._always_on
|