powerio

Parse, convert, and project power system data.

Readers produce a format neutral network model. Writers return retained source bytes where supported or report fields that a target format cannot represent. Packages, sparse matrices, graphs, and problem instances use the same parsed data::

import powerio as pio

net = pio.parse_file("case9.m")          # format inferred from the extension
print(net.n_buses, net.base_mva)         # 9 100.0
text = net.to_matpower()                 # byte-exact MATPOWER echo
raw, warnings = pio.convert_file("case9.m", "psse")
pp_json, warnings = pio.convert_file("case9.m", "pandapower-json")
pypsa_out = net.write_pypsa_csv_folder("case9-pypsa")
pkg = pio.Package.from_file("goc3_case.json", from_="goc3-json")
points = pkg.operating_points()

B = net.bprime()                         # scipy.sparse, MATPOWER Bp
Y = net.ybus()                           # 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.

GO Challenge 3 JSON is read as a static balanced network using the first interval. When it is parsed as a .pio.json package, the full source time series is exposed as replayable operating points.

import powerio and the base parse, write, and conversion 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, convert, and project power system data.
   2
   3Readers produce a format neutral network model. Writers return retained source
   4bytes where supported or report fields that a target format cannot represent.
   5Packages, sparse matrices, graphs, and problem instances use the same parsed
   6data::
   7
   8    import powerio as pio
   9
  10    net = pio.parse_file("case9.m")          # format inferred from the extension
  11    print(net.n_buses, net.base_mva)         # 9 100.0
  12    text = net.to_matpower()                 # byte-exact MATPOWER echo
  13    raw, warnings = pio.convert_file("case9.m", "psse")
  14    pp_json, warnings = pio.convert_file("case9.m", "pandapower-json")
  15    pypsa_out = net.write_pypsa_csv_folder("case9-pypsa")
  16    pkg = pio.Package.from_file("goc3_case.json", from_="goc3-json")
  17    points = pkg.operating_points()
  18
  19    B = net.bprime()                         # scipy.sparse, MATPOWER Bp
  20    Y = net.ybus()                           # complex csr, G + jB
  21    G = net.to_networkx()                    # networkx.Graph keyed by bus id
  22
  23PyPSA CSV folders carry static network topology. NetCDF and HDF5 time series
  24are tracked in https://github.com/eigenergy/powerio/issues/107.
  25
  26GO Challenge 3 JSON is read as a static balanced network using the first
  27interval. When it is parsed as a ``.pio.json`` package, the full source time
  28series is exposed as replayable operating points.
  29
  30``import powerio`` and the base parse, write, and conversion paths require no
  31third party Python package. Matrix methods require SciPy and NumPy. Graph
  32methods require NetworkX. Install them with ``powerio[matrix]``,
  33``powerio[graph]``, or ``powerio[all]``. Missing extras raise ``ImportError``.
  34"""
  35
  36from __future__ import annotations
  37
  38import importlib
  39import json as _json
  40from collections import namedtuple
  41from typing import Any, Optional
  42
  43from . import _powerio
  44from ._powerio import PowerIODataError, PowerIOError, PowerIOParseError, __version__
  45
  46__all__ = [
  47    "BalancedNetwork",
  48    "Conversion",
  49    "DenseBranch",
  50    "DenseDemand",
  51    "DenseGen",
  52    "DenseNetwork",
  53    "DenseShunt",
  54    "DisplayData",
  55    "GridfmRead",
  56    "Incidence",
  57    "Package",
  58    "PowerIODataError",
  59    "PowerIOError",
  60    "PowerIOParseError",
  61    "PwdDisplay",
  62    "PwdSubstation",
  63    "YbusParts",
  64    "__version__",
  65    "convert_file",
  66    "convert_str",
  67    "dist",
  68    "from_json",
  69    "from_ppc",
  70    "parse_bytes",
  71    "parse_display_bytes",
  72    "parse_display_file",
  73    "parse_file",
  74    "parse_geo",
  75    "parse_scopf",
  76    "parse_str",
  77    "read_gridfm",
  78    "read_gridfm_scenarios",
  79    "read_pypsa_csv_folder",
  80    "to_dense",
  81    "to_format",
  82    "to_json",
  83    "to_matpower",
  84    "write_gridfm_batch",
  85]
  86
  87Conversion = namedtuple("Conversion", ["text", "warnings"])
  88Conversion.__doc__ = """Output of :func:`convert_file`.
  89
  90``text`` is the converted file contents; ``warnings`` lists the fields the
  91target format could not represent (empty for a faithful conversion).
  92"""
  93
  94GridfmRead = namedtuple("GridfmRead", ["network", "scenario", "warnings"])
  95GridfmRead.__doc__ = """Output of :func:`read_gridfm` / :func:`read_gridfm_scenarios`.
  96
  97``network`` is the reconstructed :class:`BalancedNetwork`; ``scenario`` is the source
  98scenario ID; ``warnings`` lists fields the GridFM schema cannot retain,
  99including source bus IDs, per element load and shunt rows, HVDC, storage, and
 100piecewise costs.
 101"""
 102
 103DisplayData = namedtuple("DisplayData", ["kind", "data"])
 104DisplayData.__doc__ = """Output of :func:`parse_display_file` / :func:`parse_display_bytes`.
 105
 106``kind`` names the display format. For PowerWorld PWD data,
 107``kind == "powerworld"`` and
 108``data`` is a :class:`PwdDisplay`.
 109"""
 110
 111PwdDisplay = namedtuple(
 112    "PwdDisplay", ["canvas_width", "canvas_height", "stamp", "substations"]
 113)
 114PwdDisplay.__doc__ = """Decoded PowerWorld ``.pwd`` display metadata."""
 115
 116PwdSubstation = namedtuple("PwdSubstation", ["number", "name", "x", "y"])
 117PwdSubstation.__doc__ = """One decoded PowerWorld display substation."""
 118
 119Incidence = namedtuple("Incidence", ["A", "b", "p_shift", "branch_of_col"])
 120Incidence.__doc__ = """Output of :meth:`BalancedNetwork.incidence`.
 121
 122Shapes, with ``n`` buses and ``m`` in-service branches:
 123- ``A``: signed incidence csr_matrix, ``(n, m)``.
 124- ``b``: branch susceptances, ``(m,)``; ``b[k]`` is column ``k``.
 125- ``p_shift``: phase-shift injection, ``(n,)`` (all zero unless
 126  ``convention="matpower"``).
 127- ``branch_of_col``: column→branch index map, ``(m,)``; ``branch_of_col[k]``
 128  and ``b[k]`` are co-indexed by incidence column ``k``.
 129"""
 130
 131YbusParts = namedtuple("YbusParts", ["g", "b"])
 132YbusParts.__doc__ = (
 133    "Output of :meth:`BalancedNetwork.ybus_parts`: ``g`` = Re(Y_bus), ``b`` = Im(Y_bus), "
 134    "each a real csr_matrix. ``BalancedNetwork.ybus()`` returns ``g + 1j*b``."
 135)
 136
 137DenseBranch = namedtuple(
 138    "DenseBranch", ["from_id", "to_id", "r", "x", "b", "tap", "shift", "in_service"]
 139)
 140DenseBranch.__doc__ = """Branch arrays in source order."""
 141
 142DenseGen = namedtuple("DenseGen", ["bus", "pg", "pmax", "pmin", "in_service"])
 143DenseGen.__doc__ = """Generator arrays in source order."""
 144
 145DenseDemand = namedtuple("DenseDemand", ["pd", "qd"])
 146DenseDemand.__doc__ = """Nodal active and reactive demand arrays in bus order."""
 147
 148DenseShunt = namedtuple("DenseShunt", ["gs", "bs"])
 149DenseShunt.__doc__ = """Nodal shunt conductance and susceptance arrays in bus order."""
 150
 151DenseNetwork = namedtuple(
 152    "DenseNetwork",
 153    [
 154        "n",
 155        "m",
 156        "ng",
 157        "base_mva",
 158        "bus_ids",
 159        "branch",
 160        "gen",
 161        "demand",
 162        "shunt",
 163        "reference_bus",
 164        "n_components",
 165        "is_radial",
 166    ],
 167)
 168DenseNetwork.__doc__ = """Copied dense NumPy table export of a parsed :class:`BalancedNetwork`."""
 169
 170
 171def _require(module: str, extra: str):
 172    """Import ``module`` or raise a clear ImportError naming the extra to install."""
 173    try:
 174        return importlib.import_module(module)
 175    except ImportError as exc:
 176        # Only rewrite "module is absent". A present-but-broken install (e.g. a
 177        # failed C-extension load) raises ImportError from a sub-import; let its
 178        # own traceback through instead of misdirecting the user to reinstall.
 179        if getattr(exc, "name", None) not in (module, module.split(".")[0]):
 180            raise
 181        raise ImportError(
 182            f"powerio needs {module!r} for this call; install it with "
 183            f"`pip install 'powerio[{extra}]'`"
 184        ) from exc
 185
 186
 187def _to_csr(coo):
 188    """Assemble a ``(data, row, col, shape)`` COO tuple into a csr_matrix."""
 189    sparse = _require("scipy.sparse", "matrix")
 190    data, row, col, shape = coo
 191    return sparse.coo_matrix((data, (row, col)), shape=shape).tocsr()
 192
 193
 194def _require_gridfm() -> None:
 195    """Raise a clear ImportError if the extension lacks the gridfm Parquet surface.
 196
 197    Published wheels include this surface. A custom source build can omit the
 198    Rust feature, in which case the method names still raise a direct error
 199    instead of failing with ``AttributeError``.
 200    """
 201    if not getattr(_powerio, "_has_gridfm", False):
 202        raise ImportError(
 203            "powerio was built without the gridfm Parquet surface; reinstall a "
 204            "wheel built with gridfm support or rebuild from source with "
 205            "`maturin develop --features gridfm`."
 206        )
 207
 208
 209def _wrap_display(raw) -> DisplayData:
 210    kind, payload = raw
 211    if kind == "powerworld":
 212        substations = [
 213            PwdSubstation(
 214                row["number"],
 215                row["name"],
 216                row["x"],
 217                row["y"],
 218            )
 219            for row in payload["substations"]
 220        ]
 221        payload = PwdDisplay(
 222            payload["canvas_width"],
 223            payload["canvas_height"],
 224            payload["stamp"],
 225            substations,
 226        )
 227    return DisplayData(kind, payload)
 228
 229
 230class BalancedNetwork:
 231    """A parsed balanced power network.
 232
 233    The data attributes (``buses``, ``branches``, ``gens``, ``loads``,
 234    ``shunts``) and the non-matrix methods (``write``, ``reference_bus_index``,
 235    ``connectivity_report``, ``write_dcopf_bundle``) delegate to the compiled
 236    handle; the matrix methods below return ``scipy.sparse`` objects. Read
 237    fidelity warnings from parse time are on ``read_warnings``. Readers use this
 238    for source data they cannot model or assumptions they had to make.
 239
 240    Errors: a bad file path raises the standard ``OSError`` subclass
 241    (``FileNotFoundError``); a malformed case raises :class:`PowerIOParseError`
 242    and an unmet builder precondition (no generators, no reference bus) raises
 243    :class:`PowerIODataError`; both subclass :class:`PowerIOError`, so
 244    ``except PowerIOError`` catches either; an unknown
 245    ``scheme``/``convention``/``units`` string raises ``ValueError``.
 246    """
 247
 248    def __init__(self, inner: "_powerio._BalancedNetwork"):
 249        self._inner = inner
 250
 251    def __dir__(self):
 252        # The data attributes arrive through __getattr__, so name them here or
 253        # they stay invisible to tab completion.
 254        return sorted(set(super().__dir__()) | set(dir(self._inner)))
 255
 256    def __getattr__(self, name: str):
 257        # Reached only when normal lookup misses, so the matrix methods below
 258        # win. Guard underscore names so a lookup before _inner exists raises
 259        # AttributeError instead of recursing forever.
 260        if name.startswith("_"):
 261            raise AttributeError(
 262                f"{type(self).__name__!r} object has no attribute {name!r}"
 263            )
 264        return getattr(self._inner, name)
 265
 266    def __repr__(self) -> str:
 267        # The inner handle's __repr__ already renders the public ``BalancedNetwork(...)``
 268        # form, so this is a straight delegate.
 269        return repr(self._inner)
 270
 271    # --- canonical format and table exports -----------------------------
 272
 273    def to_matpower(self) -> str:
 274        """Serialize to MATPOWER ``.m`` text.
 275
 276        A case parsed from MATPOWER keeps its original source, so this returns a
 277        byte-exact echo. Derived cases serialize from the format neutral model.
 278        """
 279        return self._inner.to_matpower()
 280
 281    def to_json(self) -> str:
 282        """Serialize to the JSON transport."""
 283        return self._inner.to_json()
 284
 285    def geo_layer(self) -> dict[str, Any]:
 286        """This case's coordinates as a canonical GeoJSON FeatureCollection.
 287
 288        Raises :class:`PowerIOError` when the case carries none.
 289        """
 290        return _json.loads(self._inner.geo_layer_json())
 291
 292    def apply_geo_layer(
 293        self, text: str, name_hint: Optional[str] = None
 294    ) -> tuple["BalancedNetwork", dict[str, Any]]:
 295        """Apply a geographic sidecar and return ``(placed, report)``.
 296
 297        ``text`` is any form :func:`parse_geo` accepts; this case is
 298        unchanged. The report carries ``matched_buses``, ``matched_branches``,
 299        ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``,
 300        and ``notes``. The two unlocated counts cover the whole case when the
 301        pass ends, so a layer that matched nothing reads apart from a case
 302        that needed nothing. The placed copy drops the retained source text,
 303        so a same-format write re-serializes.
 304        """
 305        inner, report = self._inner.apply_geo_layer(text, name_hint)
 306        return BalancedNetwork(inner), report
 307
 308    def acopf_instance(self, units: Optional[str] = None) -> dict[str, Any]:
 309        """The matrix free AC OPF problem instance as Python data.
 310
 311        Dense 0-based indices; ``units`` is ``"perunit"`` (default) or
 312        ``"native"``.
 313        """
 314        return _json.loads(self._inner.acopf_json(units))
 315
 316    def to_format(
 317        self,
 318        to: str,
 319        missing_gen_cost: Optional[str] = None,
 320        default_gen_cost: Optional[str] = None,
 321        gen_cost_csv: Optional[Any] = None,
 322    ) -> Conversion:
 323        """Serialize this parsed case to another format.
 324
 325        ``to`` is one of the format names accepted by :func:`convert_file`.
 326        Returns a :class:`Conversion` with output text and fidelity warnings.
 327        """
 328        text, warnings = self._inner.to_format(
 329            to,
 330            missing_gen_cost=missing_gen_cost,
 331            default_gen_cost=default_gen_cost,
 332            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 333        )
 334        return Conversion(text, warnings)
 335
 336    def write_file(
 337        self,
 338        path: Any,
 339        to: str,
 340        missing_gen_cost: Optional[str] = None,
 341        default_gen_cost: Optional[str] = None,
 342        gen_cost_csv: Optional[Any] = None,
 343    ) -> list[str]:
 344        r"""Serialize this case to ``to`` and write it to ``path`` byte exact.
 345
 346        Returns the fidelity warnings. Prefer this over writing
 347        :meth:`to_format` text through ``open(path, "w")``: Python's text mode
 348        translates newlines on Windows, so a case whose retained source has
 349        CRLF line endings comes out with doubled carriage returns
 350        (``\r\r\n``), which PSS/E family tools reject.
 351        """
 352        return self._inner.write_file(
 353            str(path),
 354            to,
 355            missing_gen_cost=missing_gen_cost,
 356            default_gen_cost=default_gen_cost,
 357            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 358        )
 359
 360    def to_dense(self) -> DenseNetwork:
 361        """Dense NumPy arrays for solver and adapter code.
 362
 363        This allocates new arrays, preserves bus and branch source order, and
 364        sums loads and shunts per bus to match the Rust indexed analysis view.
 365
 366        That view is the star-lowered one, so a case with an in-service
 367        3-winding transformer reports the star bus and its three branches here
 368        even though :attr:`buses` and :attr:`branches` mirror the case file and
 369        do not. ``reference_bus``, ``n_components`` and ``is_radial`` are
 370        computed over the same lowered space, so all of them agree.
 371        """
 372        np = _require("numpy", "matrix")
 373        lowered = self._inner.lowered()
 374        buses = lowered.buses
 375        branches = lowered.branches
 376        generators = lowered.generators
 377        bus_ids = np.asarray([b["id"] for b in buses], dtype=np.int64)
 378        pd, qd, gs, bs = _bus_sums(np, buses, lowered.loads, lowered.shunts)
 379
 380        branch = DenseBranch(
 381            from_id=np.asarray([br["from_id"] for br in branches], dtype=np.int64),
 382            to_id=np.asarray([br["to_id"] for br in branches], dtype=np.int64),
 383            r=np.asarray([br["r"] for br in branches], dtype=float),
 384            x=np.asarray([br["x"] for br in branches], dtype=float),
 385            b=np.asarray([br["b"] for br in branches], dtype=float),
 386            tap=np.asarray([br["tap"] for br in branches], dtype=float),
 387            shift=np.asarray([br["shift"] for br in branches], dtype=float),
 388            in_service=np.asarray([br["in_service"] for br in branches], dtype=bool),
 389        )
 390        gen = DenseGen(
 391            bus=np.asarray([g["bus"] for g in generators], dtype=np.int64),
 392            pg=np.asarray([g["pg"] for g in generators], dtype=float),
 393            pmax=np.asarray([g["pmax"] for g in generators], dtype=float),
 394            pmin=np.asarray([g["pmin"] for g in generators], dtype=float),
 395            in_service=np.asarray([g["in_service"] for g in generators], dtype=bool),
 396        )
 397        refs = self.reference_bus_indices()
 398        return DenseNetwork(
 399            n=len(buses),
 400            m=len(branches),
 401            ng=len(generators),
 402            base_mva=self.base_mva,
 403            bus_ids=bus_ids,
 404            branch=branch,
 405            gen=gen,
 406            demand=DenseDemand(pd=pd, qd=qd),
 407            shunt=DenseShunt(gs=gs, bs=bs),
 408            reference_bus=refs[0] if len(refs) == 1 else None,
 409            n_components=self.n_connected_components,
 410            is_radial=self.is_radial,
 411        )
 412
 413    # --- matrix builders (scipy.sparse) ---------------------------------
 414
 415    def bprime(self, scheme: str = "bx"):
 416        """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
 417        return _to_csr(self._inner.bprime(scheme))
 418
 419    def bdoubleprime(self, scheme: str = "bx"):
 420        """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
 421        return _to_csr(self._inner.bdoubleprime(scheme))
 422
 423    def lacpf(self, *, include_taps: bool = True, include_shifts: bool = True):
 424        """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``."""
 425        return _to_csr(
 426            self._inner.lacpf(include_taps=include_taps, include_shifts=include_shifts)
 427        )
 428
 429    def adjacency(self):
 430        """0/1 bus adjacency matrix."""
 431        return _to_csr(self._inner.adjacency())
 432
 433    def ybus_parts(self, *, include_taps: bool = True, include_shifts: bool = True):
 434        """:class:`YbusParts` ``(g, b)`` = ``(Re(Y_bus), Im(Y_bus))``, two real
 435        csr_matrix."""
 436        g, b = self._inner.ybus_parts(
 437            include_taps=include_taps, include_shifts=include_shifts
 438        )
 439        return YbusParts(g=_to_csr(g), b=_to_csr(b))
 440
 441    def ybus(self, *, include_taps: bool = True, include_shifts: bool = True):
 442        """``Y_bus = G + jB`` as a complex csr_matrix."""
 443        g, b = self.ybus_parts(
 444            include_taps=include_taps, include_shifts=include_shifts
 445        )
 446        return (g + 1j * b).tocsr()
 447
 448    def ptdf(self, convention: str = "series", solver: str = "auto"):
 449        """DC PTDF (m×n). ``convention`` is ``"series"`` or ``"matpower"``.
 450
 451        ``solver`` is ``"auto"``, ``"dense"``, or ``"iterative"``. ``"auto"``
 452        uses the dense factorization on small cases and the iterative
 453        conjugate gradient path on large ones, the same policy as the CLI.
 454        """
 455        return _to_csr(self._inner.ptdf(convention, solver))
 456
 457    def lodf(self, convention: str = "series", solver: str = "auto"):
 458        """DC LODF (m×m). ``solver`` as in :meth:`ptdf`."""
 459        return _to_csr(self._inner.lodf(convention, solver))
 460
 461    def weighted_laplacian(self, convention: str = "series"):
 462        """Weighted Laplacian ``L = A diag(b) Aáµ€``."""
 463        return _to_csr(self._inner.weighted_laplacian(convention))
 464
 465    def incidence(self, convention: str = "series") -> "Incidence":
 466        """Signed incidence factorization as an :data:`Incidence` tuple."""
 467        np = _require("numpy", "matrix")
 468        a, b, p_shift, branch_of_col = self._inner.incidence(convention)
 469        return Incidence(
 470            A=_to_csr(a),
 471            b=np.asarray(b, dtype=float),
 472            p_shift=np.asarray(p_shift, dtype=float),
 473            branch_of_col=np.asarray(branch_of_col, dtype=np.int64),
 474        )
 475
 476    def write_gridfm(
 477        self,
 478        out_dir: Any,
 479        *,
 480        scenario: int = 0,
 481        include_y_bus: bool = True,
 482        include_taps: bool = True,
 483        include_shifts: bool = True,
 484        missing_gen_cost: Optional[str] = None,
 485        default_gen_cost: Optional[str] = None,
 486        gen_cost_csv: Optional[Any] = None,
 487    ) -> dict:
 488        """Write the gridfm-datakit Parquet dataset for this case under
 489        ``<out_dir>/<case>/raw/``.
 490
 491        Returns a dict with ``dir``, ``files``, ``dropped_zero_impedance``, and
 492        ``degenerate_cost_gens``. Published wheels include the native writer;
 493        custom source builds without the Rust ``gridfm`` feature raise
 494        ``ImportError``. For many perturbed snapshots in one dataset, see
 495        :func:`write_gridfm_batch`.
 496        """
 497        _require_gridfm()
 498        return self._inner.write_gridfm(
 499            str(out_dir),
 500            scenario=scenario,
 501            include_y_bus=include_y_bus,
 502            include_taps=include_taps,
 503            include_shifts=include_shifts,
 504            missing_gen_cost=missing_gen_cost,
 505            default_gen_cost=default_gen_cost,
 506            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 507        )
 508
 509    def write_pypsa_csv_folder(self, out_dir: Any) -> dict:
 510        """Write this case as a PyPSA CSV folder.
 511
 512        The folder contains static PyPSA component CSVs and can be imported with
 513        ``pypsa.Network().import_from_csv_folder(path)``. Returns a dict with
 514        ``dir``, ``files``, and fidelity ``warnings``.
 515        """
 516        return self._inner.write_pypsa_csv_folder(str(out_dir))
 517
 518    def to_normalized(self) -> "BalancedNetwork":
 519        """Return a normalized copy with per unit power and radian angles.
 520
 521        The result removes out of service elements, preserves source bus IDs,
 522        and normalizes bus types. It carries no retained source, so
 523        :meth:`write` serializes the derived model. Raises
 524        :class:`PowerIODataError` if the network cannot be
 525        normalized (no reference bus can be chosen, or a non-positive base MVA).
 526        """
 527        return BalancedNetwork(self._inner.to_normalized())
 528
 529    def to_normalized_with_options(
 530        self,
 531        *,
 532        clamp_angle_bounds: bool = False,
 533        angle_bound_pad: Optional[float] = None,
 534    ) -> "BalancedNetwork":
 535        """Return a normalized copy with explicit normalization options.
 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        return BalancedNetwork(
 544            self._inner.to_normalized_with_options(
 545                clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad
 546            )
 547        )
 548
 549    def to_ppc(self):
 550        """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables.
 551
 552        Values are emitted as the model holds them, so a case read from a
 553        file carries MW, MVAr, and degrees. A network from
 554        :meth:`to_normalized` holds per unit and radians, and those are what
 555        its tables carry — PYPOWER reads a ppc dict as MW and degrees, so
 556        build this from the raw network unless the consumer expects per unit.
 557
 558        Loads and shunts are summed onto their bus in the
 559        ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation
 560        :meth:`to_matpower` writes. The bus table has no per element status
 561        column, so an element the model marks out of service still
 562        contributes its value, and a de-energized bus is carried as type 4.
 563        ``gencost`` is present only when every generator carries cost data,
 564        because MATPOWER requires cost rows for all generators or none.
 565        :func:`from_ppc` reads the tables back.
 566        """
 567        np = _require("numpy", "matrix")
 568        buses = self._inner.buses
 569        bus = np.array(
 570            [
 571                (
 572                    b["id"], _PPC_BUS_TYPE.get(b["kind"], 1.0), 0.0, 0.0, 0.0, 0.0,
 573                    b["area"], b["vm"], b["va"], b["base_kv"], b["zone"],
 574                    b["vmax"], b["vmin"],
 575                )
 576                for b in buses
 577            ],
 578            dtype=float,
 579        ).reshape(len(buses), 13)
 580        bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums(
 581            np, buses, self._inner.loads, self._inner.shunts
 582        )
 583
 584        # The capability and ramp columns past PMIN are an OPF extension that a
 585        # source need not carry. Widen to the full 21 only when a generator
 586        # actually states one: a table of zeros there reads back as eleven
 587        # explicit zero limits, which a ramp aware solver takes as a generator
 588        # that cannot move.
 589        gens = self._inner.generators
 590        caps = [g["caps"] for g in gens]
 591        width = 21 if any(c is not None for row in caps for c in row) else 10
 592        gen = np.array(
 593            [
 594                [
 595                    g["bus"], g["pg"], g["qg"], g["qmax"], g["qmin"], g["vg"],
 596                    g["mbase"], float(g["in_service"]), g["pmax"], g["pmin"],
 597                ]
 598                + ([0.0 if c is None else c for c in row] if width == 21 else [])
 599                for g, row in zip(gens, caps)
 600            ],
 601            dtype=float,
 602        ).reshape(len(gens), width)
 603
 604        branches = self._inner.branches
 605        branch = np.array(
 606            [
 607                (
 608                    br["from_id"], br["to_id"], br["r"], br["x"], br["b"],
 609                    br["rate_a"], br["rate_b"], br["rate_c"], br["tap"],
 610                    br["shift"], float(br["in_service"]), br["angmin"],
 611                    br["angmax"],
 612                )
 613                for br in branches
 614            ],
 615            dtype=float,
 616        ).reshape(len(branches), 13)
 617
 618        ppc = {
 619            "version": "2",
 620            "baseMVA": float(self._inner.base_mva),
 621            "bus": bus,
 622            "gen": gen,
 623            "branch": branch,
 624        }
 625
 626        # Coefficients sit left-aligned after ncost, padded to the widest
 627        # row, which is the layout PYPOWER's own loadcase produces.
 628        costs = [g["cost"] for g in gens]
 629        if costs and all(c is not None for c in costs):
 630            gencost = np.zeros(
 631                (len(costs), 4 + max(len(c["coeffs"]) for c in costs))
 632            )
 633            for i, c in enumerate(costs):
 634                gencost[i, :4] = (
 635                    c["model"], c["startup"], c["shutdown"], c["ncost"],
 636                )
 637                gencost[i, 4:4 + len(c["coeffs"])] = c["coeffs"]
 638            ppc["gencost"] = gencost
 639        return ppc
 640
 641    def to_networkx(self):
 642        """Undirected networkx graph keyed by bus id.
 643
 644        In-service branches become edges carrying ``branch`` (index), ``r``,
 645        ``x``, and ``b``.
 646        """
 647        nx = _require("networkx", "graph")
 648        g = nx.Graph()
 649        g.add_nodes_from(bus["id"] for bus in self._inner.buses)
 650        for k, br in enumerate(self._inner.branches):
 651            if br["in_service"]:
 652                g.add_edge(
 653                    br["from_id"],
 654                    br["to_id"],
 655                    branch=k,
 656                    r=br["r"],
 657                    x=br["x"],
 658                    b=br["b"],
 659                )
 660        return g
 661
 662
 663def parse_file(path: Any, from_: Optional[str] = None) -> BalancedNetwork:
 664    """Parse a case file from a path, inferring the format from the extension.
 665
 666    Read fidelity warnings are on ``BalancedNetwork.read_warnings`` (empty for readers
 667    that don't report any; currently pandapower JSON, PyPSA CSV, and PSLF EPC
 668    report them).
 669    """
 670    return BalancedNetwork(_powerio.parse_file(str(path), from_))
 671
 672
 673def parse_display_file(path: Any, from_: Optional[str] = None) -> DisplayData:
 674    """Parse a display artifact such as a PowerWorld ``.pwd`` file."""
 675    return _wrap_display(_powerio.parse_display_file(str(path), from_))
 676
 677
 678def parse_display_bytes(data: bytes, format: str) -> DisplayData:
 679    """Parse display bytes in the named display format."""
 680    return _wrap_display(_powerio.parse_display_bytes(data, format))
 681
 682
 683def parse_str(text: str, format: str = "matpower") -> BalancedNetwork:
 684    """Parse a case from in-memory text in the named ``format``."""
 685    return BalancedNetwork(_powerio.parse_str(text, format))
 686
 687
 688def parse_bytes(data: bytes, format: str) -> BalancedNetwork:
 689    """Parse a case from in-memory bytes in the named ``format``.
 690
 691    Accepts every :func:`parse_str` format name plus ``"pwb"``. PowerWorld
 692    binary has no text form, so this is the only way to read one without a
 693    file on disk. Text formats must be UTF-8.
 694    """
 695    return BalancedNetwork(_powerio.parse_bytes(data, format))
 696
 697
 698def parse_scopf(text: str, from_: str = "goc3-json") -> dict[str, Any]:
 699    """Return a versioned SCOPF problem instance document.
 700
 701    ``from_`` currently accepts ``"goc3-json"``. The returned dictionary uses
 702    the wire schema's declared 1-based indices and retains source identities in
 703    separate fields. Parse and assembly failures raise :class:`PowerIOError`.
 704    """
 705    return _json.loads(_powerio.parse_scopf(text, from_))
 706
 707
 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    "warnings": [...]}``; ``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
 720
 721
 722def from_json(text: str) -> BalancedNetwork:
 723    """Rebuild a case from JSON produced by :meth:`BalancedNetwork.to_json`."""
 724    return BalancedNetwork(_powerio.from_json(text))
 725
 726
 727# powerio bus kind -> MATPOWER/PYPOWER BUS_TYPE code.
 728def _bus_sums(np, buses, loads, shunts):
 729    """Per bus `(pd, qd, gs, bs)` in bus order.
 730
 731    :meth:`BalancedNetwork.to_dense` and :meth:`BalancedNetwork.to_ppc` both fold the element
 732    tables onto their bus the way the Rust indexed analysis view does. This is
 733    that fold, once.
 734    """
 735    row_of = {b["id"]: i for i, b in enumerate(buses)}
 736    pd, qd, gs, bs = (np.zeros(len(buses), dtype=float) for _ in range(4))
 737    for load in loads:
 738        i = row_of.get(load["bus"])
 739        if i is not None:
 740            pd[i] += load["p"]
 741            qd[i] += load["q"]
 742    for shunt in shunts:
 743        i = row_of.get(shunt["bus"])
 744        if i is not None:
 745            gs[i] += shunt["g"]
 746            bs[i] += shunt["b"]
 747    return pd, qd, gs, bs
 748
 749
 750_PPC_BUS_TYPE = {"PQ": 1.0, "PV": 2.0, "REF": 3.0, "ISOLATED": 4.0}
 751
 752# MATPOWER case-input table widths. PYPOWER result tables append columns
 753# (LAM_P, MU_*) past these; from_ppc drops them.
 754_PPC_INPUT_WIDTH = {"bus": 13, "gen": 21, "branch": 13}
 755
 756# Columns a table must carry, which is what the MATPOWER reader requires. The
 757# gen table's capability and ramp columns are an OPF extension, so a 10 column
 758# gen table is a complete case and passes through at its own width; padding it
 759# would hand the reader eleven explicit zero limits the source never stated. A
 760# bus or branch row below 13 is truncated data, and zero padding it would
 761# invent a bus at 0 p.u. and 0 kV, so it is refused here as the reader refuses
 762# it in a `.m` file.
 763_PPC_MIN_WIDTH = {"bus": 13, "gen": 10, "branch": 13}
 764
 765
 766def _ppc_rows(name, table):
 767    """The table's rows as float lists, trimmed to the MATPOWER input width."""
 768    width = _PPC_INPUT_WIDTH.get(name)
 769    minimum = _PPC_MIN_WIDTH.get(name)
 770    out = []
 771    for i, row in enumerate(table):
 772        try:
 773            vals = [float(v) for v in row]
 774        except TypeError as e:
 775            raise ValueError(
 776                f"ppc table {name!r} row {i} is not a sequence of numbers: "
 777                f"pass a 2-D array, one row per element"
 778            ) from e
 779        except ValueError as e:
 780            raise ValueError(
 781                f"ppc table {name!r} row {i} has a non-numeric value: {e}"
 782            ) from e
 783        if minimum is not None and len(vals) < minimum:
 784            raise ValueError(
 785                f"ppc table {name!r} row {i} has {len(vals)} columns; "
 786                f"MATPOWER requires at least {minimum}"
 787            )
 788        out.append(vals[:width] if width is not None else vals)
 789    return out
 790
 791
 792def _ppc_to_matpower_text(ppc) -> str:
 793    missing = [k for k in ("baseMVA", "bus", "gen", "branch") if k not in ppc]
 794    if missing:
 795        raise ValueError(f"ppc dict is missing required keys: {missing}")
 796    lines = [
 797        "function mpc = from_ppc",
 798        f"mpc.version = '{ppc.get('version', '2')}';",
 799        f"mpc.baseMVA = {float(ppc['baseMVA'])!r};",
 800    ]
 801    names = ["bus", "gen", "branch"] + (["gencost"] if "gencost" in ppc else [])
 802    for name in names:
 803        rows = _ppc_rows(name, ppc[name])
 804        lines.append(f"mpc.{name} = [")
 805        for vals in rows:
 806            lines.append("  " + "  ".join(repr(v) for v in vals) + ";")
 807        lines.append("];")
 808    return "\n".join(lines) + "\n"
 809
 810
 811def from_ppc(ppc) -> BalancedNetwork:
 812    """Case from a PYPOWER dict (``ppc``); the inverse of :meth:`BalancedNetwork.to_ppc`.
 813
 814    The tables route through the MATPOWER reader, so the semantics match a
 815    ``.m`` case exactly: bus ``PD``/``QD`` become loads, ``GS``/``BS`` become
 816    shunts, and ``gencost`` is read when present. Result columns past the
 817    MATPOWER input widths are dropped. A 10 column ``gen`` table (the layout
 818    without the OPF capability columns) passes through at its own width, so
 819    the generators come back with no capability limits rather than eleven
 820    zero ones. Raises :class:`ValueError` when a required table is absent,
 821    when a ``bus`` or ``branch`` row is below its 13 column width, when a row
 822    is not a sequence of numbers, or when a cell is not numeric; the message
 823    names the table and the row.
 824    """
 825    return parse_str(_ppc_to_matpower_text(ppc), "matpower")
 826
 827
 828def convert_file(
 829    path: Any,
 830    to: str,
 831    from_: Optional[str] = None,
 832    missing_gen_cost: Optional[str] = None,
 833    default_gen_cost: Optional[str] = None,
 834    gen_cost_csv: Optional[Any] = None,
 835    out: Optional[Any] = None,
 836) -> Conversion:
 837    r"""Convert a case file to another format through the network model.
 838
 839    ``to`` / ``from_`` are format names: ``matpower``, ``powermodels-json``,
 840    ``egret-json``, ``pandapower-json``, ``psse``, ``powerworld``, ``pslf``,
 841    ``goc3-json``, ``surge-json``, and ``opfdata-json`` (aliases ``m``, ``pm``,
 842    ``egret``, ``pp``, ``raw``, ``aux``, ``epc``, ``goc3``, ``surge``,
 843    ``opfdata``, and ``gridopt``). The input format is
 844    inferred from the file extension unless ``from_`` overrides it. GO Challenge
 845    3 and OPFData JSON are read only. An OPFData input may be an extracted
 846    FullTop or N-1 example of any published grid size; its element counts are
 847    read from the document. PyPSA CSV folders are read with
 848    ``from_="pypsa-csv"`` and written with
 849    :meth:`BalancedNetwork.write_pypsa_csv_folder`. Returns a :class:`Conversion` with
 850    the text and any fidelity warnings. ``out`` writes the text to a file
 851    exactly as produced; prefer it over ``open(out, "w").write(text)``, whose
 852    text mode newline translation on Windows doubles the carriage returns of
 853    a CRLF source echo into ``\r\r\n``, which PSS/E family tools reject.
 854    """
 855    text, warnings = _powerio.convert_file(
 856        str(path),
 857        to,
 858        from_,
 859        missing_gen_cost=missing_gen_cost,
 860        default_gen_cost=default_gen_cost,
 861        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 862        out=None if out is None else str(out),
 863    )
 864    return Conversion(text, warnings)
 865
 866
 867def convert_str(
 868    text: str,
 869    to: str,
 870    format: str = "matpower",
 871    missing_gen_cost: Optional[str] = None,
 872    default_gen_cost: Optional[str] = None,
 873    gen_cost_csv: Optional[Any] = None,
 874) -> Conversion:
 875    """Convert in-memory case ``text`` through the network model without a
 876    temporary file.
 877
 878    ``to`` and ``format`` are format names as in :func:`convert_file`;
 879    ``format`` names the input (default ``matpower``). Returns a
 880    :class:`Conversion` with the converted text and any fidelity warnings.
 881    """
 882    out, warnings = _powerio.convert_str(
 883        text,
 884        to,
 885        format,
 886        missing_gen_cost=missing_gen_cost,
 887        default_gen_cost=default_gen_cost,
 888        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 889    )
 890    return Conversion(out, warnings)
 891
 892
 893def to_format(
 894    network: BalancedNetwork,
 895    to: str,
 896    missing_gen_cost: Optional[str] = None,
 897    default_gen_cost: Optional[str] = None,
 898    gen_cost_csv: Optional[Any] = None,
 899) -> Conversion:
 900    """Serialize ``network`` to another format."""
 901    return network.to_format(
 902        to,
 903        missing_gen_cost=missing_gen_cost,
 904        default_gen_cost=default_gen_cost,
 905        gen_cost_csv=gen_cost_csv,
 906    )
 907
 908
 909def to_matpower(network: BalancedNetwork) -> str:
 910    """Serialize ``network`` to MATPOWER ``.m`` text."""
 911    return network.to_matpower()
 912
 913
 914def to_json(network: BalancedNetwork) -> str:
 915    """Serialize ``network`` to the JSON transport."""
 916    return network.to_json()
 917
 918
 919def to_dense(network: BalancedNetwork) -> DenseNetwork:
 920    """Return copied dense NumPy tables for ``network``."""
 921    return network.to_dense()
 922
 923
 924def write_gridfm_batch(
 925    networks: "list[BalancedNetwork]",
 926    out_dir: Any,
 927    *,
 928    base_scenario: int = 0,
 929    include_y_bus: bool = True,
 930    include_taps: bool = True,
 931    include_shifts: bool = True,
 932    missing_gen_cost: Optional[str] = None,
 933    default_gen_cost: Optional[str] = None,
 934    gen_cost_csv: Optional[Any] = None,
 935) -> dict:
 936    """Write several networks as one gridfm-datakit dataset, row stacked and
 937    keyed by the ``scenario`` column.
 938
 939    Each network is one snapshot; the k-th is stamped ``base_scenario + k``. The
 940    networks must share a base element set: the same bus/branch/gen counts and
 941    bus id order (otherwise :class:`PowerIODataError` is raised). Load, dispatch,
 942    branch status, and costs may vary per scenario. Returns the same dict as
 943    :meth:`BalancedNetwork.write_gridfm`. Published wheels include the native writer;
 944    custom source builds without the Rust ``gridfm`` feature raise
 945    ``ImportError``.
 946    """
 947    _require_gridfm()
 948    inners = [c._inner for c in networks]
 949    return _powerio.write_gridfm_batch(
 950        inners,
 951        str(out_dir),
 952        base_scenario=base_scenario,
 953        include_y_bus=include_y_bus,
 954        include_taps=include_taps,
 955        include_shifts=include_shifts,
 956        missing_gen_cost=missing_gen_cost,
 957        default_gen_cost=default_gen_cost,
 958        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
 959    )
 960
 961
 962def read_gridfm(dir: Any, scenario: int = 0) -> GridfmRead:
 963    """Read one scenario of a gridfm-datakit Parquet dataset back into a case.
 964
 965    The inverse of :meth:`BalancedNetwork.write_gridfm`. ``dir`` is resolved leniently:
 966    the ``raw/`` directory holding the parquet files, a ``<case>/`` directory with
 967    a ``raw/`` child, or a parent directory with one ``*/raw/`` child all work.
 968    ``scenario`` selects one snapshot from a batch (``0``, the base case, by
 969    default). Returns a :class:`GridfmRead` ``(network, scenario, warnings)``.
 970
 971    The read recovers bus types, voltages and limits, nodal load and shunt
 972    totals, generator dispatch and bounds, branch
 973    ``r/x/b/tap/shift/rate_a`` values, angle limits, and ``baseMVA``. It cannot
 974    recover source bus IDs, per element load/shunt granularity, piecewise or
 975    cubic costs, HVDC, or storage;
 976    what it can't recover is listed in ``warnings``. Published wheels include the
 977    native reader; custom source builds without the Rust ``gridfm`` feature raise
 978    ``ImportError``.
 979    """
 980    _require_gridfm()
 981    inner, scen, warnings = _powerio.read_gridfm(str(dir), scenario)
 982    return GridfmRead(BalancedNetwork(inner), scen, warnings)
 983
 984
 985def read_gridfm_scenarios(dir: Any) -> "list[GridfmRead]":
 986    """Read every scenario of a gridfm dataset, one :class:`GridfmRead` per
 987    scenario id (ascending) over the shared topology, the read side of
 988    :func:`write_gridfm_batch`.
 989
 990    Each scenario is rebuilt independently, so two scenarios may differ in branch
 991    status, bus types, and reference bus. See :func:`read_gridfm` for the lenient
 992    directory resolution and the fidelity behavior.
 993    """
 994    _require_gridfm()
 995    return [
 996        GridfmRead(BalancedNetwork(inner), scen, warnings)
 997        for inner, scen, warnings in _powerio.read_gridfm_scenarios(str(dir))
 998    ]
 999
1000
1001def read_pypsa_csv_folder(path: Any) -> BalancedNetwork:
1002    """Read a PyPSA CSV folder into a :class:`BalancedNetwork`."""
1003    return BalancedNetwork(_powerio.read_pypsa_csv_folder(str(path)))
1004
1005
1006from . import dist  # noqa: E402  (needs Conversion defined above)
1007
1008
1009class Package:
1010    """A parsed ``.pio.json`` package.
1011
1012    Parsing occurs once; every accessor reuses the native handle.
1013    """
1014
1015    def __init__(self, inner: "_powerio._Package"):
1016        self._inner = inner
1017
1018    @classmethod
1019    def from_file(
1020        cls, path: Any, from_: Optional[str] = None, scenario: int = 0
1021    ) -> "Package":
1022        """Build a package from a case file or folder."""
1023        return cls(_powerio._Package.from_file(str(path), from_, scenario))
1024
1025    @classmethod
1026    def from_str(cls, text: str, from_: Optional[str] = None) -> "Package":
1027        """Build a package from in-memory case text."""
1028        return cls(_powerio._Package.from_str(text, from_))
1029
1030    @classmethod
1031    def from_json(cls, text: str) -> "Package":
1032        """Parse a ``.pio.json`` document."""
1033        return cls(_powerio._Package.from_json(text))
1034
1035    @classmethod
1036    def from_balanced(
1037        cls, network: BalancedNetwork, include_solver_metadata: bool = False
1038    ) -> "Package":
1039        """Wrap a balanced :class:`BalancedNetwork` in a package."""
1040        return cls(
1041            _powerio._Package.from_balanced(network._inner, include_solver_metadata)
1042        )
1043
1044    @classmethod
1045    def from_multiconductor(cls, network: "dist.MulticonductorNetwork") -> "Package":
1046        """Wrap a multiconductor network in a package."""
1047        return cls(_powerio._Package.from_multiconductor(network._inner))
1048
1049    @property
1050    def model_kind(self) -> str:
1051        """``"balanced"`` or ``"multiconductor"``."""
1052        return self._inner.model_kind()
1053
1054    def to_json(self) -> str:
1055        """Serialize to pretty ``.pio.json``."""
1056        return self._inner.to_json()
1057
1058    def as_balanced(self) -> BalancedNetwork:
1059        """Return the balanced payload as a :class:`BalancedNetwork`."""
1060        return BalancedNetwork(self._inner.as_balanced())
1061
1062    def as_multiconductor(self) -> "dist.MulticonductorNetwork":
1063        """Return the multiconductor payload."""
1064        return dist.MulticonductorNetwork(self._inner.as_multiconductor())
1065
1066    def operating_points(self) -> Any:
1067        """The operating point series as Python data, or ``None``.
1068
1069        GOC3 packages populate this from the source time series. Each point is
1070        a set of field updates over the package's static payload.
1071        """
1072        return _json.loads(self._inner.operating_points_json())
1073
1074    def set_operating_points(self, points: Any) -> None:
1075        """Replace the operating point series and rerun package validation.
1076
1077        ``None`` or an empty series clears it.
1078        """
1079        self._inner.set_operating_points_json(_json.dumps(points))
1080
1081    def study(self) -> Any:
1082        """The study block as Python data, or ``None``."""
1083        return _json.loads(self._inner.study_json())
1084
1085    def materialize_operating_point(self, index: int) -> "Package":
1086        """Materialize one operating point into a new static package."""
1087        return Package(self._inner.materialize_operating_point(index))
1088
1089    def materialize_study_commit(self, index: int) -> "Package":
1090        """Materialize one study commit into a new static package."""
1091        return Package(self._inner.materialize_study_commit(index))
1092
1093    def validate(self) -> None:
1094        """Run the package semantic validation profile in place."""
1095        self._inner.validate()
1096
1097    def validation(self) -> Any:
1098        """The validation summary as Python data."""
1099        return _json.loads(self._inner.validation_json())
1100
1101    def diagnostics(self) -> Any:
1102        """The structured diagnostics as a list of Python dicts."""
1103        return _json.loads(self._inner.diagnostics_json())
1104
1105    def multiconductor_to_balanced_preflight(self, base_mva: float = 100.0) -> Any:
1106        """Readiness report for multiconductor to balanced lowering."""
1107        return _json.loads(
1108            self._inner.multiconductor_to_balanced_preflight_json(base_mva)
1109        )
1110
1111    def lower_multiconductor_to_balanced(self, base_mva: float = 100.0) -> "Package":
1112        """Lower a multiconductor package to a new balanced package."""
1113        return Package(self._inner.lower_multiconductor_to_balanced(base_mva))
1114
1115    def __repr__(self) -> str:
1116        return repr(self._inner)
class BalancedNetwork:
231class BalancedNetwork:
232    """A parsed balanced power network.
233
234    The data attributes (``buses``, ``branches``, ``gens``, ``loads``,
235    ``shunts``) and the non-matrix methods (``write``, ``reference_bus_index``,
236    ``connectivity_report``, ``write_dcopf_bundle``) delegate to the compiled
237    handle; the matrix methods below return ``scipy.sparse`` objects. Read
238    fidelity warnings from parse time are on ``read_warnings``. Readers use this
239    for source data they cannot model or assumptions they had to make.
240
241    Errors: a bad file path raises the standard ``OSError`` subclass
242    (``FileNotFoundError``); a malformed case raises :class:`PowerIOParseError`
243    and an unmet builder precondition (no generators, no reference bus) raises
244    :class:`PowerIODataError`; both subclass :class:`PowerIOError`, so
245    ``except PowerIOError`` catches either; an unknown
246    ``scheme``/``convention``/``units`` string raises ``ValueError``.
247    """
248
249    def __init__(self, inner: "_powerio._BalancedNetwork"):
250        self._inner = inner
251
252    def __dir__(self):
253        # The data attributes arrive through __getattr__, so name them here or
254        # they stay invisible to tab completion.
255        return sorted(set(super().__dir__()) | set(dir(self._inner)))
256
257    def __getattr__(self, name: str):
258        # Reached only when normal lookup misses, so the matrix methods below
259        # win. Guard underscore names so a lookup before _inner exists raises
260        # AttributeError instead of recursing forever.
261        if name.startswith("_"):
262            raise AttributeError(
263                f"{type(self).__name__!r} object has no attribute {name!r}"
264            )
265        return getattr(self._inner, name)
266
267    def __repr__(self) -> str:
268        # The inner handle's __repr__ already renders the public ``BalancedNetwork(...)``
269        # form, so this is a straight delegate.
270        return repr(self._inner)
271
272    # --- canonical format and table exports -----------------------------
273
274    def to_matpower(self) -> str:
275        """Serialize to MATPOWER ``.m`` text.
276
277        A case parsed from MATPOWER keeps its original source, so this returns a
278        byte-exact echo. Derived cases serialize from the format neutral model.
279        """
280        return self._inner.to_matpower()
281
282    def to_json(self) -> str:
283        """Serialize to the JSON transport."""
284        return self._inner.to_json()
285
286    def geo_layer(self) -> dict[str, Any]:
287        """This case's coordinates as a canonical GeoJSON FeatureCollection.
288
289        Raises :class:`PowerIOError` when the case carries none.
290        """
291        return _json.loads(self._inner.geo_layer_json())
292
293    def apply_geo_layer(
294        self, text: str, name_hint: Optional[str] = None
295    ) -> tuple["BalancedNetwork", dict[str, Any]]:
296        """Apply a geographic sidecar and return ``(placed, report)``.
297
298        ``text`` is any form :func:`parse_geo` accepts; this case is
299        unchanged. The report carries ``matched_buses``, ``matched_branches``,
300        ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``,
301        and ``notes``. The two unlocated counts cover the whole case when the
302        pass ends, so a layer that matched nothing reads apart from a case
303        that needed nothing. The placed copy drops the retained source text,
304        so a same-format write re-serializes.
305        """
306        inner, report = self._inner.apply_geo_layer(text, name_hint)
307        return BalancedNetwork(inner), report
308
309    def acopf_instance(self, units: Optional[str] = None) -> dict[str, Any]:
310        """The matrix free AC OPF problem instance as Python data.
311
312        Dense 0-based indices; ``units`` is ``"perunit"`` (default) or
313        ``"native"``.
314        """
315        return _json.loads(self._inner.acopf_json(units))
316
317    def to_format(
318        self,
319        to: str,
320        missing_gen_cost: Optional[str] = None,
321        default_gen_cost: Optional[str] = None,
322        gen_cost_csv: Optional[Any] = None,
323    ) -> Conversion:
324        """Serialize this parsed case to another format.
325
326        ``to`` is one of the format names accepted by :func:`convert_file`.
327        Returns a :class:`Conversion` with output text and fidelity warnings.
328        """
329        text, warnings = self._inner.to_format(
330            to,
331            missing_gen_cost=missing_gen_cost,
332            default_gen_cost=default_gen_cost,
333            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
334        )
335        return Conversion(text, warnings)
336
337    def write_file(
338        self,
339        path: Any,
340        to: str,
341        missing_gen_cost: Optional[str] = None,
342        default_gen_cost: Optional[str] = None,
343        gen_cost_csv: Optional[Any] = None,
344    ) -> list[str]:
345        r"""Serialize this case to ``to`` and write it to ``path`` byte exact.
346
347        Returns the fidelity warnings. Prefer this over writing
348        :meth:`to_format` text through ``open(path, "w")``: Python's text mode
349        translates newlines on Windows, so a case whose retained source has
350        CRLF line endings comes out with doubled carriage returns
351        (``\r\r\n``), which PSS/E family tools reject.
352        """
353        return self._inner.write_file(
354            str(path),
355            to,
356            missing_gen_cost=missing_gen_cost,
357            default_gen_cost=default_gen_cost,
358            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
359        )
360
361    def to_dense(self) -> DenseNetwork:
362        """Dense NumPy arrays for solver and adapter code.
363
364        This allocates new arrays, preserves bus and branch source order, and
365        sums loads and shunts per bus to match the Rust indexed analysis view.
366
367        That view is the star-lowered one, so a case with an in-service
368        3-winding transformer reports the star bus and its three branches here
369        even though :attr:`buses` and :attr:`branches` mirror the case file and
370        do not. ``reference_bus``, ``n_components`` and ``is_radial`` are
371        computed over the same lowered space, so all of them agree.
372        """
373        np = _require("numpy", "matrix")
374        lowered = self._inner.lowered()
375        buses = lowered.buses
376        branches = lowered.branches
377        generators = lowered.generators
378        bus_ids = np.asarray([b["id"] for b in buses], dtype=np.int64)
379        pd, qd, gs, bs = _bus_sums(np, buses, lowered.loads, lowered.shunts)
380
381        branch = DenseBranch(
382            from_id=np.asarray([br["from_id"] for br in branches], dtype=np.int64),
383            to_id=np.asarray([br["to_id"] for br in branches], dtype=np.int64),
384            r=np.asarray([br["r"] for br in branches], dtype=float),
385            x=np.asarray([br["x"] for br in branches], dtype=float),
386            b=np.asarray([br["b"] for br in branches], dtype=float),
387            tap=np.asarray([br["tap"] for br in branches], dtype=float),
388            shift=np.asarray([br["shift"] for br in branches], dtype=float),
389            in_service=np.asarray([br["in_service"] for br in branches], dtype=bool),
390        )
391        gen = DenseGen(
392            bus=np.asarray([g["bus"] for g in generators], dtype=np.int64),
393            pg=np.asarray([g["pg"] for g in generators], dtype=float),
394            pmax=np.asarray([g["pmax"] for g in generators], dtype=float),
395            pmin=np.asarray([g["pmin"] for g in generators], dtype=float),
396            in_service=np.asarray([g["in_service"] for g in generators], dtype=bool),
397        )
398        refs = self.reference_bus_indices()
399        return DenseNetwork(
400            n=len(buses),
401            m=len(branches),
402            ng=len(generators),
403            base_mva=self.base_mva,
404            bus_ids=bus_ids,
405            branch=branch,
406            gen=gen,
407            demand=DenseDemand(pd=pd, qd=qd),
408            shunt=DenseShunt(gs=gs, bs=bs),
409            reference_bus=refs[0] if len(refs) == 1 else None,
410            n_components=self.n_connected_components,
411            is_radial=self.is_radial,
412        )
413
414    # --- matrix builders (scipy.sparse) ---------------------------------
415
416    def bprime(self, scheme: str = "bx"):
417        """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
418        return _to_csr(self._inner.bprime(scheme))
419
420    def bdoubleprime(self, scheme: str = "bx"):
421        """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
422        return _to_csr(self._inner.bdoubleprime(scheme))
423
424    def lacpf(self, *, include_taps: bool = True, include_shifts: bool = True):
425        """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``."""
426        return _to_csr(
427            self._inner.lacpf(include_taps=include_taps, include_shifts=include_shifts)
428        )
429
430    def adjacency(self):
431        """0/1 bus adjacency matrix."""
432        return _to_csr(self._inner.adjacency())
433
434    def ybus_parts(self, *, include_taps: bool = True, include_shifts: bool = True):
435        """:class:`YbusParts` ``(g, b)`` = ``(Re(Y_bus), Im(Y_bus))``, two real
436        csr_matrix."""
437        g, b = self._inner.ybus_parts(
438            include_taps=include_taps, include_shifts=include_shifts
439        )
440        return YbusParts(g=_to_csr(g), b=_to_csr(b))
441
442    def ybus(self, *, include_taps: bool = True, include_shifts: bool = True):
443        """``Y_bus = G + jB`` as a complex csr_matrix."""
444        g, b = self.ybus_parts(
445            include_taps=include_taps, include_shifts=include_shifts
446        )
447        return (g + 1j * b).tocsr()
448
449    def ptdf(self, convention: str = "series", solver: str = "auto"):
450        """DC PTDF (m×n). ``convention`` is ``"series"`` or ``"matpower"``.
451
452        ``solver`` is ``"auto"``, ``"dense"``, or ``"iterative"``. ``"auto"``
453        uses the dense factorization on small cases and the iterative
454        conjugate gradient path on large ones, the same policy as the CLI.
455        """
456        return _to_csr(self._inner.ptdf(convention, solver))
457
458    def lodf(self, convention: str = "series", solver: str = "auto"):
459        """DC LODF (m×m). ``solver`` as in :meth:`ptdf`."""
460        return _to_csr(self._inner.lodf(convention, solver))
461
462    def weighted_laplacian(self, convention: str = "series"):
463        """Weighted Laplacian ``L = A diag(b) Aáµ€``."""
464        return _to_csr(self._inner.weighted_laplacian(convention))
465
466    def incidence(self, convention: str = "series") -> "Incidence":
467        """Signed incidence factorization as an :data:`Incidence` tuple."""
468        np = _require("numpy", "matrix")
469        a, b, p_shift, branch_of_col = self._inner.incidence(convention)
470        return Incidence(
471            A=_to_csr(a),
472            b=np.asarray(b, dtype=float),
473            p_shift=np.asarray(p_shift, dtype=float),
474            branch_of_col=np.asarray(branch_of_col, dtype=np.int64),
475        )
476
477    def write_gridfm(
478        self,
479        out_dir: Any,
480        *,
481        scenario: int = 0,
482        include_y_bus: bool = True,
483        include_taps: bool = True,
484        include_shifts: bool = True,
485        missing_gen_cost: Optional[str] = None,
486        default_gen_cost: Optional[str] = None,
487        gen_cost_csv: Optional[Any] = None,
488    ) -> dict:
489        """Write the gridfm-datakit Parquet dataset for this case under
490        ``<out_dir>/<case>/raw/``.
491
492        Returns a dict with ``dir``, ``files``, ``dropped_zero_impedance``, and
493        ``degenerate_cost_gens``. Published wheels include the native writer;
494        custom source builds without the Rust ``gridfm`` feature raise
495        ``ImportError``. For many perturbed snapshots in one dataset, see
496        :func:`write_gridfm_batch`.
497        """
498        _require_gridfm()
499        return self._inner.write_gridfm(
500            str(out_dir),
501            scenario=scenario,
502            include_y_bus=include_y_bus,
503            include_taps=include_taps,
504            include_shifts=include_shifts,
505            missing_gen_cost=missing_gen_cost,
506            default_gen_cost=default_gen_cost,
507            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
508        )
509
510    def write_pypsa_csv_folder(self, out_dir: Any) -> dict:
511        """Write this case as a PyPSA CSV folder.
512
513        The folder contains static PyPSA component CSVs and can be imported with
514        ``pypsa.Network().import_from_csv_folder(path)``. Returns a dict with
515        ``dir``, ``files``, and fidelity ``warnings``.
516        """
517        return self._inner.write_pypsa_csv_folder(str(out_dir))
518
519    def to_normalized(self) -> "BalancedNetwork":
520        """Return a normalized copy with per unit power and radian angles.
521
522        The result removes out of service elements, preserves source bus IDs,
523        and normalizes bus types. It carries no retained source, so
524        :meth:`write` serializes the derived model. Raises
525        :class:`PowerIODataError` if the network cannot be
526        normalized (no reference bus can be chosen, or a non-positive base MVA).
527        """
528        return BalancedNetwork(self._inner.to_normalized())
529
530    def to_normalized_with_options(
531        self,
532        *,
533        clamp_angle_bounds: bool = False,
534        angle_bound_pad: Optional[float] = None,
535    ) -> "BalancedNetwork":
536        """Return a normalized copy with explicit normalization options.
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        return BalancedNetwork(
545            self._inner.to_normalized_with_options(
546                clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad
547            )
548        )
549
550    def to_ppc(self):
551        """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables.
552
553        Values are emitted as the model holds them, so a case read from a
554        file carries MW, MVAr, and degrees. A network from
555        :meth:`to_normalized` holds per unit and radians, and those are what
556        its tables carry — PYPOWER reads a ppc dict as MW and degrees, so
557        build this from the raw network unless the consumer expects per unit.
558
559        Loads and shunts are summed onto their bus in the
560        ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation
561        :meth:`to_matpower` writes. The bus table has no per element status
562        column, so an element the model marks out of service still
563        contributes its value, and a de-energized bus is carried as type 4.
564        ``gencost`` is present only when every generator carries cost data,
565        because MATPOWER requires cost rows for all generators or none.
566        :func:`from_ppc` reads the tables back.
567        """
568        np = _require("numpy", "matrix")
569        buses = self._inner.buses
570        bus = np.array(
571            [
572                (
573                    b["id"], _PPC_BUS_TYPE.get(b["kind"], 1.0), 0.0, 0.0, 0.0, 0.0,
574                    b["area"], b["vm"], b["va"], b["base_kv"], b["zone"],
575                    b["vmax"], b["vmin"],
576                )
577                for b in buses
578            ],
579            dtype=float,
580        ).reshape(len(buses), 13)
581        bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums(
582            np, buses, self._inner.loads, self._inner.shunts
583        )
584
585        # The capability and ramp columns past PMIN are an OPF extension that a
586        # source need not carry. Widen to the full 21 only when a generator
587        # actually states one: a table of zeros there reads back as eleven
588        # explicit zero limits, which a ramp aware solver takes as a generator
589        # that cannot move.
590        gens = self._inner.generators
591        caps = [g["caps"] for g in gens]
592        width = 21 if any(c is not None for row in caps for c in row) else 10
593        gen = np.array(
594            [
595                [
596                    g["bus"], g["pg"], g["qg"], g["qmax"], g["qmin"], g["vg"],
597                    g["mbase"], float(g["in_service"]), g["pmax"], g["pmin"],
598                ]
599                + ([0.0 if c is None else c for c in row] if width == 21 else [])
600                for g, row in zip(gens, caps)
601            ],
602            dtype=float,
603        ).reshape(len(gens), width)
604
605        branches = self._inner.branches
606        branch = np.array(
607            [
608                (
609                    br["from_id"], br["to_id"], br["r"], br["x"], br["b"],
610                    br["rate_a"], br["rate_b"], br["rate_c"], br["tap"],
611                    br["shift"], float(br["in_service"]), br["angmin"],
612                    br["angmax"],
613                )
614                for br in branches
615            ],
616            dtype=float,
617        ).reshape(len(branches), 13)
618
619        ppc = {
620            "version": "2",
621            "baseMVA": float(self._inner.base_mva),
622            "bus": bus,
623            "gen": gen,
624            "branch": branch,
625        }
626
627        # Coefficients sit left-aligned after ncost, padded to the widest
628        # row, which is the layout PYPOWER's own loadcase produces.
629        costs = [g["cost"] for g in gens]
630        if costs and all(c is not None for c in costs):
631            gencost = np.zeros(
632                (len(costs), 4 + max(len(c["coeffs"]) for c in costs))
633            )
634            for i, c in enumerate(costs):
635                gencost[i, :4] = (
636                    c["model"], c["startup"], c["shutdown"], c["ncost"],
637                )
638                gencost[i, 4:4 + len(c["coeffs"])] = c["coeffs"]
639            ppc["gencost"] = gencost
640        return ppc
641
642    def to_networkx(self):
643        """Undirected networkx graph keyed by bus id.
644
645        In-service branches become edges carrying ``branch`` (index), ``r``,
646        ``x``, and ``b``.
647        """
648        nx = _require("networkx", "graph")
649        g = nx.Graph()
650        g.add_nodes_from(bus["id"] for bus in self._inner.buses)
651        for k, br in enumerate(self._inner.branches):
652            if br["in_service"]:
653                g.add_edge(
654                    br["from_id"],
655                    br["to_id"],
656                    branch=k,
657                    r=br["r"],
658                    x=br["x"],
659                    b=br["b"],
660                )
661        return g

A parsed balanced power network.

The data attributes (buses, branches, gens, loads, shunts) and the non-matrix methods (write, reference_bus_index, connectivity_report, write_dcopf_bundle) delegate to the compiled handle; the matrix methods below return scipy.sparse objects. Read fidelity warnings from parse time are on read_warnings. Readers use this for source data they cannot model or assumptions they had to make.

Errors: a bad file path raises the standard OSError subclass (FileNotFoundError); a malformed case raises PowerIOParseError and an unmet builder precondition (no generators, no reference bus) raises PowerIODataError; both subclass PowerIOError, so except PowerIOError catches either; an unknown scheme/convention/units string raises ValueError.

BalancedNetwork(inner: powerio._powerio._BalancedNetwork)
249    def __init__(self, inner: "_powerio._BalancedNetwork"):
250        self._inner = inner
def to_matpower(self) -> str:
274    def to_matpower(self) -> str:
275        """Serialize to MATPOWER ``.m`` text.
276
277        A case parsed from MATPOWER keeps its original source, so this returns a
278        byte-exact echo. Derived cases serialize from the format neutral model.
279        """
280        return self._inner.to_matpower()

Serialize to MATPOWER .m text.

A case parsed from MATPOWER keeps its original source, so this returns a byte-exact echo. Derived cases serialize from the format neutral model.

def to_json(self) -> str:
282    def to_json(self) -> str:
283        """Serialize to the JSON transport."""
284        return self._inner.to_json()

Serialize to the JSON transport.

def geo_layer(self) -> dict[str, typing.Any]:
286    def geo_layer(self) -> dict[str, Any]:
287        """This case's coordinates as a canonical GeoJSON FeatureCollection.
288
289        Raises :class:`PowerIOError` when the case carries none.
290        """
291        return _json.loads(self._inner.geo_layer_json())

This case's coordinates as a canonical GeoJSON FeatureCollection.

Raises PowerIOError when the case carries none.

def apply_geo_layer( self, text: str, name_hint: Optional[str] = None) -> tuple[BalancedNetwork, dict[str, typing.Any]]:
293    def apply_geo_layer(
294        self, text: str, name_hint: Optional[str] = None
295    ) -> tuple["BalancedNetwork", dict[str, Any]]:
296        """Apply a geographic sidecar and return ``(placed, report)``.
297
298        ``text`` is any form :func:`parse_geo` accepts; this case is
299        unchanged. The report carries ``matched_buses``, ``matched_branches``,
300        ``unmatched_features``, ``unlocated_buses``, ``unlocated_branches``,
301        and ``notes``. The two unlocated counts cover the whole case when the
302        pass ends, so a layer that matched nothing reads apart from a case
303        that needed nothing. The placed copy drops the retained source text,
304        so a same-format write re-serializes.
305        """
306        inner, report = self._inner.apply_geo_layer(text, name_hint)
307        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 write re-serializes.

def acopf_instance(self, units: Optional[str] = None) -> dict[str, typing.Any]:
309    def acopf_instance(self, units: Optional[str] = None) -> dict[str, Any]:
310        """The matrix free AC OPF problem instance as Python data.
311
312        Dense 0-based indices; ``units`` is ``"perunit"`` (default) or
313        ``"native"``.
314        """
315        return _json.loads(self._inner.acopf_json(units))

The matrix free AC OPF problem instance as Python data.

Dense 0-based indices; units is "perunit" (default) or "native".

def to_format( self, to: str, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> Conversion:
317    def to_format(
318        self,
319        to: str,
320        missing_gen_cost: Optional[str] = None,
321        default_gen_cost: Optional[str] = None,
322        gen_cost_csv: Optional[Any] = None,
323    ) -> Conversion:
324        """Serialize this parsed case to another format.
325
326        ``to`` is one of the format names accepted by :func:`convert_file`.
327        Returns a :class:`Conversion` with output text and fidelity warnings.
328        """
329        text, warnings = self._inner.to_format(
330            to,
331            missing_gen_cost=missing_gen_cost,
332            default_gen_cost=default_gen_cost,
333            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
334        )
335        return Conversion(text, warnings)

Serialize this parsed case to another format.

to is one of the format names accepted by convert_file(). Returns a Conversion with output text and fidelity warnings.

def write_file( self, path: Any, to: str, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> list[str]:
337    def write_file(
338        self,
339        path: Any,
340        to: str,
341        missing_gen_cost: Optional[str] = None,
342        default_gen_cost: Optional[str] = None,
343        gen_cost_csv: Optional[Any] = None,
344    ) -> list[str]:
345        r"""Serialize this case to ``to`` and write it to ``path`` byte exact.
346
347        Returns the fidelity warnings. Prefer this over writing
348        :meth:`to_format` text through ``open(path, "w")``: Python's text mode
349        translates newlines on Windows, so a case whose retained source has
350        CRLF line endings comes out with doubled carriage returns
351        (``\r\r\n``), which PSS/E family tools reject.
352        """
353        return self._inner.write_file(
354            str(path),
355            to,
356            missing_gen_cost=missing_gen_cost,
357            default_gen_cost=default_gen_cost,
358            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
359        )

Serialize this case to to and write it to path byte exact.

Returns the fidelity warnings. Prefer this over writing to_format() text through open(path, "w"): Python's text mode translates newlines on Windows, so a case whose retained source has CRLF line endings comes out with doubled carriage returns (\r\r\n), which PSS/E family tools reject.

def to_dense(self) -> DenseNetwork:
361    def to_dense(self) -> DenseNetwork:
362        """Dense NumPy arrays for solver and adapter code.
363
364        This allocates new arrays, preserves bus and branch source order, and
365        sums loads and shunts per bus to match the Rust indexed analysis view.
366
367        That view is the star-lowered one, so a case with an in-service
368        3-winding transformer reports the star bus and its three branches here
369        even though :attr:`buses` and :attr:`branches` mirror the case file and
370        do not. ``reference_bus``, ``n_components`` and ``is_radial`` are
371        computed over the same lowered space, so all of them agree.
372        """
373        np = _require("numpy", "matrix")
374        lowered = self._inner.lowered()
375        buses = lowered.buses
376        branches = lowered.branches
377        generators = lowered.generators
378        bus_ids = np.asarray([b["id"] for b in buses], dtype=np.int64)
379        pd, qd, gs, bs = _bus_sums(np, buses, lowered.loads, lowered.shunts)
380
381        branch = DenseBranch(
382            from_id=np.asarray([br["from_id"] for br in branches], dtype=np.int64),
383            to_id=np.asarray([br["to_id"] for br in branches], dtype=np.int64),
384            r=np.asarray([br["r"] for br in branches], dtype=float),
385            x=np.asarray([br["x"] for br in branches], dtype=float),
386            b=np.asarray([br["b"] for br in branches], dtype=float),
387            tap=np.asarray([br["tap"] for br in branches], dtype=float),
388            shift=np.asarray([br["shift"] for br in branches], dtype=float),
389            in_service=np.asarray([br["in_service"] for br in branches], dtype=bool),
390        )
391        gen = DenseGen(
392            bus=np.asarray([g["bus"] for g in generators], dtype=np.int64),
393            pg=np.asarray([g["pg"] for g in generators], dtype=float),
394            pmax=np.asarray([g["pmax"] for g in generators], dtype=float),
395            pmin=np.asarray([g["pmin"] for g in generators], dtype=float),
396            in_service=np.asarray([g["in_service"] for g in generators], dtype=bool),
397        )
398        refs = self.reference_bus_indices()
399        return DenseNetwork(
400            n=len(buses),
401            m=len(branches),
402            ng=len(generators),
403            base_mva=self.base_mva,
404            bus_ids=bus_ids,
405            branch=branch,
406            gen=gen,
407            demand=DenseDemand(pd=pd, qd=qd),
408            shunt=DenseShunt(gs=gs, bs=bs),
409            reference_bus=refs[0] if len(refs) == 1 else None,
410            n_components=self.n_connected_components,
411            is_radial=self.is_radial,
412        )

Dense NumPy arrays for solver and adapter code.

This allocates new arrays, preserves bus and branch source order, and sums loads and shunts per bus to match the Rust indexed analysis view.

That view is the star-lowered one, so a case with an in-service 3-winding transformer reports the star bus and its three branches here even though buses and branches mirror the case file and do not. reference_bus, n_components and is_radial are computed over the same lowered space, so all of them agree.

def bprime(self, scheme: str = 'bx'):
416    def bprime(self, scheme: str = "bx"):
417        """MATPOWER FDPF Bp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
418        return _to_csr(self._inner.bprime(scheme))

MATPOWER FDPF Bp matrix. scheme is "bx" or "xb".

def bdoubleprime(self, scheme: str = 'bx'):
420    def bdoubleprime(self, scheme: str = "bx"):
421        """MATPOWER FDPF Bpp matrix. ``scheme`` is ``"bx"`` or ``"xb"``."""
422        return _to_csr(self._inner.bdoubleprime(scheme))

MATPOWER FDPF Bpp matrix. scheme is "bx" or "xb".

def lacpf(self, *, include_taps: bool = True, include_shifts: bool = True):
424    def lacpf(self, *, include_taps: bool = True, include_shifts: bool = True):
425        """LACPF 2n×2n block ``[[G, -B], [-B, -G]]``."""
426        return _to_csr(
427            self._inner.lacpf(include_taps=include_taps, include_shifts=include_shifts)
428        )

LACPF 2n×2n block [[G, -B], [-B, -G]].

def adjacency(self):
430    def adjacency(self):
431        """0/1 bus adjacency matrix."""
432        return _to_csr(self._inner.adjacency())

0/1 bus adjacency matrix.

def ybus_parts(self, *, include_taps: bool = True, include_shifts: bool = True):
434    def ybus_parts(self, *, include_taps: bool = True, include_shifts: bool = True):
435        """:class:`YbusParts` ``(g, b)`` = ``(Re(Y_bus), Im(Y_bus))``, two real
436        csr_matrix."""
437        g, b = self._inner.ybus_parts(
438            include_taps=include_taps, include_shifts=include_shifts
439        )
440        return YbusParts(g=_to_csr(g), b=_to_csr(b))

YbusParts (g, b) = (Re(Y_bus), Im(Y_bus)), two real csr_matrix.

def ybus(self, *, include_taps: bool = True, include_shifts: bool = True):
442    def ybus(self, *, include_taps: bool = True, include_shifts: bool = True):
443        """``Y_bus = G + jB`` as a complex csr_matrix."""
444        g, b = self.ybus_parts(
445            include_taps=include_taps, include_shifts=include_shifts
446        )
447        return (g + 1j * b).tocsr()

Y_bus = G + jB as a complex csr_matrix.

def ptdf(self, convention: str = 'series', solver: str = 'auto'):
449    def ptdf(self, convention: str = "series", solver: str = "auto"):
450        """DC PTDF (m×n). ``convention`` is ``"series"`` or ``"matpower"``.
451
452        ``solver`` is ``"auto"``, ``"dense"``, or ``"iterative"``. ``"auto"``
453        uses the dense factorization on small cases and the iterative
454        conjugate gradient path on large ones, the same policy as the CLI.
455        """
456        return _to_csr(self._inner.ptdf(convention, solver))

DC PTDF (m×n). convention is "series" or "matpower".

solver is "auto", "dense", or "iterative". "auto" uses the dense factorization on small cases and the iterative conjugate gradient path on large ones, the same policy as the CLI.

def lodf(self, convention: str = 'series', solver: str = 'auto'):
458    def lodf(self, convention: str = "series", solver: str = "auto"):
459        """DC LODF (m×m). ``solver`` as in :meth:`ptdf`."""
460        return _to_csr(self._inner.lodf(convention, solver))

DC LODF (m×m). solver as in ptdf().

def weighted_laplacian(self, convention: str = 'series'):
462    def weighted_laplacian(self, convention: str = "series"):
463        """Weighted Laplacian ``L = A diag(b) Aáµ€``."""
464        return _to_csr(self._inner.weighted_laplacian(convention))

Weighted Laplacian L = A diag(b) Aáµ€.

def incidence(self, convention: str = 'series') -> Incidence:
466    def incidence(self, convention: str = "series") -> "Incidence":
467        """Signed incidence factorization as an :data:`Incidence` tuple."""
468        np = _require("numpy", "matrix")
469        a, b, p_shift, branch_of_col = self._inner.incidence(convention)
470        return Incidence(
471            A=_to_csr(a),
472            b=np.asarray(b, dtype=float),
473            p_shift=np.asarray(p_shift, dtype=float),
474            branch_of_col=np.asarray(branch_of_col, dtype=np.int64),
475        )

Signed incidence factorization as an Incidence tuple.

def write_gridfm( self, out_dir: Any, *, scenario: int = 0, include_y_bus: bool = True, include_taps: bool = True, include_shifts: bool = True, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> dict:
477    def write_gridfm(
478        self,
479        out_dir: Any,
480        *,
481        scenario: int = 0,
482        include_y_bus: bool = True,
483        include_taps: bool = True,
484        include_shifts: bool = True,
485        missing_gen_cost: Optional[str] = None,
486        default_gen_cost: Optional[str] = None,
487        gen_cost_csv: Optional[Any] = None,
488    ) -> dict:
489        """Write the gridfm-datakit Parquet dataset for this case under
490        ``<out_dir>/<case>/raw/``.
491
492        Returns a dict with ``dir``, ``files``, ``dropped_zero_impedance``, and
493        ``degenerate_cost_gens``. Published wheels include the native writer;
494        custom source builds without the Rust ``gridfm`` feature raise
495        ``ImportError``. For many perturbed snapshots in one dataset, see
496        :func:`write_gridfm_batch`.
497        """
498        _require_gridfm()
499        return self._inner.write_gridfm(
500            str(out_dir),
501            scenario=scenario,
502            include_y_bus=include_y_bus,
503            include_taps=include_taps,
504            include_shifts=include_shifts,
505            missing_gen_cost=missing_gen_cost,
506            default_gen_cost=default_gen_cost,
507            gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
508        )

Write the gridfm-datakit Parquet dataset for this case under <out_dir>/<case>/raw/.

Returns a dict with dir, files, dropped_zero_impedance, and degenerate_cost_gens. Published wheels include the native writer; custom source builds without the Rust gridfm feature raise ImportError. For many perturbed snapshots in one dataset, see write_gridfm_batch().

def write_pypsa_csv_folder(self, out_dir: Any) -> dict:
510    def write_pypsa_csv_folder(self, out_dir: Any) -> dict:
511        """Write this case as a PyPSA CSV folder.
512
513        The folder contains static PyPSA component CSVs and can be imported with
514        ``pypsa.Network().import_from_csv_folder(path)``. Returns a dict with
515        ``dir``, ``files``, and fidelity ``warnings``.
516        """
517        return self._inner.write_pypsa_csv_folder(str(out_dir))

Write this case as a PyPSA CSV folder.

The folder contains static PyPSA component CSVs and can be imported with pypsa.Network().import_from_csv_folder(path). Returns a dict with dir, files, and fidelity warnings.

def to_normalized(self) -> BalancedNetwork:
519    def to_normalized(self) -> "BalancedNetwork":
520        """Return a normalized copy with per unit power and radian angles.
521
522        The result removes out of service elements, preserves source bus IDs,
523        and normalizes bus types. It carries no retained source, so
524        :meth:`write` serializes the derived model. Raises
525        :class:`PowerIODataError` if the network cannot be
526        normalized (no reference bus can be chosen, or a non-positive base MVA).
527        """
528        return BalancedNetwork(self._inner.to_normalized())

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 write() serializes the derived model. Raises PowerIODataError if the network cannot be normalized (no reference bus can be chosen, or a non-positive base MVA).

def to_normalized_with_options( self, *, clamp_angle_bounds: bool = False, angle_bound_pad: Optional[float] = None) -> BalancedNetwork:
530    def to_normalized_with_options(
531        self,
532        *,
533        clamp_angle_bounds: bool = False,
534        angle_bound_pad: Optional[float] = None,
535    ) -> "BalancedNetwork":
536        """Return a normalized copy with explicit normalization options.
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        return BalancedNetwork(
545            self._inner.to_normalized_with_options(
546                clamp_angle_bounds=clamp_angle_bounds, angle_bound_pad=angle_bound_pad
547            )
548        )

Return a normalized copy with explicit normalization options.

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.

def to_ppc(self):
550    def to_ppc(self):
551        """PYPOWER case dict (``ppc``) with MATPOWER-style numpy tables.
552
553        Values are emitted as the model holds them, so a case read from a
554        file carries MW, MVAr, and degrees. A network from
555        :meth:`to_normalized` holds per unit and radians, and those are what
556        its tables carry — PYPOWER reads a ppc dict as MW and degrees, so
557        build this from the raw network unless the consumer expects per unit.
558
559        Loads and shunts are summed onto their bus in the
560        ``PD``/``QD``/``GS``/``BS`` columns, the same aggregation
561        :meth:`to_matpower` writes. The bus table has no per element status
562        column, so an element the model marks out of service still
563        contributes its value, and a de-energized bus is carried as type 4.
564        ``gencost`` is present only when every generator carries cost data,
565        because MATPOWER requires cost rows for all generators or none.
566        :func:`from_ppc` reads the tables back.
567        """
568        np = _require("numpy", "matrix")
569        buses = self._inner.buses
570        bus = np.array(
571            [
572                (
573                    b["id"], _PPC_BUS_TYPE.get(b["kind"], 1.0), 0.0, 0.0, 0.0, 0.0,
574                    b["area"], b["vm"], b["va"], b["base_kv"], b["zone"],
575                    b["vmax"], b["vmin"],
576                )
577                for b in buses
578            ],
579            dtype=float,
580        ).reshape(len(buses), 13)
581        bus[:, 2], bus[:, 3], bus[:, 4], bus[:, 5] = _bus_sums(
582            np, buses, self._inner.loads, self._inner.shunts
583        )
584
585        # The capability and ramp columns past PMIN are an OPF extension that a
586        # source need not carry. Widen to the full 21 only when a generator
587        # actually states one: a table of zeros there reads back as eleven
588        # explicit zero limits, which a ramp aware solver takes as a generator
589        # that cannot move.
590        gens = self._inner.generators
591        caps = [g["caps"] for g in gens]
592        width = 21 if any(c is not None for row in caps for c in row) else 10
593        gen = np.array(
594            [
595                [
596                    g["bus"], g["pg"], g["qg"], g["qmax"], g["qmin"], g["vg"],
597                    g["mbase"], float(g["in_service"]), g["pmax"], g["pmin"],
598                ]
599                + ([0.0 if c is None else c for c in row] if width == 21 else [])
600                for g, row in zip(gens, caps)
601            ],
602            dtype=float,
603        ).reshape(len(gens), width)
604
605        branches = self._inner.branches
606        branch = np.array(
607            [
608                (
609                    br["from_id"], br["to_id"], br["r"], br["x"], br["b"],
610                    br["rate_a"], br["rate_b"], br["rate_c"], br["tap"],
611                    br["shift"], float(br["in_service"]), br["angmin"],
612                    br["angmax"],
613                )
614                for br in branches
615            ],
616            dtype=float,
617        ).reshape(len(branches), 13)
618
619        ppc = {
620            "version": "2",
621            "baseMVA": float(self._inner.base_mva),
622            "bus": bus,
623            "gen": gen,
624            "branch": branch,
625        }
626
627        # Coefficients sit left-aligned after ncost, padded to the widest
628        # row, which is the layout PYPOWER's own loadcase produces.
629        costs = [g["cost"] for g in gens]
630        if costs and all(c is not None for c in costs):
631            gencost = np.zeros(
632                (len(costs), 4 + max(len(c["coeffs"]) for c in costs))
633            )
634            for i, c in enumerate(costs):
635                gencost[i, :4] = (
636                    c["model"], c["startup"], c["shutdown"], c["ncost"],
637                )
638                gencost[i, 4:4 + len(c["coeffs"])] = c["coeffs"]
639            ppc["gencost"] = gencost
640        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 to_matpower() writes. 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.

def to_networkx(self):
642    def to_networkx(self):
643        """Undirected networkx graph keyed by bus id.
644
645        In-service branches become edges carrying ``branch`` (index), ``r``,
646        ``x``, and ``b``.
647        """
648        nx = _require("networkx", "graph")
649        g = nx.Graph()
650        g.add_nodes_from(bus["id"] for bus in self._inner.buses)
651        for k, br in enumerate(self._inner.branches):
652            if br["in_service"]:
653                g.add_edge(
654                    br["from_id"],
655                    br["to_id"],
656                    branch=k,
657                    r=br["r"],
658                    x=br["x"],
659                    b=br["b"],
660                )
661        return g

Undirected networkx graph keyed by bus id.

In-service branches become edges carrying branch (index), r, x, and b.

class Conversion(builtins.tuple):

Output of convert_file().

text is the converted file contents; warnings lists the fields the target format could not represent (empty for a faithful conversion).

Conversion(text, warnings)

Create new instance of Conversion(text, warnings)

text

Alias for field number 0

warnings

Alias for field number 1

class DenseBranch(builtins.tuple):

Branch arrays in source order.

DenseBranch(from_id, to_id, r, x, b, tap, shift, in_service)

Create new instance of DenseBranch(from_id, to_id, r, x, b, tap, shift, in_service)

from_id

Alias for field number 0

to_id

Alias for field number 1

r

Alias for field number 2

x

Alias for field number 3

b

Alias for field number 4

tap

Alias for field number 5

shift

Alias for field number 6

in_service

Alias for field number 7

class DenseDemand(builtins.tuple):

Nodal active and reactive demand arrays in bus order.

DenseDemand(pd, qd)

Create new instance of DenseDemand(pd, qd)

pd

Alias for field number 0

qd

Alias for field number 1

class DenseGen(builtins.tuple):

Generator arrays in source order.

DenseGen(bus, pg, pmax, pmin, in_service)

Create new instance of DenseGen(bus, pg, pmax, pmin, in_service)

bus

Alias for field number 0

pg

Alias for field number 1

pmax

Alias for field number 2

pmin

Alias for field number 3

in_service

Alias for field number 4

class DenseNetwork(builtins.tuple):

Copied dense NumPy table export of a parsed BalancedNetwork.

DenseNetwork( n, m, ng, base_mva, bus_ids, branch, gen, demand, shunt, reference_bus, n_components, is_radial)

Create new instance of DenseNetwork(n, m, ng, base_mva, bus_ids, branch, gen, demand, shunt, reference_bus, n_components, is_radial)

n

Alias for field number 0

m

Alias for field number 1

ng

Alias for field number 2

base_mva

Alias for field number 3

bus_ids

Alias for field number 4

branch

Alias for field number 5

gen

Alias for field number 6

demand

Alias for field number 7

shunt

Alias for field number 8

reference_bus

Alias for field number 9

n_components

Alias for field number 10

is_radial

Alias for field number 11

class DenseShunt(builtins.tuple):

Nodal shunt conductance and susceptance arrays in bus order.

DenseShunt(gs, bs)

Create new instance of DenseShunt(gs, bs)

gs

Alias for field number 0

bs

Alias for field number 1

class DisplayData(builtins.tuple):

Output of parse_display_file() / parse_display_bytes().

kind names the display format. For PowerWorld PWD data, kind == "powerworld" and data is a PwdDisplay.

DisplayData(kind, data)

Create new instance of DisplayData(kind, data)

kind

Alias for field number 0

data

Alias for field number 1

class GridfmRead(builtins.tuple):

Output of read_gridfm() / read_gridfm_scenarios().

network is the reconstructed BalancedNetwork; scenario is the source scenario ID; warnings lists fields the GridFM schema cannot retain, including source bus IDs, per element load and shunt rows, HVDC, storage, and piecewise costs.

GridfmRead(network, scenario, warnings)

Create new instance of GridfmRead(network, scenario, warnings)

network

Alias for field number 0

scenario

Alias for field number 1

warnings

Alias for field number 2

class Incidence(builtins.tuple):

Output of BalancedNetwork.incidence().

Shapes, with n buses and m in-service branches:

  • A: signed incidence csr_matrix, (n, m).
  • b: branch susceptances, (m,); b[k] is column k.
  • p_shift: phase-shift injection, (n,) (all zero unless convention="matpower").
  • branch_of_col: column→branch index map, (m,); branch_of_col[k] and b[k] are co-indexed by incidence column k.
Incidence(A, b, p_shift, branch_of_col)

Create new instance of Incidence(A, b, p_shift, branch_of_col)

A

Alias for field number 0

b

Alias for field number 1

p_shift

Alias for field number 2

branch_of_col

Alias for field number 3

class Package:
1010class Package:
1011    """A parsed ``.pio.json`` package.
1012
1013    Parsing occurs once; every accessor reuses the native handle.
1014    """
1015
1016    def __init__(self, inner: "_powerio._Package"):
1017        self._inner = inner
1018
1019    @classmethod
1020    def from_file(
1021        cls, path: Any, from_: Optional[str] = None, scenario: int = 0
1022    ) -> "Package":
1023        """Build a package from a case file or folder."""
1024        return cls(_powerio._Package.from_file(str(path), from_, scenario))
1025
1026    @classmethod
1027    def from_str(cls, text: str, from_: Optional[str] = None) -> "Package":
1028        """Build a package from in-memory case text."""
1029        return cls(_powerio._Package.from_str(text, from_))
1030
1031    @classmethod
1032    def from_json(cls, text: str) -> "Package":
1033        """Parse a ``.pio.json`` document."""
1034        return cls(_powerio._Package.from_json(text))
1035
1036    @classmethod
1037    def from_balanced(
1038        cls, network: BalancedNetwork, include_solver_metadata: bool = False
1039    ) -> "Package":
1040        """Wrap a balanced :class:`BalancedNetwork` in a package."""
1041        return cls(
1042            _powerio._Package.from_balanced(network._inner, include_solver_metadata)
1043        )
1044
1045    @classmethod
1046    def from_multiconductor(cls, network: "dist.MulticonductorNetwork") -> "Package":
1047        """Wrap a multiconductor network in a package."""
1048        return cls(_powerio._Package.from_multiconductor(network._inner))
1049
1050    @property
1051    def model_kind(self) -> str:
1052        """``"balanced"`` or ``"multiconductor"``."""
1053        return self._inner.model_kind()
1054
1055    def to_json(self) -> str:
1056        """Serialize to pretty ``.pio.json``."""
1057        return self._inner.to_json()
1058
1059    def as_balanced(self) -> BalancedNetwork:
1060        """Return the balanced payload as a :class:`BalancedNetwork`."""
1061        return BalancedNetwork(self._inner.as_balanced())
1062
1063    def as_multiconductor(self) -> "dist.MulticonductorNetwork":
1064        """Return the multiconductor payload."""
1065        return dist.MulticonductorNetwork(self._inner.as_multiconductor())
1066
1067    def operating_points(self) -> Any:
1068        """The operating point series as Python data, or ``None``.
1069
1070        GOC3 packages populate this from the source time series. Each point is
1071        a set of field updates over the package's static payload.
1072        """
1073        return _json.loads(self._inner.operating_points_json())
1074
1075    def set_operating_points(self, points: Any) -> None:
1076        """Replace the operating point series and rerun package validation.
1077
1078        ``None`` or an empty series clears it.
1079        """
1080        self._inner.set_operating_points_json(_json.dumps(points))
1081
1082    def study(self) -> Any:
1083        """The study block as Python data, or ``None``."""
1084        return _json.loads(self._inner.study_json())
1085
1086    def materialize_operating_point(self, index: int) -> "Package":
1087        """Materialize one operating point into a new static package."""
1088        return Package(self._inner.materialize_operating_point(index))
1089
1090    def materialize_study_commit(self, index: int) -> "Package":
1091        """Materialize one study commit into a new static package."""
1092        return Package(self._inner.materialize_study_commit(index))
1093
1094    def validate(self) -> None:
1095        """Run the package semantic validation profile in place."""
1096        self._inner.validate()
1097
1098    def validation(self) -> Any:
1099        """The validation summary as Python data."""
1100        return _json.loads(self._inner.validation_json())
1101
1102    def diagnostics(self) -> Any:
1103        """The structured diagnostics as a list of Python dicts."""
1104        return _json.loads(self._inner.diagnostics_json())
1105
1106    def multiconductor_to_balanced_preflight(self, base_mva: float = 100.0) -> Any:
1107        """Readiness report for multiconductor to balanced lowering."""
1108        return _json.loads(
1109            self._inner.multiconductor_to_balanced_preflight_json(base_mva)
1110        )
1111
1112    def lower_multiconductor_to_balanced(self, base_mva: float = 100.0) -> "Package":
1113        """Lower a multiconductor package to a new balanced package."""
1114        return Package(self._inner.lower_multiconductor_to_balanced(base_mva))
1115
1116    def __repr__(self) -> str:
1117        return repr(self._inner)

A parsed .pio.json package.

Parsing occurs once; every accessor reuses the native handle.

Package(inner: powerio._powerio._Package)
1016    def __init__(self, inner: "_powerio._Package"):
1017        self._inner = inner
@classmethod
def from_file( cls, path: Any, from_: Optional[str] = None, scenario: int = 0) -> Package:
1019    @classmethod
1020    def from_file(
1021        cls, path: Any, from_: Optional[str] = None, scenario: int = 0
1022    ) -> "Package":
1023        """Build a package from a case file or folder."""
1024        return cls(_powerio._Package.from_file(str(path), from_, scenario))

Build a package from a case file or folder.

@classmethod
def from_str(cls, text: str, from_: Optional[str] = None) -> Package:
1026    @classmethod
1027    def from_str(cls, text: str, from_: Optional[str] = None) -> "Package":
1028        """Build a package from in-memory case text."""
1029        return cls(_powerio._Package.from_str(text, from_))

Build a package from in-memory case text.

@classmethod
def from_json(cls, text: str) -> Package:
1031    @classmethod
1032    def from_json(cls, text: str) -> "Package":
1033        """Parse a ``.pio.json`` document."""
1034        return cls(_powerio._Package.from_json(text))

Parse a .pio.json document.

@classmethod
def from_balanced( cls, network: BalancedNetwork, include_solver_metadata: bool = False) -> Package:
1036    @classmethod
1037    def from_balanced(
1038        cls, network: BalancedNetwork, include_solver_metadata: bool = False
1039    ) -> "Package":
1040        """Wrap a balanced :class:`BalancedNetwork` in a package."""
1041        return cls(
1042            _powerio._Package.from_balanced(network._inner, include_solver_metadata)
1043        )

Wrap a balanced BalancedNetwork in a package.

@classmethod
def from_multiconductor(cls, network: powerio.dist.MulticonductorNetwork) -> Package:
1045    @classmethod
1046    def from_multiconductor(cls, network: "dist.MulticonductorNetwork") -> "Package":
1047        """Wrap a multiconductor network in a package."""
1048        return cls(_powerio._Package.from_multiconductor(network._inner))

Wrap a multiconductor network in a package.

model_kind: str
1050    @property
1051    def model_kind(self) -> str:
1052        """``"balanced"`` or ``"multiconductor"``."""
1053        return self._inner.model_kind()

"balanced" or "multiconductor".

def to_json(self) -> str:
1055    def to_json(self) -> str:
1056        """Serialize to pretty ``.pio.json``."""
1057        return self._inner.to_json()

Serialize to pretty .pio.json.

def as_balanced(self) -> BalancedNetwork:
1059    def as_balanced(self) -> BalancedNetwork:
1060        """Return the balanced payload as a :class:`BalancedNetwork`."""
1061        return BalancedNetwork(self._inner.as_balanced())

Return the balanced payload as a BalancedNetwork.

def as_multiconductor(self) -> powerio.dist.MulticonductorNetwork:
1063    def as_multiconductor(self) -> "dist.MulticonductorNetwork":
1064        """Return the multiconductor payload."""
1065        return dist.MulticonductorNetwork(self._inner.as_multiconductor())

Return the multiconductor payload.

def operating_points(self) -> Any:
1067    def operating_points(self) -> Any:
1068        """The operating point series as Python data, or ``None``.
1069
1070        GOC3 packages populate this from the source time series. Each point is
1071        a set of field updates over the package's static payload.
1072        """
1073        return _json.loads(self._inner.operating_points_json())

The operating point series as Python data, or None.

GOC3 packages populate this from the source time series. Each point is a set of field updates over the package's static payload.

def set_operating_points(self, points: Any) -> None:
1075    def set_operating_points(self, points: Any) -> None:
1076        """Replace the operating point series and rerun package validation.
1077
1078        ``None`` or an empty series clears it.
1079        """
1080        self._inner.set_operating_points_json(_json.dumps(points))

Replace the operating point series and rerun package validation.

None or an empty series clears it.

def study(self) -> Any:
1082    def study(self) -> Any:
1083        """The study block as Python data, or ``None``."""
1084        return _json.loads(self._inner.study_json())

The study block as Python data, or None.

def materialize_operating_point(self, index: int) -> Package:
1086    def materialize_operating_point(self, index: int) -> "Package":
1087        """Materialize one operating point into a new static package."""
1088        return Package(self._inner.materialize_operating_point(index))

Materialize one operating point into a new static package.

def materialize_study_commit(self, index: int) -> Package:
1090    def materialize_study_commit(self, index: int) -> "Package":
1091        """Materialize one study commit into a new static package."""
1092        return Package(self._inner.materialize_study_commit(index))

Materialize one study commit into a new static package.

def validate(self) -> None:
1094    def validate(self) -> None:
1095        """Run the package semantic validation profile in place."""
1096        self._inner.validate()

Run the package semantic validation profile in place.

def validation(self) -> Any:
1098    def validation(self) -> Any:
1099        """The validation summary as Python data."""
1100        return _json.loads(self._inner.validation_json())

The validation summary as Python data.

def diagnostics(self) -> Any:
1102    def diagnostics(self) -> Any:
1103        """The structured diagnostics as a list of Python dicts."""
1104        return _json.loads(self._inner.diagnostics_json())

The structured diagnostics as a list of Python dicts.

def multiconductor_to_balanced_preflight(self, base_mva: float = 100.0) -> Any:
1106    def multiconductor_to_balanced_preflight(self, base_mva: float = 100.0) -> Any:
1107        """Readiness report for multiconductor to balanced lowering."""
1108        return _json.loads(
1109            self._inner.multiconductor_to_balanced_preflight_json(base_mva)
1110        )

Readiness report for multiconductor to balanced lowering.

def lower_multiconductor_to_balanced(self, base_mva: float = 100.0) -> Package:
1112    def lower_multiconductor_to_balanced(self, base_mva: float = 100.0) -> "Package":
1113        """Lower a multiconductor package to a new balanced package."""
1114        return Package(self._inner.lower_multiconductor_to_balanced(base_mva))

Lower a multiconductor package to a new balanced package.

class PowerIODataError(PowerIOError):

A well-formed case cannot satisfy a requested operation (no generators, wrong reference bus count, an unknown bus reference, zero/non-finite branch impedance, a disconnected or singular network, a scenario batch shape mismatch, or a dimension/cost mismatch).

class PowerIOError(builtins.ValueError):

Base error raised by the powerio parser, converter, or matrix builders.

Subclasses ValueError: every failure it covers is a statement about a value the caller supplied, and except ValueError was what callers wrote before the hierarchy existed. I/O failures do not reach it; they raise the matching OSError subclass by value.

class PowerIOParseError(PowerIOError):

A case file is malformed or unparseable (missing/short rows, bad numbers, unbalanced brackets, format read failures).

class PwdDisplay(builtins.tuple):

Decoded PowerWorld .pwd display metadata.

PwdDisplay(canvas_width, canvas_height, stamp, substations)

Create new instance of PwdDisplay(canvas_width, canvas_height, stamp, substations)

canvas_width

Alias for field number 0

canvas_height

Alias for field number 1

stamp

Alias for field number 2

substations

Alias for field number 3

class PwdSubstation(builtins.tuple):

One decoded PowerWorld display substation.

PwdSubstation(number, name, x, y)

Create new instance of PwdSubstation(number, name, x, y)

number

Alias for field number 0

name

Alias for field number 1

x

Alias for field number 2

y

Alias for field number 3

class YbusParts(builtins.tuple):

Output of BalancedNetwork.ybus_parts(): g = Re(Y_bus), b = Im(Y_bus), each a real csr_matrix. BalancedNetwork.ybus() returns g + 1j*b.

YbusParts(g, b)

Create new instance of YbusParts(g, b)

g

Alias for field number 0

b

Alias for field number 1

__version__ = '0.9.0'
def convert_file( path: Any, to: str, from_: Optional[str] = None, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None, out: Optional[Any] = None) -> Conversion:
829def convert_file(
830    path: Any,
831    to: str,
832    from_: Optional[str] = None,
833    missing_gen_cost: Optional[str] = None,
834    default_gen_cost: Optional[str] = None,
835    gen_cost_csv: Optional[Any] = None,
836    out: Optional[Any] = None,
837) -> Conversion:
838    r"""Convert a case file to another format through the network model.
839
840    ``to`` / ``from_`` are format names: ``matpower``, ``powermodels-json``,
841    ``egret-json``, ``pandapower-json``, ``psse``, ``powerworld``, ``pslf``,
842    ``goc3-json``, ``surge-json``, and ``opfdata-json`` (aliases ``m``, ``pm``,
843    ``egret``, ``pp``, ``raw``, ``aux``, ``epc``, ``goc3``, ``surge``,
844    ``opfdata``, and ``gridopt``). The input format is
845    inferred from the file extension unless ``from_`` overrides it. GO Challenge
846    3 and OPFData JSON are read only. An OPFData input may be an extracted
847    FullTop or N-1 example of any published grid size; its element counts are
848    read from the document. PyPSA CSV folders are read with
849    ``from_="pypsa-csv"`` and written with
850    :meth:`BalancedNetwork.write_pypsa_csv_folder`. Returns a :class:`Conversion` with
851    the text and any fidelity warnings. ``out`` writes the text to a file
852    exactly as produced; prefer it over ``open(out, "w").write(text)``, whose
853    text mode newline translation on Windows doubles the carriage returns of
854    a CRLF source echo into ``\r\r\n``, which PSS/E family tools reject.
855    """
856    text, warnings = _powerio.convert_file(
857        str(path),
858        to,
859        from_,
860        missing_gen_cost=missing_gen_cost,
861        default_gen_cost=default_gen_cost,
862        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
863        out=None if out is None else str(out),
864    )
865    return Conversion(text, warnings)

Convert a case file to another format through the network model.

to / from_ are format names: matpower, powermodels-json, egret-json, pandapower-json, psse, powerworld, pslf, goc3-json, surge-json, and opfdata-json (aliases m, pm, egret, pp, raw, aux, epc, goc3, surge, opfdata, and gridopt). The input format is inferred from the file extension unless from_ overrides it. GO Challenge 3 and OPFData JSON are read only. An OPFData input may be an extracted FullTop or N-1 example of any published grid size; its element counts are read from the document. PyPSA CSV folders are read with from_="pypsa-csv" and written with BalancedNetwork.write_pypsa_csv_folder(). Returns a Conversion with the text and any fidelity warnings. out writes the text to a file exactly as produced; prefer it over open(out, "w").write(text), whose text mode newline translation on Windows doubles the carriage returns of a CRLF source echo into \r\r\n, which PSS/E family tools reject.

def convert_str( text: str, to: str, format: str = 'matpower', missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> Conversion:
868def convert_str(
869    text: str,
870    to: str,
871    format: str = "matpower",
872    missing_gen_cost: Optional[str] = None,
873    default_gen_cost: Optional[str] = None,
874    gen_cost_csv: Optional[Any] = None,
875) -> Conversion:
876    """Convert in-memory case ``text`` through the network model without a
877    temporary file.
878
879    ``to`` and ``format`` are format names as in :func:`convert_file`;
880    ``format`` names the input (default ``matpower``). Returns a
881    :class:`Conversion` with the converted text and any fidelity warnings.
882    """
883    out, warnings = _powerio.convert_str(
884        text,
885        to,
886        format,
887        missing_gen_cost=missing_gen_cost,
888        default_gen_cost=default_gen_cost,
889        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
890    )
891    return Conversion(out, warnings)

Convert in-memory case text through the network model without a temporary file.

to and format are format names as in convert_file(); format names the input (default matpower). Returns a Conversion with the converted text and any fidelity warnings.

def from_json(text: str) -> BalancedNetwork:
723def from_json(text: str) -> BalancedNetwork:
724    """Rebuild a case from JSON produced by :meth:`BalancedNetwork.to_json`."""
725    return BalancedNetwork(_powerio.from_json(text))

Rebuild a case from JSON produced by BalancedNetwork.to_json().

def from_ppc(ppc) -> BalancedNetwork:
812def from_ppc(ppc) -> BalancedNetwork:
813    """Case from a PYPOWER dict (``ppc``); the inverse of :meth:`BalancedNetwork.to_ppc`.
814
815    The tables route through the MATPOWER reader, so the semantics match a
816    ``.m`` case exactly: bus ``PD``/``QD`` become loads, ``GS``/``BS`` become
817    shunts, and ``gencost`` is read when present. Result columns past the
818    MATPOWER input widths are dropped. A 10 column ``gen`` table (the layout
819    without the OPF capability columns) passes through at its own width, so
820    the generators come back with no capability limits rather than eleven
821    zero ones. Raises :class:`ValueError` when a required table is absent,
822    when a ``bus`` or ``branch`` row is below its 13 column width, when a row
823    is not a sequence of numbers, or when a cell is not numeric; the message
824    names the table and the row.
825    """
826    return parse_str(_ppc_to_matpower_text(ppc), "matpower")

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.

def parse_bytes(data: bytes, format: str) -> BalancedNetwork:
689def parse_bytes(data: bytes, format: str) -> BalancedNetwork:
690    """Parse a case from in-memory bytes in the named ``format``.
691
692    Accepts every :func:`parse_str` format name plus ``"pwb"``. PowerWorld
693    binary has no text form, so this is the only way to read one without a
694    file on disk. Text formats must be UTF-8.
695    """
696    return BalancedNetwork(_powerio.parse_bytes(data, format))

Parse a case from in-memory bytes in the named format.

Accepts every parse_str() format name plus "pwb". PowerWorld binary has no text form, so this is the only way to read one without a file on disk. Text formats must be UTF-8.

def parse_display_bytes(data: bytes, format: str) -> DisplayData:
679def parse_display_bytes(data: bytes, format: str) -> DisplayData:
680    """Parse display bytes in the named display format."""
681    return _wrap_display(_powerio.parse_display_bytes(data, format))

Parse display bytes in the named display format.

def parse_display_file(path: Any, from_: Optional[str] = None) -> DisplayData:
674def parse_display_file(path: Any, from_: Optional[str] = None) -> DisplayData:
675    """Parse a display artifact such as a PowerWorld ``.pwd`` file."""
676    return _wrap_display(_powerio.parse_display_file(str(path), from_))

Parse a display artifact such as a PowerWorld .pwd file.

def parse_file(path: Any, from_: Optional[str] = None) -> BalancedNetwork:
664def parse_file(path: Any, from_: Optional[str] = None) -> BalancedNetwork:
665    """Parse a case file from a path, inferring the format from the extension.
666
667    Read fidelity warnings are on ``BalancedNetwork.read_warnings`` (empty for readers
668    that don't report any; currently pandapower JSON, PyPSA CSV, and PSLF EPC
669    report them).
670    """
671    return BalancedNetwork(_powerio.parse_file(str(path), from_))

Parse a case file from a path, inferring the format from the extension.

Read fidelity warnings are on BalancedNetwork.read_warnings (empty for readers that don't report any; currently pandapower JSON, PyPSA CSV, and PSLF EPC report them).

def parse_geo(text: str, name_hint: Optional[str] = None) -> dict[str, typing.Any]:
709def parse_geo(text: str, name_hint: Optional[str] = None) -> dict[str, Any]:
710    """Tolerantly read a geographic sidecar and return its canonical form.
711
712    Accepts headerless buscoords CSV, aliased CSV/JSON records, and GeoJSON
713    Point/LineString features. Returns ``{"geojson": <FeatureCollection dict>,
714    "warnings": [...]}``; ``name_hint`` (a file name) picks CSV against JSON
715    when the content alone is ambiguous. Input with no usable coordinates
716    raises :class:`PowerIOParseError`.
717    """
718    parsed = _powerio.parse_geo(text, name_hint)
719    parsed["geojson"] = _json.loads(parsed["geojson"])
720    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>, "warnings": [...]}; name_hint (a file name) picks CSV against JSON when the content alone is ambiguous. Input with no usable coordinates raises PowerIOParseError.

def parse_scopf(text: str, from_: str = 'goc3-json') -> dict[str, typing.Any]:
699def parse_scopf(text: str, from_: str = "goc3-json") -> dict[str, Any]:
700    """Return a versioned SCOPF problem instance document.
701
702    ``from_`` currently accepts ``"goc3-json"``. The returned dictionary uses
703    the wire schema's declared 1-based indices and retains source identities in
704    separate fields. Parse and assembly failures raise :class:`PowerIOError`.
705    """
706    return _json.loads(_powerio.parse_scopf(text, from_))

Return a versioned SCOPF problem instance document.

from_ currently accepts "goc3-json". The returned dictionary uses the wire schema's declared 1-based indices and retains source identities in separate fields. Parse and assembly failures raise PowerIOError.

def parse_str(text: str, format: str = 'matpower') -> BalancedNetwork:
684def parse_str(text: str, format: str = "matpower") -> BalancedNetwork:
685    """Parse a case from in-memory text in the named ``format``."""
686    return BalancedNetwork(_powerio.parse_str(text, format))

Parse a case from in-memory text in the named format.

def read_gridfm(dir: Any, scenario: int = 0) -> GridfmRead:
963def read_gridfm(dir: Any, scenario: int = 0) -> GridfmRead:
964    """Read one scenario of a gridfm-datakit Parquet dataset back into a case.
965
966    The inverse of :meth:`BalancedNetwork.write_gridfm`. ``dir`` is resolved leniently:
967    the ``raw/`` directory holding the parquet files, a ``<case>/`` directory with
968    a ``raw/`` child, or a parent directory with one ``*/raw/`` child all work.
969    ``scenario`` selects one snapshot from a batch (``0``, the base case, by
970    default). Returns a :class:`GridfmRead` ``(network, scenario, warnings)``.
971
972    The read recovers bus types, voltages and limits, nodal load and shunt
973    totals, generator dispatch and bounds, branch
974    ``r/x/b/tap/shift/rate_a`` values, angle limits, and ``baseMVA``. It cannot
975    recover source bus IDs, per element load/shunt granularity, piecewise or
976    cubic costs, HVDC, or storage;
977    what it can't recover is listed in ``warnings``. Published wheels include the
978    native reader; custom source builds without the Rust ``gridfm`` feature raise
979    ``ImportError``.
980    """
981    _require_gridfm()
982    inner, scen, warnings = _powerio.read_gridfm(str(dir), scenario)
983    return GridfmRead(BalancedNetwork(inner), scen, warnings)

Read one scenario of a gridfm-datakit Parquet dataset back into a case.

The inverse of BalancedNetwork.write_gridfm(). dir is resolved leniently: the raw/ directory holding the parquet files, a <case>/ directory with a raw/ child, or a parent directory with one */raw/ child all work. scenario selects one snapshot from a batch (0, the base case, by default). Returns a GridfmRead (network, scenario, warnings).

The read recovers bus types, voltages and limits, nodal load and shunt totals, generator dispatch and bounds, branch r/x/b/tap/shift/rate_a values, angle limits, and baseMVA. It cannot recover source bus IDs, per element load/shunt granularity, piecewise or cubic costs, HVDC, or storage; what it can't recover is listed in warnings. Published wheels include the native reader; custom source builds without the Rust gridfm feature raise ImportError.

def read_gridfm_scenarios(dir: Any) -> list[GridfmRead]:
986def read_gridfm_scenarios(dir: Any) -> "list[GridfmRead]":
987    """Read every scenario of a gridfm dataset, one :class:`GridfmRead` per
988    scenario id (ascending) over the shared topology, the read side of
989    :func:`write_gridfm_batch`.
990
991    Each scenario is rebuilt independently, so two scenarios may differ in branch
992    status, bus types, and reference bus. See :func:`read_gridfm` for the lenient
993    directory resolution and the fidelity behavior.
994    """
995    _require_gridfm()
996    return [
997        GridfmRead(BalancedNetwork(inner), scen, warnings)
998        for inner, scen, warnings in _powerio.read_gridfm_scenarios(str(dir))
999    ]

Read every scenario of a gridfm dataset, one GridfmRead per scenario id (ascending) over the shared topology, the read side of write_gridfm_batch().

Each scenario is rebuilt independently, so two scenarios may differ in branch status, bus types, and reference bus. See read_gridfm() for the lenient directory resolution and the fidelity behavior.

def read_pypsa_csv_folder(path: Any) -> BalancedNetwork:
1002def read_pypsa_csv_folder(path: Any) -> BalancedNetwork:
1003    """Read a PyPSA CSV folder into a :class:`BalancedNetwork`."""
1004    return BalancedNetwork(_powerio.read_pypsa_csv_folder(str(path)))

Read a PyPSA CSV folder into a BalancedNetwork.

def to_dense(network: BalancedNetwork) -> DenseNetwork:
920def to_dense(network: BalancedNetwork) -> DenseNetwork:
921    """Return copied dense NumPy tables for ``network``."""
922    return network.to_dense()

Return copied dense NumPy tables for network.

def to_format( network: BalancedNetwork, to: str, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> Conversion:
894def to_format(
895    network: BalancedNetwork,
896    to: str,
897    missing_gen_cost: Optional[str] = None,
898    default_gen_cost: Optional[str] = None,
899    gen_cost_csv: Optional[Any] = None,
900) -> Conversion:
901    """Serialize ``network`` to another format."""
902    return network.to_format(
903        to,
904        missing_gen_cost=missing_gen_cost,
905        default_gen_cost=default_gen_cost,
906        gen_cost_csv=gen_cost_csv,
907    )

Serialize network to another format.

def to_json(network: BalancedNetwork) -> str:
915def to_json(network: BalancedNetwork) -> str:
916    """Serialize ``network`` to the JSON transport."""
917    return network.to_json()

Serialize network to the JSON transport.

def to_matpower(network: BalancedNetwork) -> str:
910def to_matpower(network: BalancedNetwork) -> str:
911    """Serialize ``network`` to MATPOWER ``.m`` text."""
912    return network.to_matpower()

Serialize network to MATPOWER .m text.

def write_gridfm_batch( networks: list[BalancedNetwork], out_dir: Any, *, base_scenario: int = 0, include_y_bus: bool = True, include_taps: bool = True, include_shifts: bool = True, missing_gen_cost: Optional[str] = None, default_gen_cost: Optional[str] = None, gen_cost_csv: Optional[Any] = None) -> dict:
925def write_gridfm_batch(
926    networks: "list[BalancedNetwork]",
927    out_dir: Any,
928    *,
929    base_scenario: int = 0,
930    include_y_bus: bool = True,
931    include_taps: bool = True,
932    include_shifts: bool = True,
933    missing_gen_cost: Optional[str] = None,
934    default_gen_cost: Optional[str] = None,
935    gen_cost_csv: Optional[Any] = None,
936) -> dict:
937    """Write several networks as one gridfm-datakit dataset, row stacked and
938    keyed by the ``scenario`` column.
939
940    Each network is one snapshot; the k-th is stamped ``base_scenario + k``. The
941    networks must share a base element set: the same bus/branch/gen counts and
942    bus id order (otherwise :class:`PowerIODataError` is raised). Load, dispatch,
943    branch status, and costs may vary per scenario. Returns the same dict as
944    :meth:`BalancedNetwork.write_gridfm`. Published wheels include the native writer;
945    custom source builds without the Rust ``gridfm`` feature raise
946    ``ImportError``.
947    """
948    _require_gridfm()
949    inners = [c._inner for c in networks]
950    return _powerio.write_gridfm_batch(
951        inners,
952        str(out_dir),
953        base_scenario=base_scenario,
954        include_y_bus=include_y_bus,
955        include_taps=include_taps,
956        include_shifts=include_shifts,
957        missing_gen_cost=missing_gen_cost,
958        default_gen_cost=default_gen_cost,
959        gen_cost_csv=None if gen_cost_csv is None else str(gen_cost_csv),
960    )

Write several networks as one gridfm-datakit dataset, row stacked and keyed by the scenario column.

Each network is one snapshot; the k-th is stamped base_scenario + k. The networks must share a base element set: the same bus/branch/gen counts and bus id order (otherwise PowerIODataError is raised). Load, dispatch, branch status, and costs may vary per scenario. Returns the same dict as BalancedNetwork.write_gridfm(). Published wheels include the native writer; custom source builds without the Rust gridfm feature raise ImportError.