5. PowerSimData¶
This tutorial is designed to help users to use our software to carry power flow study in the U.S. electrical grid. PowerSimData is an open source package written in Python that is available on GitHub.
5.1. Grid Object¶
A Grid
object contains data representing an electric power system. An object has various attributes that are listed below:
data_loc
(str
) gives the path to the data used to create aGrid
objectgrid_model
(str
) gives the name of the power systemversion
(str
) gives the version of the grid modelmodel_immutables
(object
) contains static data specific to the power systemzone2id
andid2zone
(dict
) map load zone name (id) to load zone id (name)interconnect
(str
) indicates the geographical region coveredbus
(pandas.DataFrame
) encloses the characteristics of the busessub
(pandas.DataFrame
) encloses the characteristics of the substationsbus2sub
(pandas.DataFrame
) maps buses to substationsplant
(pandas.DataFrame
) encloses the characteristics of the plantsbranch
(pandas.DataFrame
) encloses the characteristics of the AC lines, transformers and transformer windingsgencost
(dict
) encloses the generation cost curvesdcline
(pandas.DataFrame
) encloses the characteristics of the HVDC linesstorage
(dict
) encloses information related to storage units
Two grid models representing the U.S. and
the European power system at the transmission
network level are available at this time. In addition to the full continental U.S.
or Europe, a Grid
object can represent one of the interconnection or a
combination of interconnections.
A Grid
object can be created as follows for the U.S. grid model:
U.S. grid
from powersimdata import Grid usa = Grid("USA")
Western interconnection
from powersimdata import Grid western = Grid("Western")
combination of two interconnections
from powersimdata import Grid eastern_western = Grid(["Eastern", "Western"]) texas_western = Grid(["Texas", "Western"])
While the for the European grid model, it can be achieved as follows:
European grid with 128 load zones
from powersimdata import Grid europe = Grid("Europe", source="europe_tub", reduction=128)
Nordic interconnection
from powersimdata import Grid europe = Grid("Nordic", source="europe_tub", reduction=128)
Any Grid
object can be transformed, i.e., generators/lines can be scaled or added.
This is achieved in the scenario framework.
5.2. Scenario Framework¶
A scenario is defined by the following objects:
a power grid, an interconnected network delivering electricity from producers to load buses and consisting of:
thermal (coal, natural gas, etc.) and renewable generators (wind turbines, etc.) that produce electrical power
substations that change voltage levels (from high to low, or the reverse)
transmission lines that carry power from one place to the other (between two substations, between a substation and load bus, between a generator bus and a substation, etc.) - Both, high voltage AC and DC lines are used in our model
generator cost curve that specifies the cost as a function of power generated ($/ MWh) - These are determined by fuel cost and generator efficiency
time series for renewable generators and demand - These profiles are calculated in the PreREISE package
profile for the renewable generators consists of hourly power output
load profile gives the hourly demand (MW) in various load zones, which are geographic entities such as a state or a portion of a state
a change table used to alter the grid and profiles. To illustrate:
generators and transmission lines (AC and DC) capacity can be scaled up and down
storage units, generators and transmission lines can be added
some simulation parameters such as the start and end date along with the duration of the intervals
The Scenario
class handles the following tasks:
Build a scenario (create state)
Launch the scenario and extract the output data (execute state)
Retrieve the output data (analyze state)
Delete a scenario (delete state)
Move a scenario to a backup disk (move state)
When a Scenario
class is instantiated, its state is set either to create,
execute or analyze. The initial state of the Scenario
object is set in the
constructor of the class. The Scenario
class can be instantiated as follows:
no parameter will instantiate the Scenario class in the create state and a new scenario can then be built
a valid scenario identification number (
str
orint
) or name (str
) - Then:if the scenario has been ran and its output data have been extracted, it will be in the analyze state
If the scenario has only been created or ran but not extracted, it will be in the execute state
Note that instantiating a Scenario
object with a string that doesn’t match any
existing scenarios identification number or name will result in a printout of the list
of existing scenarios and their information.
5.2.1. Creating a Scenario¶
A scenario can be created using few lines of code. This is illustrated below:
from powersimdata import Scenario
scenario = Scenario()
# print name of Scenario object state
print(scenario.state.name)
# Start building a scenario
scenario.set_grid(grid_model="usa_tamu", interconnect="Western")
# set plan and scenario names
scenario.set_name("test", "dummy")
# set start date, end date and interval
scenario.set_time("2016-08-01 00:00:00", "2016-08-31 23:00:00", "24H")
# set demand profile version
scenario.set_base_profile("demand", "vJan2021")
# set hydro profile version
scenario.set_base_profile("hydro", "vJan2021")
# set solar profile version
scenario.set_base_profile("solar", "vJan2021")
# set wind profile version
scenario.set_base_profile("wind", "vJan2021")
# scale capacity of solar plants in WA and AZ by 5 and 2.5, respectively
scenario.change_table.scale_plant_capacity(
"solar", zone_name={"Washington": 5, "Arizona": 2.5})
# scale capacity of wind farms in OR and MT by 1.5 and 2, respectively
scenario.change_table.scale_plant_capacity(
"wind", zone_name={"Oregon": 1.5, "Montana Western": 2})
# scale capacity of branches in NV and WY by 2
scenario.change_table.scale_branch_capacity(
zone_name={"Nevada": 2, "Wyoming": 2})
# add AC lines in NM and CO
scenario.change_table.add_branch(
[{"capacity": 200, "from_bus_id": 2053002, "to_bus_id": 2053303},
{"capacity": 150, "from_bus_id": 2060002, "to_bus_id": 2060046}])
# add DC line between CO and CA (Bay Area)
scenario.change_table.add_dcline(
[{"capacity": 2000, "from_bus_id": 2060771, "to_bus_id": 2021598}])
# add a solar plant in NV, a coal plant in ID and a natural gas plant in OR
scenario.change_table.add_plant(
[{"type": "solar", "bus_id": 2030454, "Pmax": 75},
{"type": "coal", "bus_id": 2074334, "Pmin": 25, "Pmax": 750, "c0": 1800, "c1": 30, "c2": 0.0025},
{"type": "ng", "bus_id": 2090018, "Pmax": 75, "c0": 900, "c1": 30, "c2": 0.0015}])
# add a new bus, and a new one-way DC line connected to this bus
scenario.change_table.add_bus(
[{"lat": 48, "lon": -125, "zone_id": 201, "baseKV": 138}])
scenario.change_table.add_dcline(
[{"from_bus_id": 2090023, "to_bus_id": 2090024, "Pmin": 0, "Pmax": 200}])
# get grid used in scenario
grid = scenario.get_grid()
# get change table used to alter the base grid.
ct = scenario.get_ct()
It can be convenient to clear the change table when creating a scenario. Let’s say for
instance that a wrong scaling factor has been applied or a generator has been attached
to the wrong bus. To do so, the clear
method of the ChangeTable
class can be
used.
There are also a couple of more advanced methods which can selectively scale branches based on the topology of the existing grid, or based on power flow results from a previous scenario. These can be called as:
scenario.change_table.scale_renewable_stubs()
or
scenario.change_table.scale_congested_mesh_branches(ref_scenario)
where ref_scenario
is a Scenario
object in analyze state.
The final step is to run the create_scenario
method:
# review information
scenario.print_scenario_info()
# create scenario
scenario.create_scenario()
# print name of Scenario object state
print(scenario.state.name)
# print status of scenario
scenario.print_scenario_status()
Once the scenario is successfully created, a scenario id is printed on screen and the state of the Scenario object is switched to execute. printed on screen.
5.2.2. Running the Scenario and Extracting Output Data¶
It is possible to execute the scenario immediately right after it has been created. One can also create a new Scenario object. This is the option we follow here.
The execute state accomplishes the three following tasks:
Prepare simulation inputs: the scaled profiles and the MAT-file enclosing all the information related to the electrical grid
Launch the simulation
Extract output data - This operation is performed once the simulation has finished running.
from powersimdata import Scenario
scenario = Scenario("dummy")
# print scenario information
scenario.print_scenario_info()
# prepare simulation inputs
scenario.prepare_simulation_input()
# launch simulation
process_run = scenario.launch_simulation()
# Get simulation status
scenario.print_scenario_status()
Note that the status of the simulation can be accessed using the
print_scenario_status
method.
As an optional parameter, the number of threads used to run the simulation can be specified using for example:
process_run = scenario.launch_simulation(threads=8)
Extracting data from the simulation engine outputs can be a memory intensive process. If there are resource constraints where the engine resides, it is possible to pause the data from being extracted using an optional parameter and then manually extracting the data at a suitable time:
process_run = scenario.launch_simulation(extract_data=False)
# Extract data
process_extract = scenario.extract_simulation_output()
Note that you will need to create a new Scenario
object via the scenario id/name to
access the output data.
5.2.3. Retrieving Scenario Output Data¶
When the Scenario
object is in the analyze state, the user can access various
scenario information and data. The following code snippet lists the methods implemented
to do so:
from powersimdata import Scenario
scenario = Scenario(600)
# print name of Scenario object state
print(scenario.state.name)
# print scenario information
scenario.print_scenario_info()
# get change table
ct = scenario.get_ct()
# get grid
grid = scenario.get_grid()
# get demand profile
demand = scenario.get_demand()
# get hydro profile
hydro = scenario.get_hydro()
# get solar profile
solar = scenario.get_solar()
# get wind profile
wind = scenario.get_wind()
# get generation profile for generators
pg = scenario.get_pg()
# get generation profile for storage units (if present in scenario)
pg_storage = scenario.get_storage_pg()
# get energy state of charge of storage units (if present in scenario)
e_storage = scenario.get_storage_e()
# get power flow profile for AC lines
pf_ac = scenario.get_pf()
# get power flow profile for DC lines
pf_dc = scenario.get_dcline_pf()
# get locational marginal price profile for each bus
lmp = scenario.get_lmp()
# get congestion (upper power flow limit) profile for AC lines
congu = scenario.get_congu()
# get congestion (lower power flow limit) profile for AC lines
congl = scenario.get_congl()
# get time averaged congestion (lower and power flow limits) for AC lines
avg_cong = scenario.get_averaged_cong()
# get load shed profile for each load bus
load_shed = scenario.get_load_shed()
If generators or AC/DC lines have been scaled or added to the grid, and/or if the demand in one or multiple load zones has been scaled for this scenario then the change table will enclose these changes and the retrieved grid and profiles will be modified accordingly. Note that the analysis of the scenario using the output data is done in the PostREISE package.
5.2.4. Deleting a Scenario¶
A scenario can be deleted. All the input and output files as well as any entries in monitoring files will be removed. The delete state is only accessible from the analyze state.
5.2.5. Moving a Scenario to Backup disk¶
A scenario can be move to a backup disk. The move state is only accessible from the analyze state. The functionality is illustrated below:
from powersimdata import Scenario
from powersimdata.scenario.move import Move
scenario = Scenario("dummy")
# print name of Scenario object state
print(scenario.state.name)
# print list of accessible states
print(scenario.state.allowed)
# switch state
scenario.change(Move)
# print name of Scenario object state
print(scenario.state.name)
# move scenario
scenario.move_scenario()
5.3. Capacity Planning Framework¶
The capacity planning framework is intended to estimate the amount of new capacity that will be required to meet future clean energy goals.
5.3.1. Required Inputs¶
At minimum, this framework requires a reference Scenario
object–used to specify
the current capacities and capacity factors of resources which count towards
state-level clean energy goals (this Scenario
object must be in analyze
state)–and a list of target areas (comprised of one or more zones) and their target
clean energy penetrations. A strategy must also be specified, either independent
(each area meets it own goal) or collaborative
(all areas with non-zero goals work
together to meet a shared goal, resembling REC trading).
The list of targets may be specified in either a CSV file or a data frame, as long as
the required columns are present: region_name
and ce_target_fraction
. Optional
columns are: allowed_resources
(defaulting to solar & wind),
external_ce_addl_historical_amount
(clean energy not modeled in our grid, defaulting
to 0), and solar_percentage
(how much of the new capacity will be solar, defaulting
to the current solar:wind ratio. This input only applies to the independent strategy,
a shared-goal new solar fraction for collaborative planning is specified in the
function call to calculate_clean_capacity_scaling
.
5.3.2. Optional Inputs¶
Since increasing penetration of renewable capacity is often associated with increased
curtailment, an expectation of this new curtailment can be passed as the
addl_curtailment
parameter. For the collaborative method, this must be passed as a
dictionary of {resource_name: value}
pairs, for the independent method this must
be passed as a data frame or as a two-layer nested dictionary which can be interpreted
as a data frame. For either method, additional curtailment must be a value between 0 and
1, representing a percentage, not percentage points. For example, if the previous
capacity factor was 30%, and additional curtailment of 10% is specified, the expected
new capacity factor will be 27%, not 20%.
Another Scenario
object can be passed as next_scenario
to specify the magnitude
of future demand (relevant for energy goals which are expressed as a fraction of total
consumption); this Scenario object may be any state, as long as
Scenario.get_demand()
can be called successfully, i.e., if the Scenario
object
is in create state, an interconnection must be defined. This allows calculation of
new capacity for a scenario which is being designed, using the demand scaling present in
the change table.
Finally, for the collaborative method, a solar_fraction
may be defined, which
determines scenario-wide how much of the new capacity should be solar (the remainder
will be wind).
5.3.3. Example Capacity Planning Function Calls¶
Basic independent call, using the demand from the reference scenario to approximate the future demand:
from powersimdata.design.generation.clean_capacity_scaling import calculate_clean_capacity_scaling
from powersimdata.scenario.scenario import Scenario
ref_scenario = Scenario(403)
targets_and_new_capacities_df = calculate_clean_capacity_scaling(
ref_scenario,
method="independent",
targets_filename="eastern_2030_clean_energy_targets.csv"
)
Complex collaborative call, using all optional parameters:
from powersimdata.design.generation.clean_capacity_scaling import calculate_clean_capacity_scaling
from powersimdata.scenario.scenario import Scenario
ref_scenario = Scenario(403)
# Start building a new scenario, to plan capacity for greater demand
new_scenario = Scenario()
new_scenario.set_grid("Eastern")
zone_demand_scaling = {"Massachusetts": 1.1, "New York City": 1.2}
new_scenario.change_table.scale_demand(zone_name=zone_demand_scaling)
# Define additional expected curtailment
addl_curtailment = {"solar": 0.1, "wind": 0.15}
targets_and_new_capacities_df = calculate_clean_capacity_scaling(
ref_scenario,
method="collaborative",
targets_filename="eastern_2030_clean_energy_targets.csv",
addl_curtailment=addl_curtailment,
next_scenario=new_scenario,
solar_fraction=0.55
)
5.3.4. Creating a Change Table from Capacity Planning Results¶
The capacity planning framework returns a data frame of capacities by resource type and
target area, but the scenario creation process ultimately requires scaling factors by
resource type and zone or plant id. A function create_change_table
exists to perform
this conversion process. Using a reference scenario, a set of scaling factors by
resource type, zone, and plant id is calculated. When applied to a base Grid
object,
these scaling factors will result in capacities that are nearly identical to the
reference scenario on a per-plant basis (subject to rounding), with the exception of
solar and wind generators, which will be scaled up to meet clean energy goals.
from powersimdata.design.generation.clean_capacity_scaling import create_change_table
change_table = create_change_table(targets_and_new_capacities_df, ref_scenario)
# The change table method only accepts zone names, not zone IDs, so we have to translate
id2zone = new_scenario.state.get_grid().id2zone
# Plants can only be scaled one resource at a time, so we need to loop through
for resource in change_table:
new_scenario.change_table.scale_plant_capacity(
resource=resource,
zone_name={
id2zone[id]: value
for id, value in change_table[resource]["zone_name"].items()
},
plant_id=change_table[resource]["zone_name"]
)
5.4. Analyzing Scenario Designs¶
5.4.1. Analysis of Transmission Upgrades¶
5.4.1.1. Cumulative Upgrade Quantity¶
Using the change table of a scenario, the number of upgrades lines/transformers and their cumulative upgraded capacity (for transformers) and cumulative upgraded megawatt-miles (for lines) can be calculated with:
powersimdata.design.transmission.mwmiles.calculate_mw_miles(scenario)
where scenario
is a Scenario
instance.
5.4.1.2. Classify Upgrades¶
The upgraded branches can also be classified into either interstate or intrastate branches by calling:
powersimdata.design.transmission.statelines.classify_interstate_intrastate(scenario)
where scenario
is a Scenario
instance.
5.4.2. Analysis of Generation Upgrades¶
5.4.2.1. Accessing and Saving Relevant Supply Information¶
Analyzing generator supply and cost curves requires the proper generator cost and plant
information to be accessed from a Grid
object. This data can be accessed using the
following:
from powersimdata.design.generation.cost_curves import get_supply_data
supply_df = get_supply_data(grid, num_segments, save)
where grid
is a Grid
object, num_segments
is the number of linearized cost
curve segments into which the provided quadratic cost curve should be split, and
save
is a string representing the desired file path and file name to which the
resulting data will be saved. save
defaults to None
. get_supply_data()
returns a pandas.DataFrame
that contains information about each generator’s fuel
type, quadratic cost curve, and linearized cost curve, as well as the interconnect and
load zone to which the generator belongs. get_supply_data()
is used within many of
the following supply and cost curve visualization and analysis functions.
5.4.2.2. Visualizing Generator Supply Curves¶
To obtain the supply curve for a particular fuel type and area, the following is used:
from powersimdata.design.generation.cost_curves import build_supply_curve
P, F = build_supply_curve(grid, num_segments, area, gen_type, area_type, plot)
where grid
is a Grid
object; num_segments
is the number of linearized cost
curve segments to create; area
is a string describing an appropriate load zone,
interconnect, or state; gen_type
is a string describing an appropriate fuel type;
area_type
is a string describing the type of region that is being considered; and
plot
is a boolean that indicates whether or not the plot is shown. area_type
defaults to None
, which allows the area type to be inferred; there are instances
where specifying the area type can be useful (e.g., Texas can refer to both a state and
an interconnect, though they are not the same thing). plot
defaults to True
.
build_supply_curve()
returns P
and F
, the supply curve capacity and price
quantities, respectively.
5.4.2.3. Comparing Supply Curves¶
When updating generator cost curve information, it can be useful to see the corresponding effect on the supply curve for a particular area and fuel type pair. Instead of only performing a visual inspection between the original and new supply curves, the maximum price difference between the two supply curves can be calculated. This metric, which is similar to the Kolmogorov-Smirnov test, serves as a goodness-of-fit test between the two supply curves, where a lower score is desired. This metric can be calculated as follows:
from powersimdata.design.generation.cost_curves import ks_test
max_diff = ks_test(P1, F1, P2, F2, area, gen_type, plot)
where P1
and P2
are lists containing supply curve capacity data; F1
and
F2
are lists containing corresponding supply curve price data; area
is a string
describing an appropriate load zone, interconnect, or state; gen_type
is a string
describing an appropriate fuel type; and plot
is a boolean that indicates whether or
not the plot is shown. The pairs of supply curve data, (P1
, F1
) and (P2
,
F2
), can be created using build_supply_curve()
or can be created manually. It
should be noted that the two supply curves must offer the same amount of capacity (i.e.,
max(P1) = max(P2)
). area
and gen_type
both default to None
. plot
defaults to True
. ks_test()
returns max_diff
, which is the maximum price
difference between the two supply curves.
5.4.2.4. Comparing Cost Curve Parameters¶
When designing generator cost curves, it can be instructive to visually compare the
quadratic cost curve parameters for generators in a particular area and fuel type pair.
The linear terms (c1
) and quadratic terms (c2
) for a given area and fuel type
can be compared in a plot using the following:
from powersimdata.design.generation.cost_curves import plot_linear_vs_quadratic_terms
plot_linear_vs_quadratic_terms(grid, area, gen_type, area_type, plot, zoom, num_sd, alpha)
where grid
is a Grid
object; area
is a string describing an appropriate load
zone, interconnect, or state; gen_type
is a string describing an appropriate fuel
type; area_type
is a string describing the type of region that is being considered;
plot
is a boolean that indicates whether or not the plot is shown; zoom
is a
boolean that indicates whether or not the zoom capability that filters out quadratic
term outliers for better visualization is enabled; num_sd
is the number of standard
deviations outside of which quadratic terms are filtered; and alpha
is the alpha
blending parameter for the scatter plot. area_type
defaults to None
, which
allows the area type to be inferred. plot
defaults to True
. zoom
defaults to
False
. num_sd
defaults to 3. alpha
, which can take values between 0 and
1, defaults to 0.1.
5.4.2.5. Comparing Generators by Capacity and Price¶
When designing generator cost curves, it can be useful to visually compare the capacity and price parameters for each generator in a specified area and fuel type pair. The generator capacity and price parameters for a given area and fuel type can be compared in a plot using the following:
from powersimdata.design.generation.cost_curves import plot_capacity_vs_price
plot_capacity_vs_price(grid, num_segments, area, gen_type, area_type, plot)
where grid
is a Grid
object; num_segments
is the number of linearized cost
curve segments to create; area
is a string describing an appropriate load zone,
interconnect, or state; gen_type
is a string describing an appropriate fuel type;
area_type
is a string describing the type of region that is being considered; and
plot
is a boolean that indicates whether or not the plot is shown. area_type
defaults to None
, which allows the area type to be inferred. plot
defaults to
True
.