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.
@@ -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"]
@@ -0,0 +1,3 @@
1
+ from .service import main
2
+
3
+ raise SystemExit(main())
@@ -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
+ }