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.

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).

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.

Public API:

  • :class:ImportDefaults
  • :func:groundinsight.io.pandapower_import.from_pandapower
  • :func:groundinsight.io.pandapower_import.preview_pandapower_import

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 Bus and Branch (Ohm * m). Source tools do not encode soil parameters, so a single project-wide value is taken from the user.

frequencies List[float]

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

default_bus_type BusType

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

default_branch_type BranchType

Default 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",
...     ),
... )

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 project-specific additions left to the caller.

Parameters:

Name Type Description Default
net

A pandapower pandapowerNet 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 Optional[str]

Optional 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 NotImplementedError.

False

Returns:

Name Type Description
Network Network

A new groundinsight Network containing the imported

Network

buses and branches, with frequencies = defaults.frequencies.

Raises:

Type Description
ImportError

If pandapower is not installed.

ValueError

If defaults.frequencies is empty.

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 project-specific
    additions left to the caller.

    Args:
        net: A pandapower ``pandapowerNet`` object (the type is not
            imported eagerly to keep pandapower optional).
        defaults: Project-level defaults; see :class:`ImportDefaults`.
        voltage_level_kV: Voltage level (``vn_kv``) of the buses to
            keep. Lines that bridge to a different level are skipped.
        network_name: Optional name for the resulting Network. Defaults
            to ``net.name`` if set, otherwise ``"pandapower_import"``.
        include_trafos: Reserved for a future release. Setting this to
            ``True`` raises ``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.
        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)
    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, _ = _classify_lines(net, kept_bus_set, bus_name_by_index)
    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_label(row.get("name"), int(idx)),
            description=str(row.get("name") or "") or None,
            type=defaults.default_branch_type,
            length=_length_km(row),
            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)

    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
) -> 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" or "line".
  • 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).
  • in_service -- Boolean flag from pandapower (best effort).
  • reason -- Skip reason if status == "skip", None otherwise.

Use this before committing to a full import to validate the mapping or diagnose unexpectedly skipped elements.

Parameters:

Name Type Description Default
net

The pandapower pandapowerNet to inspect.

required
voltage_level_kV float

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

required

Returns:

Type Description
DataFrame

pl.DataFrame: One row per bus and one per line.

Source code in src/groundinsight/io/pandapower_import.py
def preview_pandapower_import(
    net, *, voltage_level_kV: float
) -> 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"`` or ``"line"``.
    - ``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).
    - ``in_service``     -- Boolean flag from pandapower (best effort).
    - ``reason``         -- Skip reason if ``status == "skip"``,
      ``None`` otherwise.

    Use this before committing to a full import to validate the mapping
    or diagnose unexpectedly skipped elements.

    Args:
        net: The pandapower ``pandapowerNet`` to inspect.
        voltage_level_kV: Voltage level to keep (``pp.bus.vn_kv``).

    Returns:
        pl.DataFrame: One row per bus and one per line.
    """
    _require_pandapower()

    bus_name_by_index = _bus_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
    )

    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_label(row.get("name"), 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),
                "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"],
            }
        )

    return pl.DataFrame(rows)