Setup
The best way to see how to use this package is a detailed example. We'll start with how to set up the simulations you want to run.
Constructor
In order to create a GridSearchSys
, we can use the constructor. This creates all configurations of the given system and injectors, or specific given configurations.
When not given specific configurations, it uses get_permutations
to get all length-length(busgroups)
sets of items taken from the injectors
list with replacement. Essentially, get all possibilities if each bus group can be assigned any of the given injectors, then remove duplicates.
PowerSystemsExperiments.GridSearchSys
— MethodGridSearchSys(
sys::System,
injectors::Union{AbstractArray{DynamicInjection}, AbstractArray{DynamicInjection, 2}},
busgroups::Union{AbstractArray{Vector{String}}, AbstractArray{String}, Nothing} = nothing
;
include_default_results::Bool=true
)
constructor for GridSearchSys with the same behavior as makeSystems
.
If include_default_results
, automatically adds the columns error
, sim
, sm
, and dt
to allow results getter methods to be used after saving. This is very useful if you need all that data afterwards, but it drastically increases the amount of data produced. If you produce too much data, the sims will still run fine, but it might be impossible to load it all back at once (if it doesn't fit in your RAM). TLDR; if you're producing way too much data, try setting include_default_results=false
and just calling add_result!
only before running the sims.
By default, injector configuration will be represented as a "injector at {bus name or bus names joined by ', '}" for each bus or busgroup.
Args:
sys::System
: the base system to work off ofinjectors
(array ofDynamicInjection
): injectors to use. if one dimensional, all configurations of the given injectors will be returned. If 2d, each row ofinjectors
will represent output configuration.busgroups
(array of String or array ofVector{String}
, optional): if just array of strings, represents list of buses to consider. If array of Vector{String}, each vector of bus names will be grouped together and always receive the same injector type. If not passed, just considers all buses with Generators attached.include_default_results
(Bool, optional): defaults totrue
. whether to include thesim
,sm
,dt
, anderror
columns. Saves disk space to set tofalse
at the cost of not being able to useadd_result!
after running sims.
Returns:
GridSearchSys
: properly initialized GridSearchSys with all the right injectors and the default columnserror
,sim
,sm
, anddt
.
Admittedly, this is quite unclean. Ideally, injector permutations are separate from construction and are performed through the same interface as all the other sweeps. However, this was the way I began, so it's been stuck here. Hopefully in the future this can change.
Let's start with the IEEE 9-bus test case:
We need it to be all static components. We can use a .raw file to load it to a PowerSystems.System
object like this:
system_raw_file = Downloads.download("https://raw.githubusercontent.com/gecolonr/loads/main/data/raw_data/WSCC_9bus.raw");
mv(system_raw_file, system_raw_file*".raw")
system_raw_file *= ".raw"
sys = System(system_raw_file, time_series_in_memory=true);
rm(system_raw_file)
sys
Property | Value |
---|---|
Name | |
Description | |
System Units Base | SYSTEM_BASE |
Base Power | 100.0 |
Base Frequency | 60.0 |
Num Components | 35 |
Type | Count | Has Static Time Series | Has Forecasts |
---|---|---|---|
ACBus | 9 | false | false |
Arc | 9 | false | false |
Area | 1 | false | false |
Line | 6 | false | false |
LoadZone | 1 | false | false |
StandardLoad | 3 | false | false |
ThermalStandard | 3 | false | false |
Transformer2W | 3 | false | false |
I'd also recommend you do this, as it helps remove clutter from the output.
set_runchecks!(sys, false);
[ Info: Set runchecks to false
Using time_series_in_memory=true
I believe makes serialization work better but I'm not sure that it's necessary.
Now, we want to choose our injectors. Let's create a dynamic inverter and a dynamic generator.
Make sure that the names (here GFM
and SM
) of each unique injector are unique! PSE uses component names to tell different types of injectors apart.
const PSE = PowerSystemsExperiments
gfm_inj() = DynamicInverter(
"GFM", # Grid forming control
1.0, # ω_ref,
PSE.converter_high_power(), #converter
PSE.VSM_outer_control(), #outer control
PSE.GFM_inner_control(), #inner control voltage source
PSE.dc_source_lv(), #dc source
PSE.pll(), #pll
PSE.filt(), #filter
)
sm_inj() = DynamicGenerator(
"SM", # Synchronous Machine
1.0, # ω_ref,
PSE.AF_machine(), #machine
PSE.shaft_no_damping(), #shaft
PSE.avr_type1(), #avr
PSE.tg_none(), #tg
PSE.pss_none(), #pss
)
sm_inj (generic function with 1 method)
Now we're ready to call the constructor! For this example, we'll just get all combinations of the two injectors at each of the buses.
Grid following inverters CANNOT be attached to the reference bus. If you need to do this, you'll likely have to switch your reference frame to ConstantFrequency
. If you try to run simulations with a grid following inverter attached to the reference bus, you will get
┌ Error: ResidualModel failed to build
│ exception =
│ KeyError: key :ω_oc not found
│ Stacktrace:
│ [1] getindex
...
implementation details about memory usage
The injector variations created by the constructor are the only things that are not done lazily. This means that for every new injector setup, the base system will be deepcopied. Other variations (those added with add_generic_sweep!
or similar) will not instantiate new systems, so the total number of systems held in memory at once stays small enough for most use cases.
gss = GridSearchSys(sys, [gfm_inj(), sm_inj()])
GridSearchSys with 8 systems
base: System (Buses: 9)
header: AbstractString["injector at {Bus 3}", "injector at {Bus1}", "injector at {Bus 2}"]
sysdict: Dict{Vector{Any}, Function} with 8 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 4 function declarations
Here are some possible alternatives that showcase the full behavior of the constructor:
Two specific test cases
Here, notice that the injectors
array is now two dimensional. We'll try these combinations of injectors:
Bus 1 | Bus 2 | Bus 3 |
---|---|---|
GFM | SM | SM |
GFM | GFM | SM |
GridSearchSys(sys, [gfm_inj() sm_inj() sm_inj()
gfm_inj() gfm_inj() sm_inj()],
["bus1", "bus 2", "Bus 3"])
GridSearchSys with 2 systems
base: System (Buses: 9)
header: AbstractString["injector at {bus1}", "injector at {bus 2}", "injector at {Bus 3}"]
sysdict: Dict{Vector{Any}, Function} with 2 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 4 function declarations
All combinations, with bus groups
Here, we group buses one and two together so they always have the same injector. Note that they aren't electrically connected; they simply always have identical machines attached.
# bus group 1 bus group 2
GridSearchSys(sys, [gfm_inj(), sm_inj()], [["Bus1", "Bus 2"], ["Bus 3"]])
GridSearchSys with 4 systems
base: System (Buses: 9)
header: AbstractString["injector at {Bus1, Bus 2}", "injector at {Bus 3}"]
sysdict: Dict{Vector{Any}, Function} with 4 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 4 function declarations
Bus groups and specific combinations
You can combine these two approaches, too. Let's try two specific test cases with those bus groups:
Bus 1, Bus 2 | Bus 3 |
---|---|
GFM | SM |
SM | SM |
GridSearchSys(sys, [gfm_inj() sm_inj()
sm_inj() sm_inj()],
[["Bus1", "Bus 2"], ["Bus 3"]])
GridSearchSys with 2 systems
base: System (Buses: 9)
header: AbstractString["injector at {Bus1, Bus 2}", "injector at {Bus 3}"]
sysdict: Dict{Vector{Any}, Function} with 2 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 4 function declarations
- if passing specific combinations, it is recommended to also pass
busgroups
to explicitly set the order of the buses. In the second example,busgroups
is technically redundant, but it makes it clear what injector is being assigned to what bus. - When using
busgroups
, the strings are the bus names. Make sure they match precisely! You'll notice here that"Bus1"
is missing a space. That's because that is the actual name of the bus.
Sweep Config
Now that we have our system all set up, we can add sweeps of other parameters. This happens with the add_generic_sweep!
method.
PowerSystemsExperiments.add_generic_sweep!
— Functionadd_generic_sweep!(gss::GridSearchSys, title::String, adder::Function, params::Vector)
add arbitrary sweeps to a GridSearch. uses adder
to set a parameter of a system to each of the values in params
.
title
is the name of the variable this sweep changes.adder
:Fn(System, T) -> System
. Modifying the system in-place is preferred but whatever is returned will be used.params
:Vector<T>
Let's say we want to vary the load scale of the grid by scaling the power draw and supply setpoints of each generator and load. We can write a function that does this for one system:
function set_power_setpt!(sys::System, scale::Real)
for load in get_components(StandardLoad, sys)
set_impedance_active_power!(load, get_impedance_active_power(load)*scale)
set_current_active_power!(load, get_current_active_power(load)*scale)
set_constant_active_power!(load, get_constant_active_power(load)*scale)
set_impedance_reactive_power!(load, get_impedance_reactive_power(load)*scale)
set_current_reactive_power!(load, get_current_reactive_power(load)*scale)
set_constant_reactive_power!(load, get_constant_reactive_power(load)*scale)
end
for gen in get_components(Generator, sys)
if gen.bus.bustype == ACBusTypes.PV
set_active_power!(gen, get_active_power(gen) * scale)
set_reactive_power!(gen, get_reactive_power(gen) * scale)
end
end
return sys
end
set_power_setpt! (generic function with 1 method)
Notice that although this function is marked as in-place and modifies sys
, it still returns a system. This is important! The system that will actually be used in simulation is whatever is returned.
Now we can provide our function to add_generic_sweep!
to apply for all systems:
load_scales_to_try = [0.4, 1.0]
add_generic_sweep!(gss, "Load Scale", set_power_setpt!, load_scales_to_try)
gss
GridSearchSys with 16 systems
base: System (Buses: 9)
header: AbstractString["injector at {Bus 3}", "injector at {Bus1}", "injector at {Bus 2}", "Load Scale"]
sysdict: Dict{Vector{Any}, Function} with 16 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 5 function declarations
This operation is incredibly lightweight; although this output seems to indicate that there are now 16 systems (8 initially times 2 load scale options), these have not actually been instantiated. This will happen lazily to ensure we don't run out of memory.
We can continue to add as many sweeps as we want. Let's add one for ZIPE load parameters.
ZIPE_params_to_try = (x->ZIPE_loads.LoadParams(x...)).([
# Z I P E
[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
# [0.3, 0.3, 0.3, 0.1], # let's only run
# [0.2, 0.2, 0.2, 0.4], # two sets of params
# [0.1, 0.1, 0.1, 0.7], # so docs don't take
# [0.0, 0.0, 0.0, 1.0], # years to build
])
function add_zipe_load(sys::System, params::ZIPE_loads.LoadParams)::System
ZIPE_loads.create_ZIPE_load(sys, params)
return sys
end
add_generic_sweep!(gss, "ZIPE Params", add_zipe_load, ZIPE_params_to_try)
gss
GridSearchSys with 32 systems
base: System (Buses: 9)
header: AbstractString["injector at {Bus 3}", "injector at {Bus1}", "injector at {Bus 2}", "Load Scale", "ZIPE Params"]
sysdict: Dict{Vector{Any}, Function} with 32 entries
results_header: ["error", "sim", "sm", "dt"]
results_getters: Function[PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_error)}(PowerSystemsExperiments.get_error), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sim)}(PowerSystemsExperiments.get_sim), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_sm)}(PowerSystemsExperiments.get_sm), PowerSystemsExperiments.var"#19#21"{typeof(PowerSystemsExperiments.get_dt)}(PowerSystemsExperiments.get_dt)]
df: 0x0 DataFrame
chunksize: Inf
hfile: 6 function declarations
Additional Configuration
The add_generic_sweep!
method can also be used to perform any configuration you want to the system after all the other setup has been done. Simply add a dummy parameter with a single value, and perform the changes you'd like.
This can be used to set custom initial conditions: use the set_initial_conditions!
method and add a sweep.
x0 = [...] # your custom initial values here
add_generic_sweep!(gss, "x0", PSE.set_initial_conditions!, [x0])
Results
Results columns can be added before or after the simulations are run using the add_result!
method. These are the things recorded for each simulation that is run.
PowerSystemsExperiments.add_result!
— Functionadd_result!(gss::GridSearchSys, title::AbstractString, getter::Function)
Add a column to the output dataframe with a result to store. If gss.df
isn't empty, computes the result and adds it as a column.
getter
must have the following signature: (GridSearchSys, Simulation, SmallSignalOutput, String)->(Any)
Any of the inputs might be missing
.
add_result!(gss::GridSearchSys, titles::Vector{T}, getter::Function) where T <: AbstractString
add multiple columns to the output dataframe with results to store. If gss.df
isn't empty, computes the result and adds it as a column.
getter
must have the following signature: (GridSearchSys, Simulation, SmallSignalOutput, String)->(Vector{Any})
the output vector must be the same length as titles
(the column titles), and any of the inputs might be missing
.
One of these methods adds one result and the other adds multiple at a time, one for each element of the output of the getter
function.
The signature of the getter
function is very important. See Results Getters for all of the builtin getters you can try and information on how to write your own.
For now, let's just grab the eigenvalues of the system at the initial operating point and the transient current magnitude at each injector.
add_result!(gss, "Eigenvalues", PSE.get_eigenvalues)
add_result!(gss, ["Bus 3 Injector Current", "Bus 1 Injector Current", "Bus 2 Injector Current"], PSE.get_injector_currents)
Note that the vector of titles has a strange order - this is specific to match the order that get_injector_currents
returns!