Scenario Study Workflow#

This notebook demonstrates file-based AC contingency studies in gpf. A study defines a scope, the monitored elements, and a set of contingencies; it is screened in a single batch, ranked, reported, and saved as a file that can be rerun. A base solve is the same engine with one scenario. A small two-area network is constructed in memory so that the notebook is self-contained.

import contextlib, os, tempfile, pathlib
import gpf
from gpf import PsseCase, Bus, Generator, Load, Branch
from gpf.scenario import Study, TripBranch

BACKEND = "cpu"


@contextlib.contextmanager
def quiet_solver():
    """Silence the native solver's stderr diagnostic for singular or islanded
    contingencies so the notebook output stays readable. Such scenarios are
    still classified, for example as INVALID_TOPOLOGY."""
    saved, devnull = os.dup(2), os.open(os.devnull, os.O_WRONLY)
    os.dup2(devnull, 2)
    try:
        yield
    finally:
        os.dup2(saved, 2)
        os.close(saved)
        os.close(devnull)

Base case#

A study begins from a base case, in practice a RAW, RAWX, or MATPOWER file. Here an eight-bus, two-area network is constructed in memory: area 1 spans buses 1 to 4 with the slack at bus 1, area 2 spans buses 5 to 8, and the two areas are joined by ties 1 to 5 and 3 to 6. Bus 8 is connected radially through branch 4 to 8. The network has 8 buses, 10 branches, 3 generators, and 5 loads.

def build_case() -> PsseCase:
    return PsseCase(
        sbase=100.0,
        buses=[
            Bus(ibus=1, ide=3, vm=1.02, va=0.0, area=1, baskv=230.0),  # slack
            Bus(ibus=2, ide=2, vm=1.02, va=0.0, area=1, baskv=230.0),  # PV
            Bus(ibus=3, ide=1, vm=1.0,  va=0.0, area=1, baskv=230.0),
            Bus(ibus=4, ide=1, vm=1.0,  va=0.0, area=1, baskv=230.0),
            Bus(ibus=5, ide=2, vm=1.01, va=0.0, area=2, baskv=230.0),  # PV
            Bus(ibus=6, ide=1, vm=1.0,  va=0.0, area=2, baskv=230.0),
            Bus(ibus=7, ide=1, vm=1.0,  va=0.0, area=2, baskv=230.0),
            Bus(ibus=8, ide=1, vm=1.0,  va=0.0, area=2, baskv=230.0),  # radial
        ],
        generators=[
            Generator(ibus=1, machid="1", pg=0.0,  vs=1.02, qb=-300, qt=300),
            Generator(ibus=2, machid="1", pg=60.0, vs=1.02, qb=-200, qt=200),
            Generator(ibus=5, machid="1", pg=60.0, vs=1.01, qb=-200, qt=200),
        ],
        loads=[
            Load(ibus=3, pl=50, ql=15), Load(ibus=4, pl=40, ql=12),
            Load(ibus=6, pl=45, ql=14), Load(ibus=7, pl=35, ql=10),
            Load(ibus=8, pl=25, ql=8),
        ],
        branches=[
            Branch(ibus=1, jbus=2, r=0.01, x=0.08, b=0.0),
            Branch(ibus=2, jbus=3, r=0.01, x=0.08, b=0.0),
            Branch(ibus=3, jbus=4, r=0.01, x=0.08, b=0.0),
            Branch(ibus=4, jbus=1, r=0.01, x=0.08, b=0.0),
            Branch(ibus=5, jbus=6, r=0.01, x=0.08, b=0.0),
            Branch(ibus=6, jbus=7, r=0.01, x=0.08, b=0.0),
            Branch(ibus=7, jbus=5, r=0.01, x=0.08, b=0.0),
            Branch(ibus=1, jbus=5, r=0.01, x=0.10, b=0.0),   # tie
            Branch(ibus=3, jbus=6, r=0.01, x=0.10, b=0.0),   # tie
            Branch(ibus=4, jbus=8, r=0.01, x=0.08, b=0.0),   # radial to bus 8
        ],
    )


case = build_case()

Defining a study#

Devices are referenced by power-system identity (from/to/ckt, bus/id) rather than backend indices, so a definition remains valid across case revisions. The study below generates the single-branch N-1 family for the whole case and adds one explicit N-2 contingency that removes both inter-area ties. The definition is serialized to TOML by study.to_toml().

study = (
    Study(case, name="Two-area N-1 + N-2 study")
    .with_options(tol=1e-8, max_iter=40)
    .monitor(voltage_band=(0.95, 1.05))
    .generate_n1()
    .add_contingency(
        "double-tie-loss",
        [TripBranch(1, 5, "1"), TripBranch(3, 6, "1")],
        outage="n-2",
    )
)
study.defn.base_case = "system.raw"   # points to the case file in a real workflow

print(study.to_toml())
[study]
name = "Two-area N-1 + N-2 study"
base_case = "system.raw"

[study.options]
max_iter = 40
tol = 1e-08

[monitor]
branch_rating = false
voltage_band = [0.95, 1.05]

[[generate]]
kind = "single_branch"

[[contingency]]
label = "double-tie-loss"
actions = [ { trip_branch = { from = 1, to = 5, ckt = "1" } }, { trip_branch = { from = 3, to = 6, ckt = "1" } } ]
tags = { outage = "n-2" }

Screening#

run() resolves identity to backend index against the finalized case, then solves the base case and every contingency in one batch. Each scenario carries an independent status, so a failed contingency does not affect the others.

with quiet_solver():
    result = study.run(backend=BACKEND)

base_v, base_bus = result.min_voltage(result.base)
print(f"base {result.base.status.name}, min V {base_v:.4f} pu at bus {base_bus}")
print("status counts:", result.status_counts())
base CONVERGED, min V 0.9881 pu at bus 8
status counts: {'INVALID_TOPOLOGY': 2, 'CONVERGED': 9}

Ranking#

The result model answers three questions without re-solving: which scenarios failed, which buses moved most, and which scenario to inspect. explain() expands a single scenario into its convergence trace and worst bus.

print("Largest voltage deviations vs base:")
for s, dv, bus in result.top_voltage_drops(5):
    print(f"  {s.label:16s} dV {dv:.4f} pu at bus {bus}")

print("\nFailed contingencies:")
for s in result.failed():
    print(f"  {s.label:16s} {s.status.name}")

worst = result.top_voltage_drops(1)[0][0].label
print("\n" + result.explain(worst))
Largest voltage deviations vs base:
  4-1-1            dV 0.0427 pu at bus 8
  7-5-1            dV 0.0225 pu at bus 7
  2-3-1            dV 0.0218 pu at bus 3
  5-6-1            dV 0.0139 pu at bus 6
  3-4-1            dV 0.0016 pu at bus 8

Failed contingencies:
  double-tie-loss  INVALID_TOPOLOGY
  4-8-1            INVALID_TOPOLOGY

scenario '4-1-1' [generated]: CONVERGED
  iterations=6 outer=2 max_mismatch=7.078e-15 exit=0
  min V = 0.9454 pu at bus 8
  max |dV| vs base = 0.0427 pu at bus 8

Reports#

Results are exported to Markdown, CSV, or JSON. Each report records provenance (the base-case hash, the study-definition hash, the backend, and the version) so that a result is reproducible. The Markdown report follows; to_csv() and to_json() produce the other two formats from the same result.

print(result.to_markdown())
# Study: Two-area N-1 + N-2 study

- Backend: cpu (KLU), gpf 0.0.2.dev1+g3ecb0d5c2
- Study definition sha256: 64b38f5db88e
- Solve options: max_iter=40, tol=1e-08
- Scenarios: 11 (base + contingencies)

## Status counts
|Status|Count|
|-|-|
|CONVERGED|9|
|INVALID_TOPOLOGY|2|

## Base case
Converged in 5 iters (2 outer). Min V 0.9881 pu @ bus 8; max V 1.0200 pu @ bus 1.

## Top 9 voltage deviations vs base
|Rank|Scenario|Status|max abs dV (pu)|At bus|Min V (pu)|Iters|
|-|-|-|-|-|-|-|
|1|4-1-1|CONVERGED|0.0427|8|0.9454|6|
|2|7-5-1|CONVERGED|0.0225|7|0.9747|5|
|3|2-3-1|CONVERGED|0.0218|3|0.9759|5|
|4|5-6-1|CONVERGED|0.0139|6|0.9825|5|
|5|3-4-1|CONVERGED|0.0016|8|0.9865|5|
|6|3-6-1|CONVERGED|0.0013|3|0.9888|5|
|7|6-7-1|CONVERGED|0.0009|7|0.9880|5|
|8|1-5-1|CONVERGED|0.0004|3|0.9877|5|
|9|1-2-1|CONVERGED|0.0000|3|0.9881|5|

## Worst 9 low-voltage scenarios
|Scenario|Min V (pu)|At bus|Status|
|-|-|-|-|
|4-1-1|0.9454|8|CONVERGED|
|7-5-1|0.9747|7|CONVERGED|
|2-3-1|0.9759|8|CONVERGED|
|5-6-1|0.9825|6|CONVERGED|
|3-4-1|0.9865|8|CONVERGED|
|1-5-1|0.9877|8|CONVERGED|
|6-7-1|0.9880|8|CONVERGED|
|1-2-1|0.9881|8|CONVERGED|
|3-6-1|0.9888|8|CONVERGED|

## Voltage-band violations (band [0.950, 1.050] pu)
|Scenario|# buses|Worst bus|V (pu)|Side|
|-|-|-|-|-|
|4-1-1|1|8|0.9454|low|

## Failed / non-converged (2)
|Scenario|Kind|Status|Exit reason|
|-|-|-|-|
|double-tie-loss|explicit|INVALID_TOPOLOGY|0|
|4-8-1|generated|INVALID_TOPOLOGY|0|

Saving and rerunning#

A study definition is persisted as a file, reloaded, and rerun. The reload below reproduces the original status counts.

workdir = pathlib.Path(tempfile.mkdtemp())
path = workdir / "two_area_study.toml"
study.to_toml(path)

reloaded = Study.from_toml(path)
with quiet_solver():
    rerun = reloaded.run(case=case, backend=BACKEND)   # base_case path is a stub here

assert rerun.status_counts() == result.status_counts()
print("reloaded study reproduces:", rerun.status_counts())
reloaded study reproduces: {'INVALID_TOPOLOGY': 2, 'CONVERGED': 9}

Parameterized replay#

A study definition accepts parameters, so the same definition runs at several operating points. The load_scale parameter applies a global load multiplier; the sweep below shows the minimum voltage decreasing as load increases.

band_lo = 0.95
print(f"load_scale | base min V | buses < {band_lo:.2f} pu | failed")
for scale in (1.00, 1.15, 1.30):
    with quiet_solver():
        r = Study(case, load_scale=scale).generate_n1().run(backend=BACKEND)
    base_min = r.min_voltage(r.base)[0]
    violations = sum(1 for s in r.converged()
                     if (mv := r.min_voltage(s)) and mv[0] < band_lo)
    print(f"{scale:>10.2f} | {base_min:>10.4f} | {violations:>14d} | {len(r.failed())}")
load_scale | base min V | buses < 0.95 pu | failed
      1.00 |     0.9881 |              1 | 1
      1.15 |     0.9831 |              1 | 1
      1.30 |     0.9779 |              1 | 1

Scoping to a subsystem#

A scope restricts auto-generation to part of the network. Here the N-1 family is generated only for branches in area 1.

scoped = (
    Study(case, name="Area-1 N-1")
    .scope("area1", areas=[1])
    .generate_n1(subsystem="area1")
)
with quiet_solver():
    rs = scoped.run(backend=BACKEND)
print("area-1 N-1 contingencies:", [s.label for s in rs.scenarios])
area-1 N-1 contingencies: ['1-2-1', '2-3-1', '3-4-1', '4-1-1']

Importing .con / .sub / .mon files#

Existing .con, .sub, and .mon contingency files are read into the same model, so established libraries are reused without rewriting them. The import below carries a contingency block, an auto-generation rule, and a subsystem definition.

trio = pathlib.Path(tempfile.mkdtemp())
(trio / "study.con").write_text(
    "CONTINGENCY 'tie-1-5'\n"
    "DISCONNECT BRANCH FROM BUS 1 TO BUS 5 CIRCUIT '1'\n"
    "END\n"
    "SINGLE BRANCH IN SUBSYSTEM 'AREA1'\n"
    "END\n"
)
(trio / "study.sub").write_text("SUBSYSTEM 'AREA1'\nAREA 1\nEND\n")
(trio / "study.mon").write_text("MONITOR BRANCHES IN SUBSYSTEM 'AREA1'\nEND\n")

imported = Study.from_psse(trio / "study.con", sub=trio / "study.sub",
                           mon=trio / "study.mon")
with quiet_solver():
    ri = imported.run(case=case, backend=BACKEND)
print("imported study ran:", ri.status_counts())
imported study ran: {'CONVERGED': 5}

Command line#

The same workflow is available from the command line:

gpf study n-1 case.raw --area 101 --out report.md --save study.toml
gpf study run study.toml --set load_scale=1.05 --csv ranked.csv
gpf study validate study.toml          # resolve and count scenarios, no solve
gpf study import case.con --sub case.sub --mon case.mon -o study.toml

Exit codes: 0 all converged; 1 the base or a contingency failed; 2 a bad study file or unknown parameter; 3 a contingency references a missing device; 4 an action the engine cannot yet apply.