Interconnection studies#

An interconnection study determines how much generation or load can connect at a proposed location. A study file lists the planning cases, requests, candidate locations, and monitored equipment. gpf selects the applicable calculation from each request's service type.

The network#

This example uses a five-bus, 230 kV system. Buses 1, 2, and 3 form a triangle. The 1-2 branch has a 200 MVA rating, while the other two branches have 300 MVA ratings. The 1-2 branch is therefore the restrictive corridor. Buses 4 and 5 connect to bus 1 through higher-rated lines. Bus 1 has the existing generator and is the reference bus.

import pathlib, tempfile

import gpf
from gpf import GpfCase, Bus, Branch, Generator, Load, save_case
from gpf.scenario import Study


def build_case(load_scale: float) -> GpfCase:
    pl = {2: 60.0, 3: 60.0, 4: 80.0, 5: 40.0}
    return GpfCase(
        sbase=100.0,
        buses=[
            Bus(ibus=1, ide=3, vm=1.02, baskv=230.0, area=1, name="STATION A"),
            Bus(ibus=2, ide=1, vm=1.00, baskv=230.0, area=1, name="STATION B"),
            Bus(ibus=3, ide=1, vm=1.00, baskv=230.0, area=1, name="STATION C"),
            Bus(ibus=4, ide=1, vm=1.00, baskv=230.0, area=2, name="STATION D"),
            Bus(ibus=5, ide=1, vm=1.00, baskv=230.0, area=2, name="STATION E"),
        ],
        generators=[Generator(ibus=1, machid="1", pg=sum(pl.values()) * load_scale,
                              pt=1200.0, pb=-600.0, qt=600.0, qb=-600.0,
                              vs=1.02, mbase=1200.0, stat=1)],
        loads=[Load(ibus=b, loadid="1", pl=p * load_scale, ql=p * load_scale * 0.3,
                    stat=1, area=(1 if b < 4 else 2))
               for b, p in pl.items()],
        branches=[
            Branch(ibus=1, jbus=2, ckt="1", r=0.004, x=0.10, b=0.02,
                   rate1=200.0, rate2=200.0, rate3=200.0, stat=1),
            Branch(ibus=1, jbus=3, ckt="1", r=0.004, x=0.10, b=0.02,
                   rate1=300.0, rate2=300.0, rate3=300.0, stat=1),
            Branch(ibus=2, jbus=3, ckt="1", r=0.004, x=0.10, b=0.02,
                   rate1=300.0, rate2=300.0, rate3=300.0, stat=1),
            Branch(ibus=1, jbus=4, ckt="1", r=0.003, x=0.08, b=0.015,
                   rate1=400.0, rate2=400.0, rate3=400.0, stat=1),
            Branch(ibus=4, jbus=5, ckt="1", r=0.003, x=0.08, b=0.015,
                   rate1=300.0, rate2=300.0, rate3=300.0, stat=1),
            Branch(ibus=1, jbus=5, ckt="1", r=0.005, x=0.12, b=0.02,
                   rate1=300.0, rate2=300.0, rate3=300.0, stat=1),
        ],
    )


work = pathlib.Path(tempfile.mkdtemp())
for season, scale in (("summer_peak", 1.0), ("winter_peak", 0.9), ("light_load", 0.5)):
    save_case(build_case(scale), work / f"{season}.raw")

The study file#

The study file uses these blocks:

Block

Purpose

[[case]]

Names a planning case and project output assumptions.

[[project]]

Defines a generator request and its connection point.

[[load]]

Defines a load request and its connection point.

[candidates]

Lists locations to screen without a filed request.

A project's service selects its study treatment. Network-resource projects are studied together for simultaneous delivery. Other projects are studied individually.

STUDY_TOML = """
[study]
name = "Interconnection queue screen"
format = 5

[[case]]
name = "summer_peak"
file = "summer_peak.raw"
dispatch = { solar = 1.0, wind = 0.35, load = 1.0, "wind/network" = 0.40 }

[[case]]
name = "winter_peak"
file = "winter_peak.raw"
dispatch = { solar = 0.4, wind = 0.80, load = 1.0, "wind/network" = 0.45 }

[[case]]
name = "light_load"
file = "light_load.raw"
dispatch = { solar = 0.9, wind = 0.70, load = 0.4, "wind/network" = 0.80 }

[[project]]
label = "P1"
poi_bus = 2
request_mw = 300.0
resources = [ { technology = "wind", mw = 300.0, power_factor = 0.95 } ]
service = "network"
plant_bus = 90001
plant_kv = 34.5
gsu = { mva = 330.0, x_pct = 7.5 }

[[project]]
label = "P2"
poi_bus = 3
request_mw = 200.0
resources = [ { technology = "wind", mw = 200.0, power_factor = 0.95 } ]
service = "network"
plant_bus = 90002
plant_kv = 34.5
gsu = { mva = 220.0, x_pct = 7.5 }

[[project]]
label = "P3"
poi_bus = 4
request_mw = 120.0
resources = [ { technology = "solar", mw = 120.0, power_factor = 0.95 } ]
service = "energy"

[[load]]
label = "L1"
poi_bus = 5
id = "2"
mw = 90.0
power_factor = 0.98
served_mw = 0.0
service = "energy"

[candidates]
buses = [3, 4]
taps = [ { from = 4, to = 5, ckt = "1", pct = 40.0 } ]

[monitor]
voltage = { scope = { areas = [1, 2], kv_min = 100.0, kv_max = 999.0 }, voltage_band = [0.95, 1.05] }
thermal = { scope = { areas = [1, 2], kv_min = 100.0, kv_max = 999.0 }, base_rating_set = 1, contingency_rating_set = 1 }

[[sweep]]
family = "branch"

[[contingency]]
label = "loss of 1-3"
branches = [ { from = 1, to = 3, ckt = "1" } ]
"""

(work / "study.toml").write_text(STUDY_TOML)
study = Study.from_toml(work / "study.toml")
[(c.name, c.file) for c in study.defn.cases]
[('summer_peak', 'summer_peak.raw'),
 ('winter_peak', 'winter_peak.raw'),
 ('light_load', 'light_load.raw')]

The two network-resource projects use the simultaneous-delivery calculation.

[(p.label, " + ".join(p.technologies), p.service, p.request_mw)
 for p in study.defn.projects] + \
    [(p.label, "load", p.service, p.mw) for p in study.defn.load_projects]
[('P1', 'wind', 'network', 300.0),
 ('P2', 'wind', 'network', 200.0),
 ('P3', 'solar', 'energy', 120.0),
 ('L1', 'load', 'energy', 90.0)]

The queue screen#

run_screen studies each applicable request by itself. It checks the request alone and with the other queued projects in service. Each row retains both DC answers and the AC answer when verification runs, and selected names the answer the report ranks. Network-resource projects do not appear in this table because the group calculation studies them. P3 is not network-limited in these cases, so the request sets its deliverable output of 120 MW. L1 receives its full 90 MW in both peak cases. In the light-load case the queued generation already overloads the 1-2 corridor, so the network blocks the load request.

screen = gpf.run_screen(study)
[(c.name, r.label, round(r.alone.headroom_mw, 1), round(r.queued.headroom_mw, 1),
  r.selected.limited_by)
 for c in screen.cases for r in c.rows]
[('summer_peak', 'P3', 120.0, 120.0, 'request'),
 ('summer_peak', 'L1', 90.0, 90.0, 'request'),
 ('winter_peak', 'P3', 120.0, 120.0, 'request'),
 ('winter_peak', 'L1', 90.0, 90.0, 'request'),
 ('light_load', 'P3', 120.0, 120.0, 'request'),
 ('light_load', 'L1', 90.0, 0.0, 'network')]

The result reports the most restrictive planning case for each request.

[(r.label, r.case, round(r.deliverable_mw, 1)) for r in screen.binding]
[('P3', 'light_load', 120.0), ('L1', 'light_load', 0.0)]

Project headroom#

solve_project_headroom balances each request against the system instead of one specified bus. A generation request reduces existing generation in proportion to participation factors. A load request increases generation by the same rule. The load request has its full 90 MW headroom in both peak cases. In the light-load case, queued generation already overloads the 1-2 corridor. The result therefore reports negative thermal headroom.

headroom = gpf.solve_project_headroom(study)
[(c.name, r.label, round(r.headroom_mw, 1), r.limit_type, r.congested)
 for c in headroom.cases for r in c.rows]
[('summer_peak', 'P3', 120.0, 'request', False),
 ('summer_peak', 'L1', 90.0, 'request', False),
 ('winter_peak', 'P3', 120.0, 'request', False),
 ('winter_peak', 'L1', 90.0, 'request', False),
 ('light_load', 'P3', 120.0, 'request', False),
 ('light_load', 'L1', 0.0, 'thermal', True)]

Group deliverability#

The group study checks whether the two network-resource projects can deliver power at the same time. In the light-load case, P1 can individually deliver 230 MW of its assumed 240 MW output. P2 can individually deliver its full 160 MW output.

def only(label: str) -> Study:
    """The study with one network-resource member kept and the other withdrawn."""
    one = Study.from_toml(work / "study.toml")
    one.defn.projects = [p for p in one.defn.projects
                         if p.service != "network" or p.label == label]
    return one


alone = {label: {c.name: round(c.rows[0].group_mw, 1)
                 for c in gpf.solve_group_deliverability(only(label)).cases}
         for label in ("P1", "P2")}
alone
{'P1': {'summer_peak': 120.0, 'winter_peak': 135.0, 'light_load': 230.0},
 'P2': {'summer_peak': 80.0, 'winter_peak': 90.0, 'light_load': 160.0}}

The combined transfer uses both project buses and their assumed output weights. The projects share the same corridor, so the group can deliver 260 MW in the light-load case. This group result differs from both the 390 MW sum and the 160 MW minimum of the individual results.

group = gpf.solve_group_deliverability(study)
light = [c for c in group.cases if c.name == "light_load"][0]
{"alone": alone["P1"]["light_load"] + alone["P2"]["light_load"],
 "minimum": min(alone["P1"]["light_load"], alone["P2"]["light_load"]),
 "together": round(light.rows[0].group_mw, 1)}
{'alone': 390.0, 'minimum': 160.0, 'together': 260.1}

The result allocates the 260 MW group capability to the projects by their declared weights. The 1-2 corridor binds after the loss of the 1-3 branch. Both peak cases can serve the full group output.

[(c.name, r.label, round(r.weight, 2), round(r.request_mw, 1),
  round(r.headroom_mw, 1), r.verdict)
 for c in group.cases for r in c.rows]
[('summer_peak', 'P1', 0.6, 120.0, 120.0, 'full'),
 ('summer_peak', 'P2', 0.4, 80.0, 80.0, 'full'),
 ('winter_peak', 'P1', 0.6, 135.0, 135.0, 'full'),
 ('winter_peak', 'P2', 0.4, 90.0, 90.0, 'full'),
 ('light_load', 'P1', 0.6, 240.0, 156.0, 'partial'),
 ('light_load', 'P2', 0.4, 160.0, 104.0, 'partial')]

Candidate points and line taps#

solve_poi_headroom screens locations that do not yet have a project request. A candidate can be an existing bus or a tap on a line. This example places a tap 40 percent of the distance from bus 4 to bus 5. In the summer case, bus 4 has 380 MW of headroom, bus 3 has 320 MW, and the tap has 300 MW. The line segment beyond the tap limits the tap result.

poi = gpf.solve_poi_headroom(study)
summer = [c for c in poi.cases if c.name == "summer_peak"][0]
[(r.bus, round(r.dc.atc_mw, 1), r.dc.limiter.monitored, r.dc.limiter.contingency)
 for r in summer.rows]
[(4, 380.2, (4, 5, '1'), '1-4-1'),
 (3, 320.3, (1, 2, '1'), 'loss of 1-3'),
 (6, 300.0, (6, 5, '1'), '4-6-1')]

Next steps#

The API reference describes gpf.run_screen, gpf.solve_project_headroom, gpf.solve_group_deliverability, and gpf.solve_poi_headroom. The CLI reference describes the gpf study screen, headroom, and group commands.

See Transfer capability for point-to-point transfers, Scenario study workflow for contingency studies, and Transmission rights for congestion pricing.