peslite-gui 0.5.2__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.
- peslite/gui/__init__.py +17 -0
- peslite/gui/__main__.py +3 -0
- peslite/gui/descriptor.py +565 -0
- peslite/gui/service.py +295 -0
- peslite_gui-0.5.2.dist-info/METADATA +135 -0
- peslite_gui-0.5.2.dist-info/RECORD +9 -0
- peslite_gui-0.5.2.dist-info/WHEEL +5 -0
- peslite_gui-0.5.2.dist-info/licenses/LICENSE +661 -0
- peslite_gui-0.5.2.dist-info/top_level.txt +1 -0
peslite/gui/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PESLite frontend service.
|
|
2
|
+
|
|
3
|
+
The topology view, parameter editor, waveform plots and small-signal panel communicate with a
|
|
4
|
+
resident process over line-delimited JSON on stdin/stdout. Each PESLite simulation file is one
|
|
5
|
+
selectable system. Start the service with::
|
|
6
|
+
|
|
7
|
+
python -m peslite.gui
|
|
8
|
+
|
|
9
|
+
PESLite initially incorporated the frontend implementation from
|
|
10
|
+
[CodeSim](https://github.com/NoClassDefError/codesim) and has since developed it further.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .descriptor import SignalRecorder, Topology, small_signal_descriptor
|
|
14
|
+
from .service import FrontendService, discover_cases, main
|
|
15
|
+
|
|
16
|
+
__all__ = ["FrontendService", "SignalRecorder", "Topology", "discover_cases", "main",
|
|
17
|
+
"small_signal_descriptor"]
|
peslite/gui/__main__.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Translate a PESLite configuration and its run into the frontend descriptor schema.
|
|
2
|
+
|
|
3
|
+
The PESLite frontend draws a topology of buses, devices and connections, edits flat system
|
|
4
|
+
parameters, plots named signals and shows small-signal modes. This module derives those views from
|
|
5
|
+
an ordinary :class:`~peslite.assembly.params.Params` tree and read-only run observations; it adds
|
|
6
|
+
no simulation behaviour.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import cmath
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..assembly.params import Params, _unit_items, flat_paths, to_dict
|
|
22
|
+
from ..assembly.types import unit_type
|
|
23
|
+
from ..control.blocks import complex2abc
|
|
24
|
+
|
|
25
|
+
SCHEMA_VERSION = 1
|
|
26
|
+
|
|
27
|
+
# Parameter paths that are not useful or not representable as one flat override.
|
|
28
|
+
_HIDDEN_PREFIXES = ("meta.", "simulation.initial.", "simulation.progress.")
|
|
29
|
+
# ``events.<name>.set`` maps hold dotted keys themselves; they are shown, not edited.
|
|
30
|
+
_EVENT_SET = re.compile(r"^events\.[^.]+\.set\.")
|
|
31
|
+
_SLUG = re.compile(r"[^a-zA-Z0-9_-]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def slug(text: str) -> str:
|
|
35
|
+
"""Return a frontend-safe identifier."""
|
|
36
|
+
return _SLUG.sub("-", text).lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def json_safe(value: Any) -> Any:
|
|
40
|
+
"""Convert NumPy values, complex numbers and non-finite floats to JSON values."""
|
|
41
|
+
if isinstance(value, np.ndarray):
|
|
42
|
+
return json_safe(value.tolist())
|
|
43
|
+
if isinstance(value, np.generic):
|
|
44
|
+
value = value.item()
|
|
45
|
+
if isinstance(value, float):
|
|
46
|
+
return value if math.isfinite(value) else None
|
|
47
|
+
if isinstance(value, complex):
|
|
48
|
+
return {"real": json_safe(value.real), "imaginary": json_safe(value.imag),
|
|
49
|
+
"magnitude": json_safe(abs(value))}
|
|
50
|
+
if isinstance(value, Mapping):
|
|
51
|
+
return {str(key): json_safe(item) for key, item in value.items()}
|
|
52
|
+
if isinstance(value, (list, tuple)):
|
|
53
|
+
return [json_safe(item) for item in value]
|
|
54
|
+
if isinstance(value, Path):
|
|
55
|
+
return str(value)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def editable(path: str, value: Any) -> bool:
|
|
60
|
+
"""Whether one flattened parameter can be shown as an editable dotted-path override."""
|
|
61
|
+
if path.startswith(_HIDDEN_PREFIXES) or path.endswith(".type") or _EVENT_SET.match(path):
|
|
62
|
+
return False
|
|
63
|
+
if value is None or isinstance(value, (dict, complex)):
|
|
64
|
+
return False
|
|
65
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
66
|
+
return False
|
|
67
|
+
return isinstance(value, (bool, int, float, str, list))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parameter(path: str, label: str, value: Any, group: str, **extra: Any) -> dict[str, Any]:
|
|
71
|
+
return {"id": path, "label": label, "value": json_safe(value), "unit": "", "group": group,
|
|
72
|
+
**extra}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _source_lines(path: Path) -> dict[tuple[str, str], int]:
|
|
76
|
+
"""Map ``(section, name)`` to the 1-based line declaring it in a YAML simulation file."""
|
|
77
|
+
lines: dict[tuple[str, str], int] = {}
|
|
78
|
+
try:
|
|
79
|
+
text = path.read_text(encoding="utf-8").splitlines()
|
|
80
|
+
except OSError:
|
|
81
|
+
return lines
|
|
82
|
+
section, indent = "", None
|
|
83
|
+
for number, line in enumerate(text, 1):
|
|
84
|
+
stripped = line.lstrip()
|
|
85
|
+
if not stripped or stripped.startswith("#"):
|
|
86
|
+
continue
|
|
87
|
+
match = re.match(r"^([A-Za-z_][\w-]*)\s*:", line)
|
|
88
|
+
if match:
|
|
89
|
+
section, indent = match.group(1), None
|
|
90
|
+
lines.setdefault((section, ""), number)
|
|
91
|
+
continue
|
|
92
|
+
width = len(line) - len(stripped)
|
|
93
|
+
if section and indent is None:
|
|
94
|
+
indent = width
|
|
95
|
+
match = re.match(r"^([^\s:#{][^:]*?)\s*:", stripped)
|
|
96
|
+
if section and width == indent and match:
|
|
97
|
+
lines.setdefault((section, match.group(1).strip("'\"")), number)
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Entity:
|
|
103
|
+
"""One configured network entity drawn by the frontend."""
|
|
104
|
+
|
|
105
|
+
name: str # bus, source, branch or element name, or a unit path
|
|
106
|
+
section: str
|
|
107
|
+
id: str
|
|
108
|
+
owner_kind: str # "component" or "connection"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class Topology:
|
|
113
|
+
"""Frontend topology and parameter views of one loaded configuration."""
|
|
114
|
+
|
|
115
|
+
case: str
|
|
116
|
+
path: Path
|
|
117
|
+
params: Params
|
|
118
|
+
components: list[dict[str, Any]] = field(default_factory=list)
|
|
119
|
+
connections: list[dict[str, Any]] = field(default_factory=list)
|
|
120
|
+
entities: dict[str, Entity] = field(default_factory=dict)
|
|
121
|
+
# The connection carrying each device's terminal current (bus-attached devices only).
|
|
122
|
+
terminals: dict[str, str] = field(default_factory=dict)
|
|
123
|
+
_tree: dict[str, Any] = field(init=False, repr=False)
|
|
124
|
+
_editable: dict[str, Any] = field(init=False, repr=False)
|
|
125
|
+
_system: dict[str, Any] = field(init=False, repr=False)
|
|
126
|
+
_lines: dict[tuple[str, str], int] = field(init=False, repr=False)
|
|
127
|
+
_unit_paths: tuple[str, ...] = field(init=False, repr=False)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def build(cls, case: str, path: Path, params: Params) -> "Topology":
|
|
131
|
+
topology = cls(case, path.resolve(), params)
|
|
132
|
+
topology._tree = to_dict(params)
|
|
133
|
+
topology._lines = _source_lines(topology.path)
|
|
134
|
+
units = tuple(_unit_items(params.units, leaves_only=False))
|
|
135
|
+
topology._unit_paths = tuple(sorted((name for name, _unit in units),
|
|
136
|
+
key=len, reverse=True))
|
|
137
|
+
topology._build(units)
|
|
138
|
+
topology._editable = {
|
|
139
|
+
name: value for name, value in flat_paths(topology._tree).items()
|
|
140
|
+
if editable(name, value)
|
|
141
|
+
}
|
|
142
|
+
topology._system = {
|
|
143
|
+
"id": topology.system_id,
|
|
144
|
+
"name": params.meta.title or case,
|
|
145
|
+
"className": f"peslite:{topology.path.name}",
|
|
146
|
+
"parameterNames": list(topology._editable),
|
|
147
|
+
"parameters": [
|
|
148
|
+
_parameter(name, name, value, name.partition(".")[0], editable=True)
|
|
149
|
+
for name, value in topology._editable.items()
|
|
150
|
+
],
|
|
151
|
+
"source": topology._source("", ""),
|
|
152
|
+
}
|
|
153
|
+
return topology
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------ system
|
|
156
|
+
@property
|
|
157
|
+
def system_id(self) -> str:
|
|
158
|
+
return slug(self.case)
|
|
159
|
+
|
|
160
|
+
def editable_parameters(self) -> dict[str, Any]:
|
|
161
|
+
"""The current value of every parameter the system editor may override."""
|
|
162
|
+
return self._editable
|
|
163
|
+
|
|
164
|
+
def system(self) -> dict[str, Any]:
|
|
165
|
+
return self._system
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------ topology
|
|
168
|
+
def _source(self, section: str, name: str) -> dict[str, Any]:
|
|
169
|
+
line = self._lines.get((section, name.partition(".")[0])) or self._lines.get((section, ""), 1)
|
|
170
|
+
return {"file": str(self.path), "line": line, "column": 1}
|
|
171
|
+
|
|
172
|
+
def _component(self, entity: Entity, label: str, class_name: str, kind: str, icon: str,
|
|
173
|
+
tree: Mapping[str, Any], port_type: str = "") -> None:
|
|
174
|
+
prefix = f"{entity.section}.{entity.name}"
|
|
175
|
+
parameters = [_parameter(f"{prefix}.{path}", path, value, entity.section)
|
|
176
|
+
for path, value in flat_paths(dict(tree)).items()
|
|
177
|
+
if not isinstance(value, dict)]
|
|
178
|
+
source = self._source(entity.section, entity.name)
|
|
179
|
+
self.components.append({
|
|
180
|
+
"id": entity.id, "name": label, "className": class_name, "kind": kind, "icon": icon,
|
|
181
|
+
"portType": port_type, "parameters": parameters,
|
|
182
|
+
"classSource": source, "instanceSource": source,
|
|
183
|
+
})
|
|
184
|
+
self.entities.setdefault(entity.name, entity)
|
|
185
|
+
|
|
186
|
+
def _connect(self, connection_id: str, name: str, source: str, target: str, section: str,
|
|
187
|
+
entity: str, parameters: Iterable[dict[str, Any]] = (),
|
|
188
|
+
handles: tuple[str, str] = ("bottom", "top")) -> None:
|
|
189
|
+
self.connections.append({
|
|
190
|
+
"id": connection_id, "name": name, "source": source, "target": target,
|
|
191
|
+
"sourceHandle": handles[0], "targetHandle": handles[1],
|
|
192
|
+
"parameters": list(parameters), "sourceReference": self._source(section, entity),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
def _attach_point(self, bus: str | None) -> str | None:
|
|
196
|
+
"""The component a ``bus`` reference attaches to: a public bus or a unit's scoped bus."""
|
|
197
|
+
if not bus:
|
|
198
|
+
return None
|
|
199
|
+
if bus in self.params.buses:
|
|
200
|
+
return f"bus-{slug(bus)}"
|
|
201
|
+
owner = next((path for path in self._unit_paths if bus.startswith(path + ".")), None)
|
|
202
|
+
return f"unit-{slug(owner)}" if owner else None
|
|
203
|
+
|
|
204
|
+
def _attach(self, entity: Entity, bus: str | None, label: str) -> None:
|
|
205
|
+
point = self._attach_point(bus)
|
|
206
|
+
if point is None:
|
|
207
|
+
return
|
|
208
|
+
connection_id = f"{entity.id}-terminal"
|
|
209
|
+
self._connect(connection_id, label, point, entity.id, entity.section, entity.name)
|
|
210
|
+
self.terminals[entity.name] = connection_id
|
|
211
|
+
|
|
212
|
+
def _build(self, units: tuple[tuple[str, Any], ...]) -> None:
|
|
213
|
+
p, tree = self.params, self._tree
|
|
214
|
+
for name in p.buses:
|
|
215
|
+
entity = Entity(name, "buses", f"bus-{slug(name)}", "component")
|
|
216
|
+
phases = p.buses[name].phases
|
|
217
|
+
self._component(entity, name, "peslite.Bus", "bus", "bus", tree["buses"][name],
|
|
218
|
+
"ac" if phases == 3 else "dc")
|
|
219
|
+
for name, cfg in p.sources.items():
|
|
220
|
+
entity = Entity(name, "sources", f"source-{slug(name)}", "component")
|
|
221
|
+
self._component(entity, name, "peslite.Source", "grid", "grid", tree["sources"][name])
|
|
222
|
+
self._attach(entity, cfg.bus, "Terminal")
|
|
223
|
+
unit_tree = tree["units"]
|
|
224
|
+
for path, cfg in units:
|
|
225
|
+
entity = Entity(path, "units", f"unit-{slug(path)}", "component")
|
|
226
|
+
node: Any = unit_tree
|
|
227
|
+
for part in path.split("."):
|
|
228
|
+
node = node[part]
|
|
229
|
+
unit_class = unit_type(cfg.type)
|
|
230
|
+
composite = unit_class.composite
|
|
231
|
+
ctrl = cfg.ctrl.type if cfg.ctrl is not None else ""
|
|
232
|
+
kind = {"gfm": "gfm", "gfl": "gfl"}.get(ctrl, "generic")
|
|
233
|
+
icon = "device" if composite else "inverter"
|
|
234
|
+
self._component(entity, path, f"peslite.{cfg.type}", kind, icon,
|
|
235
|
+
{key: value for key, value in node.items()
|
|
236
|
+
if not (composite and key in unit_class.children)},
|
|
237
|
+
cfg.type)
|
|
238
|
+
parent = path.rpartition(".")[0]
|
|
239
|
+
if parent:
|
|
240
|
+
self._connect(f"{entity.id}-port", path.rpartition(".")[2],
|
|
241
|
+
f"unit-{slug(parent)}", entity.id, "units", path)
|
|
242
|
+
self._attach(entity, cfg.bus, "Terminal")
|
|
243
|
+
for name, cfg in p.branches.items():
|
|
244
|
+
entity = Entity(name, "branches", f"branch-{slug(name)}", "connection")
|
|
245
|
+
self.entities.setdefault(entity.name, entity)
|
|
246
|
+
self._connect(entity.id, name, f"bus-{slug(cfg.bus1)}", f"bus-{slug(cfg.bus2)}",
|
|
247
|
+
"branches", name,
|
|
248
|
+
[_parameter(f"branches.{name}.r", "Resistance", cfg.r, "branches",
|
|
249
|
+
unit="ohm"),
|
|
250
|
+
_parameter(f"branches.{name}.l", "Inductance", cfg.l, "branches",
|
|
251
|
+
unit="H")],
|
|
252
|
+
("right", "left"))
|
|
253
|
+
for name, cfg in tree["elements"].items():
|
|
254
|
+
points = [self._attach_point(cfg.get(key)) for key in ("bus1", "bus2")]
|
|
255
|
+
if all(points): # a two-terminal network element is drawn as a connection
|
|
256
|
+
entity = Entity(name, "elements", f"element-{slug(name)}", "connection")
|
|
257
|
+
self.entities.setdefault(entity.name, entity)
|
|
258
|
+
self._connect(entity.id, name, points[0], points[1], "elements", name,
|
|
259
|
+
[_parameter(f"elements.{name}.{key}", key, value, "elements")
|
|
260
|
+
for key, value in flat_paths(cfg).items()],
|
|
261
|
+
("right", "left"))
|
|
262
|
+
continue
|
|
263
|
+
entity = Entity(name, "elements", f"element-{slug(name)}", "component")
|
|
264
|
+
element_type = str(cfg.get("type", ""))
|
|
265
|
+
kind = ("capacitor" if "capacitor" in element_type
|
|
266
|
+
else "load" if any(word in element_type for word in ("load", "resistor"))
|
|
267
|
+
else "motor" if "motor" in element_type else "generic")
|
|
268
|
+
self._component(entity, name, f"peslite.{element_type or 'element'}", kind, kind, cfg)
|
|
269
|
+
self._attach(entity, cfg.get("bus"), "Terminal")
|
|
270
|
+
|
|
271
|
+
def owner(self, signal: str) -> Entity | None:
|
|
272
|
+
"""The entity whose name is the longest dotted prefix of a state or signal name."""
|
|
273
|
+
name = signal
|
|
274
|
+
while name:
|
|
275
|
+
entity = self.entities.get(name)
|
|
276
|
+
if entity is not None:
|
|
277
|
+
return entity
|
|
278
|
+
name, separator, _part = name.rpartition(".")
|
|
279
|
+
if not separator:
|
|
280
|
+
break
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
def descriptor(self, signals: list[dict[str, Any]] | None = None,
|
|
284
|
+
small_signal: Mapping[str, Any] | None = None,
|
|
285
|
+
layout: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
286
|
+
"""The complete frontend descriptor, with unavailable analyses marked accordingly."""
|
|
287
|
+
descriptor = {
|
|
288
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
289
|
+
"system": self.system(),
|
|
290
|
+
"components": self.components,
|
|
291
|
+
"connections": self.connections,
|
|
292
|
+
"signals": signals or [],
|
|
293
|
+
"smallSignal": dict(small_signal or {"available": False}),
|
|
294
|
+
"interactionAnalysis": {"available": False},
|
|
295
|
+
"transientVoltage": {"available": False},
|
|
296
|
+
}
|
|
297
|
+
if layout:
|
|
298
|
+
descriptor["layout"] = dict(layout)
|
|
299
|
+
return descriptor
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------ signals
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class _Trace:
|
|
306
|
+
id: str
|
|
307
|
+
owner: str
|
|
308
|
+
owner_kind: str
|
|
309
|
+
category: str
|
|
310
|
+
label: str
|
|
311
|
+
unit: str
|
|
312
|
+
values: list[float] = field(default_factory=list)
|
|
313
|
+
|
|
314
|
+
def describe(self, time: list[float], start: int = 0) -> dict[str, Any]:
|
|
315
|
+
return {
|
|
316
|
+
"id": self.id, "componentId": self.owner, "ownerId": self.owner,
|
|
317
|
+
"ownerKind": self.owner_kind, "category": self.category, "label": self.label,
|
|
318
|
+
"unit": self.unit, "time": time[start:],
|
|
319
|
+
"values": [value if math.isfinite(value) else None for value in self.values[start:]],
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SignalRecorder:
|
|
324
|
+
"""Collect frontend signals from ``Simulation.run(observe=...)`` snapshots.
|
|
325
|
+
|
|
326
|
+
The observer reads plant signals, controller logs and states only after the run has
|
|
327
|
+
synchronised its outputs, and never changes simulation state. ``emit`` receives each chunk of
|
|
328
|
+
new samples, already in the frontend signal schema.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
def __init__(self, topology: Topology, sim: Any, *, chunk_size: int = 50,
|
|
332
|
+
emit: Callable[[list[dict[str, Any]]], None] | None = None) -> None:
|
|
333
|
+
self.topology = topology
|
|
334
|
+
self.system = sim.system
|
|
335
|
+
self.units = sim.units
|
|
336
|
+
self.base = topology.params.base
|
|
337
|
+
self.chunk_size = chunk_size
|
|
338
|
+
self.emit = emit
|
|
339
|
+
self.observe_values = tuple(
|
|
340
|
+
f"{name}.{column}" for name, unit in self.units.items() if unit.ctrl is not None
|
|
341
|
+
for column in getattr(unit.ctrl, "log_names", lambda: ())()
|
|
342
|
+
)
|
|
343
|
+
self.traces: dict[str, _Trace] = {}
|
|
344
|
+
self.time: list[float] = []
|
|
345
|
+
self._emitted = 0
|
|
346
|
+
self.stop_reason = ""
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------ the observer
|
|
349
|
+
def observe(self, t: float, states: Mapping[str, float] | None, read_values: Callable[[], Any],
|
|
350
|
+
*, final: bool, stop_reason: str) -> None:
|
|
351
|
+
if final and stop_reason:
|
|
352
|
+
self.stop_reason = stop_reason
|
|
353
|
+
if states is None or (self.time and t <= self.time[-1]):
|
|
354
|
+
return None # an unavailable terminal state, or an event already at this time
|
|
355
|
+
signals = self.system.signals(t)
|
|
356
|
+
logs = read_values() if self.observe_values else {}
|
|
357
|
+
row: dict[tuple, float] = {}
|
|
358
|
+
self._derived(row, signals)
|
|
359
|
+
self._plant(row, signals)
|
|
360
|
+
for name in self.observe_values:
|
|
361
|
+
self._put(row, name, "output", name, _unit_of(name), logs.get(name, math.nan))
|
|
362
|
+
for name, value in states.items():
|
|
363
|
+
self._put(row, name, "state", name, "", value)
|
|
364
|
+
self._append(t, row)
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
def _put(self, row: dict, key: str, category: str, label: str, unit: str, value: float,
|
|
368
|
+
owner: tuple[str, str] | None = None) -> None:
|
|
369
|
+
row[(category, key)] = (label, unit, owner, float(value))
|
|
370
|
+
|
|
371
|
+
def _append(self, t: float, row: dict) -> None:
|
|
372
|
+
if not self.traces and row:
|
|
373
|
+
used: set[str] = set()
|
|
374
|
+
for key, (label, unit, owner, _value) in row.items():
|
|
375
|
+
if owner is None:
|
|
376
|
+
entity = self.topology.owner(key[1])
|
|
377
|
+
owner = ((entity.id, entity.owner_kind) if entity is not None
|
|
378
|
+
else (self.topology.system_id, "system"))
|
|
379
|
+
trace_id = slug(f"{owner[0]}-{label}")
|
|
380
|
+
while trace_id in used:
|
|
381
|
+
trace_id += "_"
|
|
382
|
+
used.add(trace_id)
|
|
383
|
+
self.traces[key] = _Trace(trace_id, owner[0], owner[1], key[0], label, unit)
|
|
384
|
+
self.time.append(float(t))
|
|
385
|
+
for key, trace in self.traces.items():
|
|
386
|
+
item = row.get(key)
|
|
387
|
+
trace.values.append(item[3] if item is not None else math.nan)
|
|
388
|
+
if self.emit is not None and len(self.time) - self._emitted >= self.chunk_size:
|
|
389
|
+
self.flush()
|
|
390
|
+
|
|
391
|
+
def flush(self) -> None:
|
|
392
|
+
"""Emit every sample not yet sent."""
|
|
393
|
+
if self.emit is not None and len(self.time) > self._emitted:
|
|
394
|
+
self.emit([trace.describe(self.time, self._emitted)
|
|
395
|
+
for trace in self.traces.values()])
|
|
396
|
+
self._emitted = len(self.time)
|
|
397
|
+
|
|
398
|
+
def signals(self) -> list[dict[str, Any]]:
|
|
399
|
+
return [trace.describe(self.time) for trace in self.traces.values()]
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------ signal views
|
|
402
|
+
def _derived(self, row: dict, signals: Mapping[str, Any]) -> None:
|
|
403
|
+
"""Bus voltage, connection current, device power and bus angle for frontend plots.
|
|
404
|
+
|
|
405
|
+
AC quantities are amplitude-invariant space vectors: magnitudes are in pu of the system
|
|
406
|
+
base and ``p + jq = 1.5 u i*``. Scalar (DC) voltage and current stay in SI; power is in
|
|
407
|
+
pu of the system ``s_base`` for both. Device power is positive when delivered to its bus.
|
|
408
|
+
"""
|
|
409
|
+
topology, p, base = self.topology, self.topology.params, self.base
|
|
410
|
+
v_base, i_base, s_base = base.v_phase_peak or 1.0, base.i_phase_peak or 1.0, base.s_base
|
|
411
|
+
reference = next((signals[f"{name}.angle"] for name in p.sources
|
|
412
|
+
if f"{name}.angle" in signals), None)
|
|
413
|
+
|
|
414
|
+
def bus_voltage(name: str | None):
|
|
415
|
+
return signals.get(f"{name}.u") if name else None
|
|
416
|
+
|
|
417
|
+
voltages: list[tuple] = []
|
|
418
|
+
currents: list[tuple] = []
|
|
419
|
+
powers: list[tuple] = []
|
|
420
|
+
angles: list[tuple] = []
|
|
421
|
+
for name in p.buses:
|
|
422
|
+
u = signals.get(f"{name}.u")
|
|
423
|
+
if u is None:
|
|
424
|
+
continue
|
|
425
|
+
owner = (f"bus-{slug(name)}", "component")
|
|
426
|
+
if isinstance(u, complex):
|
|
427
|
+
voltages.append((name, "pu", abs(u) / v_base, owner))
|
|
428
|
+
if reference is not None:
|
|
429
|
+
angle = math.degrees(math.remainder(cmath.phase(u) - reference, math.tau))
|
|
430
|
+
angles.append((name, "deg", angle if abs(u) > 0.0 else math.nan, owner))
|
|
431
|
+
else:
|
|
432
|
+
voltages.append((name, "V", abs(float(u)), owner))
|
|
433
|
+
for name in p.branches:
|
|
434
|
+
i = signals.get(f"{name}.i")
|
|
435
|
+
if i is not None:
|
|
436
|
+
currents.append((name, *_current(i, i_base), (f"branch-{slug(name)}", "connection")))
|
|
437
|
+
|
|
438
|
+
def device(name: str, u, i, sign: float, owner_id: str) -> None:
|
|
439
|
+
terminal = topology.terminals.get(name)
|
|
440
|
+
owner = (terminal, "connection") if terminal else (owner_id, "component")
|
|
441
|
+
if i is not None:
|
|
442
|
+
currents.append((name, *_current(i, i_base), owner))
|
|
443
|
+
if u is None or i is None:
|
|
444
|
+
return
|
|
445
|
+
component = (owner_id, "component")
|
|
446
|
+
if isinstance(u, complex) and isinstance(i, complex):
|
|
447
|
+
s = sign * 1.5 * u * i.conjugate() / s_base
|
|
448
|
+
powers.append(("P", name, s.real, component))
|
|
449
|
+
powers.append(("Q", name, s.imag, component))
|
|
450
|
+
elif not isinstance(u, complex) and not isinstance(i, complex):
|
|
451
|
+
powers.append(("P", name, sign * float(u) * float(i) / s_base, component))
|
|
452
|
+
|
|
453
|
+
for name, cfg in p.sources.items():
|
|
454
|
+
# A source current enters its bus from the grid emf.
|
|
455
|
+
device(name, bus_voltage(cfg.bus), signals.get(f"{name}.i"), 1.0,
|
|
456
|
+
f"source-{slug(name)}")
|
|
457
|
+
for name in self.units:
|
|
458
|
+
device(name, signals.get(f"{name}.u_g"), signals.get(f"{name}.i_c"), 1.0,
|
|
459
|
+
f"unit-{slug(name)}")
|
|
460
|
+
for name, cfg in p.elements.items():
|
|
461
|
+
entity = topology.entities.get(name)
|
|
462
|
+
if entity is None:
|
|
463
|
+
continue
|
|
464
|
+
i = signals.get(f"{name}.i", signals.get(f"{name}.i1"))
|
|
465
|
+
if entity.owner_kind == "connection":
|
|
466
|
+
if i is not None:
|
|
467
|
+
currents.append((name, *_current(i, i_base), (entity.id, "connection")))
|
|
468
|
+
continue
|
|
469
|
+
terminal = topology.terminals.get(name)
|
|
470
|
+
if i is not None:
|
|
471
|
+
currents.append((name, *_current(i, i_base),
|
|
472
|
+
(terminal, "connection") if terminal else (entity.id, "component")))
|
|
473
|
+
if f"{name}.power" in signals: # an element reports the power it absorbs
|
|
474
|
+
powers.append(("P", name, -float(signals[f"{name}.power"]) / s_base,
|
|
475
|
+
(entity.id, "component")))
|
|
476
|
+
|
|
477
|
+
for name, unit, value, owner in voltages:
|
|
478
|
+
self._put(row, f"|V|-{name}", "output", f"|V|-{name}", unit, value, owner)
|
|
479
|
+
for name, unit, value, owner in currents:
|
|
480
|
+
self._put(row, f"I-{name}", "output", f"I-{name}", unit, value, owner)
|
|
481
|
+
for which, name, value, owner in sorted(powers, key=lambda item: item[0]):
|
|
482
|
+
self._put(row, f"{which}-{name}", "output", f"{which}-{name}", "pu", value, owner)
|
|
483
|
+
for name, unit, value, owner in angles:
|
|
484
|
+
self._put(row, f"theta-{name}", "output", f"theta-{name}", unit, value, owner)
|
|
485
|
+
|
|
486
|
+
def _plant(self, row: dict, signals: Mapping[str, Any]) -> None:
|
|
487
|
+
"""Every plant signal under its ``plant.csv`` column name (SI, phases for AC vectors)."""
|
|
488
|
+
for key, value in signals.items():
|
|
489
|
+
if isinstance(value, complex):
|
|
490
|
+
for suffix, phase in zip(("_a", "_b", "_c"), complex2abc(value)):
|
|
491
|
+
self._put(row, key + suffix, "output", key + suffix, "", float(phase))
|
|
492
|
+
else:
|
|
493
|
+
self._put(row, key, "output", key, _unit_of(key), float(value))
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _current(value, i_base: float) -> tuple[str, float]:
|
|
497
|
+
if isinstance(value, complex):
|
|
498
|
+
return "pu", abs(value) / i_base
|
|
499
|
+
return "A", abs(float(value))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _unit_of(name: str) -> str:
|
|
503
|
+
return "pu" if name.endswith("_pu") else ""
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ------------------------------------------------------------------ small-signal modes
|
|
507
|
+
|
|
508
|
+
def small_signal_descriptor(result: Any) -> dict[str, Any]:
|
|
509
|
+
"""Serialize a :class:`SmallSignalResult` as continuous-time modes with participation.
|
|
510
|
+
|
|
511
|
+
The period map ``phi`` has multipliers ``mu``; each mode is reported by its equivalent
|
|
512
|
+
exponent ``log(mu) / period`` (principal logarithm, so frequency is ambiguous modulo
|
|
513
|
+
``1 / period``). Deadbeat multipliers (``mu == 0``, from sample-and-hold memory) have no
|
|
514
|
+
finite exponent and are counted rather than plotted.
|
|
515
|
+
"""
|
|
516
|
+
from scipy.linalg import eig
|
|
517
|
+
|
|
518
|
+
phi = np.asarray(result.phi, dtype=float)
|
|
519
|
+
if not result.periodic or phi.size == 0:
|
|
520
|
+
return {"available": False, "stability": result.stability,
|
|
521
|
+
"diagnostics": list(result.diagnostics)}
|
|
522
|
+
names = list(result.coordinates)
|
|
523
|
+
period, tolerance = float(result.period), float(result.multiplier_tolerance)
|
|
524
|
+
multipliers, left, right = eig(phi, left=True, right=True)
|
|
525
|
+
order = np.argsort(-np.abs(multipliers), kind="stable")
|
|
526
|
+
modes, deadbeat = [], 0
|
|
527
|
+
for k in order:
|
|
528
|
+
mu = complex(multipliers[k])
|
|
529
|
+
size = abs(mu)
|
|
530
|
+
if size <= 1e-12:
|
|
531
|
+
deadbeat += 1
|
|
532
|
+
continue
|
|
533
|
+
exponent = complex(math.log(size), cmath.phase(mu)) / period
|
|
534
|
+
participation = np.abs(left[:, k].conj() * right[:, k])
|
|
535
|
+
total = participation.sum()
|
|
536
|
+
if total > 0.0:
|
|
537
|
+
participation = participation / total
|
|
538
|
+
dominant = int(np.argmax(participation))
|
|
539
|
+
modes.append({
|
|
540
|
+
"mode": len(modes) + 1,
|
|
541
|
+
"realPart": exponent.real,
|
|
542
|
+
"imaginaryPart": exponent.imag,
|
|
543
|
+
"frequencyHz": abs(exponent.imag) / math.tau,
|
|
544
|
+
"dampingRatio": -exponent.real / max(abs(exponent), np.finfo(float).eps),
|
|
545
|
+
"stability": ("Unstable" if size > 1.0 + tolerance
|
|
546
|
+
else "Stable" if size < 1.0 - tolerance else "Marginal"),
|
|
547
|
+
"dominantState": names[dominant] if names else "",
|
|
548
|
+
"dominantParticipation": float(participation[dominant]),
|
|
549
|
+
"multiplier": {"real": mu.real, "imaginary": mu.imag, "magnitude": size},
|
|
550
|
+
})
|
|
551
|
+
return {
|
|
552
|
+
"available": True,
|
|
553
|
+
"normalizedTime": False,
|
|
554
|
+
"unstablePoleCount": sum(mode["stability"] == "Unstable" for mode in modes),
|
|
555
|
+
"poleTolerance": tolerance,
|
|
556
|
+
"modes": modes,
|
|
557
|
+
"stability": result.stability,
|
|
558
|
+
"operatingTime_s": float(result.at),
|
|
559
|
+
"period_s": period,
|
|
560
|
+
"periodicError": float(result.periodic_error),
|
|
561
|
+
"amplification": float(result.amplification),
|
|
562
|
+
"deadbeatModeCount": deadbeat,
|
|
563
|
+
"coordinates": names,
|
|
564
|
+
"diagnostics": list(result.diagnostics),
|
|
565
|
+
}
|