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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. ebus_panel_sim/__init__.py +119 -0
  2. ebus_panel_sim/conventions/__init__.py +11 -0
  3. ebus_panel_sim/conventions/tab_legs.py +47 -0
  4. ebus_panel_sim/emitter.py +750 -0
  5. ebus_panel_sim/energy_integrator.py +89 -0
  6. ebus_panel_sim/exceptions.py +38 -0
  7. ebus_panel_sim/manifest.py +35 -0
  8. ebus_panel_sim/manifest_physics.py +451 -0
  9. ebus_panel_sim/native_devices/__init__.py +24 -0
  10. ebus_panel_sim/native_devices/bess.py +185 -0
  11. ebus_panel_sim/native_devices/load_shedding.py +60 -0
  12. ebus_panel_sim/native_devices/protocol.py +38 -0
  13. ebus_panel_sim/panel_meter.py +216 -0
  14. ebus_panel_sim/py.typed +0 -0
  15. ebus_panel_sim/relay_resolver.py +113 -0
  16. ebus_panel_sim/snapshot.py +301 -0
  17. ebus_panel_sim/tick_inputs.py +66 -0
  18. ebus_panel_sim/wire/__init__.py +0 -0
  19. ebus_panel_sim/wire/_sdk_seam.py +55 -0
  20. ebus_panel_sim/wire/bag_builder.py +429 -0
  21. ebus_panel_sim/wire/catalogs/breaker.json +52 -0
  22. ebus_panel_sim/wire/catalogs/charge-limit.json +37 -0
  23. ebus_panel_sim/wire/catalogs/connection.json +72 -0
  24. ebus_panel_sim/wire/catalogs/door.json +17 -0
  25. ebus_panel_sim/wire/catalogs/grid.json +38 -0
  26. ebus_panel_sim/wire/catalogs/info.json +52 -0
  27. ebus_panel_sim/wire/catalogs/load-shed.json +18 -0
  28. ebus_panel_sim/wire/catalogs/meter.json +201 -0
  29. ebus_panel_sim/wire/catalogs/pcs.json +111 -0
  30. ebus_panel_sim/wire/catalogs/power-flows.json +35 -0
  31. ebus_panel_sim/wire/catalogs/shed-forecast.json +41 -0
  32. ebus_panel_sim/wire/catalogs/shed.json +24 -0
  33. ebus_panel_sim/wire/catalogs/soc.json +35 -0
  34. ebus_panel_sim/wire/catalogs/status.json +28 -0
  35. ebus_panel_sim/wire/catalogs/switch.json +29 -0
  36. ebus_panel_sim/wire/graph_builder.py +309 -0
  37. ebus_panel_sim/wire/mapping/.gitkeep +0 -0
  38. ebus_panel_sim/wire/mapping/bess.yaml +16 -0
  39. ebus_panel_sim/wire/mapping/circuit.yaml +16 -0
  40. ebus_panel_sim/wire/mapping/evse.yaml +16 -0
  41. ebus_panel_sim/wire/mapping/lugs.yaml +16 -0
  42. ebus_panel_sim/wire/mapping/mid.yaml +16 -0
  43. ebus_panel_sim/wire/mapping/panel.yaml +15 -0
  44. ebus_panel_sim/wire/mapping/pv.yaml +16 -0
  45. ebus_panel_sim/wire/mapping_loader.py +144 -0
  46. ebus_panel_sim/wire/profile_loader.py +215 -0
  47. ebus_panel_sim/wire/profiles/.gitkeep +0 -0
  48. ebus_panel_sim/wire/profiles/bess.json +58 -0
  49. ebus_panel_sim/wire/profiles/circuit.json +88 -0
  50. ebus_panel_sim/wire/profiles/evse.json +26 -0
  51. ebus_panel_sim/wire/profiles/lugs.json +52 -0
  52. ebus_panel_sim/wire/profiles/mid.json +40 -0
  53. ebus_panel_sim/wire/profiles/panel.json +160 -0
  54. ebus_panel_sim/wire/profiles/pv.json +28 -0
  55. ebus_panel_sim/wire/profiles/span/circuit.json +19 -0
  56. ebus_panel_sim/wire/profiles/span/evse.json +52 -0
  57. ebus_panel_sim/wire/profiles/span/lugs.json +16 -0
  58. ebus_panel_sim/wire/profiles/span/panel.json +77 -0
  59. ebus_panel_sim/wire/property_bag.py +56 -0
  60. ebus_panel_sim/wire/publisher.py +36 -0
  61. ebus_panel_sim/wire/set_router.py +122 -0
  62. ebus_panel_sim-0.3.0.dist-info/METADATA +14 -0
  63. ebus_panel_sim-0.3.0.dist-info/RECORD +66 -0
  64. ebus_panel_sim-0.3.0.dist-info/WHEEL +4 -0
  65. ebus_panel_sim-0.3.0.dist-info/licenses/AUTHORS +7 -0
  66. ebus_panel_sim-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
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