powerio
Parse, transform, and emit power system data.
parse returns a module whose value is the typed power system object
and whose diagnostics record what the parser found::
import powerio as pio
module = pio.parse("case9.m")
net = module.value
print(net.n_buses, net.base_mva) # 9 100.0
matpower = pio.emit(module, "matpower")
emitted = pio.emit(module, "psse", "case9.raw")
B = net.calc_bprime_matrix() # scipy.sparse, MATPOWER Bp
Y = net.calc_admittance_matrix() # complex csr, G + jB
G = net.to_networkx() # networkx.Graph keyed by bus id
PyPSA CSV folders carry static network topology. NetCDF and HDF5 time series are tracked in https://github.com/eigenergy/powerio/issues/107.
A source that defines a calculation parses to that calculation's typed value.
Use isinstance(module.value, ...) to branch on the result type.
import powerio and the base parse and emit paths require no
third party Python package. Matrix methods require SciPy and NumPy. Graph
methods require NetworkX. Install them with powerio[matrix],
powerio[graph], or powerio[all]. Missing extras raise ImportError.
1"""Parse, transform, and emit power system data. 2 3``parse`` returns a module whose ``value`` is the typed power system object 4and whose ``diagnostics`` record what the parser found:: 5 6 import powerio as pio 7 8 module = pio.parse("case9.m") 9 net = module.value 10 print(net.n_buses, net.base_mva) # 9 100.0 11 matpower = pio.emit(module, "matpower") 12 emitted = pio.emit(module, "psse", "case9.raw") 13 14 B = net.calc_bprime_matrix() # scipy.sparse, MATPOWER Bp 15 Y = net.calc_admittance_matrix() # complex csr, G + jB 16 G = net.to_networkx() # networkx.Graph keyed by bus id 17 18PyPSA CSV folders carry static network topology. NetCDF and HDF5 time series 19are tracked in https://github.com/eigenergy/powerio/issues/107. 20 21A source that defines a calculation parses to that calculation's typed value. 22Use ``isinstance(module.value, ...)`` to branch on the result type. 23 24``import powerio`` and the base parse and emit paths require no 25third party Python package. Matrix methods require SciPy and NumPy. Graph 26methods require NetworkX. Install them with ``powerio[matrix]``, 27``powerio[graph]``, or ``powerio[all]``. Missing extras raise ``ImportError``. 28""" 29 30from __future__ import annotations 31 32import importlib 33import io as _io 34import json as _json 35import operator as _operator 36import os as _os 37from collections import namedtuple 38from collections.abc import Mapping, Sequence 39from dataclasses import dataclass 40from typing import Any, Iterable, Optional, Union 41 42from . import _powerio 43from ._guard import guard as _guard 44from ._guard import guard_class as _guard_class 45from ._powerio import ( 46 ActivePower, 47 ApparentPower, 48 CalculationUpdate, 49 ComponentId, 50 Diagnostic, 51 NetworkUpdate, 52 OperatingPointUpdate, 53 PowerIODataError, 54 PowerIOError, 55 PowerIOParseError, 56 ReactivePower, 57 Residuals, 58 ScucActiveReserveZone, 59 ScucBranchSwitchingCost, 60 ScucContingency, 61 ScucDevice, 62 ScucDeviceOutputs, 63 ScucDevicePeriod, 64 ScucEnergyCostBlock, 65 ScucEnergyRequirement, 66 ScucInitialCommitment, 67 ScucInputs, 68 ScucNetworkOutputs, 69 ScucRampLimits, 70 ScucReactiveCapability, 71 ScucReactiveReserveZone, 72 ScucReserveCosts, 73 ScucReserveLimits, 74 ScucShunt, 75 ScucStartupCostAdjustment, 76 ScucStartupLimit, 77 ScucTransformerControl, 78 ScucViolationCosts, 79 SourceSpan, 80 UpdateChange, 81 UpdateReport, 82 __version__, 83) 84 85__all__ = [ 86 "AcOpfInstance", 87 "AcOpfSolution", 88 "AcPfInstance", 89 "AcPfSolution", 90 "AcScucInstance", 91 "AcScucSolution", 92 "ActivePower", 93 "ApparentPower", 94 "Artifact", 95 "BalancedNetwork", 96 "CalculationUpdate", 97 "ComponentId", 98 "DcOpfInstance", 99 "DcOpfSolution", 100 "DcPfInstance", 101 "DcPfSolution", 102 "Diagnostic", 103 "DisplayData", 104 "EmitResult", 105 "FormatInfo", 106 "GeoLayer", 107 "McAcOpfInstance", 108 "McAcOpfSolution", 109 "McAcPfInstance", 110 "McAcPfSolution", 111 "MulticonductorNetwork", 112 "NetworkUpdate", 113 "OperatingPoint", 114 "OperatingPointUpdate", 115 "PioModule", 116 "PowerIODataError", 117 "PowerIOError", 118 "PowerIOParseError", 119 "PwdDisplay", 120 "PwdSubstation", 121 "ReactivePower", 122 "Residuals", 123 "Scenario", 124 "ScenarioSet", 125 "ScucActiveReserveZone", 126 "ScucBranchSwitchingCost", 127 "ScucContingency", 128 "ScucDevice", 129 "ScucDeviceOutputs", 130 "ScucDevicePeriod", 131 "ScucEnergyCostBlock", 132 "ScucEnergyRequirement", 133 "ScucInitialCommitment", 134 "ScucInputs", 135 "ScucNetworkOutputs", 136 "ScucRampLimits", 137 "ScucReactiveCapability", 138 "ScucReactiveReserveZone", 139 "ScucReserveCosts", 140 "ScucReserveLimits", 141 "ScucShunt", 142 "ScucStartupCostAdjustment", 143 "ScucStartupLimit", 144 "ScucTransformerControl", 145 "ScucViolationCosts", 146 "SocwrOpfSolution", 147 "SourceSpan", 148 "TimePoint", 149 "TimeSeries", 150 "UpdateChange", 151 "UpdateReport", 152 "__version__", 153 "apply_bus_load_active_power", 154 "apply_updates", 155 "deserialize", 156 "dist", 157 "emit", 158 "features", 159 "from_ppc", 160 "parse", 161 "parse_display", 162 "parse_geo", 163 "resolve_format", 164 "serialize", 165 "versions", 166] 167 168@dataclass(frozen=True) 169class Artifact: 170 """One artifact produced by :func:`emit` or :func:`serialize`. 171 172 ``data`` is set for an in-memory result. ``path`` is set after committing 173 to a filesystem destination. 174 """ 175 176 name: str 177 data: Optional[bytes] 178 path: Optional[str] 179 180 @property 181 def text(self) -> str: 182 """Decode an in-memory UTF-8 artifact.""" 183 if self.data is None: 184 raise ValueError("this artifact was committed to a destination") 185 return self.data.decode("utf-8") 186 187 188@dataclass(frozen=True) 189class EmitResult: 190 """Artifact inventory and diagnostics from an emission or serialization.""" 191 192 artifacts: tuple[Artifact, ...] 193 layout: str 194 fidelity: str 195 diagnostics: tuple[Diagnostic, ...] 196 197 @property 198 def text(self) -> Optional[str]: 199 """The sole UTF-8 memory artifact, or ``None`` for other inventories.""" 200 if len(self.artifacts) != 1 or self.artifacts[0].data is None: 201 return None 202 return self.artifacts[0].text 203 204FormatInfo = namedtuple( 205 "FormatInfo", ["token", "extension", "is_directory", "can_emit"] 206) 207FormatInfo.__doc__ = """Canonical metadata returned by :func:`resolve_format`. 208 209``extension`` is the conventional filename suffix without a leading dot; it 210can be compound and is ``None`` when a directory format has no primary case 211file. ``can_emit`` reports whether a fresh universal emitter exists for the 212format. It is not a promise for every concrete module value or a feature probe. A 213false value neither promises nor forbids a same format retained source echo. 214""" 215 216DisplayData = namedtuple("DisplayData", ["kind", "data"]) 217DisplayData.__doc__ = """Output of :func:`parse_display`. 218 219``kind`` names the display format. For PowerWorld PWD data, 220``kind == "powerworld"`` and 221``data`` is a :class:`PwdDisplay`. 222""" 223 224PwdDisplay = namedtuple( 225 "PwdDisplay", ["canvas_width", "canvas_height", "stamp", "substations"] 226) 227PwdDisplay.__doc__ = """Decoded PowerWorld ``.pwd`` display metadata.""" 228 229PwdSubstation = namedtuple("PwdSubstation", ["number", "name", "x", "y"]) 230PwdSubstation.__doc__ = """One decoded PowerWorld display substation.""" 231 232def _require(module: str, extra: str): 233 """Import ``module`` or raise a clear ImportError naming the extra to install.""" 234 try: 235 return importlib.import_module(module) 236 except ImportError as exc: 237 # Only rewrite "module is absent". A present-but-broken install (e.g. a 238 # failed C-extension load) raises ImportError from a sub-import; let its 239 # own traceback through instead of misdirecting the user to reinstall. 240 if getattr(exc, "name", None) not in (module, module.split(".")[0]): 241 raise 242 raise ImportError( 243 f"powerio needs {module!r} for this call; install it with " 244 f"`pip install 'powerio[{extra}]'`" 245 ) from exc 246 247 248def _to_csr(coo): 249 """Assemble a ``(data, row, col, shape)`` COO tuple into a csr_matrix.""" 250 sparse = _require("scipy.sparse", "matrix") 251 data, row, col, shape = coo 252 return sparse.coo_matrix((data, (row, col)), shape=shape).tocsr() 253 254 255def _dc_angles(n_buses: int, voltage_angles): 256 np = _require("numpy", "matrix") 257 angles = np.asarray(voltage_angles, dtype=float) 258 if angles.ndim != 1 or angles.shape[0] != n_buses: 259 raise ValueError( 260 f"voltage_angles must be a one dimensional array of length {n_buses}" 261 ) 262 return np, angles 263 264 265def _wrap_display(raw) -> DisplayData: 266 kind, payload = raw 267 if kind == "powerworld": 268 substations = [ 269 PwdSubstation( 270 row["number"], 271 row["name"], 272 row["x"], 273 row["y"], 274 ) 275 for row in payload["substations"] 276 ] 277 payload = PwdDisplay( 278 payload["canvas_width"], 279 payload["canvas_height"], 280 payload["stamp"], 281 substations, 282 ) 283 return DisplayData(kind, payload) 284 285 286_BALANCED_DELEGATED_NAMES = frozenset( 287 { 288 "areas", 289 "base_frequency", 290 "base_mva", 291 "branches", 292 "buses", 293 "detailed_connectivity", 294 "generators", 295 "hvdc", 296 "is_radial", 297 "loads", 298 "n_areas", 299 "n_branches", 300 "n_buses", 301 "n_generators", 302 "n_hvdc", 303 "n_islands", 304 "n_loads", 305 "n_shunts", 306 "n_static_var_compensators", 307 "n_storage", 308 "n_switches", 309 "n_transformers_3w", 310 "name", 311 "reference_bus_index", 312 "reference_bus_indices", 313 "shunts", 314 "static_var_compensators", 315 "source_format", 316 "storage", 317 "switches", 318 "transformers_3w", 319 } 320) 321 322 323@_guard_class 324class BalancedNetwork: 325 """A parsed balanced power network. 326 327 The data attributes (``buses``, ``branches``, ``generators``, ``loads``, 328 ``shunts``) and reference bus queries delegate to the compiled handle; the 329 matrix methods below return ``scipy.sparse`` objects. Parse and transform 330 diagnostics belong to the owning :class:`PioModule`. 331 332 Errors: a bad file path raises the standard ``OSError`` subclass 333 (``FileNotFoundError``); a malformed case raises :class:`PowerIOParseError` 334 and an unmet calculation precondition (no generators, no reference bus) raises 335 :class:`PowerIODataError`; both subclass :class:`PowerIOError`, so 336 ``except PowerIOError`` catches either; an unknown 337 ``scheme``/``formula``/``units`` string raises ``ValueError``. 338 """ 339 340 def __init__(self, inner: "_powerio._BalancedNetwork"): 341 self._inner = inner 342 343 def __dir__(self): 344 # The data attributes arrive through __getattr__, so name them here or 345 # they stay invisible to tab completion. 346 return sorted(set(super().__dir__()) | _BALANCED_DELEGATED_NAMES) 347 348 def __getattr__(self, name: str): 349 # Reached only when normal lookup misses, so the matrix methods below 350 # win. Guard underscore names so a lookup before _inner exists raises 351 # AttributeError instead of recursing forever. 352 if name not in _BALANCED_DELEGATED_NAMES: 353 raise AttributeError( 354 f"{type(self).__name__!r} object has no attribute {name!r}" 355 ) 356 return getattr(self._inner, name) 357 358 def __repr__(self) -> str: 359 # The inner handle's __repr__ already renders the public ``BalancedNetwork(...)`` 360 # form, so this is a straight delegate. 361 return repr(self._inner) 362 363 def calc_connectivity_report(self) -> dict[str, Any]: 364 """Calculate the in-service topology summary.""" 365 return self._inner.calc_connectivity_report() 366 367 def to_geo_layer(self) -> dict[str, Any]: 368 """Transform coordinates to a canonical GeoJSON FeatureCollection. 369 370 A case without coordinates produces an empty feature collection. 371 """ 372 return _json.loads(self._inner.to_geo_layer_json()) 373 374 def apply_geo_layer( 375 self, text: str, name_hint: Optional[str] = None 376 ) -> tuple["BalancedNetwork", dict[str, Any]]: 377 """Apply a geographic sidecar and return ``(placed, report)``. 378 379 ``text`` is any form :func:`parse_geo` accepts; this case is 380 unchanged. The report carries ``matched_buses``, ``matched_branches``, 381 ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``, 382 and ``notes``. The two unlocated counts cover the whole case when the 383 pass ends, so a layer that matched nothing reads apart from a case 384 that needed nothing. The placed copy drops the retained source text, 385 so a same-format emission re-serializes. 386 """ 387 inner, report = self._inner.apply_geo_layer(text, name_hint) 388 return BalancedNetwork(inner), report 389 390 # --- matrix calculations (scipy.sparse) ----------------------------- 391 392 def calc_bprime_matrix( 393 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 394 ): 395 """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 396 397 ``skip_zero_impedance=False`` refuses a zero impedance branch 398 (``r`` and ``x`` both zero); pass ``True`` to drop it instead. 399 """ 400 return _to_csr( 401 self._inner.bprime(scheme, skip_zero_impedance=skip_zero_impedance) 402 ) 403 404 def calc_incidence_matrix(self, formula: str = "series_susceptance"): 405 """Return PowerModels incidence ``A`` (branches by buses).""" 406 return _to_csr(self._inner.calc_incidence_matrix(formula)) 407 408 def calc_branch_susceptances(self, formula: str = "series_susceptance"): 409 """Return per branch susceptances in active branch order.""" 410 np = _require("numpy", "matrix") 411 return np.asarray(self._inner.calc_branch_susceptances(formula), dtype=float) 412 413 def calc_branch_flow_matrix(self, formula: str = "series_susceptance"): 414 """Return ``Bf = diag(b) A`` as a CSR matrix.""" 415 return _to_csr(self._inner.calc_branch_flow_matrix(formula)) 416 417 def calc_bus_susceptance_matrix(self, formula: str = "series_susceptance"): 418 """Return ``B = A.T diag(b) A`` as a CSR matrix.""" 419 return _to_csr(self._inner.calc_bus_susceptance_matrix(formula)) 420 421 def calc_branch_phase_shift_injection( 422 self, formula: str = "series_susceptance" 423 ): 424 """Return ``b * shift`` in active branch order.""" 425 np = _require("numpy", "matrix") 426 return np.asarray( 427 self._inner.calc_branch_phase_shift_injection(formula), dtype=float 428 ) 429 430 def calc_bus_phase_shift_injection(self, formula: str = "series_susceptance"): 431 """Return ``A.T @ (b * shift)`` in bus order.""" 432 np = _require("numpy", "matrix") 433 return np.asarray( 434 self._inner.calc_bus_phase_shift_injection(formula), dtype=float 435 ) 436 437 def calc_branch_flow_dc(self, voltage_angles, formula: str = "series_susceptance"): 438 """Compute ``-Bf @ va + b * shift`` in active branch order.""" 439 np, angles = _dc_angles(self.n_buses, voltage_angles) 440 return np.asarray( 441 self._inner.calc_branch_flow_dc(angles.tolist(), formula), dtype=float 442 ) 443 444 def calc_bus_injection_dc( 445 self, voltage_angles, formula: str = "series_susceptance" 446 ): 447 """Compute ``-B @ va + p_shift`` in bus order.""" 448 np, angles = _dc_angles(self.n_buses, voltage_angles) 449 return np.asarray( 450 self._inner.calc_bus_injection_dc(angles.tolist(), formula), dtype=float 451 ) 452 453 def calc_bdoubleprime_matrix( 454 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 455 ): 456 """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 457 ``skip_zero_impedance`` as in :meth:`calc_bprime_matrix`. 458 """ 459 return _to_csr( 460 self._inner.bdoubleprime(scheme, skip_zero_impedance=skip_zero_impedance) 461 ) 462 463 def calc_lacpf_matrix( 464 self, 465 *, 466 include_taps: bool = True, 467 include_shifts: bool = True, 468 skip_zero_impedance: bool = False, 469 ): 470 """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``. ``skip_zero_impedance`` 471 as in :meth:`calc_bprime_matrix`.""" 472 return _to_csr( 473 self._inner.lacpf( 474 include_taps=include_taps, 475 include_shifts=include_shifts, 476 skip_zero_impedance=skip_zero_impedance, 477 ) 478 ) 479 480 def calc_adjacency_matrix(self): 481 """0/1 bus adjacency matrix.""" 482 return _to_csr(self._inner.adjacency()) 483 484 def calc_admittance_matrix( 485 self, 486 *, 487 include_taps: bool = True, 488 include_shifts: bool = True, 489 skip_zero_impedance: bool = False, 490 ): 491 """``Y_bus = G + jB`` as a complex csr_matrix. ``skip_zero_impedance`` 492 as in :meth:`calc_bprime_matrix`.""" 493 g, b = self._inner.ybus_parts( 494 include_taps=include_taps, 495 include_shifts=include_shifts, 496 skip_zero_impedance=skip_zero_impedance, 497 ) 498 g, b = _to_csr(g), _to_csr(b) 499 return (g + 1j * b).tocsr() 500 501 def calc_ptdf(self, formula: str = "series_susceptance", solver: str = "auto"): 502 """DC PTDF (m×n). ``formula`` is ``"series_susceptance"``, 503 ``"tap_adjusted_reactance"``, or ``"reactance_only"``. 504 505 ``solver`` is ``"auto"``, ``"dense"``, or ``"sparse"``. ``"auto"`` 506 uses the dense factorization on small cases and the sparse Cholesky 507 path on large ones, the same policy as the CLI. 508 """ 509 return _to_csr(self._inner.ptdf(formula, solver)) 510 511 def calc_lodf(self, formula: str = "series_susceptance", solver: str = "auto"): 512 """DC LODF (m×m). ``formula`` and ``solver`` as in :meth:`calc_ptdf`.""" 513 return _to_csr(self._inner.lodf(formula, solver)) 514 515 def calc_weighted_laplacian( 516 self, 517 formula: str = "series_susceptance", 518 ): 519 """Weighted Laplacian ``L = -B``. ``formula`` as in :meth:`calc_ptdf`.""" 520 return _to_csr(self._inner.weighted_laplacian(formula)) 521 522 def to_normalized( 523 self, 524 *, 525 clamp_angle_bounds: bool = False, 526 angle_bound_pad: Optional[float] = None, 527 ) -> "BalancedNetwork": 528 """Return a normalized copy with per unit power and radian angles. 529 530 The result removes out of service elements, preserves source bus IDs, 531 and normalizes bus types. It carries no retained source, so 532 :func:`powerio.emit` produces a grid exchange representation from the 533 derived module. Raises 534 :class:`PowerIODataError` if the network cannot be 535 normalized (no reference bus can be chosen, or a non-positive base MVA). 536 537 ``clamp_angle_bounds=True`` applies the PowerModels angle difference 538 bound repair: limits at or beyond ``+/-pi/2`` and zero/zero windows 539 become ``[-angle_bound_pad, angle_bound_pad]``. A repair that would 540 invert the interval widens to that same window. The default pad is 541 1.0472 radians. 542 """ 543 if not clamp_angle_bounds and angle_bound_pad is None: 544 return BalancedNetwork(self._inner.to_normalized()) 545 return BalancedNetwork( 546 self._inner.to_normalized_with_options( 547 clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad 548 ) 549 ) 550 551 def to_ppc(self): 552 """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables. 553 554 Values are emitted as the model holds them, so a case read from a 555 file carries MW, MVAr, and degrees. A network from 556 :meth:`to_normalized` holds per unit and radians, and those are what 557 its tables carry — PYPOWER reads a ppc dict as MW and degrees, so 558 build this from the raw network unless the consumer expects per unit. 559 560 Loads and shunts are summed onto their bus in the 561 ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation as the 562 MATPOWER emitter. The bus table has no per element status 563 column, so an element the model marks out of service still 564 contributes its value, and a de-energized bus is carried as type 4. 565 ``gencost`` is present only when every generator carries cost data, 566 because MATPOWER requires cost rows for all generators or none. 567 :func:`from_ppc` reads the tables back. 568 """ 569 np = _require("numpy", "matrix") 570 buses = self._inner.buses 571 bus = np.array( 572 [ 573 ( 574 b["id"], 575 _PPC_BUS_TYPE.get(b["kind"], 1.0), 576 0.0, 577 0.0, 578 0.0, 579 0.0, 580 b["area"], 581 b["vm"], 582 b["va"], 583 b["base_kv"], 584 b["zone"], 585 b["vmax"], 586 b["vmin"], 587 ) 588 for b in buses 589 ], 590 dtype=float, 591 ).reshape(len(buses), 13) 592 bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums( 593 np, buses, self._inner.loads, self._inner.shunts 594 ) 595 596 # The capability and ramp columns past PMIN are an OPF extension that a 597 # source need not carry. Widen to the full 21 only when a generator 598 # actually states one: a table of zeros there reads back as eleven 599 # explicit zero limits, which a ramp aware solver takes as a generator 600 # that cannot move. 601 gens = self._inner.generators 602 caps = [g["caps"] for g in gens] 603 width = 21 if any(c is not None for row in caps for c in row) else 10 604 gen = np.array( 605 [ 606 [ 607 g["bus"], 608 g["pg"], 609 g["qg"], 610 g["qmax"], 611 g["qmin"], 612 g["vg"], 613 g["mbase"], 614 float(g["in_service"]), 615 g["pmax"], 616 g["pmin"], 617 ] 618 + ([0.0 if c is None else c for c in row] if width == 21 else []) 619 for g, row in zip(gens, caps) 620 ], 621 dtype=float, 622 ).reshape(len(gens), width) 623 624 branches = self._inner.branches 625 branch = np.array( 626 [ 627 ( 628 br["from_id"], 629 br["to_id"], 630 br["r"], 631 br["x"], 632 br["b"], 633 br["rate_a"], 634 br["rate_b"], 635 br["rate_c"], 636 br["tap"], 637 br["shift"], 638 float(br["in_service"]), 639 br["angmin"], 640 br["angmax"], 641 ) 642 for br in branches 643 ], 644 dtype=float, 645 ).reshape(len(branches), 13) 646 647 ppc = { 648 "version": "2", 649 "baseMVA": float(self._inner.base_mva), 650 "bus": bus, 651 "gen": gen, 652 "branch": branch, 653 } 654 655 # Coefficients sit left-aligned after ncost, padded to the widest 656 # row, which is the layout PYPOWER's own loadcase produces. 657 costs = [g["cost"] for g in gens] 658 if costs and all(c is not None for c in costs): 659 gencost = np.zeros((len(costs), 4 + max(len(c["coeffs"]) for c in costs))) 660 for i, c in enumerate(costs): 661 gencost[i, :4] = ( 662 c["model"], 663 c["startup"], 664 c["shutdown"], 665 c["ncost"], 666 ) 667 gencost[i, 4 : 4 + len(c["coeffs"])] = c["coeffs"] 668 ppc["gencost"] = gencost 669 return ppc 670 671 def to_networkx(self): 672 """Undirected networkx graph keyed by bus id. 673 674 In-service branches become edges carrying ``branch`` (index), ``r``, 675 ``x``, and ``b``. 676 """ 677 nx = _require("networkx", "graph") 678 g = nx.Graph() 679 g.add_nodes_from(bus["id"] for bus in self._inner.buses) 680 for k, br in enumerate(self._inner.branches): 681 if br["in_service"]: 682 g.add_edge( 683 br["from_id"], 684 br["to_id"], 685 branch=k, 686 r=br["r"], 687 x=br["x"], 688 b=br["b"], 689 ) 690 return g 691 692 693@_guard 694def parse_display(path: Any, format: Optional[str] = None) -> DisplayData: 695 """Parse a display artifact such as a PowerWorld ``.pwd`` file.""" 696 return _wrap_display(_powerio.parse_display(str(path), format)) 697 698 699@_guard 700def resolve_format(name: str) -> Optional[FormatInfo]: 701 """Resolve a format token or common alias to its canonical metadata.""" 702 resolved = _powerio.resolve_format(name) 703 return None if resolved is None else FormatInfo(*resolved) 704 705 706@_guard 707def parse_geo(text: str, name_hint: Optional[str] = None) -> dict[str, Any]: 708 """Tolerantly read a geographic sidecar and return its canonical form. 709 710 Accepts headerless buscoords CSV, aliased CSV/JSON records, and GeoJSON 711 Point/LineString features. Returns ``{"geojson": <FeatureCollection dict>, 712 "diagnostics": [...]}``; ``name_hint`` (a file name) picks CSV against JSON 713 when the content alone is ambiguous. Input with no usable coordinates 714 raises :class:`PowerIOParseError`. 715 """ 716 parsed = _powerio.parse_geo(text, name_hint) 717 parsed["geojson"] = _json.loads(parsed["geojson"]) 718 return parsed 719 720 721# powerio bus kind -> MATPOWER/PYPOWER BUS_TYPE code. 722def _bus_sums(np, buses, loads, shunts): 723 """Per bus `(pd, qd, gs, bs)` in bus order. 724 725 :meth:`BalancedNetwork.to_ppc` folds the element 726 tables onto their bus the way the Rust indexed analysis view does. This is 727 that fold, once. 728 """ 729 row_of = {b["id"]: i for i, b in enumerate(buses)} 730 pd, qd, gs, bs = (np.zeros(len(buses), dtype=float) for _ in range(4)) 731 for load in loads: 732 i = row_of.get(load["bus"]) 733 if i is not None: 734 pd[i] += load["p"] 735 qd[i] += load["q"] 736 for shunt in shunts: 737 i = row_of.get(shunt["bus"]) 738 if i is not None: 739 gs[i] += shunt["g"] 740 bs[i] += shunt["b"] 741 return pd, qd, gs, bs 742 743 744_PPC_BUS_TYPE = {"PQ": 1.0, "PV": 2.0, "REF": 3.0, "ISOLATED": 4.0} 745 746# MATPOWER case-input table widths. PYPOWER result tables append columns 747# (LAM_P, MU_*) past these; from_ppc drops them. 748_PPC_INPUT_WIDTH = {"bus": 13, "gen": 21, "branch": 13} 749 750# Columns a table must carry, which is what the MATPOWER reader requires. The 751# gen table's capability and ramp columns are an OPF extension, so a 10 column 752# gen table is a complete case and passes through at its own width; padding it 753# would hand the reader eleven explicit zero limits the source never stated. A 754# bus or branch row below 13 is truncated data, and zero padding it would 755# invent a bus at 0 p.u. and 0 kV, so it is refused here as the reader refuses 756# it in a `.m` file. 757_PPC_MIN_WIDTH = {"bus": 13, "gen": 10, "branch": 13} 758 759 760def _ppc_rows(name, table): 761 """The table's rows as float lists, trimmed to the MATPOWER input width.""" 762 width = _PPC_INPUT_WIDTH.get(name) 763 minimum = _PPC_MIN_WIDTH.get(name) 764 out = [] 765 for i, row in enumerate(table): 766 try: 767 vals = [float(v) for v in row] 768 except TypeError as e: 769 raise ValueError( 770 f"ppc table {name!r} row {i} is not a sequence of numbers: " 771 f"pass a 2-D array, one row per element" 772 ) from e 773 except ValueError as e: 774 raise ValueError( 775 f"ppc table {name!r} row {i} has a non-numeric value: {e}" 776 ) from e 777 if minimum is not None and len(vals) < minimum: 778 raise ValueError( 779 f"ppc table {name!r} row {i} has {len(vals)} columns; " 780 f"MATPOWER requires at least {minimum}" 781 ) 782 out.append(vals[:width] if width is not None else vals) 783 return out 784 785 786def _ppc_to_matpower_text(ppc) -> str: 787 missing = [k for k in ("baseMVA", "bus", "gen", "branch") if k not in ppc] 788 if missing: 789 raise ValueError(f"ppc dict is missing required keys: {missing}") 790 lines = [ 791 "function mpc = from_ppc", 792 f"mpc.version = '{ppc.get('version', '2')}';", 793 f"mpc.baseMVA = {float(ppc['baseMVA'])!r};", 794 ] 795 names = ["bus", "gen", "branch"] + (["gencost"] if "gencost" in ppc else []) 796 for name in names: 797 rows = _ppc_rows(name, ppc[name]) 798 lines.append(f"mpc.{name} = [") 799 for vals in rows: 800 lines.append(" " + " ".join(repr(v) for v in vals) + ";") 801 lines.append("];") 802 return "\n".join(lines) + "\n" 803 804 805@_guard 806def from_ppc(ppc) -> BalancedNetwork: 807 """Case from a PYPOWER dict (``ppc``); the inverse of :meth:`BalancedNetwork.to_ppc`. 808 809 The tables route through the MATPOWER reader, so the semantics match a 810 ``.m`` case exactly: bus ``PD``/``QD`` become loads, ``GS``/``BS`` become 811 shunts, and ``gencost`` is read when present. Result columns past the 812 MATPOWER input widths are dropped. A 10 column ``gen`` table (the layout 813 without the OPF capability columns) passes through at its own width, so 814 the generators come back with no capability limits rather than eleven 815 zero ones. Raises :class:`ValueError` when a required table is absent, 816 when a ``bus`` or ``branch`` row is below its 13 column width, when a row 817 is not a sequence of numbers, or when a cell is not numeric; the message 818 names the table and the row. 819 """ 820 value = parse( 821 _io.StringIO(_ppc_to_matpower_text(ppc)), 822 format="matpower", 823 name="from_ppc.m", 824 ).value 825 assert isinstance(value, BalancedNetwork) 826 return value 827 828 829from . import dist # noqa: E402 (needs EmitResult defined above) 830 831MulticonductorNetwork = dist.MulticonductorNetwork 832 833 834@_guard 835def versions() -> Any: 836 """Return the PowerIO release, sole IR identity, and BMOPF schema.""" 837 return _json.loads(_powerio.versions_json()) 838 839 840class _TypedValue: 841 """Typed view rooted in its owning :class:`PioModule`.""" 842 843 __slots__ = ("module", "_collection_entry") 844 845 def __init__(self, module: "PioModule") -> None: 846 self.module = module 847 self._collection_entry = None 848 849 def __repr__(self) -> str: 850 return f"{type(self).__name__}()" 851 852 853@dataclass(frozen=True) 854class TimePoint: 855 label: str 856 duration_seconds: Optional[float] = None 857 858 859@dataclass(frozen=True) 860class Scenario: 861 id: str 862 probability: Optional[float] = None 863 864 865@dataclass(frozen=True) 866class _CollectionEntry: 867 root: "PioModule" 868 time_index: Optional[int] = None 869 scenario_id: Optional[str] = None 870 871 872def _bind_collection_entry( 873 value: Any, 874 location: _CollectionEntry, 875) -> Any: 876 value._collection_entry = location 877 return value 878 879 880@_guard_class 881class TimeSeries(_TypedValue, Sequence): 882 """Values of one type ordered in time.""" 883 884 def __init__( 885 self, 886 values: Sequence[Any], 887 *, 888 time_points: Sequence[TimePoint], 889 ) -> None: 890 if isinstance(values, (str, bytes, bytearray)) or not isinstance( 891 values, Sequence 892 ): 893 raise TypeError("TimeSeries values must be a sequence of PowerIO values") 894 if not isinstance(time_points, Sequence): 895 raise TypeError("time_points must be a sequence of TimePoint values") 896 points = tuple(time_points) 897 if not all(isinstance(point, TimePoint) for point in points): 898 raise TypeError("time_points must contain only TimePoint values") 899 modules = [PioModule.from_value(value)._inner for value in values] 900 inner = _powerio._PioModule._from_time_series( 901 modules, 902 [(point.label, point.duration_seconds) for point in points], 903 ) 904 super().__init__(PioModule(inner)) 905 906 @classmethod 907 def _from_module(cls, module: "PioModule") -> "TimeSeries": 908 value = object.__new__(cls) 909 _TypedValue.__init__(value, module) 910 return value 911 912 @property 913 def time_points(self) -> tuple[TimePoint, ...]: 914 return tuple(TimePoint(*point) for point in self.module._inner._time_series_points()) 915 916 def __len__(self) -> int: 917 return self.module._inner._time_series_len() 918 919 def __getitem__(self, index): 920 if isinstance(index, slice): 921 return [self[position] for position in range(*index.indices(len(self)))] 922 try: 923 position = _operator.index(index) 924 except TypeError: 925 raise TypeError("time series indices must be integers") from None 926 if position < 0: 927 position += len(self) 928 if position < 0 or position >= len(self): 929 raise IndexError("time series index out of range") 930 current = self._collection_entry or _CollectionEntry(self.module) 931 if current.time_index is not None: 932 raise TypeError("nested TimeSeries values are not supported") 933 value = PioModule(self.module._inner._time_series_get(position)).value 934 return _bind_collection_entry( 935 value, 936 _CollectionEntry( 937 root=current.root, 938 time_index=position, 939 scenario_id=current.scenario_id, 940 ), 941 ) 942 943 def __iter__(self): 944 return (self[position] for position in range(len(self))) 945 946 947@_guard_class 948class ScenarioSet(_TypedValue, Mapping): 949 """Named alternatives of one type, with optional probabilities.""" 950 951 def __init__( 952 self, 953 values: Mapping[str, Any], 954 *, 955 probabilities: Optional[Mapping[str, float]] = None, 956 ) -> None: 957 if not isinstance(values, Mapping): 958 raise TypeError("ScenarioSet values must be a mapping from IDs to values") 959 if probabilities is not None and not isinstance(probabilities, Mapping): 960 raise TypeError("probabilities must be a mapping from scenario IDs to numbers") 961 ids = list(values) 962 modules = [PioModule.from_value(values[id])._inner for id in ids] 963 inner = _powerio._PioModule._from_scenario_set( 964 modules, 965 ids, 966 None if probabilities is None else dict(probabilities), 967 ) 968 super().__init__(PioModule(inner)) 969 970 @classmethod 971 def _from_module(cls, module: "PioModule") -> "ScenarioSet": 972 value = object.__new__(cls) 973 _TypedValue.__init__(value, module) 974 return value 975 976 @property 977 def scenarios(self) -> tuple[Scenario, ...]: 978 return tuple(Scenario(*entry) for entry in self.module._inner._scenario_entries()) 979 980 def __len__(self) -> int: 981 return len(self.scenarios) 982 983 def __iter__(self): 984 return (scenario.id for scenario in self.scenarios) 985 986 def __contains__(self, scenario: object) -> bool: 987 return isinstance(scenario, str) and any( 988 entry.id == scenario for entry in self.scenarios 989 ) 990 991 def __getitem__(self, scenario: str) -> Any: 992 if not isinstance(scenario, str): 993 raise TypeError("scenario keys must be strings") 994 if scenario not in self: 995 raise KeyError(scenario) 996 current = self._collection_entry or _CollectionEntry(self.module) 997 if current.scenario_id is not None: 998 raise TypeError("nested ScenarioSet values are not supported") 999 value = PioModule(self.module._inner._scenario_get(scenario)).value 1000 return _bind_collection_entry( 1001 value, 1002 _CollectionEntry( 1003 root=current.root, 1004 time_index=current.time_index, 1005 scenario_id=scenario, 1006 ), 1007 ) 1008 1009 1010@_guard_class 1011class OperatingPoint(_TypedValue): 1012 """A possibly partial assignment over fixed equipment identities.""" 1013 1014 1015@_guard_class 1016class _BalancedCalculation(_TypedValue): 1017 """A calculation over one shared balanced network.""" 1018 1019 @property 1020 def network(self) -> BalancedNetwork: 1021 """The balanced network used by this calculation.""" 1022 return BalancedNetwork(self.module._inner._balanced_calculation_network()) 1023 1024 1025@_guard_class 1026class _MulticonductorCalculation(_TypedValue): 1027 """A calculation over one shared multiconductor network.""" 1028 1029 @property 1030 def network(self) -> MulticonductorNetwork: 1031 """The multiconductor network used by this calculation.""" 1032 return MulticonductorNetwork( 1033 self.module._inner._multiconductor_calculation_network() 1034 ) 1035 1036 1037@_guard_class 1038class _CalculationSolution(_TypedValue): 1039 """A solution that retains the exact typed instance it solves.""" 1040 1041 @property 1042 def instance(self) -> _TypedValue: 1043 """The calculation instance solved by this result.""" 1044 return PioModule(self.module._inner._calculation_solution_instance()).value 1045 1046 1047class DcPfInstance(_BalancedCalculation): 1048 """A DC power flow calculation instance.""" 1049 1050 1051class AcPfInstance(_BalancedCalculation): 1052 """An AC power flow calculation instance.""" 1053 1054 1055class DcOpfInstance(_BalancedCalculation): 1056 """A DC optimal power flow calculation instance.""" 1057 1058 1059class AcOpfInstance(_BalancedCalculation): 1060 """An AC optimal power flow calculation instance.""" 1061 1062 1063class McAcPfInstance(_MulticonductorCalculation): 1064 """A multiconductor AC power flow calculation instance.""" 1065 1066 1067class McAcOpfInstance(_MulticonductorCalculation): 1068 """A multiconductor AC optimal power flow calculation instance.""" 1069 1070 1071@_guard_class 1072class AcScucInstance(_BalancedCalculation): 1073 """An AC security constrained unit commitment calculation instance.""" 1074 1075 @property 1076 def inputs(self) -> ScucInputs: 1077 """Scheduling, reserve, and contingency inputs.""" 1078 return self.module._inner._ac_scuc_inputs() 1079 1080 1081class DcPfSolution(_BalancedCalculation, _CalculationSolution): 1082 """A DC power flow solution.""" 1083 1084 1085class AcPfSolution(_BalancedCalculation, _CalculationSolution): 1086 """An AC power flow solution.""" 1087 1088 1089class DcOpfSolution(_BalancedCalculation, _CalculationSolution): 1090 """A DC optimal power flow solution.""" 1091 1092 1093class AcOpfSolution(_BalancedCalculation, _CalculationSolution): 1094 """An AC optimal power flow solution.""" 1095 1096 1097class SocwrOpfSolution(_BalancedCalculation, _CalculationSolution): 1098 """A PowerModels SOCWR relaxation solution and objective lower bound.""" 1099 1100 1101class McAcPfSolution(_MulticonductorCalculation, _CalculationSolution): 1102 """A multiconductor AC power flow solution.""" 1103 1104 1105class McAcOpfSolution(_MulticonductorCalculation, _CalculationSolution): 1106 """A multiconductor AC optimal power flow solution.""" 1107 1108 1109@_guard_class 1110class AcScucSolution(_BalancedCalculation, _CalculationSolution): 1111 """An AC security constrained unit commitment solution.""" 1112 1113 @property 1114 def termination(self) -> str: 1115 """How the calculation ended.""" 1116 return self.module._inner._ac_scuc_solution_termination() 1117 1118 @property 1119 def residuals(self) -> Residuals: 1120 """Reported active and reactive power balance residuals.""" 1121 return self.module._inner._ac_scuc_solution_residuals() 1122 1123 @property 1124 def producer(self) -> Optional[str]: 1125 """Producer or solver identity, when recorded.""" 1126 return self.module._inner._ac_scuc_solution_producer() 1127 1128 @property 1129 def network_outputs(self) -> ScucNetworkOutputs: 1130 """Per interval network outputs.""" 1131 return self.module._inner._ac_scuc_solution_network_outputs() 1132 1133 @property 1134 def device_outputs(self) -> ScucDeviceOutputs: 1135 """Per interval dispatchable device outputs.""" 1136 return self.module._inner._ac_scuc_solution_device_outputs() 1137 1138 @property 1139 def objective(self) -> Optional[float]: 1140 """Reported objective value, when present.""" 1141 return self.module._inner._ac_scuc_solution_objective() 1142 1143 1144@_guard_class 1145class GeoLayer(_TypedValue): 1146 """A standalone geographic document: element points and routes keyed by 1147 element identity, in one coordinate space. 1148 1149 :func:`parse` returns it for the canonical ``.geo.json``, GeoJSON, aliased 1150 CSV or JSON records, headerless buscoords CSV, and a PowerWorld ``.pwd`` 1151 display. :meth:`PioModule.emit` writes the canonical document as 1152 ``geo-json``, and :func:`serialize` carries the layer through PowerIO IR. 1153 Place a layer onto a case with 1154 ``network.apply_geo_layer(layer.geojson)``. 1155 """ 1156 1157 @property 1158 def geojson(self) -> str: 1159 """The canonical ``.geo.json`` document for this layer.""" 1160 result = emit(self.module, "geo-json") 1161 data = result.artifacts[0].data 1162 if data is None: 1163 raise ValueError("the layer emission returned no artifact bytes") 1164 return data.decode("utf-8") 1165 1166 1167 1168_VALUE_CLASSES: dict[str, type[_TypedValue]] = { 1169 "powerio.GeoLayer": GeoLayer, 1170 "powerio.OperatingPoint<powerio.BalancedNetwork>": OperatingPoint, 1171 "powerio.OperatingPoint<powerio.MulticonductorNetwork>": OperatingPoint, 1172 "powerio.DcPfInstance": DcPfInstance, 1173 "powerio.AcPfInstance": AcPfInstance, 1174 "powerio.DcOpfInstance": DcOpfInstance, 1175 "powerio.AcOpfInstance": AcOpfInstance, 1176 "powerio.McAcPfInstance": McAcPfInstance, 1177 "powerio.McAcOpfInstance": McAcOpfInstance, 1178 "powerio.AcScucInstance": AcScucInstance, 1179 "powerio.DcPfSolution": DcPfSolution, 1180 "powerio.AcPfSolution": AcPfSolution, 1181 "powerio.DcOpfSolution": DcOpfSolution, 1182 "powerio.AcOpfSolution": AcOpfSolution, 1183 "powerio.SocwrOpfSolution": SocwrOpfSolution, 1184 "powerio.McAcPfSolution": McAcPfSolution, 1185 "powerio.McAcOpfSolution": McAcOpfSolution, 1186 "powerio.AcScucSolution": AcScucSolution, 1187} 1188 1189 1190@_guard_class 1191class PioModule: 1192 """One typed value with diagnostics, producer, sources, source mappings, 1193 history, and extensions. 1194 """ 1195 1196 def __init__(self, inner: "_powerio._PioModule"): 1197 self._inner = inner 1198 1199 @classmethod 1200 def from_value(cls, value: Any) -> "PioModule": 1201 """Wrap an existing typed value without serializing it.""" 1202 if isinstance(value, BalancedNetwork): 1203 return cls(_powerio._PioModule.from_balanced_network(value._inner)) 1204 if isinstance(value, dist.MulticonductorNetwork): 1205 return cls(_powerio._PioModule.from_multiconductor_network(value._inner)) 1206 if isinstance(value, _TypedValue): 1207 location = value._collection_entry 1208 inner = value.module._inner 1209 if location is not None: 1210 inner = location.root._inner 1211 if location.scenario_id is not None: 1212 inner = inner._scenario_get(location.scenario_id) 1213 if location.time_index is not None: 1214 inner = inner._time_series_get(location.time_index) 1215 return cls(inner._copy()) 1216 raise TypeError("PioModule.from_value expects a typed PowerIO value") 1217 1218 @property 1219 def value(self) -> Any: 1220 """The contained typed value.""" 1221 type_name = self._inner._type_name 1222 if type_name == "powerio.BalancedNetwork": 1223 return BalancedNetwork(self._inner.as_balanced_network()) 1224 if type_name == "powerio.MulticonductorNetwork": 1225 return dist.MulticonductorNetwork(self._inner.as_multiconductor_network()) 1226 if type_name.startswith("powerio.TimeSeries<"): 1227 return TimeSeries._from_module(self) 1228 if type_name.startswith("powerio.ScenarioSet<"): 1229 return ScenarioSet._from_module(self) 1230 value_class = _VALUE_CLASSES.get(type_name) 1231 if value_class is None: 1232 raise RuntimeError(f"this binding has no Python class for {type_name}") 1233 return value_class(self) 1234 1235 @property 1236 def diagnostics(self) -> list[Diagnostic]: 1237 """The diagnostics stored on this module, in encounter order.""" 1238 return list(self._inner.diagnostics) 1239 1240 def to_balanced_report(self, base_mva: float = 100.0) -> Any: 1241 """Report whether a multiconductor network can become balanced.""" 1242 return _json.loads(self._inner.lowering_readiness_json(base_mva)) 1243 1244 def to_balanced(self, base_mva: float = 100.0) -> "PioModule": 1245 """Transform a multiconductor network to a balanced module.""" 1246 return PioModule(self._inner.lower_to_balanced(base_mva)) 1247 1248 def to_dc_pf_instance(self) -> "PioModule": 1249 """Build a DC power flow instance from a balanced network module.""" 1250 return PioModule(self._inner._to_dc_pf_instance()) 1251 1252 def to_ac_pf_instance(self) -> "PioModule": 1253 """Build an AC power flow instance from a balanced network module.""" 1254 return PioModule(self._inner._to_ac_pf_instance()) 1255 1256 def to_dc_opf_instance(self) -> "PioModule": 1257 """Build a DC optimal power flow instance from a balanced network module.""" 1258 return PioModule(self._inner._to_dc_opf_instance()) 1259 1260 def to_ac_opf_instance(self) -> "PioModule": 1261 """Build an AC optimal power flow instance from a balanced network module.""" 1262 return PioModule(self._inner._to_ac_opf_instance()) 1263 1264 def to_mc_ac_pf_instance(self) -> "PioModule": 1265 """Build a multiconductor AC power flow instance from a network module.""" 1266 return PioModule(self._inner._to_mc_ac_pf_instance()) 1267 1268 def to_mc_ac_opf_instance(self) -> "PioModule": 1269 """Build a multiconductor AC optimal power flow instance from a network module.""" 1270 return PioModule(self._inner._to_mc_ac_opf_instance()) 1271 1272 def __repr__(self) -> str: 1273 return repr(self._inner) 1274 1275 1276def _selected_collection_value(location: _CollectionEntry) -> Any: 1277 value = location.root.value 1278 time_index = location.time_index 1279 scenario_id = location.scenario_id 1280 while time_index is not None or scenario_id is not None: 1281 if isinstance(value, TimeSeries) and time_index is not None: 1282 value = value[time_index] 1283 time_index = None 1284 elif isinstance(value, ScenarioSet) and scenario_id is not None: 1285 value = value[scenario_id] 1286 scenario_id = None 1287 elif time_index is not None: 1288 raise TypeError("the selected value is not a TimeSeries") 1289 else: 1290 raise TypeError("the selected value is not a ScenarioSet") 1291 return value 1292 1293 1294def _refresh_collection_entry(target: Any, location: _CollectionEntry) -> None: 1295 refreshed = _selected_collection_value(location) 1296 if type(refreshed) is not type(target): 1297 raise RuntimeError("a collection update changed the entry type") 1298 if isinstance(target, BalancedNetwork): 1299 target._inner = refreshed._inner 1300 elif isinstance(target, dist.MulticonductorNetwork): 1301 target._inner = refreshed._inner 1302 elif isinstance(target, _TypedValue): 1303 target.module = refreshed.module 1304 target._collection_entry = refreshed._collection_entry 1305 else: 1306 raise TypeError("the selected value does not support typed updates") 1307 1308 1309@_guard 1310def apply_updates( 1311 target: Any, 1312 updates: Union[ 1313 Iterable[OperatingPointUpdate], 1314 Iterable[NetworkUpdate], 1315 Iterable[CalculationUpdate], 1316 ], 1317) -> UpdateReport: 1318 """Validate and apply one batch of typed updates atomically. 1319 1320 ``updates`` contains one update class: :class:`OperatingPointUpdate`, 1321 :class:`NetworkUpdate`, or :class:`CalculationUpdate`. Values are absolute 1322 replacements and power quantities carry their units in the typed value. 1323 ``target`` is a module or a value obtained by indexing a :class:`TimeSeries` 1324 or :class:`ScenarioSet`. If validation fails, the module is unchanged. 1325 """ 1326 batch = list(updates) 1327 if isinstance(target, PioModule): 1328 return target._inner._apply_updates(batch) 1329 location = getattr(target, "_collection_entry", None) 1330 if not isinstance(location, _CollectionEntry): 1331 raise TypeError( 1332 "target must be a PioModule or a TimeSeries/ScenarioSet entry" 1333 ) 1334 report = location.root._inner._apply_collection_updates( 1335 batch, 1336 time_index=location.time_index, 1337 scenario_id=location.scenario_id, 1338 ) 1339 _refresh_collection_entry(target, location) 1340 return report 1341 1342 1343@_guard 1344def apply_bus_load_active_power( 1345 module: PioModule, 1346 bus_id: int, 1347 total: ActivePower, 1348 *, 1349 allocation: str = "proportional_to_current_active_power", 1350) -> UpdateReport: 1351 """Replace aggregate bus demand through an explicit PowerIO allocation rule. 1352 1353 ``"proportional_to_current_active_power"`` preserves each participating 1354 load's current share. ``"equal"`` gives every participating load the same 1355 share, including when their current aggregate demand is zero. PowerIO 1356 requires stable load IDs and reports each load changed. 1357 """ 1358 if not isinstance(module, PioModule): 1359 raise TypeError("module must be a PioModule") 1360 if not isinstance(total, ActivePower): 1361 raise TypeError("total must be an ActivePower") 1362 return module._inner._apply_bus_load_active_power( 1363 bus_id, 1364 total, 1365 allocation=allocation, 1366 ) 1367 1368 1369def _path_from_source(source: Any) -> Optional[str]: 1370 if isinstance(source, str): 1371 return source 1372 if isinstance(source, (bytes, bytearray, memoryview)): 1373 return None 1374 try: 1375 path = _os.fspath(source) 1376 except TypeError: 1377 return None 1378 if isinstance(path, bytes): 1379 raise TypeError("a path-like source must return str, not bytes") 1380 return path 1381 1382 1383def _memory_from_source(source: Any, name: Optional[str]) -> tuple[bytes, str]: 1384 if isinstance(source, (bytes, bytearray, memoryview)): 1385 data = bytes(source) 1386 else: 1387 read = getattr(source, "read", None) 1388 if read is None: 1389 raise TypeError( 1390 "source must be a path, file object, or bytes-like object" 1391 ) 1392 data = read() 1393 if isinstance(data, str): 1394 data = data.encode("utf-8") 1395 elif isinstance(data, (bytes, bytearray, memoryview)): 1396 data = bytes(data) 1397 else: 1398 raise TypeError("source.read() must return str or bytes-like data") 1399 if name is None: 1400 candidate = getattr(source, "name", None) 1401 try: 1402 candidate = _os.fspath(candidate) if candidate is not None else None 1403 except TypeError: 1404 candidate = None 1405 name = candidate if isinstance(candidate, str) else "<memory>" 1406 if not isinstance(name, str): 1407 raise TypeError("name must be a string") 1408 return data, name 1409 1410 1411@_guard 1412def parse( 1413 source: Any, 1414 *, 1415 format: Optional[str] = None, 1416 name: Optional[str] = None, 1417) -> PioModule: 1418 """Parse a path, file object, or bytes-like source. 1419 1420 A string is always a path. Pass raw text through ``io.StringIO`` or 1421 another file object. 1422 """ 1423 path = _path_from_source(source) 1424 if path is not None: 1425 if name is not None: 1426 raise ValueError("name is only valid for memory and file object sources") 1427 return PioModule(_powerio._PioModule._parse_path(path, format)) 1428 data, source_name = _memory_from_source(source, name) 1429 return PioModule(_powerio._PioModule._parse_memory(data, source_name, format)) 1430 1431 1432def _result_from_native(result: dict[str, Any]) -> EmitResult: 1433 return EmitResult( 1434 artifacts=tuple(Artifact(**artifact) for artifact in result["artifacts"]), 1435 layout=result["layout"], 1436 fidelity=result["fidelity"], 1437 diagnostics=tuple(result["diagnostics"]), 1438 ) 1439 1440 1441def _emit_to_destination( 1442 module: PioModule, 1443 destination: Optional[Any], 1444 memory_call: Any, 1445 path_call: Any, 1446) -> EmitResult: 1447 if not isinstance(module, PioModule): 1448 raise TypeError("module must be a PioModule") 1449 if destination is None: 1450 return _result_from_native(memory_call()) 1451 path = _path_from_source(destination) 1452 if path is not None: 1453 return _result_from_native(path_call(path)) 1454 write = getattr(destination, "write", None) 1455 if write is None: 1456 raise TypeError("destination must be a path or writable file object") 1457 result = _result_from_native(memory_call()) 1458 if result.layout != "file" or len(result.artifacts) != 1: 1459 raise ValueError("a directory emission requires a path destination") 1460 data = result.artifacts[0].data 1461 if data is None: 1462 raise ValueError("the emission returned no artifact bytes to write") 1463 # A text mode stream takes str and a binary one takes bytes. Ask a real 1464 # stream which it is rather than writing bytes and retrying on TypeError: 1465 # a TypeError raised inside the stream's own write would otherwise trigger 1466 # a second, partially duplicated write. A duck typed sink states neither, 1467 # so it keeps the retry. 1468 if isinstance(destination, _io.TextIOBase) or isinstance( 1469 getattr(destination, "encoding", None), str 1470 ): 1471 write(data.decode("utf-8")) 1472 elif isinstance(destination, (_io.RawIOBase, _io.BufferedIOBase)): 1473 write(data) 1474 else: 1475 try: 1476 write(data) 1477 except TypeError: 1478 write(data.decode("utf-8")) 1479 return result 1480 1481 1482@_guard 1483def emit(module: PioModule, format: str, destination: Optional[Any] = None) -> EmitResult: 1484 """Emit a module as one grid exchange format.""" 1485 return _emit_to_destination( 1486 module, 1487 destination, 1488 lambda: module._inner._emit_memory(format), 1489 lambda path: module._inner._emit_path(format, path), 1490 ) 1491 1492 1493@_guard 1494def serialize(module: PioModule, destination: Optional[Any] = None) -> EmitResult: 1495 """Serialize a module as PowerIO IR.""" 1496 return _emit_to_destination( 1497 module, 1498 destination, 1499 module._inner._serialize_memory, 1500 module._inner._serialize_path, 1501 ) 1502 1503 1504@_guard 1505def deserialize(source: Any) -> PioModule: 1506 """Deserialize PowerIO IR from a path, file object, or bytes-like source.""" 1507 path = _path_from_source(source) 1508 if path is not None: 1509 return PioModule(_powerio._PioModule._deserialize_path(path)) 1510 data, _ = _memory_from_source(source, None) 1511 return PioModule(_powerio._PioModule._deserialize_memory(data)) 1512 1513 1514@_guard 1515def features() -> dict[str, bool]: 1516 """The build-time features compiled into this powerio installation. 1517 1518 ``matrix``, ``dist``, and ``prob`` are unconditional dependencies of the 1519 extension and are always ``True``. ``gridfm`` reports whether GridFM 1520 Parquet parsing and emission were compiled in; the published wheel 1521 includes them, while a custom source build can omit them. 1522 """ 1523 return { 1524 "matrix": True, 1525 "gridfm": bool(getattr(_powerio, "_has_gridfm", False)), 1526 "dist": True, 1527 "prob": True, 1528 }
1060class AcOpfInstance(_BalancedCalculation): 1061 """An AC optimal power flow calculation instance."""
An AC optimal power flow calculation instance.
Inherited Members
1094class AcOpfSolution(_BalancedCalculation, _CalculationSolution): 1095 """An AC optimal power flow solution."""
An AC optimal power flow solution.
Inherited Members
An AC power flow calculation instance.
Inherited Members
1086class AcPfSolution(_BalancedCalculation, _CalculationSolution): 1087 """An AC power flow solution."""
An AC power flow solution.
Inherited Members
1072@_guard_class 1073class AcScucInstance(_BalancedCalculation): 1074 """An AC security constrained unit commitment calculation instance.""" 1075 1076 @property 1077 def inputs(self) -> ScucInputs: 1078 """Scheduling, reserve, and contingency inputs.""" 1079 return self.module._inner._ac_scuc_inputs()
An AC security constrained unit commitment calculation instance.
1076 @property 1077 def inputs(self) -> ScucInputs: 1078 """Scheduling, reserve, and contingency inputs.""" 1079 return self.module._inner._ac_scuc_inputs()
Scheduling, reserve, and contingency inputs.
Inherited Members
1110@_guard_class 1111class AcScucSolution(_BalancedCalculation, _CalculationSolution): 1112 """An AC security constrained unit commitment solution.""" 1113 1114 @property 1115 def termination(self) -> str: 1116 """How the calculation ended.""" 1117 return self.module._inner._ac_scuc_solution_termination() 1118 1119 @property 1120 def residuals(self) -> Residuals: 1121 """Reported active and reactive power balance residuals.""" 1122 return self.module._inner._ac_scuc_solution_residuals() 1123 1124 @property 1125 def producer(self) -> Optional[str]: 1126 """Producer or solver identity, when recorded.""" 1127 return self.module._inner._ac_scuc_solution_producer() 1128 1129 @property 1130 def network_outputs(self) -> ScucNetworkOutputs: 1131 """Per interval network outputs.""" 1132 return self.module._inner._ac_scuc_solution_network_outputs() 1133 1134 @property 1135 def device_outputs(self) -> ScucDeviceOutputs: 1136 """Per interval dispatchable device outputs.""" 1137 return self.module._inner._ac_scuc_solution_device_outputs() 1138 1139 @property 1140 def objective(self) -> Optional[float]: 1141 """Reported objective value, when present.""" 1142 return self.module._inner._ac_scuc_solution_objective()
An AC security constrained unit commitment solution.
1114 @property 1115 def termination(self) -> str: 1116 """How the calculation ended.""" 1117 return self.module._inner._ac_scuc_solution_termination()
How the calculation ended.
1119 @property 1120 def residuals(self) -> Residuals: 1121 """Reported active and reactive power balance residuals.""" 1122 return self.module._inner._ac_scuc_solution_residuals()
Reported active and reactive power balance residuals.
1124 @property 1125 def producer(self) -> Optional[str]: 1126 """Producer or solver identity, when recorded.""" 1127 return self.module._inner._ac_scuc_solution_producer()
Producer or solver identity, when recorded.
1129 @property 1130 def network_outputs(self) -> ScucNetworkOutputs: 1131 """Per interval network outputs.""" 1132 return self.module._inner._ac_scuc_solution_network_outputs()
Per interval network outputs.
1134 @property 1135 def device_outputs(self) -> ScucDeviceOutputs: 1136 """Per interval dispatchable device outputs.""" 1137 return self.module._inner._ac_scuc_solution_device_outputs()
Per interval dispatchable device outputs.
1139 @property 1140 def objective(self) -> Optional[float]: 1141 """Reported objective value, when present.""" 1142 return self.module._inner._ac_scuc_solution_objective()
Reported objective value, when present.
Inherited Members
169@dataclass(frozen=True) 170class Artifact: 171 """One artifact produced by :func:`emit` or :func:`serialize`. 172 173 ``data`` is set for an in-memory result. ``path`` is set after committing 174 to a filesystem destination. 175 """ 176 177 name: str 178 data: Optional[bytes] 179 path: Optional[str] 180 181 @property 182 def text(self) -> str: 183 """Decode an in-memory UTF-8 artifact.""" 184 if self.data is None: 185 raise ValueError("this artifact was committed to a destination") 186 return self.data.decode("utf-8")
One artifact produced by emit() or serialize().
data is set for an in-memory result. path is set after committing
to a filesystem destination.
324@_guard_class 325class BalancedNetwork: 326 """A parsed balanced power network. 327 328 The data attributes (``buses``, ``branches``, ``generators``, ``loads``, 329 ``shunts``) and reference bus queries delegate to the compiled handle; the 330 matrix methods below return ``scipy.sparse`` objects. Parse and transform 331 diagnostics belong to the owning :class:`PioModule`. 332 333 Errors: a bad file path raises the standard ``OSError`` subclass 334 (``FileNotFoundError``); a malformed case raises :class:`PowerIOParseError` 335 and an unmet calculation precondition (no generators, no reference bus) raises 336 :class:`PowerIODataError`; both subclass :class:`PowerIOError`, so 337 ``except PowerIOError`` catches either; an unknown 338 ``scheme``/``formula``/``units`` string raises ``ValueError``. 339 """ 340 341 def __init__(self, inner: "_powerio._BalancedNetwork"): 342 self._inner = inner 343 344 def __dir__(self): 345 # The data attributes arrive through __getattr__, so name them here or 346 # they stay invisible to tab completion. 347 return sorted(set(super().__dir__()) | _BALANCED_DELEGATED_NAMES) 348 349 def __getattr__(self, name: str): 350 # Reached only when normal lookup misses, so the matrix methods below 351 # win. Guard underscore names so a lookup before _inner exists raises 352 # AttributeError instead of recursing forever. 353 if name not in _BALANCED_DELEGATED_NAMES: 354 raise AttributeError( 355 f"{type(self).__name__!r} object has no attribute {name!r}" 356 ) 357 return getattr(self._inner, name) 358 359 def __repr__(self) -> str: 360 # The inner handle's __repr__ already renders the public ``BalancedNetwork(...)`` 361 # form, so this is a straight delegate. 362 return repr(self._inner) 363 364 def calc_connectivity_report(self) -> dict[str, Any]: 365 """Calculate the in-service topology summary.""" 366 return self._inner.calc_connectivity_report() 367 368 def to_geo_layer(self) -> dict[str, Any]: 369 """Transform coordinates to a canonical GeoJSON FeatureCollection. 370 371 A case without coordinates produces an empty feature collection. 372 """ 373 return _json.loads(self._inner.to_geo_layer_json()) 374 375 def apply_geo_layer( 376 self, text: str, name_hint: Optional[str] = None 377 ) -> tuple["BalancedNetwork", dict[str, Any]]: 378 """Apply a geographic sidecar and return ``(placed, report)``. 379 380 ``text`` is any form :func:`parse_geo` accepts; this case is 381 unchanged. The report carries ``matched_buses``, ``matched_branches``, 382 ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``, 383 and ``notes``. The two unlocated counts cover the whole case when the 384 pass ends, so a layer that matched nothing reads apart from a case 385 that needed nothing. The placed copy drops the retained source text, 386 so a same-format emission re-serializes. 387 """ 388 inner, report = self._inner.apply_geo_layer(text, name_hint) 389 return BalancedNetwork(inner), report 390 391 # --- matrix calculations (scipy.sparse) ----------------------------- 392 393 def calc_bprime_matrix( 394 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 395 ): 396 """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 397 398 ``skip_zero_impedance=False`` refuses a zero impedance branch 399 (``r`` and ``x`` both zero); pass ``True`` to drop it instead. 400 """ 401 return _to_csr( 402 self._inner.bprime(scheme, skip_zero_impedance=skip_zero_impedance) 403 ) 404 405 def calc_incidence_matrix(self, formula: str = "series_susceptance"): 406 """Return PowerModels incidence ``A`` (branches by buses).""" 407 return _to_csr(self._inner.calc_incidence_matrix(formula)) 408 409 def calc_branch_susceptances(self, formula: str = "series_susceptance"): 410 """Return per branch susceptances in active branch order.""" 411 np = _require("numpy", "matrix") 412 return np.asarray(self._inner.calc_branch_susceptances(formula), dtype=float) 413 414 def calc_branch_flow_matrix(self, formula: str = "series_susceptance"): 415 """Return ``Bf = diag(b) A`` as a CSR matrix.""" 416 return _to_csr(self._inner.calc_branch_flow_matrix(formula)) 417 418 def calc_bus_susceptance_matrix(self, formula: str = "series_susceptance"): 419 """Return ``B = A.T diag(b) A`` as a CSR matrix.""" 420 return _to_csr(self._inner.calc_bus_susceptance_matrix(formula)) 421 422 def calc_branch_phase_shift_injection( 423 self, formula: str = "series_susceptance" 424 ): 425 """Return ``b * shift`` in active branch order.""" 426 np = _require("numpy", "matrix") 427 return np.asarray( 428 self._inner.calc_branch_phase_shift_injection(formula), dtype=float 429 ) 430 431 def calc_bus_phase_shift_injection(self, formula: str = "series_susceptance"): 432 """Return ``A.T @ (b * shift)`` in bus order.""" 433 np = _require("numpy", "matrix") 434 return np.asarray( 435 self._inner.calc_bus_phase_shift_injection(formula), dtype=float 436 ) 437 438 def calc_branch_flow_dc(self, voltage_angles, formula: str = "series_susceptance"): 439 """Compute ``-Bf @ va + b * shift`` in active branch order.""" 440 np, angles = _dc_angles(self.n_buses, voltage_angles) 441 return np.asarray( 442 self._inner.calc_branch_flow_dc(angles.tolist(), formula), dtype=float 443 ) 444 445 def calc_bus_injection_dc( 446 self, voltage_angles, formula: str = "series_susceptance" 447 ): 448 """Compute ``-B @ va + p_shift`` in bus order.""" 449 np, angles = _dc_angles(self.n_buses, voltage_angles) 450 return np.asarray( 451 self._inner.calc_bus_injection_dc(angles.tolist(), formula), dtype=float 452 ) 453 454 def calc_bdoubleprime_matrix( 455 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 456 ): 457 """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 458 ``skip_zero_impedance`` as in :meth:`calc_bprime_matrix`. 459 """ 460 return _to_csr( 461 self._inner.bdoubleprime(scheme, skip_zero_impedance=skip_zero_impedance) 462 ) 463 464 def calc_lacpf_matrix( 465 self, 466 *, 467 include_taps: bool = True, 468 include_shifts: bool = True, 469 skip_zero_impedance: bool = False, 470 ): 471 """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``. ``skip_zero_impedance`` 472 as in :meth:`calc_bprime_matrix`.""" 473 return _to_csr( 474 self._inner.lacpf( 475 include_taps=include_taps, 476 include_shifts=include_shifts, 477 skip_zero_impedance=skip_zero_impedance, 478 ) 479 ) 480 481 def calc_adjacency_matrix(self): 482 """0/1 bus adjacency matrix.""" 483 return _to_csr(self._inner.adjacency()) 484 485 def calc_admittance_matrix( 486 self, 487 *, 488 include_taps: bool = True, 489 include_shifts: bool = True, 490 skip_zero_impedance: bool = False, 491 ): 492 """``Y_bus = G + jB`` as a complex csr_matrix. ``skip_zero_impedance`` 493 as in :meth:`calc_bprime_matrix`.""" 494 g, b = self._inner.ybus_parts( 495 include_taps=include_taps, 496 include_shifts=include_shifts, 497 skip_zero_impedance=skip_zero_impedance, 498 ) 499 g, b = _to_csr(g), _to_csr(b) 500 return (g + 1j * b).tocsr() 501 502 def calc_ptdf(self, formula: str = "series_susceptance", solver: str = "auto"): 503 """DC PTDF (m×n). ``formula`` is ``"series_susceptance"``, 504 ``"tap_adjusted_reactance"``, or ``"reactance_only"``. 505 506 ``solver`` is ``"auto"``, ``"dense"``, or ``"sparse"``. ``"auto"`` 507 uses the dense factorization on small cases and the sparse Cholesky 508 path on large ones, the same policy as the CLI. 509 """ 510 return _to_csr(self._inner.ptdf(formula, solver)) 511 512 def calc_lodf(self, formula: str = "series_susceptance", solver: str = "auto"): 513 """DC LODF (m×m). ``formula`` and ``solver`` as in :meth:`calc_ptdf`.""" 514 return _to_csr(self._inner.lodf(formula, solver)) 515 516 def calc_weighted_laplacian( 517 self, 518 formula: str = "series_susceptance", 519 ): 520 """Weighted Laplacian ``L = -B``. ``formula`` as in :meth:`calc_ptdf`.""" 521 return _to_csr(self._inner.weighted_laplacian(formula)) 522 523 def to_normalized( 524 self, 525 *, 526 clamp_angle_bounds: bool = False, 527 angle_bound_pad: Optional[float] = None, 528 ) -> "BalancedNetwork": 529 """Return a normalized copy with per unit power and radian angles. 530 531 The result removes out of service elements, preserves source bus IDs, 532 and normalizes bus types. It carries no retained source, so 533 :func:`powerio.emit` produces a grid exchange representation from the 534 derived module. Raises 535 :class:`PowerIODataError` if the network cannot be 536 normalized (no reference bus can be chosen, or a non-positive base MVA). 537 538 ``clamp_angle_bounds=True`` applies the PowerModels angle difference 539 bound repair: limits at or beyond ``+/-pi/2`` and zero/zero windows 540 become ``[-angle_bound_pad, angle_bound_pad]``. A repair that would 541 invert the interval widens to that same window. The default pad is 542 1.0472 radians. 543 """ 544 if not clamp_angle_bounds and angle_bound_pad is None: 545 return BalancedNetwork(self._inner.to_normalized()) 546 return BalancedNetwork( 547 self._inner.to_normalized_with_options( 548 clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad 549 ) 550 ) 551 552 def to_ppc(self): 553 """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables. 554 555 Values are emitted as the model holds them, so a case read from a 556 file carries MW, MVAr, and degrees. A network from 557 :meth:`to_normalized` holds per unit and radians, and those are what 558 its tables carry — PYPOWER reads a ppc dict as MW and degrees, so 559 build this from the raw network unless the consumer expects per unit. 560 561 Loads and shunts are summed onto their bus in the 562 ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation as the 563 MATPOWER emitter. The bus table has no per element status 564 column, so an element the model marks out of service still 565 contributes its value, and a de-energized bus is carried as type 4. 566 ``gencost`` is present only when every generator carries cost data, 567 because MATPOWER requires cost rows for all generators or none. 568 :func:`from_ppc` reads the tables back. 569 """ 570 np = _require("numpy", "matrix") 571 buses = self._inner.buses 572 bus = np.array( 573 [ 574 ( 575 b["id"], 576 _PPC_BUS_TYPE.get(b["kind"], 1.0), 577 0.0, 578 0.0, 579 0.0, 580 0.0, 581 b["area"], 582 b["vm"], 583 b["va"], 584 b["base_kv"], 585 b["zone"], 586 b["vmax"], 587 b["vmin"], 588 ) 589 for b in buses 590 ], 591 dtype=float, 592 ).reshape(len(buses), 13) 593 bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums( 594 np, buses, self._inner.loads, self._inner.shunts 595 ) 596 597 # The capability and ramp columns past PMIN are an OPF extension that a 598 # source need not carry. Widen to the full 21 only when a generator 599 # actually states one: a table of zeros there reads back as eleven 600 # explicit zero limits, which a ramp aware solver takes as a generator 601 # that cannot move. 602 gens = self._inner.generators 603 caps = [g["caps"] for g in gens] 604 width = 21 if any(c is not None for row in caps for c in row) else 10 605 gen = np.array( 606 [ 607 [ 608 g["bus"], 609 g["pg"], 610 g["qg"], 611 g["qmax"], 612 g["qmin"], 613 g["vg"], 614 g["mbase"], 615 float(g["in_service"]), 616 g["pmax"], 617 g["pmin"], 618 ] 619 + ([0.0 if c is None else c for c in row] if width == 21 else []) 620 for g, row in zip(gens, caps) 621 ], 622 dtype=float, 623 ).reshape(len(gens), width) 624 625 branches = self._inner.branches 626 branch = np.array( 627 [ 628 ( 629 br["from_id"], 630 br["to_id"], 631 br["r"], 632 br["x"], 633 br["b"], 634 br["rate_a"], 635 br["rate_b"], 636 br["rate_c"], 637 br["tap"], 638 br["shift"], 639 float(br["in_service"]), 640 br["angmin"], 641 br["angmax"], 642 ) 643 for br in branches 644 ], 645 dtype=float, 646 ).reshape(len(branches), 13) 647 648 ppc = { 649 "version": "2", 650 "baseMVA": float(self._inner.base_mva), 651 "bus": bus, 652 "gen": gen, 653 "branch": branch, 654 } 655 656 # Coefficients sit left-aligned after ncost, padded to the widest 657 # row, which is the layout PYPOWER's own loadcase produces. 658 costs = [g["cost"] for g in gens] 659 if costs and all(c is not None for c in costs): 660 gencost = np.zeros((len(costs), 4 + max(len(c["coeffs"]) for c in costs))) 661 for i, c in enumerate(costs): 662 gencost[i, :4] = ( 663 c["model"], 664 c["startup"], 665 c["shutdown"], 666 c["ncost"], 667 ) 668 gencost[i, 4 : 4 + len(c["coeffs"])] = c["coeffs"] 669 ppc["gencost"] = gencost 670 return ppc 671 672 def to_networkx(self): 673 """Undirected networkx graph keyed by bus id. 674 675 In-service branches become edges carrying ``branch`` (index), ``r``, 676 ``x``, and ``b``. 677 """ 678 nx = _require("networkx", "graph") 679 g = nx.Graph() 680 g.add_nodes_from(bus["id"] for bus in self._inner.buses) 681 for k, br in enumerate(self._inner.branches): 682 if br["in_service"]: 683 g.add_edge( 684 br["from_id"], 685 br["to_id"], 686 branch=k, 687 r=br["r"], 688 x=br["x"], 689 b=br["b"], 690 ) 691 return g
A parsed balanced power network.
The data attributes (buses, branches, generators, loads,
shunts) and reference bus queries delegate to the compiled handle; the
matrix methods below return scipy.sparse objects. Parse and transform
diagnostics belong to the owning PioModule.
Errors: a bad file path raises the standard OSError subclass
(FileNotFoundError); a malformed case raises PowerIOParseError
and an unmet calculation precondition (no generators, no reference bus) raises
PowerIODataError; both subclass PowerIOError, so
except PowerIOError catches either; an unknown
scheme/formula/units string raises ValueError.
364 def calc_connectivity_report(self) -> dict[str, Any]: 365 """Calculate the in-service topology summary.""" 366 return self._inner.calc_connectivity_report()
Calculate the in-service topology summary.
368 def to_geo_layer(self) -> dict[str, Any]: 369 """Transform coordinates to a canonical GeoJSON FeatureCollection. 370 371 A case without coordinates produces an empty feature collection. 372 """ 373 return _json.loads(self._inner.to_geo_layer_json())
Transform coordinates to a canonical GeoJSON FeatureCollection.
A case without coordinates produces an empty feature collection.
375 def apply_geo_layer( 376 self, text: str, name_hint: Optional[str] = None 377 ) -> tuple["BalancedNetwork", dict[str, Any]]: 378 """Apply a geographic sidecar and return ``(placed, report)``. 379 380 ``text`` is any form :func:`parse_geo` accepts; this case is 381 unchanged. The report carries ``matched_buses``, ``matched_branches``, 382 ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``, 383 and ``notes``. The two unlocated counts cover the whole case when the 384 pass ends, so a layer that matched nothing reads apart from a case 385 that needed nothing. The placed copy drops the retained source text, 386 so a same-format emission re-serializes. 387 """ 388 inner, report = self._inner.apply_geo_layer(text, name_hint) 389 return BalancedNetwork(inner), report
Apply a geographic sidecar and return (placed, report).
text is any form parse_geo() accepts; this case is
unchanged. The report carries matched_buses, matched_branches,
unmatched_features, unlocated_buses, unlocated_branches,
and notes. The two unlocated counts cover the whole case when the
pass ends, so a layer that matched nothing reads apart from a case
that needed nothing. The placed copy drops the retained source text,
so a same-format emission re-serializes.
393 def calc_bprime_matrix( 394 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 395 ): 396 """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 397 398 ``skip_zero_impedance=False`` refuses a zero impedance branch 399 (``r`` and ``x`` both zero); pass ``True`` to drop it instead. 400 """ 401 return _to_csr( 402 self._inner.bprime(scheme, skip_zero_impedance=skip_zero_impedance) 403 )
MATPOWER FDPF Bp matrix. scheme is "bx" or "xb".
skip_zero_impedance=False refuses a zero impedance branch
(r and x both zero); pass True to drop it instead.
405 def calc_incidence_matrix(self, formula: str = "series_susceptance"): 406 """Return PowerModels incidence ``A`` (branches by buses).""" 407 return _to_csr(self._inner.calc_incidence_matrix(formula))
Return PowerModels incidence A (branches by buses).
409 def calc_branch_susceptances(self, formula: str = "series_susceptance"): 410 """Return per branch susceptances in active branch order.""" 411 np = _require("numpy", "matrix") 412 return np.asarray(self._inner.calc_branch_susceptances(formula), dtype=float)
Return per branch susceptances in active branch order.
414 def calc_branch_flow_matrix(self, formula: str = "series_susceptance"): 415 """Return ``Bf = diag(b) A`` as a CSR matrix.""" 416 return _to_csr(self._inner.calc_branch_flow_matrix(formula))
Return Bf = diag(b) A as a CSR matrix.
418 def calc_bus_susceptance_matrix(self, formula: str = "series_susceptance"): 419 """Return ``B = A.T diag(b) A`` as a CSR matrix.""" 420 return _to_csr(self._inner.calc_bus_susceptance_matrix(formula))
Return B = A.T diag(b) A as a CSR matrix.
422 def calc_branch_phase_shift_injection( 423 self, formula: str = "series_susceptance" 424 ): 425 """Return ``b * shift`` in active branch order.""" 426 np = _require("numpy", "matrix") 427 return np.asarray( 428 self._inner.calc_branch_phase_shift_injection(formula), dtype=float 429 )
Return b * shift in active branch order.
431 def calc_bus_phase_shift_injection(self, formula: str = "series_susceptance"): 432 """Return ``A.T @ (b * shift)`` in bus order.""" 433 np = _require("numpy", "matrix") 434 return np.asarray( 435 self._inner.calc_bus_phase_shift_injection(formula), dtype=float 436 )
Return A.T @ (b * shift) in bus order.
438 def calc_branch_flow_dc(self, voltage_angles, formula: str = "series_susceptance"): 439 """Compute ``-Bf @ va + b * shift`` in active branch order.""" 440 np, angles = _dc_angles(self.n_buses, voltage_angles) 441 return np.asarray( 442 self._inner.calc_branch_flow_dc(angles.tolist(), formula), dtype=float 443 )
Compute -Bf @ va + b * shift in active branch order.
445 def calc_bus_injection_dc( 446 self, voltage_angles, formula: str = "series_susceptance" 447 ): 448 """Compute ``-B @ va + p_shift`` in bus order.""" 449 np, angles = _dc_angles(self.n_buses, voltage_angles) 450 return np.asarray( 451 self._inner.calc_bus_injection_dc(angles.tolist(), formula), dtype=float 452 )
Compute -B @ va + p_shift in bus order.
454 def calc_bdoubleprime_matrix( 455 self, scheme: str = "bx", *, skip_zero_impedance: bool = False 456 ): 457 """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``. 458 ``skip_zero_impedance`` as in :meth:`calc_bprime_matrix`. 459 """ 460 return _to_csr( 461 self._inner.bdoubleprime(scheme, skip_zero_impedance=skip_zero_impedance) 462 )
MATPOWER FDPF Bpp matrix. scheme is "bx" or "xb".
skip_zero_impedance as in calc_bprime_matrix().
464 def calc_lacpf_matrix( 465 self, 466 *, 467 include_taps: bool = True, 468 include_shifts: bool = True, 469 skip_zero_impedance: bool = False, 470 ): 471 """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``. ``skip_zero_impedance`` 472 as in :meth:`calc_bprime_matrix`.""" 473 return _to_csr( 474 self._inner.lacpf( 475 include_taps=include_taps, 476 include_shifts=include_shifts, 477 skip_zero_impedance=skip_zero_impedance, 478 ) 479 )
LACPF 2n×2n block [[G, -B], [-B, -G]]. skip_zero_impedance
as in calc_bprime_matrix().
481 def calc_adjacency_matrix(self): 482 """0/1 bus adjacency matrix.""" 483 return _to_csr(self._inner.adjacency())
0/1 bus adjacency matrix.
485 def calc_admittance_matrix( 486 self, 487 *, 488 include_taps: bool = True, 489 include_shifts: bool = True, 490 skip_zero_impedance: bool = False, 491 ): 492 """``Y_bus = G + jB`` as a complex csr_matrix. ``skip_zero_impedance`` 493 as in :meth:`calc_bprime_matrix`.""" 494 g, b = self._inner.ybus_parts( 495 include_taps=include_taps, 496 include_shifts=include_shifts, 497 skip_zero_impedance=skip_zero_impedance, 498 ) 499 g, b = _to_csr(g), _to_csr(b) 500 return (g + 1j * b).tocsr()
Y_bus = G + jB as a complex csr_matrix. skip_zero_impedance
as in calc_bprime_matrix().
502 def calc_ptdf(self, formula: str = "series_susceptance", solver: str = "auto"): 503 """DC PTDF (m×n). ``formula`` is ``"series_susceptance"``, 504 ``"tap_adjusted_reactance"``, or ``"reactance_only"``. 505 506 ``solver`` is ``"auto"``, ``"dense"``, or ``"sparse"``. ``"auto"`` 507 uses the dense factorization on small cases and the sparse Cholesky 508 path on large ones, the same policy as the CLI. 509 """ 510 return _to_csr(self._inner.ptdf(formula, solver))
DC PTDF (m×n). formula is "series_susceptance",
"tap_adjusted_reactance", or "reactance_only".
solver is "auto", "dense", or "sparse". "auto"
uses the dense factorization on small cases and the sparse Cholesky
path on large ones, the same policy as the CLI.
512 def calc_lodf(self, formula: str = "series_susceptance", solver: str = "auto"): 513 """DC LODF (m×m). ``formula`` and ``solver`` as in :meth:`calc_ptdf`.""" 514 return _to_csr(self._inner.lodf(formula, solver))
DC LODF (m×m). formula and solver as in calc_ptdf().
516 def calc_weighted_laplacian( 517 self, 518 formula: str = "series_susceptance", 519 ): 520 """Weighted Laplacian ``L = -B``. ``formula`` as in :meth:`calc_ptdf`.""" 521 return _to_csr(self._inner.weighted_laplacian(formula))
Weighted Laplacian L = -B. formula as in calc_ptdf().
523 def to_normalized( 524 self, 525 *, 526 clamp_angle_bounds: bool = False, 527 angle_bound_pad: Optional[float] = None, 528 ) -> "BalancedNetwork": 529 """Return a normalized copy with per unit power and radian angles. 530 531 The result removes out of service elements, preserves source bus IDs, 532 and normalizes bus types. It carries no retained source, so 533 :func:`powerio.emit` produces a grid exchange representation from the 534 derived module. Raises 535 :class:`PowerIODataError` if the network cannot be 536 normalized (no reference bus can be chosen, or a non-positive base MVA). 537 538 ``clamp_angle_bounds=True`` applies the PowerModels angle difference 539 bound repair: limits at or beyond ``+/-pi/2`` and zero/zero windows 540 become ``[-angle_bound_pad, angle_bound_pad]``. A repair that would 541 invert the interval widens to that same window. The default pad is 542 1.0472 radians. 543 """ 544 if not clamp_angle_bounds and angle_bound_pad is None: 545 return BalancedNetwork(self._inner.to_normalized()) 546 return BalancedNetwork( 547 self._inner.to_normalized_with_options( 548 clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad 549 ) 550 )
Return a normalized copy with per unit power and radian angles.
The result removes out of service elements, preserves source bus IDs,
and normalizes bus types. It carries no retained source, so
powerio.emit() produces a grid exchange representation from the
derived module. Raises
PowerIODataError if the network cannot be
normalized (no reference bus can be chosen, or a non-positive base MVA).
clamp_angle_bounds=True applies the PowerModels angle difference
bound repair: limits at or beyond +/-pi/2 and zero/zero windows
become [-angle_bound_pad, angle_bound_pad]. A repair that would
invert the interval widens to that same window. The default pad is
1.0472 radians.
552 def to_ppc(self): 553 """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables. 554 555 Values are emitted as the model holds them, so a case read from a 556 file carries MW, MVAr, and degrees. A network from 557 :meth:`to_normalized` holds per unit and radians, and those are what 558 its tables carry — PYPOWER reads a ppc dict as MW and degrees, so 559 build this from the raw network unless the consumer expects per unit. 560 561 Loads and shunts are summed onto their bus in the 562 ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation as the 563 MATPOWER emitter. The bus table has no per element status 564 column, so an element the model marks out of service still 565 contributes its value, and a de-energized bus is carried as type 4. 566 ``gencost`` is present only when every generator carries cost data, 567 because MATPOWER requires cost rows for all generators or none. 568 :func:`from_ppc` reads the tables back. 569 """ 570 np = _require("numpy", "matrix") 571 buses = self._inner.buses 572 bus = np.array( 573 [ 574 ( 575 b["id"], 576 _PPC_BUS_TYPE.get(b["kind"], 1.0), 577 0.0, 578 0.0, 579 0.0, 580 0.0, 581 b["area"], 582 b["vm"], 583 b["va"], 584 b["base_kv"], 585 b["zone"], 586 b["vmax"], 587 b["vmin"], 588 ) 589 for b in buses 590 ], 591 dtype=float, 592 ).reshape(len(buses), 13) 593 bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums( 594 np, buses, self._inner.loads, self._inner.shunts 595 ) 596 597 # The capability and ramp columns past PMIN are an OPF extension that a 598 # source need not carry. Widen to the full 21 only when a generator 599 # actually states one: a table of zeros there reads back as eleven 600 # explicit zero limits, which a ramp aware solver takes as a generator 601 # that cannot move. 602 gens = self._inner.generators 603 caps = [g["caps"] for g in gens] 604 width = 21 if any(c is not None for row in caps for c in row) else 10 605 gen = np.array( 606 [ 607 [ 608 g["bus"], 609 g["pg"], 610 g["qg"], 611 g["qmax"], 612 g["qmin"], 613 g["vg"], 614 g["mbase"], 615 float(g["in_service"]), 616 g["pmax"], 617 g["pmin"], 618 ] 619 + ([0.0 if c is None else c for c in row] if width == 21 else []) 620 for g, row in zip(gens, caps) 621 ], 622 dtype=float, 623 ).reshape(len(gens), width) 624 625 branches = self._inner.branches 626 branch = np.array( 627 [ 628 ( 629 br["from_id"], 630 br["to_id"], 631 br["r"], 632 br["x"], 633 br["b"], 634 br["rate_a"], 635 br["rate_b"], 636 br["rate_c"], 637 br["tap"], 638 br["shift"], 639 float(br["in_service"]), 640 br["angmin"], 641 br["angmax"], 642 ) 643 for br in branches 644 ], 645 dtype=float, 646 ).reshape(len(branches), 13) 647 648 ppc = { 649 "version": "2", 650 "baseMVA": float(self._inner.base_mva), 651 "bus": bus, 652 "gen": gen, 653 "branch": branch, 654 } 655 656 # Coefficients sit left-aligned after ncost, padded to the widest 657 # row, which is the layout PYPOWER's own loadcase produces. 658 costs = [g["cost"] for g in gens] 659 if costs and all(c is not None for c in costs): 660 gencost = np.zeros((len(costs), 4 + max(len(c["coeffs"]) for c in costs))) 661 for i, c in enumerate(costs): 662 gencost[i, :4] = ( 663 c["model"], 664 c["startup"], 665 c["shutdown"], 666 c["ncost"], 667 ) 668 gencost[i, 4 : 4 + len(c["coeffs"])] = c["coeffs"] 669 ppc["gencost"] = gencost 670 return ppc
PYPOWER case dict (ppc) with MATPOWER-style numpy tables.
Values are emitted as the model holds them, so a case read from a
file carries MW, MVAr, and degrees. A network from
to_normalized() holds per unit and radians, and those are what
its tables carry — PYPOWER reads a ppc dict as MW and degrees, so
build this from the raw network unless the consumer expects per unit.
Loads and shunts are summed onto their bus in the
PD/QD/GS/BS columns, the same aggregation as the
MATPOWER emitter. The bus table has no per element status
column, so an element the model marks out of service still
contributes its value, and a de-energized bus is carried as type 4.
gencost is present only when every generator carries cost data,
because MATPOWER requires cost rows for all generators or none.
from_ppc() reads the tables back.
672 def to_networkx(self): 673 """Undirected networkx graph keyed by bus id. 674 675 In-service branches become edges carrying ``branch`` (index), ``r``, 676 ``x``, and ``b``. 677 """ 678 nx = _require("networkx", "graph") 679 g = nx.Graph() 680 g.add_nodes_from(bus["id"] for bus in self._inner.buses) 681 for k, br in enumerate(self._inner.branches): 682 if br["in_service"]: 683 g.add_edge( 684 br["from_id"], 685 br["to_id"], 686 branch=k, 687 r=br["r"], 688 x=br["x"], 689 b=br["b"], 690 ) 691 return g
Undirected networkx graph keyed by bus id.
In-service branches become edges carrying branch (index), r,
x, and b.
1056class DcOpfInstance(_BalancedCalculation): 1057 """A DC optimal power flow calculation instance."""
A DC optimal power flow calculation instance.
Inherited Members
1090class DcOpfSolution(_BalancedCalculation, _CalculationSolution): 1091 """A DC optimal power flow solution."""
A DC optimal power flow solution.
Inherited Members
A DC power flow calculation instance.
Inherited Members
1082class DcPfSolution(_BalancedCalculation, _CalculationSolution): 1083 """A DC power flow solution."""
A DC power flow solution.
Inherited Members
One coded, user facing finding from a parse, read, transform, or write
pass: the Python mirror of powerio_core::Diagnostic. Every module
carries a list of these; PioModule.diagnostics returns them natively
instead of the diagnostics_json string form.
DisplayData(kind, data)
189@dataclass(frozen=True) 190class EmitResult: 191 """Artifact inventory and diagnostics from an emission or serialization.""" 192 193 artifacts: tuple[Artifact, ...] 194 layout: str 195 fidelity: str 196 diagnostics: tuple[Diagnostic, ...] 197 198 @property 199 def text(self) -> Optional[str]: 200 """The sole UTF-8 memory artifact, or ``None`` for other inventories.""" 201 if len(self.artifacts) != 1 or self.artifacts[0].data is None: 202 return None 203 return self.artifacts[0].text
Artifact inventory and diagnostics from an emission or serialization.
198 @property 199 def text(self) -> Optional[str]: 200 """The sole UTF-8 memory artifact, or ``None`` for other inventories.""" 201 if len(self.artifacts) != 1 or self.artifacts[0].data is None: 202 return None 203 return self.artifacts[0].text
The sole UTF-8 memory artifact, or None for other inventories.
FormatInfo(token, extension, is_directory, can_emit)
1145@_guard_class 1146class GeoLayer(_TypedValue): 1147 """A standalone geographic document: element points and routes keyed by 1148 element identity, in one coordinate space. 1149 1150 :func:`parse` returns it for the canonical ``.geo.json``, GeoJSON, aliased 1151 CSV or JSON records, headerless buscoords CSV, and a PowerWorld ``.pwd`` 1152 display. :meth:`PioModule.emit` writes the canonical document as 1153 ``geo-json``, and :func:`serialize` carries the layer through PowerIO IR. 1154 Place a layer onto a case with 1155 ``network.apply_geo_layer(layer.geojson)``. 1156 """ 1157 1158 @property 1159 def geojson(self) -> str: 1160 """The canonical ``.geo.json`` document for this layer.""" 1161 result = emit(self.module, "geo-json") 1162 data = result.artifacts[0].data 1163 if data is None: 1164 raise ValueError("the layer emission returned no artifact bytes") 1165 return data.decode("utf-8")
A standalone geographic document: element points and routes keyed by element identity, in one coordinate space.
parse() returns it for the canonical .geo.json, GeoJSON, aliased
CSV or JSON records, headerless buscoords CSV, and a PowerWorld .pwd
display. PioModule.emit() writes the canonical document as
geo-json, and serialize() carries the layer through PowerIO IR.
Place a layer onto a case with
network.apply_geo_layer(layer.geojson).
1158 @property 1159 def geojson(self) -> str: 1160 """The canonical ``.geo.json`` document for this layer.""" 1161 result = emit(self.module, "geo-json") 1162 data = result.artifacts[0].data 1163 if data is None: 1164 raise ValueError("the layer emission returned no artifact bytes") 1165 return data.decode("utf-8")
The canonical .geo.json document for this layer.
Inherited Members
1068class McAcOpfInstance(_MulticonductorCalculation): 1069 """A multiconductor AC optimal power flow calculation instance."""
A multiconductor AC optimal power flow calculation instance.
Inherited Members
1106class McAcOpfSolution(_MulticonductorCalculation, _CalculationSolution): 1107 """A multiconductor AC optimal power flow solution."""
A multiconductor AC optimal power flow solution.
Inherited Members
1064class McAcPfInstance(_MulticonductorCalculation): 1065 """A multiconductor AC power flow calculation instance."""
A multiconductor AC power flow calculation instance.
Inherited Members
1102class McAcPfSolution(_MulticonductorCalculation, _CalculationSolution): 1103 """A multiconductor AC power flow solution."""
A multiconductor AC power flow solution.
Inherited Members
28@_guard_class 29class MulticonductorNetwork: 30 """A parsed multiconductor distribution network in wire coordinates. 31 32 Buses carry named terminals, lines carry conductor impedance matrices, and 33 transformers carry per winding connections. This type is distinct from the 34 positive sequence :class:`powerio.BalancedNetwork`; balanced matrix calculations do not 35 accept it. 36 """ 37 38 def __init__(self, inner) -> None: 39 self._inner = inner 40 41 @property 42 def name(self) -> Optional[str]: 43 """Distribution network name when the source format carries one.""" 44 return self._inner.name() 45 46 @property 47 def source_format(self) -> Optional[str]: 48 """Format parsed from: ``dss``, ``pmd-json``, or ``bmopf-json``.""" 49 return self._inner.source_format() 50 51 @property 52 def base_frequency(self) -> float: 53 """System base frequency in hertz.""" 54 return self._inner.base_frequency() 55 56 @property 57 def n_buses(self) -> int: 58 return self._inner.n_buses() 59 60 @property 61 def n_lines(self) -> int: 62 return self._inner.n_lines() 63 64 @property 65 def n_line_codes(self) -> int: 66 return self._inner.n_line_codes() 67 68 @property 69 def n_switches(self) -> int: 70 return self._inner.n_switches() 71 72 @property 73 def n_transformers(self) -> int: 74 return self._inner.n_transformers() 75 76 @property 77 def n_loads(self) -> int: 78 return self._inner.n_loads() 79 80 @property 81 def n_generators(self) -> int: 82 return self._inner.n_generators() 83 84 @property 85 def n_ibrs(self) -> int: 86 return self._inner.n_ibrs() 87 88 @property 89 def n_control_profiles(self) -> int: 90 return self._inner.n_control_profiles() 91 92 @property 93 def n_shunts(self) -> int: 94 return self._inner.n_shunts() 95 96 @property 97 def n_capacitors(self) -> int: 98 return self._inner.n_capacitors() 99 100 @property 101 def n_voltage_sources(self) -> int: 102 """Number of grid forming voltage sources.""" 103 return self._inner.n_voltage_sources() 104 105 @property 106 def n_untyped_objects(self) -> int: 107 return self._inner.n_untyped_objects() 108 109 # These properties are copies of the native model tables. Nested field 110 # names come from the Rust model's serialization, so this wrapper does not 111 # maintain a second distribution schema. 112 113 @property 114 def buses(self) -> "list[dict[str, Any]]": 115 return self._inner.buses() 116 117 @property 118 def line_codes(self) -> "list[dict[str, Any]]": 119 return self._inner.line_codes() 120 121 @property 122 def lines(self) -> "list[dict[str, Any]]": 123 return self._inner.lines() 124 125 @property 126 def switches(self) -> "list[dict[str, Any]]": 127 return self._inner.switches() 128 129 @property 130 def transformers(self) -> "list[dict[str, Any]]": 131 return self._inner.transformers() 132 133 @property 134 def loads(self) -> "list[dict[str, Any]]": 135 return self._inner.loads() 136 137 @property 138 def generators(self) -> "list[dict[str, Any]]": 139 return self._inner.generators() 140 141 @property 142 def ibrs(self) -> "list[dict[str, Any]]": 143 return self._inner.ibrs() 144 145 @property 146 def control_profiles(self) -> "list[dict[str, Any]]": 147 return self._inner.control_profiles() 148 149 @property 150 def shunts(self) -> "list[dict[str, Any]]": 151 return self._inner.shunts() 152 153 @property 154 def capacitors(self) -> "list[dict[str, Any]]": 155 return self._inner.capacitors() 156 157 @property 158 def voltage_sources(self) -> "list[dict[str, Any]]": 159 return self._inner.voltage_sources() 160 161 @property 162 def untyped_objects(self) -> "list[dict[str, Any]]": 163 return self._inner.untyped_objects() 164 165 def to_graph(self) -> Any: 166 """Transform the network to collapsed bus and terminal graph data.""" 167 return _json.loads(self._inner.graph_json()) 168 169 def to_geo_layer(self) -> Any: 170 """Transform coordinates to a canonical GeoJSON FeatureCollection. 171 172 A network without coordinates produces an empty feature collection. 173 """ 174 return _json.loads(self._inner.to_geo_layer_json()) 175 176 def apply_geo_layer( 177 self, text: str, name_hint: Optional[str] = None 178 ) -> tuple["MulticonductorNetwork", Any]: 179 """Apply a geographic sidecar and return ``(placed, report)``. 180 181 ``text`` is any form :func:`powerio.parse_geo` accepts. This network 182 is unchanged; the placed copy drops the retained source text, so a 183 same-format emission re-serializes. 184 """ 185 inner, report = self._inner.apply_geo_layer(text, name_hint) 186 return MulticonductorNetwork(inner), report 187 188 def __repr__(self) -> str: 189 return self._inner.__repr__()
A parsed multiconductor distribution network in wire coordinates.
Buses carry named terminals, lines carry conductor impedance matrices, and
transformers carry per winding connections. This type is distinct from the
positive sequence powerio.BalancedNetwork; balanced matrix calculations do not
accept it.
41 @property 42 def name(self) -> Optional[str]: 43 """Distribution network name when the source format carries one.""" 44 return self._inner.name()
Distribution network name when the source format carries one.
46 @property 47 def source_format(self) -> Optional[str]: 48 """Format parsed from: ``dss``, ``pmd-json``, or ``bmopf-json``.""" 49 return self._inner.source_format()
Format parsed from: dss, pmd-json, or bmopf-json.
51 @property 52 def base_frequency(self) -> float: 53 """System base frequency in hertz.""" 54 return self._inner.base_frequency()
System base frequency in hertz.
100 @property 101 def n_voltage_sources(self) -> int: 102 """Number of grid forming voltage sources.""" 103 return self._inner.n_voltage_sources()
Number of grid forming voltage sources.
165 def to_graph(self) -> Any: 166 """Transform the network to collapsed bus and terminal graph data.""" 167 return _json.loads(self._inner.graph_json())
Transform the network to collapsed bus and terminal graph data.
169 def to_geo_layer(self) -> Any: 170 """Transform coordinates to a canonical GeoJSON FeatureCollection. 171 172 A network without coordinates produces an empty feature collection. 173 """ 174 return _json.loads(self._inner.to_geo_layer_json())
Transform coordinates to a canonical GeoJSON FeatureCollection.
A network without coordinates produces an empty feature collection.
176 def apply_geo_layer( 177 self, text: str, name_hint: Optional[str] = None 178 ) -> tuple["MulticonductorNetwork", Any]: 179 """Apply a geographic sidecar and return ``(placed, report)``. 180 181 ``text`` is any form :func:`powerio.parse_geo` accepts. This network 182 is unchanged; the placed copy drops the retained source text, so a 183 same-format emission re-serializes. 184 """ 185 inner, report = self._inner.apply_geo_layer(text, name_hint) 186 return MulticonductorNetwork(inner), report
Apply a geographic sidecar and return (placed, report).
text is any form powerio.parse_geo() accepts. This network
is unchanged; the placed copy drops the retained source text, so a
same-format emission re-serializes.
1011@_guard_class 1012class OperatingPoint(_TypedValue): 1013 """A possibly partial assignment over fixed equipment identities."""
A possibly partial assignment over fixed equipment identities.
Inherited Members
1191@_guard_class 1192class PioModule: 1193 """One typed value with diagnostics, producer, sources, source mappings, 1194 history, and extensions. 1195 """ 1196 1197 def __init__(self, inner: "_powerio._PioModule"): 1198 self._inner = inner 1199 1200 @classmethod 1201 def from_value(cls, value: Any) -> "PioModule": 1202 """Wrap an existing typed value without serializing it.""" 1203 if isinstance(value, BalancedNetwork): 1204 return cls(_powerio._PioModule.from_balanced_network(value._inner)) 1205 if isinstance(value, dist.MulticonductorNetwork): 1206 return cls(_powerio._PioModule.from_multiconductor_network(value._inner)) 1207 if isinstance(value, _TypedValue): 1208 location = value._collection_entry 1209 inner = value.module._inner 1210 if location is not None: 1211 inner = location.root._inner 1212 if location.scenario_id is not None: 1213 inner = inner._scenario_get(location.scenario_id) 1214 if location.time_index is not None: 1215 inner = inner._time_series_get(location.time_index) 1216 return cls(inner._copy()) 1217 raise TypeError("PioModule.from_value expects a typed PowerIO value") 1218 1219 @property 1220 def value(self) -> Any: 1221 """The contained typed value.""" 1222 type_name = self._inner._type_name 1223 if type_name == "powerio.BalancedNetwork": 1224 return BalancedNetwork(self._inner.as_balanced_network()) 1225 if type_name == "powerio.MulticonductorNetwork": 1226 return dist.MulticonductorNetwork(self._inner.as_multiconductor_network()) 1227 if type_name.startswith("powerio.TimeSeries<"): 1228 return TimeSeries._from_module(self) 1229 if type_name.startswith("powerio.ScenarioSet<"): 1230 return ScenarioSet._from_module(self) 1231 value_class = _VALUE_CLASSES.get(type_name) 1232 if value_class is None: 1233 raise RuntimeError(f"this binding has no Python class for {type_name}") 1234 return value_class(self) 1235 1236 @property 1237 def diagnostics(self) -> list[Diagnostic]: 1238 """The diagnostics stored on this module, in encounter order.""" 1239 return list(self._inner.diagnostics) 1240 1241 def to_balanced_report(self, base_mva: float = 100.0) -> Any: 1242 """Report whether a multiconductor network can become balanced.""" 1243 return _json.loads(self._inner.lowering_readiness_json(base_mva)) 1244 1245 def to_balanced(self, base_mva: float = 100.0) -> "PioModule": 1246 """Transform a multiconductor network to a balanced module.""" 1247 return PioModule(self._inner.lower_to_balanced(base_mva)) 1248 1249 def to_dc_pf_instance(self) -> "PioModule": 1250 """Build a DC power flow instance from a balanced network module.""" 1251 return PioModule(self._inner._to_dc_pf_instance()) 1252 1253 def to_ac_pf_instance(self) -> "PioModule": 1254 """Build an AC power flow instance from a balanced network module.""" 1255 return PioModule(self._inner._to_ac_pf_instance()) 1256 1257 def to_dc_opf_instance(self) -> "PioModule": 1258 """Build a DC optimal power flow instance from a balanced network module.""" 1259 return PioModule(self._inner._to_dc_opf_instance()) 1260 1261 def to_ac_opf_instance(self) -> "PioModule": 1262 """Build an AC optimal power flow instance from a balanced network module.""" 1263 return PioModule(self._inner._to_ac_opf_instance()) 1264 1265 def to_mc_ac_pf_instance(self) -> "PioModule": 1266 """Build a multiconductor AC power flow instance from a network module.""" 1267 return PioModule(self._inner._to_mc_ac_pf_instance()) 1268 1269 def to_mc_ac_opf_instance(self) -> "PioModule": 1270 """Build a multiconductor AC optimal power flow instance from a network module.""" 1271 return PioModule(self._inner._to_mc_ac_opf_instance()) 1272 1273 def __repr__(self) -> str: 1274 return repr(self._inner)
Abstract base class for generic types.
On Python 3.12 and newer, generic classes implicitly inherit from Generic when they declare a parameter list after the class's name::
class Mapping[KT, VT]:
def __getitem__(self, key: KT) -> VT:
...
# Etc.
On older versions of Python, however, generic classes have to explicitly inherit from Generic.
After a class has been declared to be generic, it can then be used as follows::
def lookup_name[KT, VT](mapping: Mapping[KT, VT], key: KT, default: VT) -> VT:
try:
return mapping[key]
except KeyError:
return default
1200 @classmethod 1201 def from_value(cls, value: Any) -> "PioModule": 1202 """Wrap an existing typed value without serializing it.""" 1203 if isinstance(value, BalancedNetwork): 1204 return cls(_powerio._PioModule.from_balanced_network(value._inner)) 1205 if isinstance(value, dist.MulticonductorNetwork): 1206 return cls(_powerio._PioModule.from_multiconductor_network(value._inner)) 1207 if isinstance(value, _TypedValue): 1208 location = value._collection_entry 1209 inner = value.module._inner 1210 if location is not None: 1211 inner = location.root._inner 1212 if location.scenario_id is not None: 1213 inner = inner._scenario_get(location.scenario_id) 1214 if location.time_index is not None: 1215 inner = inner._time_series_get(location.time_index) 1216 return cls(inner._copy()) 1217 raise TypeError("PioModule.from_value expects a typed PowerIO value")
Wrap an existing typed value without serializing it.
1219 @property 1220 def value(self) -> Any: 1221 """The contained typed value.""" 1222 type_name = self._inner._type_name 1223 if type_name == "powerio.BalancedNetwork": 1224 return BalancedNetwork(self._inner.as_balanced_network()) 1225 if type_name == "powerio.MulticonductorNetwork": 1226 return dist.MulticonductorNetwork(self._inner.as_multiconductor_network()) 1227 if type_name.startswith("powerio.TimeSeries<"): 1228 return TimeSeries._from_module(self) 1229 if type_name.startswith("powerio.ScenarioSet<"): 1230 return ScenarioSet._from_module(self) 1231 value_class = _VALUE_CLASSES.get(type_name) 1232 if value_class is None: 1233 raise RuntimeError(f"this binding has no Python class for {type_name}") 1234 return value_class(self)
The contained typed value.
1236 @property 1237 def diagnostics(self) -> list[Diagnostic]: 1238 """The diagnostics stored on this module, in encounter order.""" 1239 return list(self._inner.diagnostics)
The diagnostics stored on this module, in encounter order.
1241 def to_balanced_report(self, base_mva: float = 100.0) -> Any: 1242 """Report whether a multiconductor network can become balanced.""" 1243 return _json.loads(self._inner.lowering_readiness_json(base_mva))
Report whether a multiconductor network can become balanced.
1245 def to_balanced(self, base_mva: float = 100.0) -> "PioModule": 1246 """Transform a multiconductor network to a balanced module.""" 1247 return PioModule(self._inner.lower_to_balanced(base_mva))
Transform a multiconductor network to a balanced module.
1249 def to_dc_pf_instance(self) -> "PioModule": 1250 """Build a DC power flow instance from a balanced network module.""" 1251 return PioModule(self._inner._to_dc_pf_instance())
Build a DC power flow instance from a balanced network module.
1253 def to_ac_pf_instance(self) -> "PioModule": 1254 """Build an AC power flow instance from a balanced network module.""" 1255 return PioModule(self._inner._to_ac_pf_instance())
Build an AC power flow instance from a balanced network module.
1257 def to_dc_opf_instance(self) -> "PioModule": 1258 """Build a DC optimal power flow instance from a balanced network module.""" 1259 return PioModule(self._inner._to_dc_opf_instance())
Build a DC optimal power flow instance from a balanced network module.
1261 def to_ac_opf_instance(self) -> "PioModule": 1262 """Build an AC optimal power flow instance from a balanced network module.""" 1263 return PioModule(self._inner._to_ac_opf_instance())
Build an AC optimal power flow instance from a balanced network module.
1265 def to_mc_ac_pf_instance(self) -> "PioModule": 1266 """Build a multiconductor AC power flow instance from a network module.""" 1267 return PioModule(self._inner._to_mc_ac_pf_instance())
Build a multiconductor AC power flow instance from a network module.
A well-formed case cannot satisfy a requested operation.
A refused pass (e.g. PioModule.to_balanced()) additionally sets
diagnostics: the pass's structured findings, each a dict with
code, severity, message, and target. Absent on a
PowerIODataError raised elsewhere.
Base error from the powerio parser, emitter, or matrix calculations.
Failures mapped from the Rust core carry the diagnostic code string as
code; it is set at raise time, so it is instance-only.
A case file is malformed or unparseable.
PwdDisplay(canvas_width, canvas_height, stamp, substations)
PwdSubstation(number, name, x, y)
948@_guard_class 949class ScenarioSet(_TypedValue, Mapping): 950 """Named alternatives of one type, with optional probabilities.""" 951 952 def __init__( 953 self, 954 values: Mapping[str, Any], 955 *, 956 probabilities: Optional[Mapping[str, float]] = None, 957 ) -> None: 958 if not isinstance(values, Mapping): 959 raise TypeError("ScenarioSet values must be a mapping from IDs to values") 960 if probabilities is not None and not isinstance(probabilities, Mapping): 961 raise TypeError("probabilities must be a mapping from scenario IDs to numbers") 962 ids = list(values) 963 modules = [PioModule.from_value(values[id])._inner for id in ids] 964 inner = _powerio._PioModule._from_scenario_set( 965 modules, 966 ids, 967 None if probabilities is None else dict(probabilities), 968 ) 969 super().__init__(PioModule(inner)) 970 971 @classmethod 972 def _from_module(cls, module: "PioModule") -> "ScenarioSet": 973 value = object.__new__(cls) 974 _TypedValue.__init__(value, module) 975 return value 976 977 @property 978 def scenarios(self) -> tuple[Scenario, ...]: 979 return tuple(Scenario(*entry) for entry in self.module._inner._scenario_entries()) 980 981 def __len__(self) -> int: 982 return len(self.scenarios) 983 984 def __iter__(self): 985 return (scenario.id for scenario in self.scenarios) 986 987 def __contains__(self, scenario: object) -> bool: 988 return isinstance(scenario, str) and any( 989 entry.id == scenario for entry in self.scenarios 990 ) 991 992 def __getitem__(self, scenario: str) -> Any: 993 if not isinstance(scenario, str): 994 raise TypeError("scenario keys must be strings") 995 if scenario not in self: 996 raise KeyError(scenario) 997 current = self._collection_entry or _CollectionEntry(self.module) 998 if current.scenario_id is not None: 999 raise TypeError("nested ScenarioSet values are not supported") 1000 value = PioModule(self.module._inner._scenario_get(scenario)).value 1001 return _bind_collection_entry( 1002 value, 1003 _CollectionEntry( 1004 root=current.root, 1005 time_index=current.time_index, 1006 scenario_id=scenario, 1007 ), 1008 )
A Mapping is a generic container for associating key/value pairs.
This class provides concrete generic implementations of all methods except for __getitem__, __iter__, and __len__.
952 def __init__( 953 self, 954 values: Mapping[str, Any], 955 *, 956 probabilities: Optional[Mapping[str, float]] = None, 957 ) -> None: 958 if not isinstance(values, Mapping): 959 raise TypeError("ScenarioSet values must be a mapping from IDs to values") 960 if probabilities is not None and not isinstance(probabilities, Mapping): 961 raise TypeError("probabilities must be a mapping from scenario IDs to numbers") 962 ids = list(values) 963 modules = [PioModule.from_value(values[id])._inner for id in ids] 964 inner = _powerio._PioModule._from_scenario_set( 965 modules, 966 ids, 967 None if probabilities is None else dict(probabilities), 968 ) 969 super().__init__(PioModule(inner))
Inherited Members
1098class SocwrOpfSolution(_BalancedCalculation, _CalculationSolution): 1099 """A PowerModels SOCWR relaxation solution and objective lower bound."""
A PowerModels SOCWR relaxation solution and objective lower bound.
Inherited Members
One source byte range a diagnostic points at: the Python mirror of
powerio_core::SourceSpan. source is the source ID string, not the
bytes themselves; a caller resolves it against the owning module's
sources.
881@_guard_class 882class TimeSeries(_TypedValue, Sequence): 883 """Values of one type ordered in time.""" 884 885 def __init__( 886 self, 887 values: Sequence[Any], 888 *, 889 time_points: Sequence[TimePoint], 890 ) -> None: 891 if isinstance(values, (str, bytes, bytearray)) or not isinstance( 892 values, Sequence 893 ): 894 raise TypeError("TimeSeries values must be a sequence of PowerIO values") 895 if not isinstance(time_points, Sequence): 896 raise TypeError("time_points must be a sequence of TimePoint values") 897 points = tuple(time_points) 898 if not all(isinstance(point, TimePoint) for point in points): 899 raise TypeError("time_points must contain only TimePoint values") 900 modules = [PioModule.from_value(value)._inner for value in values] 901 inner = _powerio._PioModule._from_time_series( 902 modules, 903 [(point.label, point.duration_seconds) for point in points], 904 ) 905 super().__init__(PioModule(inner)) 906 907 @classmethod 908 def _from_module(cls, module: "PioModule") -> "TimeSeries": 909 value = object.__new__(cls) 910 _TypedValue.__init__(value, module) 911 return value 912 913 @property 914 def time_points(self) -> tuple[TimePoint, ...]: 915 return tuple(TimePoint(*point) for point in self.module._inner._time_series_points()) 916 917 def __len__(self) -> int: 918 return self.module._inner._time_series_len() 919 920 def __getitem__(self, index): 921 if isinstance(index, slice): 922 return [self[position] for position in range(*index.indices(len(self)))] 923 try: 924 position = _operator.index(index) 925 except TypeError: 926 raise TypeError("time series indices must be integers") from None 927 if position < 0: 928 position += len(self) 929 if position < 0 or position >= len(self): 930 raise IndexError("time series index out of range") 931 current = self._collection_entry or _CollectionEntry(self.module) 932 if current.time_index is not None: 933 raise TypeError("nested TimeSeries values are not supported") 934 value = PioModule(self.module._inner._time_series_get(position)).value 935 return _bind_collection_entry( 936 value, 937 _CollectionEntry( 938 root=current.root, 939 time_index=position, 940 scenario_id=current.scenario_id, 941 ), 942 ) 943 944 def __iter__(self): 945 return (self[position] for position in range(len(self)))
All the operations on a read-only sequence.
Concrete subclasses must override __new__ or __init__, __getitem__, and __len__.
885 def __init__( 886 self, 887 values: Sequence[Any], 888 *, 889 time_points: Sequence[TimePoint], 890 ) -> None: 891 if isinstance(values, (str, bytes, bytearray)) or not isinstance( 892 values, Sequence 893 ): 894 raise TypeError("TimeSeries values must be a sequence of PowerIO values") 895 if not isinstance(time_points, Sequence): 896 raise TypeError("time_points must be a sequence of TimePoint values") 897 points = tuple(time_points) 898 if not all(isinstance(point, TimePoint) for point in points): 899 raise TypeError("time_points must contain only TimePoint values") 900 modules = [PioModule.from_value(value)._inner for value in values] 901 inner = _powerio._PioModule._from_time_series( 902 modules, 903 [(point.label, point.duration_seconds) for point in points], 904 ) 905 super().__init__(PioModule(inner))
Inherited Members
1344@_guard 1345def apply_bus_load_active_power( 1346 module: PioModule, 1347 bus_id: int, 1348 total: ActivePower, 1349 *, 1350 allocation: str = "proportional_to_current_active_power", 1351) -> UpdateReport: 1352 """Replace aggregate bus demand through an explicit PowerIO allocation rule. 1353 1354 ``"proportional_to_current_active_power"`` preserves each participating 1355 load's current share. ``"equal"`` gives every participating load the same 1356 share, including when their current aggregate demand is zero. PowerIO 1357 requires stable load IDs and reports each load changed. 1358 """ 1359 if not isinstance(module, PioModule): 1360 raise TypeError("module must be a PioModule") 1361 if not isinstance(total, ActivePower): 1362 raise TypeError("total must be an ActivePower") 1363 return module._inner._apply_bus_load_active_power( 1364 bus_id, 1365 total, 1366 allocation=allocation, 1367 )
Replace aggregate bus demand through an explicit PowerIO allocation rule.
"proportional_to_current_active_power" preserves each participating
load's current share. "equal" gives every participating load the same
share, including when their current aggregate demand is zero. PowerIO
requires stable load IDs and reports each load changed.
1310@_guard 1311def apply_updates( 1312 target: Any, 1313 updates: Union[ 1314 Iterable[OperatingPointUpdate], 1315 Iterable[NetworkUpdate], 1316 Iterable[CalculationUpdate], 1317 ], 1318) -> UpdateReport: 1319 """Validate and apply one batch of typed updates atomically. 1320 1321 ``updates`` contains one update class: :class:`OperatingPointUpdate`, 1322 :class:`NetworkUpdate`, or :class:`CalculationUpdate`. Values are absolute 1323 replacements and power quantities carry their units in the typed value. 1324 ``target`` is a module or a value obtained by indexing a :class:`TimeSeries` 1325 or :class:`ScenarioSet`. If validation fails, the module is unchanged. 1326 """ 1327 batch = list(updates) 1328 if isinstance(target, PioModule): 1329 return target._inner._apply_updates(batch) 1330 location = getattr(target, "_collection_entry", None) 1331 if not isinstance(location, _CollectionEntry): 1332 raise TypeError( 1333 "target must be a PioModule or a TimeSeries/ScenarioSet entry" 1334 ) 1335 report = location.root._inner._apply_collection_updates( 1336 batch, 1337 time_index=location.time_index, 1338 scenario_id=location.scenario_id, 1339 ) 1340 _refresh_collection_entry(target, location) 1341 return report
Validate and apply one batch of typed updates atomically.
updates contains one update class: OperatingPointUpdate,
NetworkUpdate, or CalculationUpdate. Values are absolute
replacements and power quantities carry their units in the typed value.
target is a module or a value obtained by indexing a TimeSeries
or ScenarioSet. If validation fails, the module is unchanged.
1505@_guard 1506def deserialize(source: Any) -> PioModule: 1507 """Deserialize PowerIO IR from a path, file object, or bytes-like source.""" 1508 path = _path_from_source(source) 1509 if path is not None: 1510 return PioModule(_powerio._PioModule._deserialize_path(path)) 1511 data, _ = _memory_from_source(source, None) 1512 return PioModule(_powerio._PioModule._deserialize_memory(data))
Deserialize PowerIO IR from a path, file object, or bytes-like source.
1483@_guard 1484def emit(module: PioModule, format: str, destination: Optional[Any] = None) -> EmitResult: 1485 """Emit a module as one grid exchange format.""" 1486 return _emit_to_destination( 1487 module, 1488 destination, 1489 lambda: module._inner._emit_memory(format), 1490 lambda path: module._inner._emit_path(format, path), 1491 )
Emit a module as one grid exchange format.
1515@_guard 1516def features() -> dict[str, bool]: 1517 """The build-time features compiled into this powerio installation. 1518 1519 ``matrix``, ``dist``, and ``prob`` are unconditional dependencies of the 1520 extension and are always ``True``. ``gridfm`` reports whether GridFM 1521 Parquet parsing and emission were compiled in; the published wheel 1522 includes them, while a custom source build can omit them. 1523 """ 1524 return { 1525 "matrix": True, 1526 "gridfm": bool(getattr(_powerio, "_has_gridfm", False)), 1527 "dist": True, 1528 "prob": True, 1529 }
The build-time features compiled into this powerio installation.
matrix, dist, and prob are unconditional dependencies of the
extension and are always True. gridfm reports whether GridFM
Parquet parsing and emission were compiled in; the published wheel
includes them, while a custom source build can omit them.
806@_guard 807def from_ppc(ppc) -> BalancedNetwork: 808 """Case from a PYPOWER dict (``ppc``); the inverse of :meth:`BalancedNetwork.to_ppc`. 809 810 The tables route through the MATPOWER reader, so the semantics match a 811 ``.m`` case exactly: bus ``PD``/``QD`` become loads, ``GS``/``BS`` become 812 shunts, and ``gencost`` is read when present. Result columns past the 813 MATPOWER input widths are dropped. A 10 column ``gen`` table (the layout 814 without the OPF capability columns) passes through at its own width, so 815 the generators come back with no capability limits rather than eleven 816 zero ones. Raises :class:`ValueError` when a required table is absent, 817 when a ``bus`` or ``branch`` row is below its 13 column width, when a row 818 is not a sequence of numbers, or when a cell is not numeric; the message 819 names the table and the row. 820 """ 821 value = parse( 822 _io.StringIO(_ppc_to_matpower_text(ppc)), 823 format="matpower", 824 name="from_ppc.m", 825 ).value 826 assert isinstance(value, BalancedNetwork) 827 return value
Case from a PYPOWER dict (ppc); the inverse of BalancedNetwork.to_ppc().
The tables route through the MATPOWER reader, so the semantics match a
.m case exactly: bus PD/QD become loads, GS/BS become
shunts, and gencost is read when present. Result columns past the
MATPOWER input widths are dropped. A 10 column gen table (the layout
without the OPF capability columns) passes through at its own width, so
the generators come back with no capability limits rather than eleven
zero ones. Raises ValueError when a required table is absent,
when a bus or branch row is below its 13 column width, when a row
is not a sequence of numbers, or when a cell is not numeric; the message
names the table and the row.
1412@_guard 1413def parse( 1414 source: Any, 1415 *, 1416 format: Optional[str] = None, 1417 name: Optional[str] = None, 1418) -> PioModule: 1419 """Parse a path, file object, or bytes-like source. 1420 1421 A string is always a path. Pass raw text through ``io.StringIO`` or 1422 another file object. 1423 """ 1424 path = _path_from_source(source) 1425 if path is not None: 1426 if name is not None: 1427 raise ValueError("name is only valid for memory and file object sources") 1428 return PioModule(_powerio._PioModule._parse_path(path, format)) 1429 data, source_name = _memory_from_source(source, name) 1430 return PioModule(_powerio._PioModule._parse_memory(data, source_name, format))
Parse a path, file object, or bytes-like source.
A string is always a path. Pass raw text through io.StringIO or
another file object.
694@_guard 695def parse_display(path: Any, format: Optional[str] = None) -> DisplayData: 696 """Parse a display artifact such as a PowerWorld ``.pwd`` file.""" 697 return _wrap_display(_powerio.parse_display(str(path), format))
Parse a display artifact such as a PowerWorld .pwd file.
707@_guard 708def parse_geo(text: str, name_hint: Optional[str] = None) -> dict[str, Any]: 709 """Tolerantly read a geographic sidecar and return its canonical form. 710 711 Accepts headerless buscoords CSV, aliased CSV/JSON records, and GeoJSON 712 Point/LineString features. Returns ``{"geojson": <FeatureCollection dict>, 713 "diagnostics": [...]}``; ``name_hint`` (a file name) picks CSV against JSON 714 when the content alone is ambiguous. Input with no usable coordinates 715 raises :class:`PowerIOParseError`. 716 """ 717 parsed = _powerio.parse_geo(text, name_hint) 718 parsed["geojson"] = _json.loads(parsed["geojson"]) 719 return parsed
Tolerantly read a geographic sidecar and return its canonical form.
Accepts headerless buscoords CSV, aliased CSV/JSON records, and GeoJSON
Point/LineString features. Returns {"geojson": <FeatureCollection dict>,
"diagnostics": [...]}; name_hint (a file name) picks CSV against JSON
when the content alone is ambiguous. Input with no usable coordinates
raises PowerIOParseError.
700@_guard 701def resolve_format(name: str) -> Optional[FormatInfo]: 702 """Resolve a format token or common alias to its canonical metadata.""" 703 resolved = _powerio.resolve_format(name) 704 return None if resolved is None else FormatInfo(*resolved)
Resolve a format token or common alias to its canonical metadata.
1494@_guard 1495def serialize(module: PioModule, destination: Optional[Any] = None) -> EmitResult: 1496 """Serialize a module as PowerIO IR.""" 1497 return _emit_to_destination( 1498 module, 1499 destination, 1500 module._inner._serialize_memory, 1501 module._inner._serialize_path, 1502 )
Serialize a module as PowerIO IR.
835@_guard 836def versions() -> Any: 837 """Return the PowerIO release, sole IR identity, and BMOPF schema.""" 838 return _json.loads(_powerio.versions_json())
Return the PowerIO release, sole IR identity, and BMOPF schema.