Scenario Study Workflow#

This tutorial shows how to define and run an AC contingency study. A study file specifies the network scope, monitored equipment, contingencies, and operating changes. A branch N-1 checks thermal limits by default. gpf runs the scenarios, ranks the results, and creates reports.

import contextlib, os, tempfile, pathlib
import gpf
from gpf import GpfCase, Bus, Generator, Load, Branch
from gpf import Scope
from gpf import DispatchGroup
from gpf.scenario import Study

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 starts from a RAW, RAWX, or MATPOWER base case. This example creates an eight-bus, two-area network in Python. Buses 1 to 4 form area 1, and buses 5 to 8 form area 2. Branches 1-5 and 3-6 connect the areas. Branch 4-8 supplies the radial bus 8.

def build_case() -> GpfCase:
    return GpfCase(
        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, rate1=150.0),
            Branch(ibus=2, jbus=3, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=3, jbus=4, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=4, jbus=1, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=5, jbus=6, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=6, jbus=7, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=7, jbus=5, r=0.01, x=0.08, b=0.0, rate1=150.0),
            Branch(ibus=1, jbus=5, r=0.01, x=0.10, b=0.0, rate1=150.0), # tie
            Branch(ibus=3, jbus=6, r=0.01, x=0.10, b=0.0, rate1=150.0), # tie
            Branch(ibus=4, jbus=8, r=0.01, x=0.08, b=0.0, rate1=150.0), # radial
        ],
    )


case = build_case()

Defining a study#

Identify branches by from, to, and ckt. Identify generators and loads by bus and id. The study below creates one contingency for each branch outage and adds an N-2 contingency that removes both inter-area ties. The N-1 generator adds an exact slot-1 thermal monitor over every finalized AC branch. Its outage scope and monitor scope are independent. Use study.to_toml() to save the definition and result policy as 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))
    .sweep()
    .add_contingency("double-tie-loss", branches=[(1, 5, "1"), (3, 6, "1")])
)
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"
format = 5
base_case = "system.raw"

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

[monitor]
voltage = { voltage_band = [0.95, 1.05] }
thermal = {  }
interfaces = { base_rating_set = 1, contingency_rating_set = 1 }

[[sweep]]
family = "branch"

[[contingency]]
label = "double-tie-loss"
branches = [ { from = 1, to = 5, ckt = "1" }, { from = 3, to = 6, ckt = "1" } ]

Screening#

run() solves the base case and each contingency. Every scenario has its own status, so one failed scenario does not stop the other scenarios. The verdict is pass, fail, or incomplete. Coverage states which criteria ran and which ratings or scenarios were unavailable.

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())
print("verdict:", result.verdict)
print("coverage:", result.coverage)
base CONVERGED, min V 0.9881 pu at bus 8
status counts: {'CONVERGED': 11}
verdict: fail
coverage: {'result_policy': 'criteria', 'thermal_checked': True, 'voltage_checked': True, 'interfaces_checked': False, 'declared_scenarios': 12, 'evaluated_scenarios': 10, 'refused_scenarios': 0, 'nonconverged_scenarios': 0, 'recovered_scenarios': 2, 'unevaluated_swing_trips': 0, 'monitored_branches': 10, 'rated_base_branches': 10, 'rated_contingency_branches': 10, 'missing_rating_branches': 0, 'unlimited_branches': 0, 'unmonitored_interfaces': 0}

Ranking#

The result shows which scenarios failed and which buses had the largest voltage changes. Use explain() to inspect one scenario, its convergence data, and its 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:
  1-4-1            dV 0.0427 pu at bus 8
  5-7-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
  4-8-1            dV 0.0063 pu at bus 4

Failed contingencies:

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

Reports#

Export results as Markdown, CSV, or JSON. Each report identifies the case, study definition, solver, version, result policy, and rating coverage. The example below creates a Markdown report. Use to_csv() or to_json() for the other formats.

result

Contingency report: Two-area N-1 + N-2 study

  • Backend: cpu (KLU), gpf 0.5.1.dev91+g835357422.d20260823

  • Verdict: FAIL

  • Scenarios: 11 (base + contingencies), solved in 2 ms (7106/s)

  • Engine: 2 worker(s), chunks of 8; limited by chunks from 32 worker(s) and chunks of 8

Criteria

  • Thermal: base rating set 1 (RATE1), contingency rating set 1 (RATE1), > 100% loading, 10 monitored branches

  • Thermal basis: mva; missing ratings: incomplete

  • Voltage: band [0.950, 1.050] pu, 8 monitored buses

Status counts

Status

Count

converged

12

Summary

  • Contingencies with violations: 1 (recovered by shedding: 2)

  • Violations: 0 branch overloads, 1 low-voltage, 0 high-voltage

Worst contingencies

Scenario

Status

Worst branch %

Min V (pu)

# violations

Shed (MW)

double-tie-loss

converged

42.1

0.9888

0

140.0

4-8-1

converged

37.6

0.9974

0

25.0

1-4-1

converged

70.0

0.9454

1

0.0

Element envelope

Buses (voltage extremes across all contingencies)

Bus

Base V

Min V

At

Max V

At

8

0.9881

0.9454

1-4-1

0.9888

double-tie-loss

Violations by contingency

1-4-1 — converged (worst branch 70.0%, min V 0.9454 pu)

Type

Element

Value

Limit

Base

New

bus_voltage_low

bus 8

0.9454 pu

0.9500 pu

0.9881 pu

yes

Recovered by shedding (2)

Converged only after de-energizing a slackless component; the shed generation/load means these are not clean passes.

Scenario

Isolated buses

Shed gen (MW)

Shed load (MW)

double-tie-loss

3

60.0

80.0

4-8-1

1

0.0

25.0

Saving and rerunning#

Save a study definition to rerun it later. The reloaded definition below produces the same scenario 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: {'CONVERGED': 11}

Base-case dispatch#

A base-case dispatch changes the operating point for every scenario without changing the input case. Available operations scale loads, generators, or shunts, and redistribute generation among a named group of units. The example below increases area 2 generation and increases all loads by 10 percent. The minimum base-case voltage falls below 0.9881 pu. to_toml() shows the matching [group.<name>] and [[dispatch]] tables.

dispatch = (
    Study(case, name="area-2 lift with a load increase")
    .with_group("wind", [(5, "1", 1.0)])
    .with_dispatch("dispatch_generators", group="wind", delta_mw=30.0)
    .with_dispatch("scale_loads", multiplier=1.10)
)
print(dispatch.to_toml())
[study]
name = "area-2 lift with a load increase"
format = 5

[group.wind]
machines = [ { bus = 5, id = "1", factor = 1.0 } ]

[[dispatch]]
op = "dispatch_generators"
delta_mw = 30.0
group = "wind"

[[dispatch]]
op = "scale_loads"
multiplier = 1.1
pl_before = [float(ld.pl) for ld in case.loads]
with quiet_solver():
    rd = dispatch.run(backend=BACKEND)
{
    "base": rd.base.status.name,
    "min_V_pu": round(rd.min_voltage(rd.base)[0], 4),
    "caller_case_unchanged": [float(ld.pl) for ld in case.loads] == pl_before,
}
{'base': 'CONVERGED', 'min_V_pu': 0.9849, 'caller_case_unchanged': True}

Per-contingency remedial redispatch#

Use injection= to apply remedial generation changes to one contingency. The current option is dispatch_generators. A transfer uses a positive delta_mw for the source group and a negative delta_mw for the sink group. The calculation does not redispatch a generator that the contingency disconnects. In the example below, the tie outage changes bus angles. The remedial redispatch changes those angles again while regulated-bus voltage magnitudes remain fixed.

remedial = (
    Study(case, name="tie loss with remedial redispatch")
    .with_group("wind", [(5, "1", 1.0)])
    .add_contingency(
        "tie-3-6", branches=[(3, 6, "1")],
        injection=[DispatchGroup(group="wind", delta_mw=40.0)],
    )
)
print(remedial.to_toml())
[study]
name = "tie loss with remedial redispatch"
format = 5

[group.wind]
machines = [ { bus = 5, id = "1", factor = 1.0 } ]

[[contingency]]
label = "tie-3-6"
branches = [ { from = 3, to = 6, ckt = "1" } ]
[[contingency.injection]]
op = "dispatch_generators"
delta_mw = 40.0
group = "wind"
import numpy as np

plain = Study(case).add_contingency("tie-3-6", branches=[(3, 6, "1")])
with quiet_solver():
    r_with, r_without = remedial.run(backend=BACKEND), plain.run(backend=BACKEND)
    with_inj = r_with.by_label("tie-3-6")
    without_inj = r_without.by_label("tie-3-6")
    # A study keeps only its screening reductions by default, so the solved
    # voltages are materialized on request rather than read off the scenario.
    sol_with = r_with.solutions(["tie-3-6"])[with_inj.index]
    sol_without = r_without.solutions(["tie-3-6"])[without_inj.index]
{
    "status": with_inj.status.name,
    "max_dVa_rad": round(float(np.max(np.abs(sol_with.va - sol_without.va))), 5),
    "max_dVm_pu": round(float(np.max(np.abs(sol_with.vm - sol_without.vm))), 5),
}
{'status': 'CONVERGED', 'max_dVa_rad': 0.03922, 'max_dVm_pu': 0.0}

Parameterized replay#

A parameterized replay runs the same study at several operating points. The example varies the scale_loads multiplier. The minimum base-case voltage decreases as load increases.

band_lo = 0.95
print(f"multiplier | base min V | buses < {band_lo:.2f} pu | failed")
for scale in (1.00, 1.15, 1.30):
    with quiet_solver():
        r = Study(case).with_dispatch("scale_loads", multiplier=scale).sweep().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())}")
multiplier | base min V | buses < 0.95 pu | failed
      1.00 |     0.9881 |              1 | 0
      1.15 |     0.9831 |              1 | 0
      1.30 |     0.9779 |              1 | 0

Scoping to a subsystem#

A scope limits automatically generated contingencies to part of the network. This example creates branch-outage contingencies only for area 1. The default thermal monitor still checks all finalized AC branches.

scoped = (
    Study(case, name="Area-1 N-1")
    .sweep("branch", Scope(areas=[1]))
)
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', '1-4-1']

Importing .con / .sub / .mon files#

Import existing .con, .sub, and .mon files to reuse contingency and subsystem definitions. The example imports an explicit contingency, an automatic contingency 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 --missing-rating error --out report.md --save study.toml
gpf study run study.toml --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 statuses are:

Status

Meaning

0

The verdict is pass.

1

The verdict is fail or incomplete.

2

The study file or a parameter is invalid.

3

A contingency refers to missing equipment.

4

The requested action is not supported.