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 |
1 |
The verdict is |
2 |
The study file or a parameter is invalid. |
3 |
A contingency refers to missing equipment. |
4 |
The requested action is not supported. |