stepss 3.59__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.
stepss/helios.py ADDED
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """Python interface to the Helios AC power-flow engine.
4
+
5
+ Helios is the STEPSS power-flow calculator (successor of the Fortran PFC).
6
+ This module wraps the bundled ``libhelios_api`` C shared library with an
7
+ explicit ctypes binding and exposes :class:`HeliosSession`, a stateful
8
+ session object with a load → (modify) → solve → query lifecycle.
9
+
10
+ Unlike the rest of stepss (which follows the historical camelCase RAMSES
11
+ conventions), this module deliberately uses PEP 8 snake_case naming.
12
+
13
+ Quick start::
14
+
15
+ from stepss.helios import HeliosSession
16
+
17
+ with HeliosSession() as pf:
18
+ pf.load_file("network.dat")
19
+ pf.solve()
20
+ v, angle = pf.get_bus_voltage("A")
21
+ v_all, angle_all = pf.get_bus_voltages()
22
+
23
+ Each session owns an independent solver instance; sessions may be used
24
+ concurrently from different threads (do not share one session between
25
+ threads without locking).
26
+ """
27
+
28
+ import ctypes
29
+ import enum
30
+ import os
31
+ import sys
32
+ from dataclasses import dataclass, field
33
+
34
+ import numpy
35
+
36
+ from .globals import HeliosError, __platlibdir__
37
+
38
+ _c_int_p = ctypes.POINTER(ctypes.c_int)
39
+ _c_double_p = ctypes.POINTER(ctypes.c_double)
40
+
41
+ #: Buffer size sufficient for any element name (mirrors HELIOS_NAME_MAX).
42
+ NAME_MAX = 64
43
+ #: Buffer size for free-text strings such as violations (HELIOS_TEXT_MAX).
44
+ TEXT_MAX = 256
45
+
46
+ #: Oldest / newest C API major.minor this wrapper is tested against.
47
+ _SUPPORTED_API = ((1, 1), (1, None))
48
+
49
+
50
+ class SolverStatus(enum.IntEnum):
51
+ """Detailed solver status reported by :attr:`HeliosSession.solver_status`."""
52
+
53
+ NOT_RUN = 0
54
+ CONVERGED = 1
55
+ DIVERGED = 2
56
+ MAX_ITERATIONS = 3
57
+ SINGULAR = 4
58
+
59
+
60
+ class Option(enum.IntEnum):
61
+ """Solver options accepted by :meth:`HeliosSession.set_option` (HeliosOption enum)."""
62
+
63
+ SBASE = 0 #: system base, MVA
64
+ TOLAC = 1 #: active power tolerance, MW ($TOLAC)
65
+ TOLREAC = 2 #: reactive power tolerance, Mvar ($TOLREAC)
66
+ MAX_ITER = 3 #: maximum iterations ($NBITMA)
67
+ TH_BLOCK = 4 #: tap-blocking mismatch threshold, MVA ($MISBLOC)
68
+ TH_ADJ = 5 #: tap-adjustment mismatch threshold, MVA ($MISADJ)
69
+ TH_QLIM = 6 #: Q-limit switching mismatch threshold, MVA ($MISQLIM)
70
+ PLIM = 7 #: 0/1 slack P-limit relaxation ($PLIM)
71
+ DIVDET = 8 #: divergence detection threshold ($DIVDET)
72
+
73
+
74
+ class BusType(enum.IntEnum):
75
+ """Bus type reported by :meth:`HeliosSession.get_bus_info`."""
76
+
77
+ PQ = 0
78
+ PV = 1
79
+ SLACK = 2
80
+ ISOLATED = 3
81
+
82
+
83
+ @dataclass
84
+ class BusInfo:
85
+ """Static and solved data of one bus."""
86
+
87
+ name: str
88
+ v_pu: float
89
+ angle_rad: float
90
+ bus_type: BusType
91
+ vnom_kv: float
92
+ p_load_mw: float
93
+ q_load_mvar: float
94
+
95
+
96
+ @dataclass
97
+ class BranchInfo:
98
+ """Static data and solved flows of one branch."""
99
+
100
+ name: str
101
+ from_bus: str
102
+ to_bus: str
103
+ closed: bool
104
+ is_transformer: bool
105
+ snom_mva: float
106
+ p_from_mw: float
107
+ q_from_mvar: float
108
+ p_to_mw: float
109
+ q_to_mvar: float
110
+
111
+
112
+ @dataclass
113
+ class GeneratorInfo:
114
+ """Static data and solved outputs of one generator."""
115
+
116
+ name: str
117
+ bus: str
118
+ in_service: bool
119
+ p_mw: float
120
+ q_mvar: float
121
+ v_setpoint_pu: float
122
+ p_min_mw: float
123
+ p_max_mw: float
124
+ q_min_mvar: float
125
+ q_max_mvar: float
126
+ has_p_limits: bool
127
+
128
+
129
+ @dataclass
130
+ class ContingencyResult:
131
+ """Outcome of one contingency run."""
132
+
133
+ name: str
134
+ converged: bool
135
+ accepted: bool
136
+ min_v_pu: float
137
+ max_v_pu: float
138
+ min_v_bus: str
139
+ max_v_bus: str
140
+ max_loading_pct: float
141
+ max_loading_branch: str
142
+ violations: list = field(default_factory=list)
143
+
144
+
145
+ def _lib_name():
146
+ """Return the platform-specific shared-library filename."""
147
+ if sys.platform in ("win32", "cygwin"):
148
+ return "helios_api.dll"
149
+ if sys.platform == "darwin":
150
+ return "libhelios_api.dylib"
151
+ return "libhelios_api.so"
152
+
153
+
154
+ # Explicit binding table: {symbol: (restype, [argtypes])}. The bundled
155
+ # libs/helios_api.h is the authoritative reference for these signatures;
156
+ # an explicit table is used (instead of parsing the header, as the RAMSES
157
+ # wrapper does) because helios_api.h contains enums, typedefs, and
158
+ # multi-line prototypes that a line-based parser cannot handle.
159
+ _H = ctypes.c_void_p
160
+ _I = ctypes.c_int
161
+ _D = ctypes.c_double
162
+ _S = ctypes.c_char_p
163
+ _SIGNATURES = {
164
+ "helios_create": (_H, []),
165
+ "helios_destroy": (None, [_H]),
166
+ "helios_get_api_version": (None, [_c_int_p, _c_int_p, _c_int_p]),
167
+ "helios_get_build_info": (_S, []),
168
+ "helios_get_last_error": (_S, [_H]),
169
+ "helios_clear_last_error": (None, [_H]),
170
+ "helios_load_file": (_I, [_H, _S]),
171
+ "helios_solve": (_I, [_H]),
172
+ "helios_get_convergence_status": (_I, [_H]),
173
+ "helios_get_bus_voltage": (_I, [_H, _S, _c_double_p, _c_double_p]),
174
+ "helios_get_branch_flow": (_I, [_H, _S, _c_double_p, _c_double_p]),
175
+ "helios_get_output_text": (_S, [_H]),
176
+ "helios_set_option": (_I, [_H, _I, _D]),
177
+ "helios_get_option": (_I, [_H, _I, _c_double_p]),
178
+ "helios_get_iteration_count": (_I, [_H]),
179
+ "helios_get_max_mismatch": (_I, [_H, _c_double_p, _c_double_p]),
180
+ "helios_get_solver_status": (_I, [_H]),
181
+ "helios_get_bus_count": (_I, [_H]),
182
+ "helios_get_branch_count": (_I, [_H]),
183
+ "helios_get_generator_count": (_I, [_H]),
184
+ "helios_get_svc_count": (_I, [_H]),
185
+ "helios_get_zone_count": (_I, [_H]),
186
+ "helios_get_cut_count": (_I, [_H]),
187
+ "helios_get_bus_name": (_I, [_H, _I, _S, _I]),
188
+ "helios_get_branch_name": (_I, [_H, _I, _S, _I]),
189
+ "helios_get_generator_name": (_I, [_H, _I, _S, _I]),
190
+ "helios_get_svc_name": (_I, [_H, _I, _S, _I]),
191
+ "helios_get_zone_name": (_I, [_H, _I, _S, _I]),
192
+ "helios_get_cut_name": (_I, [_H, _I, _S, _I]),
193
+ "helios_find_bus": (_I, [_H, _S]),
194
+ "helios_find_branch": (_I, [_H, _S]),
195
+ "helios_find_generator": (_I, [_H, _S]),
196
+ "helios_find_svc": (_I, [_H, _S]),
197
+ "helios_find_zone": (_I, [_H, _S]),
198
+ "helios_find_cut": (_I, [_H, _S]),
199
+ "helios_get_bus_info": (_I, [_H, _I, _c_double_p, _c_double_p, _c_int_p, _c_double_p]),
200
+ "helios_get_bus_load": (_I, [_H, _I, _c_double_p, _c_double_p]),
201
+ "helios_get_bus_shunt": (_I, [_H, _I, _c_double_p, _c_double_p]),
202
+ "helios_get_bus_zone": (_I, [_H, _I]),
203
+ "helios_get_branch_info": (_I, [_H, _I, _c_int_p, _c_int_p, _c_int_p, _c_int_p, _c_double_p]),
204
+ "helios_get_branch_flow_by_index": (_I, [_H, _I, _c_double_p, _c_double_p, _c_double_p, _c_double_p]),
205
+ "helios_get_generator_info": (_I, [_H, _I, _c_int_p, _c_int_p, _c_double_p, _c_double_p, _c_double_p]),
206
+ "helios_get_generator_limits": (_I, [_H, _I, _c_double_p, _c_double_p, _c_double_p, _c_double_p, _c_int_p]),
207
+ "helios_get_svc_info": (_I, [_H, _I, _c_int_p, _c_int_p, _c_double_p, _c_double_p, _c_int_p]),
208
+ "helios_get_cut_flow": (_I, [_H, _I, _c_double_p, _c_double_p]),
209
+ "helios_copy_bus_voltages": (_I, [_H, _c_double_p, _c_double_p, _I]),
210
+ "helios_copy_bus_loads": (_I, [_H, _c_double_p, _c_double_p, _I]),
211
+ "helios_copy_branch_flows": (_I, [_H, _c_double_p, _c_double_p, _c_double_p, _c_double_p, _I]),
212
+ "helios_copy_branch_status": (_I, [_H, _c_int_p, _I]),
213
+ "helios_copy_generator_outputs": (_I, [_H, _c_double_p, _c_double_p, _c_int_p, _I]),
214
+ "helios_copy_svc_outputs": (_I, [_H, _c_double_p, _c_int_p, _I]),
215
+ "helios_trip_branch": (_I, [_H, _S]),
216
+ "helios_connect_branch": (_I, [_H, _S]),
217
+ "helios_trip_generator": (_I, [_H, _S]),
218
+ "helios_connect_generator": (_I, [_H, _S]),
219
+ "helios_trip_svc": (_I, [_H, _S]),
220
+ "helios_connect_svc": (_I, [_H, _S]),
221
+ "helios_set_generator_voltage": (_I, [_H, _S, _D]),
222
+ "helios_set_generator_pq": (_I, [_H, _S, _D]),
223
+ "helios_change_generation": (_I, [_H, _S, _D]),
224
+ "helios_change_load": (_I, [_H, _S, _D, _D]),
225
+ "helios_change_shunt": (_I, [_H, _S, _D]),
226
+ "helios_change_zone_load": (_I, [_H, _S, _D, _D]),
227
+ "helios_change_zone_generation": (_I, [_H, _S, _D]),
228
+ "helios_apply_changes": (_I, [_H]),
229
+ "helios_reset": (_I, [_H]),
230
+ "helios_write_dump": (_I, [_H, _S]),
231
+ "helios_write_voltrat": (_I, [_H, _S]),
232
+ "helios_write_matlab": (_I, [_H, _S]),
233
+ "helios_write_diagram": (_I, [_H, _S, _S]),
234
+ "helios_run_contingencies_file": (_I, [_H, _S, _D, _D, _D, _D]),
235
+ "helios_run_contingencies_n1": (_I, [_H, _I, _I, _I, _D, _D, _D, _D]),
236
+ "helios_get_contingency_count": (_I, [_H]),
237
+ "helios_get_contingency_name": (_I, [_H, _I, _S, _I]),
238
+ "helios_get_contingency_result": (_I, [_H, _I, _c_int_p, _c_int_p, _c_double_p, _c_double_p, _c_double_p]),
239
+ "helios_get_contingency_extremes": (_I, [_H, _I, _S, _S, _S, _I]),
240
+ "helios_get_contingency_violation_count": (_I, [_H, _I]),
241
+ "helios_get_contingency_violation": (_I, [_H, _I, _I, _S, _I]),
242
+ }
243
+
244
+ _lib_cache = {}
245
+
246
+
247
+ def _load_library(lib_dir=None):
248
+ """Load (or return the cached) helios shared library and bind signatures.
249
+
250
+ Without *lib_dir* the platform-specific bundled copy is used
251
+ (``libs/win``, ``libs/lin`` or ``libs/mac``); a custom *lib_dir* must
252
+ contain the library file directly (no platform subfolder).
253
+ """
254
+ directory = os.path.realpath(lib_dir) if lib_dir else __platlibdir__
255
+ path = os.path.join(directory, _lib_name())
256
+ if path in _lib_cache:
257
+ return _lib_cache[path]
258
+ try:
259
+ lib = ctypes.CDLL(path)
260
+ except OSError as exc:
261
+ raise ImportError(
262
+ "Could not load the helios shared library from %r: %s" % (path, exc)
263
+ ) from exc
264
+ for name, (restype, argtypes) in _SIGNATURES.items():
265
+ try:
266
+ fn = getattr(lib, name)
267
+ except AttributeError as exc:
268
+ raise ImportError(
269
+ "Symbol %r missing from %r — library too old for this wrapper?" % (name, path)
270
+ ) from exc
271
+ fn.restype = restype
272
+ fn.argtypes = argtypes
273
+
274
+ major = ctypes.c_int()
275
+ minor = ctypes.c_int()
276
+ patch = ctypes.c_int()
277
+ lib.helios_get_api_version(ctypes.byref(major), ctypes.byref(minor), ctypes.byref(patch))
278
+ (min_major, min_minor), (max_major, _) = _SUPPORTED_API
279
+ if not (min_major, min_minor) <= (major.value, minor.value) or major.value > max_major:
280
+ raise ImportError(
281
+ "helios library version %d.%d.%d is outside the supported range (>= %d.%d, major <= %d)"
282
+ % (major.value, minor.value, patch.value, min_major, min_minor, max_major)
283
+ )
284
+
285
+ _lib_cache[path] = lib
286
+ return lib
287
+
288
+
289
+ class HeliosSession:
290
+ """A Helios power-flow session: load → (modify) → solve → query.
291
+
292
+ :param str lib_dir: optional directory containing the helios shared
293
+ library, overriding the copy bundled with stepss (useful for
294
+ testing pre-release engine builds).
295
+
296
+ The session is a context manager; on exit the native handle is
297
+ destroyed. Explicit :meth:`close` is idempotent.
298
+ """
299
+
300
+ def __init__(self, lib_dir=None):
301
+ self._lib = _load_library(lib_dir)
302
+ self._handle = self._lib.helios_create()
303
+ if not self._handle:
304
+ raise HeliosError("helios_create failed (out of memory?)")
305
+
306
+ # -- lifecycle ---------------------------------------------------------
307
+
308
+ def close(self):
309
+ """Destroy the native handle. Safe to call more than once."""
310
+ if getattr(self, "_handle", None):
311
+ self._lib.helios_destroy(self._handle)
312
+ self._handle = None
313
+
314
+ def __enter__(self):
315
+ return self
316
+
317
+ def __exit__(self, exc_type, exc_value, traceback):
318
+ self.close()
319
+ return False
320
+
321
+ def __del__(self):
322
+ try:
323
+ self.close()
324
+ except Exception:
325
+ pass
326
+
327
+ def _check(self, ret, what):
328
+ """Raise :class:`HeliosError` when *ret* is a negative status code."""
329
+ if ret < 0:
330
+ raise HeliosError("%s failed (%d): %s" % (what, ret, self.last_error))
331
+ return ret
332
+
333
+ @property
334
+ def _h(self):
335
+ if not self._handle:
336
+ raise HeliosError("session is closed")
337
+ return self._handle
338
+
339
+ # -- library information ------------------------------------------------
340
+
341
+ def api_version(self):
342
+ """Return the C API version as a ``(major, minor, patch)`` tuple."""
343
+ major = ctypes.c_int()
344
+ minor = ctypes.c_int()
345
+ patch = ctypes.c_int()
346
+ self._lib.helios_get_api_version(
347
+ ctypes.byref(major), ctypes.byref(minor), ctypes.byref(patch))
348
+ return (major.value, minor.value, patch.value)
349
+
350
+ def build_info(self):
351
+ """Return the library build description string."""
352
+ return self._lib.helios_get_build_info().decode()
353
+
354
+ @property
355
+ def last_error(self):
356
+ """Diagnostic text of the most recent failing call (or warning)."""
357
+ return self._lib.helios_get_last_error(self._h).decode()
358
+
359
+ def clear_last_error(self):
360
+ """Clear the per-session diagnostic text."""
361
+ self._lib.helios_clear_last_error(self._h)
362
+
363
+ # -- load and solve ------------------------------------------------------
364
+
365
+ def load_file(self, path):
366
+ """Load a STEPSS data file, replacing any previously loaded network."""
367
+ self._check(self._lib.helios_load_file(self._h, str(path).encode("utf-8")),
368
+ "load_file(%s)" % path)
369
+
370
+ def solve(self):
371
+ """Run the power flow. Returns ``True`` if converged.
372
+
373
+ Raises :class:`HeliosError` on hard errors (no network loaded,
374
+ solver exception); mere non-convergence returns ``False``.
375
+ """
376
+ ret = self._check(self._lib.helios_solve(self._h), "solve")
377
+ return ret == 0
378
+
379
+ def reset(self):
380
+ """Restore the state saved at load and drop pending modifications."""
381
+ self._check(self._lib.helios_reset(self._h), "reset")
382
+
383
+ @property
384
+ def converged(self):
385
+ """``True`` when the last solve converged."""
386
+ return self._check(self._lib.helios_get_convergence_status(self._h),
387
+ "get_convergence_status") == 0
388
+
389
+ @property
390
+ def solver_status(self):
391
+ """Detailed :class:`SolverStatus` of the last solve."""
392
+ return SolverStatus(self._check(self._lib.helios_get_solver_status(self._h),
393
+ "get_solver_status"))
394
+
395
+ @property
396
+ def iterations(self):
397
+ """Iterations used by the last solve."""
398
+ return self._check(self._lib.helios_get_iteration_count(self._h),
399
+ "get_iteration_count")
400
+
401
+ @property
402
+ def max_mismatch(self):
403
+ """Largest remaining ``(mw, mvar)`` mismatches of the last solve."""
404
+ mw = ctypes.c_double()
405
+ mvar = ctypes.c_double()
406
+ self._check(self._lib.helios_get_max_mismatch(
407
+ self._h, ctypes.byref(mw), ctypes.byref(mvar)), "get_max_mismatch")
408
+ return (mw.value, mvar.value)
409
+
410
+ # -- solver options -------------------------------------------------------
411
+
412
+ def set_option(self, option, value):
413
+ """Set a solver :class:`Option`. Call after :meth:`load_file`
414
+ (loading overwrites options with the file's ``$PARAM`` values)."""
415
+ self._check(self._lib.helios_set_option(self._h, int(option), float(value)),
416
+ "set_option(%s)" % option)
417
+
418
+ def get_option(self, option):
419
+ """Return the current value of a solver :class:`Option`."""
420
+ value = ctypes.c_double()
421
+ self._check(self._lib.helios_get_option(self._h, int(option), ctypes.byref(value)),
422
+ "get_option(%s)" % option)
423
+ return value.value
424
+
425
+ # -- counts, names, lookup -------------------------------------------------
426
+
427
+ @property
428
+ def bus_count(self):
429
+ return self._check(self._lib.helios_get_bus_count(self._h), "get_bus_count")
430
+
431
+ @property
432
+ def branch_count(self):
433
+ return self._check(self._lib.helios_get_branch_count(self._h), "get_branch_count")
434
+
435
+ @property
436
+ def generator_count(self):
437
+ return self._check(self._lib.helios_get_generator_count(self._h),
438
+ "get_generator_count")
439
+
440
+ @property
441
+ def svc_count(self):
442
+ return self._check(self._lib.helios_get_svc_count(self._h), "get_svc_count")
443
+
444
+ @property
445
+ def zone_count(self):
446
+ return self._check(self._lib.helios_get_zone_count(self._h), "get_zone_count")
447
+
448
+ @property
449
+ def cut_count(self):
450
+ return self._check(self._lib.helios_get_cut_count(self._h), "get_cut_count")
451
+
452
+ def _names(self, count, getter, what):
453
+ buf = ctypes.create_string_buffer(NAME_MAX)
454
+ names = []
455
+ for i in range(count):
456
+ self._check(getter(self._h, i, buf, NAME_MAX), what)
457
+ names.append(buf.value.decode())
458
+ return names
459
+
460
+ def bus_names(self):
461
+ """Names of all buses, in element-index order."""
462
+ return self._names(self.bus_count, self._lib.helios_get_bus_name, "get_bus_name")
463
+
464
+ def branch_names(self):
465
+ """Names of all branches, in element-index order."""
466
+ return self._names(self.branch_count, self._lib.helios_get_branch_name,
467
+ "get_branch_name")
468
+
469
+ def generator_names(self):
470
+ """Names of all generators, in element-index order."""
471
+ return self._names(self.generator_count, self._lib.helios_get_generator_name,
472
+ "get_generator_name")
473
+
474
+ def svc_names(self):
475
+ """Names of all SVCs, in element-index order."""
476
+ return self._names(self.svc_count, self._lib.helios_get_svc_name, "get_svc_name")
477
+
478
+ def zone_names(self):
479
+ """Names of all zones, in element-index order."""
480
+ return self._names(self.zone_count, self._lib.helios_get_zone_name, "get_zone_name")
481
+
482
+ def cut_names(self):
483
+ """Names of all cuts, in element-index order."""
484
+ return self._names(self.cut_count, self._lib.helios_get_cut_name, "get_cut_name")
485
+
486
+ def find_bus(self, name):
487
+ """Element index of the named bus (raises if unknown)."""
488
+ return self._check(self._lib.helios_find_bus(self._h, name.encode("utf-8")),
489
+ "find_bus(%s)" % name)
490
+
491
+ def find_branch(self, name):
492
+ """Element index of the named branch (raises if unknown)."""
493
+ return self._check(self._lib.helios_find_branch(self._h, name.encode("utf-8")),
494
+ "find_branch(%s)" % name)
495
+
496
+ def find_generator(self, name):
497
+ """Element index of the named generator (raises if unknown)."""
498
+ return self._check(self._lib.helios_find_generator(self._h, name.encode("utf-8")),
499
+ "find_generator(%s)" % name)
500
+
501
+ # -- per-element queries ------------------------------------------------------
502
+
503
+ def get_bus_voltage(self, bus):
504
+ """Solved voltage of the named bus as ``(v_pu, angle_rad)``."""
505
+ v = ctypes.c_double()
506
+ angle = ctypes.c_double()
507
+ self._check(self._lib.helios_get_bus_voltage(
508
+ self._h, bus.encode("utf-8"), ctypes.byref(v), ctypes.byref(angle)),
509
+ "get_bus_voltage(%s)" % bus)
510
+ return (v.value, angle.value)
511
+
512
+ def get_branch_flow(self, branch):
513
+ """From-end flow of the named branch as ``(p_mw, q_mvar)``."""
514
+ p = ctypes.c_double()
515
+ q = ctypes.c_double()
516
+ self._check(self._lib.helios_get_branch_flow(
517
+ self._h, branch.encode("utf-8"), ctypes.byref(p), ctypes.byref(q)),
518
+ "get_branch_flow(%s)" % branch)
519
+ return (p.value, q.value)
520
+
521
+ def get_bus_info(self, bus):
522
+ """Full :class:`BusInfo` of the named bus."""
523
+ idx = self.find_bus(bus)
524
+ v = ctypes.c_double()
525
+ angle = ctypes.c_double()
526
+ btype = ctypes.c_int()
527
+ vnom = ctypes.c_double()
528
+ self._check(self._lib.helios_get_bus_info(
529
+ self._h, idx, ctypes.byref(v), ctypes.byref(angle),
530
+ ctypes.byref(btype), ctypes.byref(vnom)), "get_bus_info(%s)" % bus)
531
+ p = ctypes.c_double()
532
+ q = ctypes.c_double()
533
+ self._check(self._lib.helios_get_bus_load(
534
+ self._h, idx, ctypes.byref(p), ctypes.byref(q)), "get_bus_load(%s)" % bus)
535
+ return BusInfo(name=bus, v_pu=v.value, angle_rad=angle.value,
536
+ bus_type=BusType(btype.value), vnom_kv=vnom.value,
537
+ p_load_mw=p.value, q_load_mvar=q.value)
538
+
539
+ def get_branch_info(self, branch):
540
+ """Full :class:`BranchInfo` of the named branch."""
541
+ idx = self.find_branch(branch)
542
+ frm = ctypes.c_int()
543
+ to = ctypes.c_int()
544
+ status = ctypes.c_int()
545
+ is_trfo = ctypes.c_int()
546
+ snom = ctypes.c_double()
547
+ self._check(self._lib.helios_get_branch_info(
548
+ self._h, idx, ctypes.byref(frm), ctypes.byref(to), ctypes.byref(status),
549
+ ctypes.byref(is_trfo), ctypes.byref(snom)), "get_branch_info(%s)" % branch)
550
+ pf = ctypes.c_double()
551
+ qf = ctypes.c_double()
552
+ pt = ctypes.c_double()
553
+ qt = ctypes.c_double()
554
+ self._check(self._lib.helios_get_branch_flow_by_index(
555
+ self._h, idx, ctypes.byref(pf), ctypes.byref(qf),
556
+ ctypes.byref(pt), ctypes.byref(qt)), "get_branch_flow_by_index(%s)" % branch)
557
+ buses = self.bus_names()
558
+ return BranchInfo(name=branch,
559
+ from_bus=buses[frm.value] if frm.value >= 0 else "",
560
+ to_bus=buses[to.value] if to.value >= 0 else "",
561
+ closed=status.value == 1,
562
+ is_transformer=bool(is_trfo.value),
563
+ snom_mva=snom.value,
564
+ p_from_mw=pf.value, q_from_mvar=qf.value,
565
+ p_to_mw=pt.value, q_to_mvar=qt.value)
566
+
567
+ def get_generator_info(self, generator):
568
+ """Full :class:`GeneratorInfo` of the named generator."""
569
+ idx = self.find_generator(generator)
570
+ bus = ctypes.c_int()
571
+ status = ctypes.c_int()
572
+ p = ctypes.c_double()
573
+ q = ctypes.c_double()
574
+ v = ctypes.c_double()
575
+ self._check(self._lib.helios_get_generator_info(
576
+ self._h, idx, ctypes.byref(bus), ctypes.byref(status), ctypes.byref(p),
577
+ ctypes.byref(q), ctypes.byref(v)), "get_generator_info(%s)" % generator)
578
+ pmin = ctypes.c_double()
579
+ pmax = ctypes.c_double()
580
+ qmin = ctypes.c_double()
581
+ qmax = ctypes.c_double()
582
+ has_p = ctypes.c_int()
583
+ self._check(self._lib.helios_get_generator_limits(
584
+ self._h, idx, ctypes.byref(pmin), ctypes.byref(pmax), ctypes.byref(qmin),
585
+ ctypes.byref(qmax), ctypes.byref(has_p)), "get_generator_limits(%s)" % generator)
586
+ buses = self.bus_names()
587
+ return GeneratorInfo(name=generator,
588
+ bus=buses[bus.value] if bus.value >= 0 else "",
589
+ in_service=status.value == 1,
590
+ p_mw=p.value, q_mvar=q.value, v_setpoint_pu=v.value,
591
+ p_min_mw=pmin.value, p_max_mw=pmax.value,
592
+ q_min_mvar=qmin.value, q_max_mvar=qmax.value,
593
+ has_p_limits=bool(has_p.value))
594
+
595
+ def get_cut_flow(self, cut):
596
+ """Signed total ``(p_mw, q_mvar)`` flow through the named cut."""
597
+ idx = self._check(self._lib.helios_find_cut(self._h, cut.encode("utf-8")),
598
+ "find_cut(%s)" % cut)
599
+ p = ctypes.c_double()
600
+ q = ctypes.c_double()
601
+ self._check(self._lib.helios_get_cut_flow(
602
+ self._h, idx, ctypes.byref(p), ctypes.byref(q)), "get_cut_flow(%s)" % cut)
603
+ return (p.value, q.value)
604
+
605
+ # -- bulk NumPy getters ------------------------------------------------------
606
+
607
+ def _copy(self, count, fn, arrays, what):
608
+ """Call a bulk copy function, filling freshly allocated NumPy arrays."""
609
+ out = []
610
+ args = []
611
+ for kind in arrays:
612
+ arr = numpy.empty(count, dtype=numpy.float64 if kind == "d" else numpy.int32)
613
+ out.append(arr)
614
+ ptr_type = _c_double_p if kind == "d" else _c_int_p
615
+ args.append(arr.ctypes.data_as(ptr_type))
616
+ self._check(fn(self._h, *args, count), what)
617
+ return tuple(out)
618
+
619
+ def get_bus_voltages(self):
620
+ """All bus voltages as ``(v_pu, angle_rad)`` NumPy arrays."""
621
+ return self._copy(self.bus_count, self._lib.helios_copy_bus_voltages,
622
+ "dd", "copy_bus_voltages")
623
+
624
+ def get_bus_loads(self):
625
+ """All bus loads as ``(p_mw, q_mvar)`` NumPy arrays."""
626
+ return self._copy(self.bus_count, self._lib.helios_copy_bus_loads,
627
+ "dd", "copy_bus_loads")
628
+
629
+ def get_branch_flows(self):
630
+ """All branch flows as ``(p_from_mw, q_from_mvar, p_to_mw, q_to_mvar)`` arrays."""
631
+ return self._copy(self.branch_count, self._lib.helios_copy_branch_flows,
632
+ "dddd", "copy_branch_flows")
633
+
634
+ def get_branch_status(self):
635
+ """All branch statuses (1=closed, 0=open) as an int array."""
636
+ return self._copy(self.branch_count, self._lib.helios_copy_branch_status,
637
+ "i", "copy_branch_status")[0]
638
+
639
+ def get_generator_outputs(self):
640
+ """All generator outputs as ``(p_mw, q_mvar, status)`` arrays."""
641
+ return self._copy(self.generator_count, self._lib.helios_copy_generator_outputs,
642
+ "ddi", "copy_generator_outputs")
643
+
644
+ def get_svc_outputs(self):
645
+ """All SVC outputs as ``(q_mvar, status)`` arrays."""
646
+ return self._copy(self.svc_count, self._lib.helios_copy_svc_outputs,
647
+ "di", "copy_svc_outputs")
648
+
649
+ # -- network modification -------------------------------------------------
650
+
651
+ def trip_branch(self, name):
652
+ """Open the named branch (settle with :meth:`apply_changes`)."""
653
+ self._check(self._lib.helios_trip_branch(self._h, name.encode("utf-8")),
654
+ "trip_branch(%s)" % name)
655
+
656
+ def connect_branch(self, name):
657
+ """Close the named branch."""
658
+ self._check(self._lib.helios_connect_branch(self._h, name.encode("utf-8")),
659
+ "connect_branch(%s)" % name)
660
+
661
+ def trip_generator(self, name):
662
+ """Take the named generator out of service (slack refused)."""
663
+ self._check(self._lib.helios_trip_generator(self._h, name.encode("utf-8")),
664
+ "trip_generator(%s)" % name)
665
+
666
+ def connect_generator(self, name):
667
+ """Return the named generator to service in PV mode."""
668
+ self._check(self._lib.helios_connect_generator(self._h, name.encode("utf-8")),
669
+ "connect_generator(%s)" % name)
670
+
671
+ def trip_svc(self, name):
672
+ """Take the named SVC out of service."""
673
+ self._check(self._lib.helios_trip_svc(self._h, name.encode("utf-8")),
674
+ "trip_svc(%s)" % name)
675
+
676
+ def connect_svc(self, name):
677
+ """Return the named SVC to service in voltage-control mode."""
678
+ self._check(self._lib.helios_connect_svc(self._h, name.encode("utf-8")),
679
+ "connect_svc(%s)" % name)
680
+
681
+ def set_generator_voltage(self, name, v_pu):
682
+ """Set the voltage setpoint of the named generator."""
683
+ self._check(self._lib.helios_set_generator_voltage(
684
+ self._h, name.encode("utf-8"), float(v_pu)),
685
+ "set_generator_voltage(%s)" % name)
686
+
687
+ def set_generator_pq(self, name, q_mvar):
688
+ """Switch the named generator to PQ mode with the given Q output."""
689
+ self._check(self._lib.helios_set_generator_pq(
690
+ self._h, name.encode("utf-8"), float(q_mvar)),
691
+ "set_generator_pq(%s)" % name)
692
+
693
+ def change_generation(self, generator, delta_p_mw):
694
+ """Change the named generator's active output by *delta_p_mw* (a delta)."""
695
+ self._check(self._lib.helios_change_generation(
696
+ self._h, generator.encode("utf-8"), float(delta_p_mw)),
697
+ "change_generation(%s)" % generator)
698
+
699
+ def change_load(self, bus, delta_p_mw, delta_q_mvar=0.0):
700
+ """Change the named bus load by the given deltas."""
701
+ self._check(self._lib.helios_change_load(
702
+ self._h, bus.encode("utf-8"), float(delta_p_mw), float(delta_q_mvar)),
703
+ "change_load(%s)" % bus)
704
+
705
+ def set_load(self, bus, p_mw, q_mvar):
706
+ """Set the named bus load to absolute values (reads the current load
707
+ and applies the difference)."""
708
+ idx = self.find_bus(bus)
709
+ p0 = ctypes.c_double()
710
+ q0 = ctypes.c_double()
711
+ self._check(self._lib.helios_get_bus_load(
712
+ self._h, idx, ctypes.byref(p0), ctypes.byref(q0)), "get_bus_load(%s)" % bus)
713
+ self.change_load(bus, float(p_mw) - p0.value, float(q_mvar) - q0.value)
714
+
715
+ def change_shunt(self, bus, delta_b_mvar):
716
+ """Change the named bus shunt susceptance by *delta_b_mvar* (at 1 pu)."""
717
+ self._check(self._lib.helios_change_shunt(
718
+ self._h, bus.encode("utf-8"), float(delta_b_mvar)),
719
+ "change_shunt(%s)" % bus)
720
+
721
+ def change_zone_load(self, zone, delta_p_mw, delta_q_mvar=0.0):
722
+ """Distribute a load change over the named zone's buses by participation."""
723
+ self._check(self._lib.helios_change_zone_load(
724
+ self._h, zone.encode("utf-8"), float(delta_p_mw), float(delta_q_mvar)),
725
+ "change_zone_load(%s)" % zone)
726
+
727
+ def change_zone_generation(self, zone, delta_p_mw):
728
+ """Distribute a generation change over the named zone by participation."""
729
+ self._check(self._lib.helios_change_zone_generation(
730
+ self._h, zone.encode("utf-8"), float(delta_p_mw)),
731
+ "change_zone_generation(%s)" % zone)
732
+
733
+ def apply_changes(self):
734
+ """Settle pending modifications: connectivity check + redispatch +
735
+ re-solve. Returns ``True`` if the re-solve converged. The redispatch
736
+ summary (if any) is available via :attr:`last_error`."""
737
+ ret = self._check(self._lib.helios_apply_changes(self._h), "apply_changes")
738
+ return ret == 0
739
+
740
+ # -- file writers ------------------------------------------------------------
741
+
742
+ def write_dump(self, path):
743
+ """Write the current network as a re-loadable data file (DF)."""
744
+ self._check(self._lib.helios_write_dump(self._h, str(path).encode("utf-8")),
745
+ "write_dump(%s)" % path)
746
+
747
+ def write_voltrat(self, path):
748
+ """Write the operating-point voltages / LTC data (VT, LFRESV records)."""
749
+ self._check(self._lib.helios_write_voltrat(self._h, str(path).encode("utf-8")),
750
+ "write_voltrat(%s)" % path)
751
+
752
+ def write_matlab(self, path):
753
+ """Write the operating point and Y-bus as a MATLAB script (S)."""
754
+ self._check(self._lib.helios_write_matlab(self._h, str(path).encode("utf-8")),
755
+ "write_matlab(%s)" % path)
756
+
757
+ def write_diagram(self, template_svg, output_svg):
758
+ """Render an SVG one-line diagram from a placeholder template."""
759
+ self._check(self._lib.helios_write_diagram(
760
+ self._h, str(template_svg).encode("utf-8"), str(output_svg).encode("utf-8")),
761
+ "write_diagram(%s)" % template_svg)
762
+
763
+ # -- contingency analysis -------------------------------------------------------
764
+
765
+ def run_contingencies(self, file=None, branches=False, generators=False,
766
+ svcs=False, v_min=0.9, v_max=1.1, overload=100.0,
767
+ dv_max=999.0):
768
+ """Run contingency analysis and return a list of :class:`ContingencyResult`.
769
+
770
+ Pass either ``file`` (a Fortran-format contingency file) or one or
771
+ more of ``branches``/``generators``/``svcs`` for automatic N-1 over
772
+ those equipment classes. The base network state is unchanged
773
+ afterwards.
774
+
775
+ :param file: contingency file path, or ``None`` for N-1 mode
776
+ :param branches: N-1 mode — include in-service branches
777
+ :param generators: N-1 mode — include in-service generators (except slack)
778
+ :param svcs: N-1 mode — include in-service SVCs
779
+ :param v_min: acceptance band lower voltage, pu
780
+ :param v_max: acceptance band upper voltage, pu
781
+ :param overload: branch loading threshold, % of Snom
782
+ :param dv_max: max allowed voltage change vs base case, pu (999 = off)
783
+ """
784
+ if file is not None:
785
+ count = self._check(self._lib.helios_run_contingencies_file(
786
+ self._h, str(file).encode("utf-8"),
787
+ float(v_min), float(v_max), float(overload), float(dv_max)),
788
+ "run_contingencies_file(%s)" % file)
789
+ else:
790
+ if not (branches or generators or svcs):
791
+ raise HeliosError(
792
+ "run_contingencies: pass file= or enable at least one of "
793
+ "branches/generators/svcs")
794
+ count = self._check(self._lib.helios_run_contingencies_n1(
795
+ self._h, int(branches), int(generators), int(svcs),
796
+ float(v_min), float(v_max), float(overload), float(dv_max)),
797
+ "run_contingencies_n1")
798
+
799
+ results = []
800
+ name = ctypes.create_string_buffer(TEXT_MAX)
801
+ bmin = ctypes.create_string_buffer(NAME_MAX)
802
+ bmax = ctypes.create_string_buffer(NAME_MAX)
803
+ brload = ctypes.create_string_buffer(NAME_MAX)
804
+ viol = ctypes.create_string_buffer(TEXT_MAX)
805
+ for i in range(count):
806
+ self._check(self._lib.helios_get_contingency_name(
807
+ self._h, i, name, TEXT_MAX), "get_contingency_name")
808
+ converged = ctypes.c_int()
809
+ accepted = ctypes.c_int()
810
+ vmin = ctypes.c_double()
811
+ vmax = ctypes.c_double()
812
+ loading = ctypes.c_double()
813
+ self._check(self._lib.helios_get_contingency_result(
814
+ self._h, i, ctypes.byref(converged), ctypes.byref(accepted),
815
+ ctypes.byref(vmin), ctypes.byref(vmax), ctypes.byref(loading)),
816
+ "get_contingency_result")
817
+ self._check(self._lib.helios_get_contingency_extremes(
818
+ self._h, i, bmin, bmax, brload, NAME_MAX), "get_contingency_extremes")
819
+ violations = []
820
+ nviol = self._check(self._lib.helios_get_contingency_violation_count(self._h, i),
821
+ "get_contingency_violation_count")
822
+ for j in range(nviol):
823
+ self._check(self._lib.helios_get_contingency_violation(
824
+ self._h, i, j, viol, TEXT_MAX), "get_contingency_violation")
825
+ violations.append(viol.value.decode())
826
+ results.append(ContingencyResult(
827
+ name=name.value.decode(),
828
+ converged=bool(converged.value),
829
+ accepted=bool(accepted.value),
830
+ min_v_pu=vmin.value, max_v_pu=vmax.value,
831
+ min_v_bus=bmin.value.decode().strip(),
832
+ max_v_bus=bmax.value.decode().strip(),
833
+ max_loading_pct=loading.value,
834
+ max_loading_branch=brload.value.decode().strip(),
835
+ violations=violations))
836
+ return results