Skip to content

Network importers (I/O)

External-network importers convert existing power-system models from third-party tools into a groundinsight.Network. They do not build grounding networks from scratch — instead they project an existing distribution-network topology onto the bus / branch primitives that groundinsight solves for, while the user supplies the grounding-side impedance formulas via an ImportDefaults object. The first inhabitant is the pandapower importer; PowerFactory .dgs and the live PowerFactory Python API are on the roadmap.

Two kinds of import are on this page. The topology import (preview_pandapower_import, from_pandapower) answers which buses and branches exist. The short-circuit result import (read_shortcircuit_results, apply_shortcircuit_characteristics) answers how hard the fault drives them — it takes a case already solved by pandapower.shortcircuit.calc_sc and turns it into the IEC 60909 excitation of the grounding model. The two are independent: a hand-built Network can take the short-circuit import just as well, as long as its bus names match.

Physical / modelling context

Distribution-network databases (pandapower, PowerFactory, NEPLAN, PSS®E) carry the electrical topology: buses, lines, transformers, their resistance and reactance per phase. They do not carry the grounding-side model — neither the bus grounding impedance \(Z_{\text{B}}(\rho_E, f)\) nor the cable-shield self / mutual impedance \(Z_{\text{self}}, Z_{\text{mutual}}\) that groundinsight needs. The importer therefore adopts the pandapower topology (which buses exist, which lines connect them, how long the lines are) and asks the user to supply the grounding-side parameters via ImportDefaults. That separation keeps the importer schema-stable across tools — every importer accepts the same ImportDefaults shape — and lets the user attach the same set of grounding formulas to networks coming from different sources.

The active flag (default True) is propagated from pandapower.in_service so that out-of-service equipment is excluded from the nodal solve straight away — see the outage-study reference for the runtime equivalent.

Example

import pandapower.networks as pn
import groundinsight as gi

# Any pandapower MV / LV demo network — here a simple 4-bus MV ring.
net_pp = pn.example_simple()

defaults = gi.ImportDefaults(
    rho=100.0,
    frequencies=[50.0, 250.0],
    default_bus_type=gi.BusType(
        name="MVbus",
        description="Default substation grounding grid",
        system_type="Substation",
        voltage_level=20.0,
        impedance_formula="rho * 0.01 + j * f * 1/50 * 0.1",
    ),
    default_branch_type=gi.BranchType(
        name="MVcable",
        description="Default 20 kV cable",
        grounding_conductor=True,
        self_impedance_formula="(0.25 + j * f * 0.012) * l",
        mutual_impedance_formula="(0.0  + j * f * 0.012) * l",
    ),
)

# 1. Pre-flight summary: kept vs. skipped elements with a reason column.
preview = gi.preview_pandapower_import(net_pp, voltage_level_kV=20.0)
print(preview)

# 2. Build the Network. Only buses / lines on the chosen voltage
#    level are imported; switches, ext_grids, sgens, loads are ignored.
net = gi.from_pandapower(
    net_pp,
    defaults=defaults,
    voltage_level_kV=20.0,
    network_name="MV ring (from pandapower)",
)

print(f"Imported {len(net.buses)} buses, {len(net.branches)} branches.")

The pandapower extra is optional; install with pip install 'groundinsight[pandapower]' (or poetry install --extras pandapower).

Skip-reason vocabulary

Both preview_pandapower_import and from_pandapower produce warning records on the groundinsight.io.pandapower_import logger whenever they discard a row. The reason column of the preview frame matches the warning text:

Reason Element Trigger
voltage_level_mismatch bus bus.vn_kv != voltage_level_kV.
vn_kv_unparsable bus bus.vn_kv is missing, None, or NaN. New in 0.5.
endpoint_off_target_voltage_level line One endpoint sits on a different voltage level.
endpoint_bus_missing line An endpoint bus could not be resolved.
self_loop line from_bus == to_bus. New in 0.5.

If the importer produces a Network with zero buses or zero branches it now also emits a logger.warning so a wrong voltage_level_kV argument fails loudly rather than silently.

Short-circuit characteristics (IEC 60909-0)

The grounding model is excited by a fault current, and a limit check needs more than its RMS magnitude: the peak current \(i_p\) and the thermally equivalent current \(I_{th}\) both depend on the fault-loop \(R/X\) and the clearing time \(t_k\). Those follow from the phase-side short-circuit calculation, which pandapower already performs. Rather than re-implementing IEC 60909 next to it, groundinsight reads the solved case.

read_shortcircuit_results converts net.res_bus_sc into a tidy, unit-explicit Polars frame — one row per bus, currents in amperes (not kA) to match the rest of the package. The fault type, case and clearing time are taken from net._options, so a solved net is self-describing and the call usually needs no arguments. apply_shortcircuit_characteristics then writes the row belonging to the fault bus onto the model: the loop \(R/X\) and \(\kappa\) go to every source feeding the fault, together with that source's share of \(I_k''\); the clearing time \(t_k\) and the AC heat factor \(n\) go to the Fault, where they belong — they describe the protection, not the infeed. With set_source_values=True the source injections are overwritten by those shares as well, which is off by default so a hand-tuned excitation is never silently replaced. The returned frame is an audit trail: it carries i_k_previous_a next to i_k_a, so a review sees exactly what changed.

import pandapower.shortcircuit as sc
import groundinsight as gi

sc.calc_sc(net_pp, fault="1ph", case="max", ip=True, ith=True, tk_s=0.5)

# Inspect first ...
gi.read_shortcircuit_results(net_pp).select(
    "bus_name", "i_k_a", "r_to_x", "kappa", "kappa_origin", "i_p_a", "i_th_a"
)

# ... then write onto the grounding model and check the conductors.
gi.apply_shortcircuit_characteristics(
    net_gi, net_pp, "fault1", set_source_values=True
)
gi.run_fault(net_gi, "fault1")
gi.check_conductor_limits(net_gi, "fault1")

Two deliberate deviations from pandapower

Both are documented in the module docstring of groundinsight.io.pandapower_sc and are visible in the output, not hidden:

fault="1ph" — the case relevant for grounding studies — leaves pandapower's ip_ka and ith_ka entirely NaN; the quantities are only published for the polyphase faults. read_shortcircuit_results fills them in from the closed form \(\kappa = 1.02 + 0.98\,e^{-3R/X}\), evaluated on the earth-fault loop \(R/X = (2R_1 + R_0)/(2X_1 + X_0)\) rather than on the positive-sequence ratio \(R_1/X_1\) — the loop the earth-fault current actually traverses. Where pandapower does publish ip_ka (the polyphase cases), its topology-aware value is used unchanged. The kappa_origin column records which of the two applied ("pandapower", "iec_closed_form" or "unavailable"); keep it in reports, it is the difference between a reproducible study and a magic number.

I_th is recomputed rather than taken over, because pandapower's _calc_ith sets the DC heat factor \(m\) to zero for \(\kappa > 1.99\), where the analytic limit is \(m \to 2\). A vanishing \(m\) makes \(I_{th}\) too small — the unsafe direction for a limit check — and precisely in the high-\(\kappa\), DC-dominated cases where the thermal stress is worst.

Walkthrough notebook

The end-to-end example for the pandapower importer is shipped as Pandapower import in the Examples section. It shows the full preview → commit flow on a real MV ring net and is the recommended starting point for case-study setups based on existing distribution-network models.

API reference

io

External-network importers.

This sub-package converts existing power-system models from third-party tools (pandapower today, PowerFactory .dgs next) into a :class:groundinsight.models.core_models.Network. Importers do not build grounding networks from scratch -- they project an existing distribution-network topology onto the bus/branch primitives that groundinsight solves for, and let the user supply the impedance formulas via :class:ImportDefaults.

Beyond the topology, the sub-package also imports results: a solved pandapower short-circuit case can be read as IEC 60909 characteristic quantities and applied to the sources and faults of an existing groundinsight model, which is what feeds the thermal conductor check.

Public API:

  • :class:ImportDefaults
  • :func:groundinsight.io.pandapower_import.from_pandapower
  • :func:groundinsight.io.pandapower_import.preview_pandapower_import
  • :func:groundinsight.io.pandapower_sc.read_shortcircuit_results
  • :func:groundinsight.io.pandapower_sc.apply_shortcircuit_characteristics

The importers themselves live in tool-specific submodules so that the optional third-party dependency is loaded lazily.

ImportDefaults

Bases: BaseModel

Per-import defaults shared by every external-network importer.

Attributes:

Name Type Description
rho float

Specific earth resistance applied to every imported :class:Bus and :class:Branch (Ohm * m). Source tools do not encode soil parameters, so a single project-wide value is taken from the user.

frequencies list of float

Frequencies at which impedance values are evaluated. Becomes Network.frequencies; bus and branch impedance dicts are sized accordingly.

default_bus_type BusType

Default :class:BusType assigned to every imported bus on the selected voltage level.

default_branch_type BranchType

Default :class:BranchType assigned to every imported line / cable on the selected voltage level.

Examples:

>>> from groundinsight.models.core_models import BusType, BranchType
>>> from groundinsight.io import ImportDefaults
>>> defaults = ImportDefaults(
...     rho=100.0,
...     frequencies=[50.0],
...     default_bus_type=BusType(
...         name="ImportedBus",
...         system_type="Grounded",
...         voltage_level=20.0,
...         impedance_formula="rho * 0 + 1.0 + I * f * 0",
...     ),
...     default_branch_type=BranchType(
...         name="ImportedCable",
...         grounding_conductor=True,
...         self_impedance_formula="(0.25 + I * 0.6) * l",
...         mutual_impedance_formula="(0.0 + I * 0.6) * l",
...     ),
... )

apply_shortcircuit_characteristics

apply_shortcircuit_characteristics(
    network: Network,
    sc_results,
    fault_name: str,
    *,
    pp_bus: Optional[int] = None,
    bus_name: Optional[str] = None,
    sources: Optional[Sequence[str]] = None,
    set_source_values: bool = False,
    frequency: Optional[float] = None,
    t_k_s: Optional[float] = None,
    n_factor: Optional[float] = None
) -> pl.DataFrame

Write pandapower short-circuit characteristics onto a groundinsight model.

Takes the IEC 60909 quantities computed at the fault bus and distributes them over the sources that feed that fault: every source receives the loop's r_to_x and kappa, and a share of I_k'' proportional to its present injection. The clearing time T_k and the AC heat factor n are written onto the :class:Fault, where they belong — they describe the protection, not the infeed.

With set_source_values=True the source injections themselves are overwritten with those shares, turning a pandapower earth-fault result directly into the excitation of the grounding model. This is off by default so an existing, hand-tuned excitation is never silently replaced.

Parameters:

Name Type Description Default
network Network

The groundinsight network to update, in place.

required
sc_results pandapowerNet or DataFrame

Either a solved pandapower net (passed through :func:read_shortcircuit_results) or a frame previously returned by it.

required
fault_name str

Name of the :class:Fault in network the results belong to.

required
pp_bus int

pandapower bus index to read the characteristics from. Defaults to the row whose bus_name equals the fault's bus, which is the natural match for a network built by :func:~groundinsight.io.from_pandapower.

None
bus_name str

Alternative to pp_bus: select the row by name. Mutually exclusive with pp_bus.

None
sources sequence of str

Restrict the update to these sources. Defaults to every source with a path to the fault, or — if no paths are built — every active source in the network.

None
set_source_values bool

If True, also overwrite Source.values at frequency with each source's share of I_k''.

False
frequency float

Frequency at which the shares are computed and, if requested, written. Defaults to the lowest positive network frequency.

None
t_k_s float

Overrides the clearing time from sc_results, and is the way to set one when the short-circuit run carries none.

None
n_factor float

Overrides the AC heat factor from sc_results, and is the way to set one at all — n is not a pandapower quantity.

None

Returns:

Type Description
DataFrame

An audit trail with one row per updated source: fault_name, source_name, bus, frequency, i_k_total_a, share, i_k_previous_a, i_k_a, r_to_x, kappa, kappa_origin, t_k_s, n_factor and values_updated. i_k_previous_a is the magnitude the source injected before the call, so a review can see exactly what changed. t_k_s and n_factor report the values in effect on the fault after the call, which for an untouched quantity is the one that was already there.

Raises:

Type Description
ValueError

If the fault does not exist, if both pp_bus and bus_name are given, if no matching row is found in sc_results, or if no source is eligible for the update.

Notes

Only the linear quantities are distributed. i_p and I_th are deliberately not written per source: they are non-linear in the current and must be evaluated on the aggregate, which is what :func:~groundinsight.analysis.shortcircuit.resolve_fault_sc_characteristics does.

Fault.t_k_s and Fault.n_factor describe the protection, and a short-circuit run only occasionally knows anything about it. They are therefore updated conservatively: a clearing time is written only if the calc_sc run genuinely carried one (see :func:_tk_s_from_options) or t_k_s=... is passed here, and an AC heat factor only if it differs from the neutral 1.0 or n_factor=... is passed here. Anything else leaves the value on the fault untouched instead of resetting it to a pandapower placeholder — a shortened T_k raises the admissible current by 1/sqrt(t_k) and would make an undersized conductor pass. Whenever a value is replaced by a different one, a logger.warning names both.

Examples:

>>> rep = gi.apply_shortcircuit_characteristics(net_gi, net_pp, "F1")
>>> rep.select("source_name", "i_k_previous_a", "i_k_a", "kappa")
Source code in src/groundinsight/io/pandapower_sc.py
def apply_shortcircuit_characteristics(
    network: Network,
    sc_results,
    fault_name: str,
    *,
    pp_bus: Optional[int] = None,
    bus_name: Optional[str] = None,
    sources: Optional[Sequence[str]] = None,
    set_source_values: bool = False,
    frequency: Optional[float] = None,
    t_k_s: Optional[float] = None,
    n_factor: Optional[float] = None,
) -> pl.DataFrame:
    """
    Write pandapower short-circuit characteristics onto a groundinsight model.

    Takes the IEC 60909 quantities computed at the **fault bus** and
    distributes them over the sources that feed that fault: every source
    receives the loop's ``r_to_x`` and ``kappa``, and a share of ``I_k''``
    proportional to its present injection. The clearing time ``T_k`` and
    the AC heat factor ``n`` are written onto the :class:`Fault`, where
    they belong — they describe the protection, not the infeed.

    With ``set_source_values=True`` the source injections themselves are
    overwritten with those shares, turning a pandapower earth-fault result
    directly into the excitation of the grounding model. This is off by
    default so an existing, hand-tuned excitation is never silently
    replaced.

    Parameters
    ----------
    network : Network
        The groundinsight network to update, in place.
    sc_results : pandapower.auxiliary.pandapowerNet or polars.DataFrame
        Either a solved pandapower net (passed through
        :func:`read_shortcircuit_results`) or a frame previously returned
        by it.
    fault_name : str
        Name of the :class:`Fault` in ``network`` the results belong to.
    pp_bus : int, optional
        pandapower bus index to read the characteristics from. Defaults to
        the row whose ``bus_name`` equals the fault's bus, which is the
        natural match for a network built by
        :func:`~groundinsight.io.from_pandapower`.
    bus_name : str, optional
        Alternative to ``pp_bus``: select the row by name. Mutually
        exclusive with ``pp_bus``.
    sources : sequence of str, optional
        Restrict the update to these sources. Defaults to every source
        with a path to the fault, or — if no paths are built — every
        active source in the network.
    set_source_values : bool, default False
        If ``True``, also overwrite ``Source.values`` at ``frequency``
        with each source's share of ``I_k''``.
    frequency : float, optional
        Frequency at which the shares are computed and, if requested,
        written. Defaults to the lowest positive network frequency.
    t_k_s : float, optional
        Overrides the clearing time from ``sc_results``, and is the way to
        set one when the short-circuit run carries none.
    n_factor : float, optional
        Overrides the AC heat factor from ``sc_results``, and is the way
        to set one at all — ``n`` is not a pandapower quantity.

    Returns
    -------
    polars.DataFrame
        An audit trail with one row per updated source: ``fault_name``,
        ``source_name``, ``bus``, ``frequency``, ``i_k_total_a``,
        ``share``, ``i_k_previous_a``, ``i_k_a``, ``r_to_x``, ``kappa``,
        ``kappa_origin``, ``t_k_s``, ``n_factor`` and ``values_updated``.
        ``i_k_previous_a`` is the magnitude the source injected *before*
        the call, so a review can see exactly what changed. ``t_k_s`` and
        ``n_factor`` report the values *in effect on the fault after* the
        call, which for an untouched quantity is the one that was already
        there.

    Raises
    ------
    ValueError
        If the fault does not exist, if both ``pp_bus`` and ``bus_name``
        are given, if no matching row is found in ``sc_results``, or if
        no source is eligible for the update.

    Notes
    -----
    Only the *linear* quantities are distributed. ``i_p`` and ``I_th`` are
    deliberately **not** written per source: they are non-linear in the
    current and must be evaluated on the aggregate, which is what
    :func:`~groundinsight.analysis.shortcircuit.resolve_fault_sc_characteristics`
    does.

    ``Fault.t_k_s`` and ``Fault.n_factor`` describe the *protection*, and a
    short-circuit run only occasionally knows anything about it. They are
    therefore updated conservatively: a clearing time is written only if
    the ``calc_sc`` run genuinely carried one (see
    :func:`_tk_s_from_options`) or ``t_k_s=...`` is passed here, and an AC
    heat factor only if it differs from the neutral 1.0 or ``n_factor=...``
    is passed here. Anything else leaves the value on the fault untouched
    instead of resetting it to a pandapower placeholder — a shortened
    ``T_k`` raises the admissible current by ``1/sqrt(t_k)`` and would make
    an undersized conductor pass. Whenever a value *is* replaced by a
    different one, a ``logger.warning`` names both.

    Examples
    --------
    >>> rep = gi.apply_shortcircuit_characteristics(net_gi, net_pp, "F1")  # doctest: +SKIP
    >>> rep.select("source_name", "i_k_previous_a", "i_k_a", "kappa")  # doctest: +SKIP
    """
    if pp_bus is not None and bus_name is not None:
        raise ValueError("Provide at most one of 'pp_bus' or 'bus_name'.")

    fault = network.faults.get(fault_name)
    if fault is None:
        raise ValueError(
            f"Fault {fault_name!r} is not in network {network.name!r}. "
            f"Available: {sorted(network.faults)}."
        )

    frame = (
        sc_results
        if isinstance(sc_results, pl.DataFrame)
        else read_shortcircuit_results(sc_results)
    )
    if frame.height == 0:
        raise ValueError("The short-circuit result frame is empty.")

    # --- pick the row describing the fault location --------------------
    target = bus_name if bus_name is not None else fault.bus
    if pp_bus is not None:
        matches = frame.filter(pl.col("pp_bus_index") == int(pp_bus))
        selector = f"pp_bus={pp_bus}"
    else:
        matches = frame.filter(pl.col("bus_name") == target)
        selector = f"bus_name={target!r}"
    if matches.height == 0:
        raise ValueError(
            f"No short-circuit result for {selector}. Available buses: "
            f"{frame['bus_name'].to_list()}. Pass pp_bus=... or bus_name=... "
            "if the groundinsight bus names differ from the pandapower ones."
        )
    if matches.height > 1:
        logger.warning(
            "%d short-circuit rows match %s; using the first one.",
            matches.height, selector,
        )
    row = matches.to_dicts()[0]

    i_k_total = row.get("i_k_a")
    if i_k_total is None:
        raise ValueError(
            f"The short-circuit result for {selector} has no I_k''; nothing to apply."
        )
    kappa = row.get("kappa")
    r_to_x = row.get("r_to_x")
    kappa_origin = row.get("kappa_origin")

    # --- the protection-side quantities belong to the fault ------------
    # An explicit argument always wins. Otherwise the frame is only
    # believed where it can actually have learnt something: a ``t_k_s``
    # that survived _tk_s_from_options, and an ``n_factor`` that is not the
    # neutral default (pandapower does not model ``n``, so a 1.0 in the
    # frame is the argument default of read_shortcircuit_results and says
    # nothing about this fault).
    if t_k_s is not None:
        t_k = float(t_k_s)
    else:
        t_k = row.get("t_k_s")
    if n_factor is not None:
        n_val = float(n_factor)
    else:
        frame_n = row.get("n_factor")
        n_val = (
            frame_n
            if frame_n is not None and frame_n != _NEUTRAL_N_FACTOR
            else None
        )
    _adopt_fault_value(fault, "t_k_s", t_k, fault_name)
    _adopt_fault_value(fault, "n_factor", n_val, fault_name)
    # Report what is in effect afterwards, not what was offered.
    effective_t_k = fault.t_k_s
    effective_n = fault.n_factor

    if frequency is None:
        frequency = next((float(x) for x in network.frequencies if x > 0), 50.0)
    frequency = float(frequency)

    # --- pick the sources to update ------------------------------------
    if sources is not None:
        names = list(sources)
        unknown = [s for s in names if s not in network.sources]
        if unknown:
            raise ValueError(
                f"Unknown source(s) {unknown} in network {network.name!r}."
            )
    else:
        names = _sources_feeding(network, fault_name)
    if not names:
        raise ValueError(
            f"No sources are eligible for fault {fault_name!r}. Build the paths "
            "(run_fault / build_paths) or pass sources=[...] explicitly."
        )

    # --- distribute I_k'' proportionally to the present injection ------
    previous: Dict[str, float] = {}
    for name in names:
        src = network.sources[name]
        values = src.values or {}
        entry = values.get(frequency)
        if entry is None:
            previous[name] = 0.0
        else:
            previous[name] = abs(complex(entry.real, entry.imag))

    total_previous = sum(previous.values())
    if total_previous > 0:
        shares = {name: previous[name] / total_previous for name in names}
    else:
        shares = {name: 1.0 / len(names) for name in names}
        if len(names) > 1:
            logger.warning(
                "Sources %s inject nothing at f=%.4g Hz; splitting I_k'' equally. "
                "Set the relative infeed shares first if the split is not uniform.",
                names, frequency,
            )

    rows: List[Dict[str, Any]] = []
    for name in names:
        src = network.sources[name]
        share = shares[name]
        i_k_share = i_k_total * share

        src.i_k_a = i_k_share if i_k_share > 0 else None
        src.r_to_x = r_to_x
        src.kappa = kappa

        updated = False
        if set_source_values and src.source_type == "current":
            values = dict(src.values or {})
            values[frequency] = ComplexNumber(real=i_k_share, imag=0.0)
            src.values = values
            updated = True
        elif set_source_values:
            logger.warning(
                "Source '%s' is a voltage source; its injection was not "
                "overwritten (set_source_values only applies to current sources).",
                name,
            )

        rows.append(
            {
                "fault_name": fault_name,
                "source_name": name,
                "bus": src.bus,
                "frequency": frequency,
                "i_k_total_a": i_k_total,
                "share": share,
                "i_k_previous_a": previous[name],
                "i_k_a": i_k_share,
                "r_to_x": r_to_x,
                "kappa": kappa,
                "kappa_origin": kappa_origin,
                "t_k_s": effective_t_k,
                "n_factor": effective_n,
                "values_updated": updated,
            }
        )

    logger.info(
        "Applied IEC 60909 characteristics of bus '%s' to fault '%s': "
        "I_k''=%.1f A, kappa=%s (%s), T_k=%s s over %d source(s).",
        row.get("bus_name"), fault_name, i_k_total,
        "n/a" if kappa is None else f"{kappa:.4f}", kappa_origin,
        effective_t_k, len(names),
    )

    return pl.DataFrame(rows, schema=_REPORT_SCHEMA)

from_pandapower

from_pandapower(
    net,
    *,
    defaults: ImportDefaults,
    voltage_level_kV: float,
    network_name: Optional[str] = None,
    include_trafos: bool = False
) -> Network

Build a :class:~groundinsight.models.core_models.Network from a pandapower net, restricted to a single voltage level.

The mapping is intentionally narrow:

  • pp.bus rows whose vn_kv matches voltage_level_kV become :class:~groundinsight.models.core_models.Bus instances using defaults.default_bus_type. in_service=False propagates to Bus.active=False so the outage helpers can reason over them.
  • pp.line rows whose endpoints both lie on the target voltage level become :class:~groundinsight.models.core_models.Branch instances using defaults.default_branch_type with length=length_km. in_service=False propagates to Branch.active=False.
  • Switches, ext_grids, sgens, loads etc. are ignored. Trafos are skipped unless include_trafos=True, which is reserved for a future release (raises NotImplementedError for now).

No fault, source or path is created — those are left to the caller.

length = length_km holds literally: a length is never rescaled. A line whose length_km is missing (None / NaN / inf / unparsable) is imported with a fallback of 1.0 km and a logger.warning naming the row, because that fallback silently rescales every impedance of the branch. A line whose length_km is zero or negative is not importable at all and raises ValueError; see :func:_reject_unusable_lengths for the reasoning and :func:preview_pandapower_import for listing the affected rows beforehand.

Parameters:

Name Type Description Default
net pandapowerNet

A pandapower network object (the type is not imported eagerly to keep pandapower optional).

required
defaults ImportDefaults

Project-level defaults; see :class:ImportDefaults.

required
voltage_level_kV float

Voltage level (vn_kv) of the buses to keep. Lines that bridge to a different level are skipped.

required
network_name str

Name for the resulting Network. Defaults to net.name if set, otherwise "pandapower_import".

None
include_trafos bool

Reserved for a future release. Setting this to True raises :class:NotImplementedError.

False

Returns:

Type Description
Network

A new groundinsight Network containing the imported buses and branches, with frequencies = defaults.frequencies.

Raises:

Type Description
ImportError

If pandapower is not installed.

ValueError

If defaults.frequencies is empty, or if a line that would be imported carries a zero or negative length_km.

NotImplementedError

If include_trafos=True.

Examples:

>>> import pandapower as pp
>>> from groundinsight.io import ImportDefaults, from_pandapower
>>> net = pp.networks.create_kerber_landnetz_freileitung_1()
>>> network = from_pandapower(
...     net,
...     defaults=defaults,
...     voltage_level_kV=0.4,
...     network_name="kerber_lv",
... )
Source code in src/groundinsight/io/pandapower_import.py
def from_pandapower(
    net,
    *,
    defaults: ImportDefaults,
    voltage_level_kV: float,
    network_name: Optional[str] = None,
    include_trafos: bool = False,
) -> Network:
    """
    Build a :class:`~groundinsight.models.core_models.Network` from a
    pandapower ``net``, restricted to a single voltage level.

    The mapping is intentionally narrow:

    - ``pp.bus`` rows whose ``vn_kv`` matches ``voltage_level_kV`` become
      :class:`~groundinsight.models.core_models.Bus` instances using
      ``defaults.default_bus_type``. ``in_service=False`` propagates to
      ``Bus.active=False`` so the outage helpers can reason over them.
    - ``pp.line`` rows whose endpoints both lie on the target voltage
      level become :class:`~groundinsight.models.core_models.Branch`
      instances using ``defaults.default_branch_type`` with
      ``length=length_km``. ``in_service=False`` propagates to
      ``Branch.active=False``.
    - Switches, ext_grids, sgens, loads etc. are ignored. Trafos are
      skipped unless ``include_trafos=True``, which is reserved for a
      future release (raises ``NotImplementedError`` for now).

    No fault, source or path is created — those are left to the caller.

    ``length = length_km`` holds literally: a length is never rescaled.
    A line whose ``length_km`` is missing (``None`` / ``NaN`` / ``inf`` /
    unparsable) is imported with a fallback of 1.0 km **and** a
    ``logger.warning`` naming the row, because that fallback silently
    rescales every impedance of the branch. A line whose ``length_km`` is
    zero or negative is not importable at all and raises ``ValueError``;
    see :func:`_reject_unusable_lengths` for the reasoning and
    :func:`preview_pandapower_import` for listing the affected rows
    beforehand.

    Parameters
    ----------
    net : pandapowerNet
        A pandapower network object (the type is not imported eagerly to
        keep pandapower optional).
    defaults : ImportDefaults
        Project-level defaults; see :class:`ImportDefaults`.
    voltage_level_kV : float
        Voltage level (``vn_kv``) of the buses to keep. Lines that bridge
        to a different level are skipped.
    network_name : str, optional
        Name for the resulting Network. Defaults to ``net.name`` if set,
        otherwise ``"pandapower_import"``.
    include_trafos : bool, optional
        Reserved for a future release. Setting this to ``True`` raises
        :class:`NotImplementedError`.

    Returns
    -------
    Network
        A new groundinsight Network containing the imported buses and
        branches, with ``frequencies = defaults.frequencies``.

    Raises
    ------
    ImportError
        If pandapower is not installed.
    ValueError
        If ``defaults.frequencies`` is empty, or if a line that would be
        imported carries a zero or negative ``length_km``.
    NotImplementedError
        If ``include_trafos=True``.

    Examples
    --------
    >>> import pandapower as pp                                      # doctest: +SKIP
    >>> from groundinsight.io import ImportDefaults, from_pandapower  # doctest: +SKIP
    >>> net = pp.networks.create_kerber_landnetz_freileitung_1()      # doctest: +SKIP
    >>> network = from_pandapower(                                    # doctest: +SKIP
    ...     net,
    ...     defaults=defaults,
    ...     voltage_level_kV=0.4,
    ...     network_name="kerber_lv",
    ... )
    """
    _require_pandapower()

    if include_trafos:
        raise NotImplementedError(
            "include_trafos=True is reserved for a future release; trafos "
            "are not imported yet."
        )
    if not defaults.frequencies:
        raise ValueError("ImportDefaults.frequencies must not be empty.")

    name = network_name or getattr(net, "name", None) or "pandapower_import"
    network = Network(name=str(name), frequencies=list(defaults.frequencies))

    bus_name_by_index = _bus_index_to_name(net)
    line_name_by_index = _line_index_to_name(net)
    kept_bus_indices, _ = _classify_buses(net, voltage_level_kV)
    kept_bus_set = set(kept_bus_indices)

    # Buses
    for idx in kept_bus_indices:
        row = net.bus.loc[idx]
        bus = Bus(
            name=bus_name_by_index[idx],
            description=str(row.get("name") or "") or None,
            type=defaults.default_bus_type,
            impedance={},
            specific_earth_resistance=float(defaults.rho),
            active=_bus_in_service(row),
        )
        network.add_bus(bus)

    # Branches
    kept_lines, skipped_lines = _classify_lines(
        net, kept_bus_set, bus_name_by_index, line_name_by_index
    )
    _reject_unusable_lengths(skipped_lines)
    for idx in kept_lines:
        row = net.line.loc[idx]
        from_idx = int(row["from_bus"])
        to_idx = int(row["to_bus"])
        branch = Branch(
            name=line_name_by_index[int(idx)],
            description=str(row.get("name") or "") or None,
            type=defaults.default_branch_type,
            length=_length_km(
                row, name=line_name_by_index[int(idx)], pp_index=int(idx)
            ),
            from_bus=bus_name_by_index[from_idx],
            to_bus=bus_name_by_index[to_idx],
            self_impedance={},
            mutual_impedance={},
            specific_earth_resistance=float(defaults.rho),
            active=_bus_in_service(row),
        )
        network.add_branch(branch)

    if not network.buses or not network.branches:
        # Promote the zero-bus / zero-branch case to a warning so the
        # user notices that the import produced an unusable result
        # (typically because of a wrong ``voltage_level_kV`` argument).
        logger.warning(
            "Imported pandapower net into '%s': %d buses, %d branches at %.3f kV — "
            "result is empty or branch-less; verify the voltage_level_kV argument.",
            network.name,
            len(network.buses),
            len(network.branches),
            float(voltage_level_kV),
        )
    else:
        logger.info(
            "Imported pandapower net into '%s': %d buses, %d branches at %.3f kV.",
            network.name,
            len(network.buses),
            len(network.branches),
            float(voltage_level_kV),
        )
    return network

preview_pandapower_import

preview_pandapower_import(
    net,
    *,
    voltage_level_kV: float,
    include_trafos: bool = False
) -> pl.DataFrame

Return a Polars DataFrame describing what :func:from_pandapower would do on this net at this voltage level, without building a Network.

The frame has the following columns:

  • kind -- "bus", "line" or "trafo" (the trafo kind only appears when include_trafos=True).
  • status -- "keep" or "skip".
  • pp_index -- The pandapower index of the row.
  • name -- Resolved groundinsight name (with fallback).
  • vn_kv -- Bus voltage level (None for lines).
  • from_bus -- Resolved from-bus name (lines only).
  • to_bus -- Resolved to-bus name (lines only).
  • length_km -- Line length in km (lines only). For a kept line this is the length :func:from_pandapower would assign, so preview and commit never disagree — including the _MISSING_LENGTH_FALLBACK_KM substitution (with the same warning) when pandapower carries no usable number. For a skipped line it is the raw parsed value, None when unusable.
  • in_service -- Boolean flag from pandapower (best effort).
  • reason -- Skip reason if status == "skip", None otherwise.

The dtypes are pinned by _PREVIEW_SCHEMA rather than inferred, so the frame is identical in shape for a two-bus net and for a thousand-bus one, and an all-None column keeps a usable dtype.

Use this before committing to a full import to validate the mapping or diagnose unexpectedly skipped elements. Unlike :func:from_pandapower, this function never raises on bad data: a line whose length_km is zero or negative — which aborts the commit — is reported here as status="skip" with reason="zero_length" / "negative_length", so every affected row can be enumerated in one go before the import is attempted.

Parameters:

Name Type Description Default
net pandapowerNet

The pandapower network to inspect.

required
voltage_level_kV float

Voltage level to keep (pp.bus.vn_kv).

required
include_trafos bool

Mirrors the parameter of :func:from_pandapower. Reserved for a future release — setting this to True raises :class:NotImplementedError so that preview ↔ commit cannot disagree silently about trafo handling.

False

Returns:

Type Description
DataFrame

One row per bus and one per line, with the dtypes of _PREVIEW_SCHEMA.

Raises:

Type Description
NotImplementedError

If include_trafos=True.

Source code in src/groundinsight/io/pandapower_import.py
def preview_pandapower_import(
    net,
    *,
    voltage_level_kV: float,
    include_trafos: bool = False,
) -> pl.DataFrame:
    """
    Return a Polars DataFrame describing what :func:`from_pandapower`
    would do on this ``net`` at this voltage level, without building a
    Network.

    The frame has the following columns:

    - ``kind``           -- ``"bus"``, ``"line"`` or ``"trafo"`` (the
      trafo kind only appears when ``include_trafos=True``).
    - ``status``         -- ``"keep"`` or ``"skip"``.
    - ``pp_index``       -- The pandapower index of the row.
    - ``name``           -- Resolved groundinsight name (with fallback).
    - ``vn_kv``          -- Bus voltage level (``None`` for lines).
    - ``from_bus``       -- Resolved from-bus name (lines only).
    - ``to_bus``         -- Resolved to-bus name (lines only).
    - ``length_km``      -- Line length in km (lines only). For a kept
      line this is the length :func:`from_pandapower` would assign, so
      preview and commit never disagree — including the
      ``_MISSING_LENGTH_FALLBACK_KM`` substitution (with the same
      warning) when pandapower carries no usable number. For a skipped
      line it is the raw parsed value, ``None`` when unusable.
    - ``in_service``     -- Boolean flag from pandapower (best effort).
    - ``reason``         -- Skip reason if ``status == "skip"``,
      ``None`` otherwise.

    The dtypes are pinned by ``_PREVIEW_SCHEMA`` rather than inferred, so
    the frame is identical in shape for a two-bus net and for a
    thousand-bus one, and an all-``None`` column keeps a usable dtype.

    Use this before committing to a full import to validate the mapping
    or diagnose unexpectedly skipped elements. Unlike
    :func:`from_pandapower`, this function never raises on bad *data*: a
    line whose ``length_km`` is zero or negative — which aborts the
    commit — is reported here as ``status="skip"`` with
    ``reason="zero_length"`` / ``"negative_length"``, so every affected
    row can be enumerated in one go before the import is attempted.

    Parameters
    ----------
    net : pandapowerNet
        The pandapower network to inspect.
    voltage_level_kV : float
        Voltage level to keep (``pp.bus.vn_kv``).
    include_trafos : bool, optional
        Mirrors the parameter of :func:`from_pandapower`. Reserved for a
        future release — setting this to ``True`` raises
        :class:`NotImplementedError` so that ``preview ↔ commit`` cannot
        disagree silently about trafo handling.

    Returns
    -------
    polars.DataFrame
        One row per bus and one per line, with the dtypes of
        ``_PREVIEW_SCHEMA``.

    Raises
    ------
    NotImplementedError
        If ``include_trafos=True``.
    """
    _require_pandapower()

    if include_trafos:
        raise NotImplementedError(
            "include_trafos=True is reserved for a future release; "
            "trafos are not previewed yet — matches the commit-side "
            "behaviour of from_pandapower()."
        )

    bus_name_by_index = _bus_index_to_name(net)
    line_name_by_index = _line_index_to_name(net)
    kept_bus_indices, skipped_buses = _classify_buses(net, voltage_level_kV)
    kept_bus_set = set(kept_bus_indices)
    kept_lines, skipped_lines = _classify_lines(
        net, kept_bus_set, bus_name_by_index, line_name_by_index
    )

    rows: List[Dict[str, Any]] = []

    # Kept buses
    for idx in kept_bus_indices:
        row = net.bus.loc[idx]
        rows.append(
            {
                "kind": "bus",
                "status": "keep",
                "pp_index": int(idx),
                "name": bus_name_by_index[int(idx)],
                "vn_kv": float(row.get("vn_kv", 0.0) or 0.0),
                "from_bus": None,
                "to_bus": None,
                "length_km": None,
                "in_service": _bus_in_service(row),
                "reason": None,
            }
        )

    # Skipped buses
    for entry in skipped_buses:
        rows.append(
            {
                "kind": "bus",
                "status": "skip",
                "pp_index": entry["pp_index"],
                "name": entry["name"],
                "vn_kv": entry.get("vn_kv"),
                "from_bus": None,
                "to_bus": None,
                "length_km": None,
                "in_service": None,
                "reason": entry["reason"],
            }
        )

    # Kept lines
    for idx in kept_lines:
        row = net.line.loc[idx]
        rows.append(
            {
                "kind": "line",
                "status": "keep",
                "pp_index": int(idx),
                "name": line_name_by_index[int(idx)],
                "vn_kv": None,
                "from_bus": bus_name_by_index[int(row["from_bus"])],
                "to_bus": bus_name_by_index[int(row["to_bus"])],
                "length_km": _length_km(
                    row, name=line_name_by_index[int(idx)], pp_index=int(idx)
                ),
                "in_service": _bus_in_service(row),
                "reason": None,
            }
        )

    # Skipped lines
    for entry in skipped_lines:
        rows.append(
            {
                "kind": "line",
                "status": "skip",
                "pp_index": entry["pp_index"],
                "name": entry["name"],
                "vn_kv": None,
                "from_bus": entry.get("from_bus"),
                "to_bus": entry.get("to_bus"),
                "length_km": entry.get("length_km"),
                "in_service": None,
                "reason": entry["reason"],
            }
        )

    # The schema is pinned, never inferred: polars would only look at the
    # first 100 dicts, which on a net with >= 100 buses are all bus rows
    # with a ``None`` from_bus / to_bus / length_km, and the first line row
    # would then fail to append with a ComputeError.
    return pl.DataFrame(rows, schema=_PREVIEW_SCHEMA)

read_shortcircuit_results

read_shortcircuit_results(
    net,
    *,
    t_k_s: Optional[float] = None,
    n_factor: float = 1.0,
    f: float = 50.0,
    buses: Optional[Sequence[int]] = None
) -> pl.DataFrame

Read a solved pandapower short-circuit case as IEC 60909 quantities.

Converts net.res_bus_sc into a tidy, unit-explicit :class:polars.DataFrame in which the quantities missing from pandapower's single line-to-earth path (kappa, i_p, I_th) are filled in, and I_th is recomputed with a DC heat factor that is correct in the kappa -> 2 limit. See the module docstring for the reasoning.

The fault type, case and clearing time are taken from net._options (written by calc_sc) so that a solved net is self-describing; the t_k_s and n_factor arguments override them. The clearing time is adopted only when the calc_sc run genuinely carried one -- pandapower stores its signature default tk_s=1.0 even for a run that asked for no thermal current at all, and passing that placeholder off as a protection setting is unsafe; see :func:_tk_s_from_options.

Parameters:

Name Type Description Default
net pandapowerNet

A pandapower network on which pandapower.shortcircuit.calc_sc has already been run.

required
t_k_s float

Short-circuit duration T_k in seconds. Defaults to the tk_s of the calc_sc run, but only if that run requested a thermal-equivalent current (ith=True) or passed a tk_s other than pandapower's default of 1.0 s. Without it m and I_th cannot be computed and are reported as None.

None
n_factor float

IEC 60909-0 AC heat factor n. 1.0 is the far-from-generator case. pandapower does not model n, so this argument is the only source of a non-neutral value.

1.0
f float

System frequency in Hz used in the m factor.

50.0
buses sequence of int

Restrict the output to these pandapower bus indices. Defaults to every bus in res_bus_sc.

None

Returns:

Type Description
DataFrame

One row per bus with the columns pp_bus_index, bus_name, vn_kv, fault_type, case, i_k_a (in amperes, not kA), r1_ohm, x1_ohm, r0_ohm, x0_ohm, r_to_x, kappa, kappa_origin, t_k_s, n_factor, m, i_p_a and i_th_a.

Raises:

Type Description
ImportError

If pandapower is not installed.

ValueError

If net carries no short-circuit results, or if buses references indices absent from them.

Notes

kappa_origin documents where each peak factor came from: "pandapower" (recovered from ip_ka, topology-aware), "iec_closed_form" (1.02 + 0.98*exp(-3*R/X)) or "unavailable". Keep it in reports — it is the difference between a reproducible study and a magic number.

Examples:

>>> import pandapower.shortcircuit as sc
>>> sc.calc_sc(net, fault="1ph", case="max", tk_s=0.5)
>>> gi.read_shortcircuit_results(net).select("bus_name", "i_k_a", "kappa")
Source code in src/groundinsight/io/pandapower_sc.py
def read_shortcircuit_results(
    net,
    *,
    t_k_s: Optional[float] = None,
    n_factor: float = 1.0,
    f: float = 50.0,
    buses: Optional[Sequence[int]] = None,
) -> pl.DataFrame:
    """
    Read a solved pandapower short-circuit case as IEC 60909 quantities.

    Converts ``net.res_bus_sc`` into a tidy, unit-explicit
    :class:`polars.DataFrame` in which the quantities missing from
    pandapower's single line-to-earth path (``kappa``, ``i_p``, ``I_th``)
    are filled in, and ``I_th`` is recomputed with a DC heat factor that is
    correct in the ``kappa -> 2`` limit. See the module docstring for the
    reasoning.

    The fault type, case and clearing time are taken from ``net._options``
    (written by ``calc_sc``) so that a solved net is self-describing; the
    ``t_k_s`` and ``n_factor`` arguments override them. The clearing time
    is adopted only when the ``calc_sc`` run genuinely carried one --
    pandapower stores its signature default ``tk_s=1.0`` even for a run
    that asked for no thermal current at all, and passing that placeholder
    off as a protection setting is unsafe; see :func:`_tk_s_from_options`.

    Parameters
    ----------
    net : pandapower.auxiliary.pandapowerNet
        A pandapower network on which ``pandapower.shortcircuit.calc_sc``
        has already been run.
    t_k_s : float, optional
        Short-circuit duration ``T_k`` in seconds. Defaults to the
        ``tk_s`` of the ``calc_sc`` run, but only if that run requested a
        thermal-equivalent current (``ith=True``) or passed a ``tk_s``
        other than pandapower's default of 1.0 s. Without it ``m`` and
        ``I_th`` cannot be computed and are reported as ``None``.
    n_factor : float, default 1.0
        IEC 60909-0 AC heat factor ``n``. ``1.0`` is the
        far-from-generator case. pandapower does not model ``n``, so this
        argument is the only source of a non-neutral value.
    f : float, default 50.0
        System frequency in Hz used in the ``m`` factor.
    buses : sequence of int, optional
        Restrict the output to these pandapower bus indices. Defaults to
        every bus in ``res_bus_sc``.

    Returns
    -------
    polars.DataFrame
        One row per bus with the columns ``pp_bus_index``, ``bus_name``,
        ``vn_kv``, ``fault_type``, ``case``, ``i_k_a`` (in **amperes**,
        not kA), ``r1_ohm``, ``x1_ohm``, ``r0_ohm``, ``x0_ohm``,
        ``r_to_x``, ``kappa``, ``kappa_origin``, ``t_k_s``, ``n_factor``,
        ``m``, ``i_p_a`` and ``i_th_a``.

    Raises
    ------
    ImportError
        If pandapower is not installed.
    ValueError
        If ``net`` carries no short-circuit results, or if ``buses``
        references indices absent from them.

    Notes
    -----
    ``kappa_origin`` documents where each peak factor came from:
    ``"pandapower"`` (recovered from ``ip_ka``, topology-aware),
    ``"iec_closed_form"`` (``1.02 + 0.98*exp(-3*R/X)``) or
    ``"unavailable"``. Keep it in reports — it is the difference between a
    reproducible study and a magic number.

    Examples
    --------
    >>> import pandapower.shortcircuit as sc  # doctest: +SKIP
    >>> sc.calc_sc(net, fault="1ph", case="max", tk_s=0.5)  # doctest: +SKIP
    >>> gi.read_shortcircuit_results(net).select("bus_name", "i_k_a", "kappa")  # doctest: +SKIP
    """
    _require_pandapower()

    res = getattr(net, "res_bus_sc", None)
    if res is None or len(res) == 0:
        raise ValueError(
            "The pandapower net carries no short-circuit results. Run "
            "pandapower.shortcircuit.calc_sc(net, fault='1ph', case='max', "
            "ip=True, ith=True, tk_s=...) before calling "
            "read_shortcircuit_results()."
        )

    options: Dict[str, Any] = dict(getattr(net, "_options", None) or {})
    fault_type = str(options.get("fault", "unknown"))
    case = str(options.get("case", "unknown"))
    if t_k_s is None:
        t_k_s = _tk_s_from_options(options)
    if t_k_s is not None:
        t_k_s = float(t_k_s)
        if t_k_s <= 0:
            raise ValueError(f"t_k_s must be strictly positive, got {t_k_s!r}.")
    if not (0.0 < float(n_factor) <= 1.0):
        raise ValueError(f"n_factor must lie in (0, 1], got {n_factor!r}.")
    n_factor = float(n_factor)

    # ``fault="1ph"`` is the earth-fault case; anything reporting a
    # zero-sequence impedance is treated the same way.
    earth_fault = fault_type in _EARTH_FAULT_TYPES or "rk0_ohm" in res.columns

    index_to_name = _bus_index_to_name(net)

    if buses is not None:
        wanted = [int(b) for b in buses]
        missing = [b for b in wanted if b not in res.index]
        if missing:
            raise ValueError(
                f"Bus indices {missing} have no short-circuit results. "
                f"Available: {sorted(int(i) for i in res.index)}."
            )
        selection = wanted
    else:
        selection = [int(i) for i in res.index]

    rows: List[Dict[str, Any]] = []
    for idx in selection:
        row = res.loc[idx]
        bus_name = index_to_name.get(idx, f"bus_{idx}")

        ikss_ka = _finite(row.get("ikss_ka"))
        i_k_a = None if ikss_ka is None else ikss_ka * 1000.0
        r1 = _finite(row.get("rk_ohm"))
        x1 = _finite(row.get("xk_ohm"))
        r0 = _finite(row.get("rk0_ohm"))
        x0 = _finite(row.get("xk0_ohm"))

        r_to_x = _loop_r_to_x(r1, x1, r0, x0, earth_fault)
        kappa, kappa_origin = _kappa_for_row(
            i_k_a, _finite(row.get("ip_ka")), r_to_x, bus_name
        )

        m = None
        i_p_a = None
        i_th_a = None
        if kappa is not None:
            if i_k_a is not None:
                i_p_a = peak_short_circuit_current(i_k_a, kappa)
            if t_k_s is not None:
                m = iec60909_m(kappa, f, t_k_s)
                if i_k_a is not None:
                    i_th_a = thermal_equivalent_current(i_k_a, m, n_factor)

        rows.append(
            {
                "pp_bus_index": int(idx),
                "bus_name": bus_name,
                "vn_kv": _finite(net.bus.at[idx, "vn_kv"]) if idx in net.bus.index else None,
                "fault_type": fault_type,
                "case": case,
                "i_k_a": i_k_a,
                "r1_ohm": r1,
                "x1_ohm": x1,
                "r0_ohm": r0,
                "x0_ohm": x0,
                "r_to_x": r_to_x,
                "kappa": kappa,
                "kappa_origin": kappa_origin,
                "t_k_s": t_k_s,
                "n_factor": n_factor,
                "m": m,
                "i_p_a": i_p_a,
                "i_th_a": i_th_a,
            }
        )

    unavailable = [r["bus_name"] for r in rows if r["kappa_origin"] == "unavailable"]
    if unavailable:
        logger.warning(
            "No kappa could be derived for %d bus(es) (%s): neither ip_ka nor a "
            "usable R/X is available. For fault='1ph' make sure the zero-sequence "
            "data (x0x_max, r0x0_max, vk0_percent, r0_ohm_per_km, ...) is complete.",
            len(unavailable), unavailable,
        )

    return pl.DataFrame(rows, schema=_SC_SCHEMA)