Transmission rights#
A financial transmission right (FTR) pays the congestion-price difference between a source and a sink. It can hedge the price spread caused by network congestion. This tutorial checks whether a set of rights is feasible, clears an FTR auction, allocates auction revenue rights (ARR), and values a portfolio across several scenarios.
The example is built in memory so the notebook runs without external files. In practice, load a RAW, RAWX, or MATPOWER case. See optimal power flow for an explanation of locational prices and their congestion component.
Building the example#
This example uses a three-bus network. Bus 1 has a low-cost generator and is the reference bus. Bus 2 has a higher-cost generator. Bus 3 has a 180 MW load. Two parallel 1-3 circuits each have a 60 MW rating. The low-cost generator cannot supply the full load without exceeding these ratings, so congestion causes different prices at buses 1 and 3.
from gpf import (Bus, Branch, Generator, GenCost, Load, GpfCase)
def build_case(pl=180.0, pg1=120.0, pg2=60.0):
"""A three-bus test system with two parallel 1-3 circuits (60 MW normal,
90 MW emergency). Bus 1 is the reference and carries the cheap generator,
bus 2 the expensive one, bus 3 the load."""
buses = [Bus(ibus=1, ide=3, baskv=138.0),
Bus(ibus=2, ide=2, baskv=138.0),
Bus(ibus=3, ide=1, baskv=138.0)]
gens = [Generator(ibus=1, machid="1", pg=pg1, pt=250.0, pb=0.0,
qt=200, qb=-200, vs=1.0, stat=1),
Generator(ibus=2, machid="1", pg=pg2, pt=150.0, pb=0.0,
qt=200, qb=-200, vs=1.0, stat=1)]
branches = [
Branch(ibus=1, jbus=2, ckt="1", r=0.005, x=0.1, rate1=200, rate3=200, stat=1),
Branch(ibus=1, jbus=3, ckt="1", r=0.005, x=0.1, rate1=60, rate3=90, stat=1),
Branch(ibus=1, jbus=3, ckt="2", r=0.005, x=0.1, rate1=60, rate3=90, stat=1),
Branch(ibus=2, jbus=3, ckt="1", r=0.005, x=0.1, rate1=200, rate3=200, stat=1),
]
loads = [Load(ibus=3, loadid="1", pl=pl, ql=0.0, stat=1)]
costs = [GenCost(ibus=1, machid="1", model=2, cost=[10.0, 0.0]),
GenCost(ibus=2, machid="1", model=2, cost=[40.0, 0.0])]
return GpfCase(sbase=100.0, rev=33, basfrq=60.0, buses=buses,
generators=gens, branches=branches, loads=loads,
fixed_shunts=[], transformers=[], gen_costs=costs)
case = build_case()
len(case.buses), len(case.branches)
(3, 4)
The spread a right is written against#
The base dispatch sets the bus 1 price to 10 $/MWh and the bus 3 price to 70 $/MWh. A 1 MW right from bus 1 to bus 3 receives the 60 $/MWh difference for each hour.
from gpf import solve_dc_opf
base = solve_dc_opf(case)
{"lmp": {b: round(base.lmp[b], 1) for b in (1, 2, 3)},
"spread_1_to_3": round(base.lmp[3] - base.lmp[1], 1)}
{'lmp': {1: 10.0, 2: 40.0, 3: 70.0}, 'spread_1_to_3': 60.0}
Simultaneous feasibility#
The simultaneous feasibility test checks all awarded rights as one combined
transfer. Every monitored branch must remain within its rating. Each megawatt
transferred from bus 1 to bus 3 adds 0.4 MW to a monitored 1-3 circuit. Its
60 MW rating therefore permits a maximum 150 MW obligation. The test accepts
a 150 MW position and rejects a 200 MW position. This example names the base
case; contingencies selects the outages a test applies as well.
from gpf import BASE_ONLY, FtrPosition, check_simultaneous_feasibility
flowgate = [(1, 3, "1")]
feasible = {mw: check_simultaneous_feasibility(
case, [FtrPosition(1, 3, mw, label="p")], monitored=flowgate,
contingencies=BASE_ONLY).feasible
for mw in (150.0, 200.0)}
assert feasible == {150.0: True, 200.0: False}
feasible
{150.0: True, 200.0: False}
Clearing an auction#
The auction selects the highest-value feasible set of source-to-sink rights. A single 300 MW bid on the 1-to-3 path can receive only 150 MW because of the network limit. At a bid price of 20 $/MW, the cleared value is 3000 $.
from gpf import FtrBid, clear_ftr_auction
auction = clear_ftr_auction(case, [FtrBid(1, 3, 300.0, 20.0, label="b")], contingencies=BASE_ONLY,
monitored=flowgate)
assert auction.feasible
assert abs(auction.awards["b"] - 150.0) < 1e-4
assert abs(auction.objective - 3000.0) < 1e-4
{"awarded_mw": round(auction.awards["b"], 1),
"value": round(auction.objective, 1)}
{'awarded_mw': 150.0, 'value': 3000.0}
Revenue rights and self-scheduling#
Auction revenue rights are allocated before the FTR auction. If requests exceed the feasible amount, the allocation reduces them proportionally. Two equal 200 MW requests on the 1-to-3 path therefore receive 75 MW each from the available 150 MW.
from gpf import ARRRequest, allocate_arrs
arr = allocate_arrs(case, [ARRRequest(1, 3, 200.0, label="A"),
ARRRequest(1, 3, 200.0, label="B")],
monitored=flowgate, contingencies=BASE_ONLY)
assert arr.feasible
assert set(arr.allocated) == {"A", "B"}
assert all(abs(mw - 75.0) < 1e-4 for mw in arr.allocated.values())
{k: round(v, 1) for k, v in arr.allocated.items()}
{'A': 75.0, 'B': 75.0}
A holder can self-schedule an ARR allocation into the FTR auction as a price-taker bid. A competing 30 $/MW bid sets the clearing price in this example. Each 75 MW entitlement clears in full and receives 2250 $.
from gpf import self_schedule, arr_revenue
bids = self_schedule(arr) + [FtrBid(1, 3, 300.0, 30.0, label="mkt")]
cleared = clear_ftr_auction(case, bids, monitored=flowgate, contingencies=BASE_ONLY)
assert cleared.feasible
assert abs(cleared.awards["A"] - 75.0) < 1e-4
{"cleared_mw": round(cleared.awards["A"], 1),
"clearing_price": round(cleared.clearing_prices["A"], 1),
"revenue": round(arr_revenue(arr, cleared)["A"], 1)}
{'cleared_mw': 75.0, 'clearing_price': 30.0, 'revenue': 2250.0}
Valuing a portfolio across scenarios#
A right's payout changes with network conditions. A scenario sweep values the portfolio across a range of load and outage conditions and summarizes the payout distribution.
A 30 MW obligation from bus 1 to bus 3 pays the 1-3 price spread multiplied by 30 MW. The path is congested in most of the 24 scenarios below. The median payout is therefore 1800 $, based on a 60 $/MWh spread. The average payout is 1612.5 $, the standard deviation is 518 $, and the 5th-percentile payout is 0 $. A zero payout occurs when the path is not congested.
quantiles_exact states whether the reported percentiles are exact. A value of
False means that the result uses estimated percentiles.
from gpf import (FtrPortfolio, load_scenario, outage_scenario,
monte_carlo_load_scenarios, sweep)
scenarios = ([load_scenario(m, label=f"load{int(m * 100)}") for m in (0.8, 1.0, 1.2)]
+ [outage_scenario((1, 2, "1"), label="lose_tie_1-2")]
+ monte_carlo_load_scenarios(20, seed=7, sigma=0.1))
portfolio = FtrPortfolio(positions=[FtrPosition(source=1, sink=3, mw=30.0,
kind="obligation", label="1to3")])
priced = sweep(case, scenarios, portfolio=portfolio)
stats = priced.valuation.stats["total_payout"]
{k: (round(stats[k], 1) if isinstance(stats[k], float) else stats[k])
for k in ("mean", "std", "min", "p5", "p50", "p95", "quantiles_exact")}
{'mean': 1612.5,
'std': 518.3,
'min': 0.0,
'p5': 0.0,
'p50': 1800.0,
'p95': 1800.0,
'quantiles_exact': True}
Next steps#
The API reference describes these transmission-right functions:
check_simultaneous_feasibility, clear_ftr_auction,
clear_ftr_auction_multiperiod, allocate_arrs, self_schedule, and
arr_revenue, and value_ftr_portfolio.
Optimal power flow explains how the congestion prices are calculated. Transfer capability explains the network limits that restrict an award.