Skip to content

Outage and what-if studies

The groundinsight.simulation.outage module turns the new active flag on Bus and Branch into a first-class what-if API. Multiple topology scenarios (e.g. "cable A out", "substation isolator open", "shield broken") can be evaluated against one base case in a single call, with comparison DataFrames produced automatically.

Physical / modelling context

The nodal-admittance solver assembles \(Y(f)\,\underline{u}(f) = \underline{i}(f)\) over active elements only. An inactive Bus is removed from the system entirely (its row and column drop from \(Y\)); an inactive Branch contributes no self-admittance, no mutual-coupling Norton injection, and reports a zero current in the result — the open-circuit limit. The pathfinder also skips inactive elements when enumerating source-to-fault paths, which keeps the per-path Norton bookkeeping consistent.

That makes maintenance scenarios, planned outages, broken shields, and N-1 contingencies expressible as scenarios without rebuilding the network. The outage_context context manager flips the listed elements to active=False for the duration of a with block and restores the previous state — including the cached path list — afterwards. run_outage_study orchestrates a base run plus one fault calculation per scenario and returns long-format Polars DataFrames with absolute and relative deltas against a reference scenario (default: the base case).

Example

import groundinsight as gi

# Assume a pre-built network `net` with sources, buses, branches and
# at least one fault, e.g. from the quickstart or from
# gi.from_pandapower(...).

scenario_cable_out = gi.Outage(
    name="cable_1_oos",
    description="MS-cable cable_1 out of service",
    disabled_buses=[],
    disabled_branches=["cable_1"],
)

scenario_bus_islanded = gi.Outage(
    name="bus_x_islanded",
    description="Bus bus_x removed from the network",
    disabled_buses=["bus_x"],
    disabled_branches=[],
)

study = gi.run_outage_study(
    network=net,
    fault="fault1",
    scenarios=[scenario_cable_out, scenario_bus_islanded],
    include_base_case=True,
)

# Per-scenario result DataFrames
print(study.compare_buses())     # EPR per bus, with delta vs. base
print(study.compare_branches())  # branch currents, with delta vs. base

Use outage_context(network, outage) directly when a single one-off modification is needed:

with gi.outage_context(net, scenario_cable_out):
    gi.run_fault(network=net, fault_name="fault1")
    print(net.res_all_impedances())
# net is fully restored on exit — including the cached paths.

API reference

outage

Outage / What-If Study Module.

Convenience layer on top of the active flag of :class:groundinsight.models.core_models.Bus and :class:groundinsight.models.core_models.Branch. An :class:Outage is a named scenario that lists buses and/or branches to be deactivated; the :func:outage_context context manager applies the scenario for the duration of a with block and restores the previous state on exit, and :func:run_outage_study runs run_fault for the base case and a list of scenarios in one go and packages the results into an :class:OutageStudyResult with ready-made comparison helpers.

Example:

>>> import groundinsight as gi
>>> # build a network, add fault "F1", then:
>>> study = gi.run_outage_study(
...     network=net,
...     fault="F1",
...     scenarios=[
...         gi.Outage(name="branch_b12_open", disabled_branches=["B12"]),
...         gi.Outage(name="bus5_isolated", disabled_buses=["bus5"]),
...     ],
... )
>>> df = study.compare_buses()
>>> df_branch = study.compare_branches()

Inactive elements are physically modelled as open circuits / removed nodes in the solver (see :mod:groundinsight.electrical_network and :mod:groundinsight.pathfinder); the outage layer does not duplicate that logic, it only flips the active flag and re-runs the existing run_fault pipeline.

Outage

Bases: BaseModel

Definition of a single what-if scenario.

An outage is described by the names of buses and/or branches that should be deactivated for the duration of the scenario. The names must refer to elements that exist in the network when the scenario is applied; unknown names raise ValueError at apply time.

Attributes:

Name Type Description
name str

Unique label used in result keys and DataFrame columns.

disabled_buses List[str]

Names of buses to deactivate.

disabled_branches List[str]

Names of branches to deactivate.

description Optional[str]

Free-form description for documentation.

OutageStudyResult

Bases: BaseModel

Result of an outage study.

Holds bus and branch result DataFrames per scenario (and for the base case, if it was included). The compare_* methods produce long-format Polars DataFrames suitable for plotting or further aggregation.

Attributes:

Name Type Description
fault str

Name of the fault that was solved.

base_label str

Label of the base scenario (typically "base").

scenarios List[Outage]

The scenarios in the order they were run.

bus_results Dict[str, DataFrame]

label -> res_buses(fault) for the base case (if present) and every scenario.

branch_results Dict[str, DataFrame]

Same for branch results.

compare_branches

compare_branches(
    *,
    against: Optional[str] = None,
    columns: Sequence[str] = ("I_branch_A",)
) -> pl.DataFrame

Long-format branch comparison across scenarios.

Same shape as :meth:compare_buses but keyed by (branch_name, frequency_Hz).

Parameters:

Name Type Description Default
against Optional[str]

Reference scenario label.

None
columns Sequence[str]

Branch result metrics to compare. Defaults to ("I_branch_A",).

('I_branch_A',)

Returns:

Type Description
DataFrame

pl.DataFrame: Long-format comparison frame.

Raises:

Type Description
ValueError

If against is not present in the stored results or if a requested column is missing from a scenario frame.

Source code in src/groundinsight/simulation/outage.py
def compare_branches(
    self,
    *,
    against: Optional[str] = None,
    columns: Sequence[str] = ("I_branch_A",),
) -> pl.DataFrame:
    """
    Long-format branch comparison across scenarios.

    Same shape as :meth:`compare_buses` but keyed by
    ``(branch_name, frequency_Hz)``.

    Args:
        against (Optional[str]): Reference scenario label.
        columns (Sequence[str]): Branch result metrics to compare.
            Defaults to ``("I_branch_A",)``.

    Returns:
        pl.DataFrame: Long-format comparison frame.

    Raises:
        ValueError: If ``against`` is not present in the stored results
            or if a requested column is missing from a scenario frame.
    """
    return self._compare(
        tables=self.branch_results,
        id_columns=("branch_name", "frequency_Hz"),
        metrics=columns,
        against=against,
    )

compare_buses

compare_buses(
    *,
    against: Optional[str] = None,
    columns: Sequence[str] = ("EPR_V",)
) -> pl.DataFrame

Long-format bus comparison across scenarios.

For every (bus_name, frequency_Hz) pair the table contains one row per scenario per metric, plus the delta_vs_<against> and delta_pct_vs_<against> columns relative to the reference scenario.

Parameters:

Name Type Description Default
against Optional[str]

Reference scenario label. Defaults to base_label if it exists in the result, otherwise to the first available label.

None
columns Sequence[str]

Bus result metrics to compare. Defaults to ("EPR_V",). Must be column names of the per-scenario res_buses DataFrame (e.g. "EPR_V", "I_bus_A").

('EPR_V',)

Returns:

Type Description
DataFrame

pl.DataFrame: Long-format frame with columns

DataFrame

["bus_name", "frequency_Hz", "scenario", "metric", "value", "delta_vs_<ref>", "delta_pct_vs_<ref>"].

Raises:

Type Description
ValueError

If against is not present in the stored results or if a requested column is missing from a scenario frame.

Source code in src/groundinsight/simulation/outage.py
def compare_buses(
    self,
    *,
    against: Optional[str] = None,
    columns: Sequence[str] = ("EPR_V",),
) -> pl.DataFrame:
    """
    Long-format bus comparison across scenarios.

    For every ``(bus_name, frequency_Hz)`` pair the table contains one
    row per scenario per metric, plus the ``delta_vs_<against>`` and
    ``delta_pct_vs_<against>`` columns relative to the reference scenario.

    Args:
        against (Optional[str]): Reference scenario label. Defaults to
            ``base_label`` if it exists in the result, otherwise to the
            first available label.
        columns (Sequence[str]): Bus result metrics to compare. Defaults
            to ``("EPR_V",)``. Must be column names of the per-scenario
            ``res_buses`` DataFrame (e.g. ``"EPR_V"``, ``"I_bus_A"``).

    Returns:
        pl.DataFrame: Long-format frame with columns
        ``["bus_name", "frequency_Hz", "scenario", "metric", "value",
          "delta_vs_<ref>", "delta_pct_vs_<ref>"]``.

    Raises:
        ValueError: If ``against`` is not present in the stored results
            or if a requested column is missing from a scenario frame.
    """
    return self._compare(
        tables=self.bus_results,
        id_columns=("bus_name", "frequency_Hz"),
        metrics=columns,
        against=against,
    )

labels

labels() -> List[str]

Return the labels of all stored scenarios.

The base label is included first (if present), followed by the user scenarios in the order they were submitted to run_outage_study.

Returns:

Type Description
List[str]

List[str]: Scenario labels.

Source code in src/groundinsight/simulation/outage.py
def labels(self) -> List[str]:
    """
    Return the labels of all stored scenarios.

    The base label is included first (if present), followed by the user
    scenarios in the order they were submitted to ``run_outage_study``.

    Returns:
        List[str]: Scenario labels.
    """
    order = []
    if self.base_label in self.bus_results:
        order.append(self.base_label)
    for scenario in self.scenarios:
        if scenario.name in self.bus_results:
            order.append(scenario.name)
    return order

outage_context

outage_context(
    network: Network, outage: Outage
) -> Iterator[Network]

Apply an :class:Outage to network for the duration of a with block.

On entry, the active flag of the listed buses and branches is set to False. On exit, the original active values are restored even if the body raises. Path information is invalidated on entry and on exit because both the outage and its rollback can change topology.

Parameters:

Name Type Description Default
network Network

The network to mutate in place.

required
outage Outage

Scenario describing what to deactivate.

required

Yields:

Name Type Description
Network Network

The same network instance, mutated for the scenario.

Raises:

Type Description
ValueError

If a name in disabled_buses or disabled_branches is not part of the network.

Examples:

>>> with outage_context(net, Outage(name="b12", disabled_branches=["B12"])):
...     gi.run_fault(net, "F1")
...     df = net.res_buses()
Source code in src/groundinsight/simulation/outage.py
@contextmanager
def outage_context(network: Network, outage: Outage) -> Iterator[Network]:
    """
    Apply an :class:`Outage` to ``network`` for the duration of a ``with`` block.

    On entry, the ``active`` flag of the listed buses and branches is set to
    ``False``. On exit, the original ``active`` values are restored even if
    the body raises. Path information is invalidated on entry and on exit
    because both the outage and its rollback can change topology.

    Args:
        network (Network): The network to mutate in place.
        outage (Outage): Scenario describing what to deactivate.

    Yields:
        Network: The same ``network`` instance, mutated for the scenario.

    Raises:
        ValueError: If a name in ``disabled_buses`` or ``disabled_branches``
            is not part of the network.

    Examples:
        >>> with outage_context(net, Outage(name="b12", disabled_branches=["B12"])):
        ...     gi.run_fault(net, "F1")
        ...     df = net.res_buses()
    """
    unknown_buses = [b for b in outage.disabled_buses if b not in network.buses]
    if unknown_buses:
        raise ValueError(
            f"Outage '{outage.name}' references unknown buses: {unknown_buses}"
        )
    unknown_branches = [
        b for b in outage.disabled_branches if b not in network.branches
    ]
    if unknown_branches:
        raise ValueError(
            f"Outage '{outage.name}' references unknown branches: {unknown_branches}"
        )

    saved_buses = {
        name: network.buses[name].active for name in outage.disabled_buses
    }
    saved_branches = {
        name: network.branches[name].active for name in outage.disabled_branches
    }
    saved_paths = dict(network.paths)

    try:
        for name in outage.disabled_buses:
            network.buses[name].active = False
        for name in outage.disabled_branches:
            network.branches[name].active = False
        # The pre-existing path cache is no longer valid for the new topology.
        network.paths = {}
        yield network
    finally:
        for name, value in saved_buses.items():
            network.buses[name].active = value
        for name, value in saved_branches.items():
            network.branches[name].active = value
        network.paths = saved_paths

run_outage_study

run_outage_study(
    network: Network,
    *,
    fault: str,
    scenarios: List[Outage],
    include_base: bool = True,
    base_label: str = "base",
    auto_parallel_coefficients: bool = False,
    redefine_paths: bool = True
) -> OutageStudyResult

Solve fault for the base case (optional) and a list of outage scenarios.

Each scenario is applied via :func:outage_context, then run_fault is called. Bus and branch result DataFrames are pulled out of the network and stored under the scenario label so that the network's own results dict is left in a single, reproducible state at the end of the study (the last scenario's solution).

Parameters:

Name Type Description Default
network Network

The network to study. Must contain fault.

required
fault str

Name of the fault to solve in every scenario.

required
scenarios List[Outage]

Scenarios to run, in order.

required
include_base bool

If True (default) the base case (no outages) is solved first and stored under base_label.

True
base_label str

Label used to identify the base case in the stored DataFrames. Defaults to "base".

'base'
auto_parallel_coefficients bool

Forwarded to run_fault.

False
redefine_paths bool

If True (default), the pre-existing path cache on network is dropped at the start of the study so that path definitions match the current topology. Each scenario then triggers its own path definition through run_fault.

True

Returns:

Name Type Description
OutageStudyResult OutageStudyResult

Aggregated result with comparison helpers.

Raises:

Type Description
ValueError

If fault is not part of the network or if a scenario label collides with base_label.

Examples:

>>> study = run_outage_study(
...     net,
...     fault="F1",
...     scenarios=[
...         Outage(name="b12_open", disabled_branches=["B12"]),
...         Outage(name="bus5_isolated", disabled_buses=["bus5"]),
...     ],
... )
>>> study.compare_buses()
>>> study.compare_branches()
Source code in src/groundinsight/simulation/outage.py
def run_outage_study(
    network: Network,
    *,
    fault: str,
    scenarios: List[Outage],
    include_base: bool = True,
    base_label: str = "base",
    auto_parallel_coefficients: bool = False,
    redefine_paths: bool = True,
) -> OutageStudyResult:
    """
    Solve ``fault`` for the base case (optional) and a list of outage scenarios.

    Each scenario is applied via :func:`outage_context`, then ``run_fault`` is
    called. Bus and branch result DataFrames are pulled out of the network
    and stored under the scenario label so that the network's own
    ``results`` dict is left in a single, reproducible state at the end of
    the study (the last scenario's solution).

    Args:
        network (Network): The network to study. Must contain ``fault``.
        fault (str): Name of the fault to solve in every scenario.
        scenarios (List[Outage]): Scenarios to run, in order.
        include_base (bool): If ``True`` (default) the base case (no
            outages) is solved first and stored under ``base_label``.
        base_label (str): Label used to identify the base case in the
            stored DataFrames. Defaults to ``"base"``.
        auto_parallel_coefficients (bool): Forwarded to ``run_fault``.
        redefine_paths (bool): If ``True`` (default), the pre-existing path
            cache on ``network`` is dropped at the start of the study so
            that path definitions match the current topology. Each scenario
            then triggers its own path definition through ``run_fault``.

    Returns:
        OutageStudyResult: Aggregated result with comparison helpers.

    Raises:
        ValueError: If ``fault`` is not part of the network or if a scenario
            label collides with ``base_label``.

    Examples:
        >>> study = run_outage_study(
        ...     net,
        ...     fault="F1",
        ...     scenarios=[
        ...         Outage(name="b12_open", disabled_branches=["B12"]),
        ...         Outage(name="bus5_isolated", disabled_buses=["bus5"]),
        ...     ],
        ... )
        >>> study.compare_buses()         # doctest: +SKIP
        >>> study.compare_branches()      # doctest: +SKIP
    """
    if fault not in network.faults:
        raise ValueError(f"Fault '{fault}' is not part of network '{network.name}'.")

    seen_labels = set()
    if include_base:
        seen_labels.add(base_label)
    for scenario in scenarios:
        if scenario.name == base_label and include_base:
            raise ValueError(
                f"Scenario name '{scenario.name}' collides with the base label."
            )
        if scenario.name in seen_labels:
            raise ValueError(f"Duplicate scenario label '{scenario.name}'.")
        seen_labels.add(scenario.name)

    bus_results: Dict[str, pl.DataFrame] = {}
    branch_results: Dict[str, pl.DataFrame] = {}

    if redefine_paths:
        network.paths = {}

    # Local import to avoid a circular dependency between simulation and
    # network_operations (network_operations imports core_models, which the
    # outage module also touches at module load time via Pydantic).
    from groundinsight.network_operations import run_fault

    if include_base:
        logger.info(
            "Outage study: solving base case for fault '%s' under label '%s'.",
            fault,
            base_label,
        )
        run_fault(
            network,
            fault_name=fault,
            auto_parallel_coefficients=auto_parallel_coefficients,
        )
        bus_results[base_label] = network.res_buses(fault=fault)
        branch_results[base_label] = network.res_branches(fault=fault)

    for scenario in scenarios:
        logger.info(
            "Outage study: running scenario '%s' (%s buses, %s branches).",
            scenario.name,
            len(scenario.disabled_buses),
            len(scenario.disabled_branches),
        )
        with outage_context(network, scenario):
            run_fault(
                network,
                fault_name=fault,
                auto_parallel_coefficients=auto_parallel_coefficients,
            )
            bus_results[scenario.name] = network.res_buses(fault=fault)
            branch_results[scenario.name] = network.res_branches(fault=fault)

    return OutageStudyResult(
        fault=fault,
        base_label=base_label,
        scenarios=list(scenarios),
        bus_results=bus_results,
        branch_results=branch_results,
    )