User guide

This page shows how to build PLECS models and Python scripts so that simulations can be automated with plecsutil.

Introduction

Creating the PLECS model

To work with plecsutil, the PLECS model needs at least two elements: an output data port in the top-level circuit, and a link to the .m file containing the model parameters.

The output data port (or ports) in the top-level circuit defines which signals are sent to Python at the end of each simulation. A data port can contain a single signal, or many signals can be multiplexed and connected to a single data port. Fig. 1 shows an example of a Buck converter with output data ports in the top-level circuit. (You can download the example by clicking here.)

Output data port in top-level.

Fig. 1 PLECS model with output data port in the top-level circuit.

The link to the .m file is given in the initialization tab of the simulation parameters, as shown in Fig. 2. It is important that the name of the .m matches the name of the PLECS model (the simulation file). Thus, if the name of the PLECS model is buck_intro.plecs, the name of the initialization file must be indicated in the simulation parameters must be buck_intro.m.

Initialization of simulation parameters .

Fig. 2 Initialization of the simulation parameters.

Creating the Python script

Running the PLECS simulation requires creating a plecsutil.ui.PlecsModel object informing the name of the PLECS model, its path, and a Python dictionary containing all of the parameters of the model.

An example of a Python script is shown below. Note that the script assumes to be in the same folder as that the PLECS model, since it defines the plecs_file_path as the current working directory.

Listing 1 Running a PLECS simulation with plecsutil (download source)
import os
import plecsutil as pu
import matplotlib.pyplot as plt
plt.ion()

# Model params
model_params = {
    'f_pwm': 100e3,
    'V_in': 24,
    'R': 10,
    'L': 47e-6,
    'C': 220e-6
    }

# Plecs file
plecs_file = 'buck_intro'
plecs_file_path = os.path.abspath(os.getcwd())

# Plecs model
pm = pu.ui.PlecsModel(plecs_file, plecs_file_path, model_params)

# Runs simulation
data = pm.sim()

# Plots results
plt.figure()
plt.plot(data.t, data.data[:, 1])

In this example, when calling plecsutil.ui.PlecsModel.sim() to run the simulation, plecsutil will generate a .m file. This files contains the model parameters given by the model_params dictionary is used to initialize the plecsutil.ui.PlecsModel object. After generating the .m file, the simulation is launched, which then uses the .m as source.

Simulation results

After running a simulation, plecsutil.ui.PlecsModel.sim() returns a plecsutil.ui.DataSet dataclass holding the simulation results. In addition to the data points, plecsutil.ui.DataSet contains a meta field. This field has a dictionary with a model_params entry that stores the model parameters used to produce the associated simulation results. Thus, the simulation results are always saved with the parameters used to produce them.

Running simulations with different model parameters

The plecsutil.ui.PlecsModel.sim() accepts a sim_params dictionary that can be used to overwrite the default model parameters defined when initializing the plecsutil.ui.PlecsModel object. The sim_params dictionary doesn’t need to contain all model parameters, but just the parameters to be overwritten. In the example Python script shown above, calling plecsutil.ui.PlecsModel.sim() as

data = pm.sim({'R': 4})

will run the simulation with R = 4, instead of the default value of 10 defined by model_params.

An example of running the simulation of the Buck converter with different R values is shown below. In this example, plecsutil.ui.PlecsModel.sim() is called with sim_params specifying different values for R.

Listing 2 Running the PLECS model with different model parameters (download source)
import os
import plecsutil as pu
import matplotlib.pyplot as plt
plt.ion()

# Model params
model_params = {
    'f_pwm': 100e3,
    'V_in': 24,
    'R': 10,
    'L': 47e-6,
    'C': 220e-6
    }

# Plecs file
plecs_file = 'buck_intro'
plecs_file_path = os.path.abspath(os.getcwd())

# Plecs model
pm = pu.ui.PlecsModel(plecs_file, plecs_file_path, model_params)

# Runs simulation
R_vals = [2, 5, 10]
data = []
for r in R_vals:
    d = pm.sim(sim_params={'R': r})
    data.append(d)

# Plots results
plt.figure()
for d in data:
    label = 'R = {:}'.format(d.meta['model_params']['R'])
    plt.plot(d.t, d.data[:, 1], label=label)
plt.legend()

The model.py file

In the previous examples, the dictionary containing the model parameters (model_params) was defined in the script that runs the simulation. This makes managing the model parameters more difficult when there are multiple Python scripts to perform different simulations of the same model. In the Buck model used as example, one might create one script to run the model with different values for R, and another one for different values of f_pwm. If the parameters of the PLECS model need to be updated, all scripts need to be modified to include the new parameters.

This can be avoided by creating a model.py associated with the PLECS model. In this way, the model parameters are defined in a single location, and all scripts running simulations can simply import the model from the model.py file.

Although the model.py can be created and define the dictionary with the model parameters, it is more flexible to create a function that returns the parameters instead. The advantages of this approach become clear when adding controllers to the simulation.

For the Buck example discussed thus far, we can create an associated model.py with the following contents:

Listing 3 Example of model.py file (download source)
def params():

    model_params = {
        'f_pwm': 100e3,
        'V_in': 24,
        'R': 10,
        'L': 47e-6,
        'C': 220e-6
        }

    return model_params

Now, model.py can be imported by Python scripts to get the model parameters. For example, to run the Buck converter model with varying values for R:

Listing 4 Using model.py to run simulations (download source)
import os
import plecsutil as pu
import matplotlib.pyplot as plt
plt.ion()

import model

# Plecs file
plecs_file = 'buck_intro'
plecs_file_path = os.path.abspath(os.getcwd())

# Plecs model
pm = pu.ui.PlecsModel(plecs_file, plecs_file_path, model.params())

# Runs simulation
R_vals = [2, 5, 10]
data = []
for r in R_vals:
    d = pm.sim(sim_params={'R': r})
    data.append(d)

# Plots results
plt.figure()
for d in data:
    label = 'R = {:}'.format(d.meta['model_params']['R'])
    plt.plot(d.t, d.data[:, 1], label=label)
plt.legend()

Working with controllers

It is often interesting to see how a closed-loop system behaves, especially when changing controller parameters. There are also case where we have more than one controller and would like to compare them. plecsutil provides a way to define controllers as part of the model, and vary their parameters.

Single controller

When the model has a single controller, changing the parameters of the controller can be done much in the same way as changing the parameters of the model. After all, the controller is part of the model. However, it is often the case that we don’t want to specify the controller gains directly, but rather through some other specifications. For example, we may have a design method, where we specify the settling time of the controller to get the gains. Then, it is a question of concatenating the parameters of the plant with the parameters of the controller, in order to create a single dictionary with all parameters of the model.

Consider state-feedback controller for a Buck converter shown in Fig. 3. This controller has two parameters, which are part of the model parameters: the vector Kx and the gain Ke.

State-feedback controller for a buck converter.

Fig. 3 State-feedback controller for a Buck converter.

These two gains can be determined based on time response specifications of the closed-loop system, such as settling time and overshoot. This is possible with pole placement, based on a model of the converter.

Now, we can identify two sets of parameters: the plant parameters, and the controller parameters. These two combined compose the model parameters. In our model.py file, we can then create three functions: one for the parameters of the plant, one for the parameters of the controller, and one that concatenates them. An example of such structure is shown in the script below.

Listing 5 model.py with plant, controller and model parameters (download source)
import numpy as np
import scipy.signal

def plant_params():

    V_in = 20
    Vo_ref = 10

    L = 47e-6
    C_out = 220e-6

    f_pwm = 100e3

    R = 10
    Rd = 10

    params = {}
    params['L'] = L
    params['C_out'] = C_out
    params['R'] = R
    params['Rd'] = Rd

    params['V_in'] = V_in
    params['Vo_ref'] = Vo_ref
    
    params['f_pwm'] = 100e3

    return params


def controller_gains(ctl_params):
    
    ts = ctl_params['ts']
    os = ctl_params['os']

    _plant_params = plant_params()
    R = _plant_params['R']
    L = _plant_params['L']
    C = _plant_params['C_out']
    V_in = _plant_params['V_in']
    
    A = np.array([
        [0,       -1 / L],
        [1 / C,   -1 / R / C]
        ])
    
    B = np.array([
        [V_in / L],
        [0]
        ])

    C = np.array([0, 1])
    
    Aa = np.zeros((3,3))
    Aa[:2, :2] = A
    Aa[2, :2] = -C

    Ba = np.zeros((3, 1))
    Ba[:2, 0] = B[:, 0]

    zeta = -np.log(os / 100) / np.sqrt( np.pi**2 + np.log(os / 100)**2 )
    wn = 4 / ts / zeta

    p1 = - zeta * wn + 1j * wn * np.sqrt(1 - zeta**2)
    p2 = np.conj(p1)
    p3 = 5 * p1.real
    
    K = scipy.signal.place_poles(Aa, Ba, (p1, p2, p3)).gain_matrix

    Kx = K[0, :2]
    Ke = K[0, 2]
    
    return {'Kx': Kx, 'Ke': Ke}


def params():

    # Plant parameters
    _plant_params = plant_params()

    # Control parameters
    ts = 2e-3
    os = 5
    _ctl_params = controller_gains({'ts':ts, 'os':os})

    # Model params
    _params = {}
    _params.update( _plant_params )
    _params.update( _ctl_params )
    
    return _params

Note that the controller_gains gain function takes as argument a dictionary, which contains the parameters of the controller. In this case, the parameters are ts (settling time) and os (overshoot). The function also returns a dictionary, containing the gains that are used in the model.

When there is a controller, plecsutil is made aware of it by informing the function that returns the controller gains when creating the plecsutil.ui.PlecsModel object. When running the simulation, the user can set the parameters of the controller and pass them to plecsutil.ui.PlecsModel.sim(). Internally, plecsutil calls the function to get the gains, and updates the parameters of the model automatically. This is demonstrated in the script below.

Listing 6 Running a simulation with a single controller (download source and PLECS model)
import os

import plecsutil as pu
import model

import matplotlib.pyplot as plt
plt.ion()

# --- Input ---
plecs_file = 'buck_single_controller'
plecs_file_path = os.path.abspath(os.getcwd())

# --- Sim ---
# Plecs model
pm = pu.ui.PlecsModel(
    plecs_file, plecs_file_path,
    model.params(),
    get_ctl_gains=model.controller_gains
    )

# Runs simulation
data = pm.sim(ctl_params={'ts': 2.5e-3, 'os': 5})

# --- Results ---
plt.figure()
xlim = [0, 20]

ax = plt.subplot(2,1,1)
plt.title('Duty-cycle')
plt.step(data.t / 1e-3, data.data[:, 3], where='post')
plt.grid()
plt.ylabel('$u$')
plt.gca().tick_params(labelbottom=False)

plt.subplot(2,1,2, sharex=ax)
plt.title('Output voltage')
plt.plot(data.t / 1e-3, data.data[:, 1])
plt.grid()
plt.ylabel('Voltage (V)')
plt.xlabel('Time (ms)')
plt.xlim(xlim)

plt.tight_layout()

Note that plecsutil.ui.PlecsModel.sim() is called with ctl_params set to {'ts': 2.5e-3, 'os': 5}. These parameters are used to generate the controller gains, which in turn are updated in the model parameters file.

Now, running the simulation with different controller parameters is just a matter of calling plecsutil.ui.PlecsModel.sim() with different control parameters. An example is demonstrated in the script below.

Listing 7 Running a simulation with different controller parameters (download source)
import os

import plecsutil as pu
import model

import matplotlib.pyplot as plt
plt.ion()

# --- Input ---
plecs_file = 'buck_single_controller'
plecs_file_path = os.path.abspath(os.getcwd())

# --- Sim ---
# Plecs model
pm = pu.ui.PlecsModel(
    plecs_file, plecs_file_path,
    model.params(),
    get_ctl_gains=model.controller_gains
    )

# Runs simulations
data = []
ctl_params = [
    {'ts': 1e-3, 'os': 5},
    {'ts': 3e-3, 'os': 5},
    {'ts': 5e-3, 'os': 5}
    ]
for cp in ctl_params:
    d = pm.sim(ctl_params=cp, close_sim=False)
    data.append(d)

# --- Results ---
plt.figure()
xlim = [0, 20]

ax = plt.subplot(3,1,1)
plt.title('Duty-cycle')
for d in data:
    label = '$T_s = {:}$ ms'.format( d.meta['ctl_params']['ts'] / 1e-3 )
    plt.step(d.t / 1e-3, d.data[:, 3], label=label, where='post')
plt.grid()
plt.ylabel('$u$')
plt.legend()
plt.gca().tick_params(labelbottom=False)

plt.subplot(3,1,2, sharex=ax)
plt.title('Inductor current')
for d in data:
    plt.plot(d.t / 1e-3, d.data[:, 0])
plt.grid()
plt.ylabel('Current (A)')
plt.gca().tick_params(labelbottom=False)

plt.subplot(3,1,3, sharex=ax)
plt.title('Output voltage')
for d in data:
    plt.plot(d.t / 1e-3, d.data[:, 1])
plt.grid()
plt.ylabel('Voltage (V)')
plt.xlabel('Time (ms)')
plt.xlim(xlim)

plt.tight_layout()

Multiple controllers

There are many cases where we would like to have multiple controllers in the model, so that we can easily compare them. This case is also supported in plecsutil, by following a couple of rules in the PLECS model, and by defining the controllers as an plecsutil.ui.Controller object.

First, let’s consider the Buck model we’ve been using so far, and let’s assume we want to compare two controllers: a state feedback and a cascaded controller. First, we define our controller as a configurable subsystem in PLECS, as shown in Fig. 4.

Buck model with two controllers as configurable subsystem

Fig. 4 Buck model with controllers as a configurable subsystem (download model).

Next, we open the configurable subsystem (right-click on subsystem, Subsystem, Open subsystem) and create the controllers there. For this example, we’ve created two subsystems: state_feedback and cascaded, representing the controllers we want to compare. This is shown in Fig. 5.

Controllers as a configurable subsystem

Fig. 5 Controllers in the configurable subsystem.

Now, we go back to the Controller subsystem (Fig. 4), double click on the configurable subsystem containing the controllers, and in the Configuration option we select <reference> and type CTL_SEL, as shown in Fig. 6.

Setting configuration as reference

Fig. 6 Setting CTL_SEL as the configuration of the configurable subsystem.

Now, we will build the model.py file much in the same way as before. We can create one function for the parameters of the plant, and one function for each controller. Then, a single function is created to concatenate all the parameters in a single model parameter dictionary. A script for this is shown below.

Listing 8 model.py file for multiple controllers (download source)
import numpy as np
import scipy.signal

import plecsutil as pu

def plant_params():

    V_in = 20
    Vo_ref = 10

    L = 47e-6
    C_out = 220e-6

    f_pwm = 100e3

    R = 10
    Rd = 10

    _params = {}
    _params['L'] = L
    _params['C_out'] = C_out
    _params['R'] = R
    _params['Rd'] = Rd

    _params['V_in'] = V_in
    _params['Vo_ref'] = Vo_ref
    
    _params['f_pwm'] = 100e3

    return _params


def sfb_get_gains(ctl_params):
    
    ts = ctl_params['ts']
    os = ctl_params['os']

    _plant_params = plant_params()
    R = _plant_params['R']
    L = _plant_params['L']
    C = _plant_params['C_out']
    V_in = _plant_params['V_in']
    
    A = np.array([
        [0,       -1 / L],
        [1 / C,   -1 / R / C]
        ])
    
    B = np.array([
        [V_in / L],
        [0]
        ])

    C = np.array([0, 1])
    
    Aa = np.zeros((3,3))
    Aa[:2, :2] = A
    Aa[2, :2] = -C

    Ba = np.zeros((3, 1))
    Ba[:2, 0] = B[:, 0]

    zeta, wn = _zeta_wn(ts, os)

    p1 = - zeta * wn + 1j * wn * np.sqrt(1 - zeta**2)
    p2 = np.conj(p1)
    p3 = 5 * p1.real
    
    K = scipy.signal.place_poles(Aa, Ba, (p1, p2, p3)).gain_matrix

    Kx = K[0, :2]
    Ke = K[0, 2]
    
    return {'Kx': Kx, 'Ke': Ke}


def cascaded_get_gains(ctl_params):

    _plant_params = plant_params()
    R = _plant_params['R']
    L = _plant_params['L']
    C = _plant_params['C_out']
    V_in = _plant_params['V_in']

    ts_v = ctl_params['ts_v']
    os_v = ctl_params['os_v']
    
    ts_i = ctl_params['ts_i']
    os_i = ctl_params['os_i']

    zeta_i, wn_i = _zeta_wn(ts_i, os_i)
    ki = (L / V_in) * 2 * zeta_i * wn_i
    k_ei = (L / V_in) * ( - wn_i**2 )

    zeta_v, wn_v = _zeta_wn(ts_v, os_v)
    kv = ( C ) * ( 2 * zeta_v * wn_v - 1 / R / C )
    k_ev = ( C ) * ( - wn_v**2 )

    params = {'ki': ki, 'k_ei': k_ei, 'kv': kv, 'k_ev':k_ev}

    return params


def params():

    # Plant parameters
    _plant_params = plant_params()

    # Control parameters
    ts = 2e-3
    os = 5

    _sfb_params = sfb_get_gains({'ts':ts, 'os':os})
    _casc_params = cascaded_get_gains(
        {'ts_v':ts, 'os_v':os, 'ts_i':ts/5, 'os_i':os}
        )

    _ctl_params = {}
    _ctl_params.update( _sfb_params)
    _ctl_params.update( _casc_params)

    # List of controllers
    n_ctl = len(CONTROLLERS)
    active_ctl = 0
    l_ctl = pu.ui.gen_controllers_params(n_ctl, active_ctl)

    # Params for plecs
    _params = {}
    _params.update( _plant_params )
    _params.update( _ctl_params )
    _params.update(l_ctl)
    
    return _params


def _zeta_wn(ts, os):

    zeta = -np.log(os / 100) / np.sqrt( np.pi**2 + np.log(os / 100)**2 )
    wn = 4 / ts / zeta

    return (zeta, wn)


CONTROLLERS = {
    'sfb' : pu.ui.Controller(idx=1, get_gains=sfb_get_gains, label='State feedback'),
    'casc': pu.ui.Controller(idx=2, get_gains=cascaded_get_gains, label='Cascaded')
}

In the script above, there are two main differences compared to the single controller case. The first one is that the params function includes a call to plecsutil.ui.gen_controllers_params(). This function generates the variable CTL_SEL seen in Fig. 6, which is used to select the controller. The second difference is that at the end of the script, a CONTROLLERS dictionary is defined, containing the two controllers of the model. Each entry of this dictionary contains a label for the controller, and a plecsutil.ui.Controller object initialized with an idx, a get_gains callback, and a label parameter. The value of idx must match the pattern used in the subsystem of the model. In PLECS, subsystems within a configurable subsystem are assigned indices starting at 1 and incrementing from left to right, i.e. state_feedback is index 1, and cascaded is index 2 (see Fig. 5).

Now, we can finally build the script to run the simulations with different controllers, with an example given below. The plecsutil.ui.PlecsModel is created as before, but now we give the controllers of the model with controllers=model.CONTROLLERS. Then, when running the simulation, we give ctl_params just like we do for the single controller models, but we additionally give ctl, which must be one of the controllers defined in model.CONTROLLERS. Note that ctl_params changes for each controller (although they could also be the same).

Listing 9 Running models with multiple controllers (download source)
import os
import plecsutil as pu

import numpy as np

import matplotlib
import matplotlib.pyplot as plt

import model

plt.ion()

# --- Input ---
plecs_file = 'buck_multiple_controllers'
plecs_file_path = os.path.abspath(os.getcwd())

# --- Sim ---
# Plecs model
pm = pu.ui.PlecsModel(
    plecs_file, plecs_file_path,
    model.params(),
    controllers=model.CONTROLLERS
    )

# Runs simulations
d1 = pm.sim(ctl='sfb', ctl_params={'ts':1.5e-3, 'os':5}, save='tst')
d2 = pm.sim(ctl='casc', ctl_params={'ts_v':1.5e-3, 'os_v':5, 'ts_i':0.15e-3, 'os_i':5})
data  = [d1, d2]

# --- Results ---
plt.figure()
xlim = [0, 20]

ax = plt.subplot(2,1,1)
plt.title('Duty-cycle')
for d in data:
    label = '{:}'.format( d.meta['ctl_label'] )
    plt.step(d.t / 1e-3, d.data[:, 3], where='post', label=label)
plt.grid()
plt.ylabel('$u$')
plt.legend()
plt.gca().tick_params(labelbottom=False)

plt.subplot(2,1,2, sharex=ax)
plt.title('Output voltage')
for d in data:
    plt.plot(d.t / 1e-3, d.data[:, 1])
plt.grid()
plt.ylabel('Voltage (V)')
plt.xlabel('Time (ms)')
plt.xlim(xlim)

plt.tight_layout()

Saving and loading simulation results

With plecsutil, it is possible to save and load simulations results. When calling plecsutil.ui.PlecsModel.sim() to run a simulation, it is possible to automatically save the results after a simulation finishes by setting save to a nonempty string. For example, running

pm.sim(ctl='sfb', ctl_params={'ts':1.5e-3, 'os':5}, save='sfb_sim_results', ret_data=False)

will save the simulation results in a file called sfb_sim_results.zip. The data is saved as a plecsutil.ui.DataSet object, which includes simulation and meta data, such as model and controller parameters.

Data is loaded with plecsutil.ui.load_data():

data = pu.ui.load_data('sfb_sim_results')

data is a plecsutil.ui.DataSet object, and can be handled exactly in the same way as simulation results returned by plecsutil.ui.PlecsModel.sim().

Warning

plecsutil uses pickle under the hood for serializing and de-serializing the simulation results. This is simple and efficient, but is not safe for file sharing. Only load simulation data that you generated yourself, or that you trust the source.