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.GridSearchSysMethod
GridSearchSys(
    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 of
  • injectors (array of DynamicInjection): injectors to use. if one dimensional, all configurations of the given injectors will be returned. If 2d, each row of injectors will represent output configuration.
  • busgroups (array of String or array of Vector{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 to true. whether to include the sim, sm, dt, and error columns. Saves disk space to set to false at the cost of not being able to use add_result! after running sims.

Returns:

  • GridSearchSys: properly initialized GridSearchSys with all the right injectors and the default columns error, sim, sm, and dt.
source
Future Changes Possible

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:

IEEE WSCC 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
System
Property Value
Name
Description
System Units Base SYSTEM_BASE
Base Power 100.0
Base Frequency 60.0
Num Components 35
Static Components
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.

component naming

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

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 1Bus 2Bus 3
GFMSMSM
GFMGFMSM
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 2Bus 3
GFMSM
SMSM
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
a few notes
  • 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!Function
add_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>
source

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!Function
add_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.

source
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.

source

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!