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 GpfCase, 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() -> 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),
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.