Implementing a new device model

A current-source approach is adopted in this simulator. Therefore all models must be implemented using independent and voltage-controlled current sources. This is not a limitation any circuit component can be described in that way, including ideal voltage sources and state-variable defined nonlinear models.

To implement a new model, simply create a Python file in the devices directory. That file constitutes a module to be imported into the device library. The model itself must be implemented in a class defined as follows:

import numpy as np
import circuit as cir
# Physical constants, global variables
from globalVars import const, glVar
# Automatic differentiation
import cppaddev as ad

class Device(cir.Element):
    """
    Document new model here
    """
    # Device category
    category = "Basic Components"

    # devtype is the 'model' name
    devType = "emptydev"

    # Number of terminals.
    numTerms = 2

    # Model parameters
    paramDict = dict(
        cir.Element.tempItem,
        r = ('Resistance', 'Ohms', float, 0.),
        rsh = ('Sheet resistance', 'Ohms', float, 0.),
        l = ('Lenght', 'm', float, 0.),
        w = ('Width', 'm', float, 0.),
        )

    def __init__(self, instanceName):
        """
        Initialization code
        """
        # Do not include here parameter-dependent code
        cir.Element.__init__(self, instanceName)


    def process_params(self):
        """
        Prepares the device for simulation

        Raises cir.CircuitError if a fatal error is found
        """
        # if device is based on cppaddev, make sure tape is re-generated
        # ad.delete_tape(self)

        # Use the following to make sure connections to internal
        # terminals are not repeated if this process_params is called
        # many times.
        self.clean_internal_terms()

        # This adds one internal terminal (in addition to any
        # existing ones). First argument is the internal variable
        # name and second is the variable unit. Returns terminal index
        ti1 = self.add_internal_term('i1', 'A')

        # Ambient temperature (temp) by default set to 27 C
        # Calculate temperature-dependent variables (if any)
        # self.set_temp_vars(self.temp)

If the class name is not Device as shown in this example, the corresponding class name (or names) must be added to a list to tell the program which of the defined classes contain device models (see mosEKV.py for an example). This allows the definition of two or more device models in the same module:

# First device model class
class MOSlevel1(cir.Element):
      pass

# Second device model class
class MOSlevel2(cir.Element):
      pass

# List with all device models defined in module
devList = [MOSlevel1, MOSlevel2]

It is recommended to copy one of the existing device files to a new file name and use that as a starting point to create a new device.

Module documentation

Documentation for the device library catalog goes in the Device class docstring in reStructuredText (reST) format. Use an underlined main title, to be included as an entry in the Device Library Catalog. If the device contains internal nodes, document the internal topology here. Example for diode device:

"""
Junction Diode
--------------

Model based on spice model. Connection diagram::

           o  1
           |
         --+--
          / \
         '-+-'
           |
           o  0

Includes depletion and diffusion charges.

Netlist examples::

    diode:d1 1 0 isat=10fA cj0=20fF

    # Electrothermal device
    diode_t:d2 2 3 1000 gnd cj0=10pF tt=1e-12 rs=100 bv = 4.

    # Model statement
    .model dmodel1 diode (cj0 = 10pF tt=1ps)
"""

Attributes and functions description

Mandatory attribute: devType = 'string'. Specifies the netlist name of the device model, for example ‘res’ for a resistor model.

Another mandatory attribute is category = 'string'. This is the broad category to classify the current model in the Device Library Catalog (for example Basic Components). You can use one of the existing categories or create a new one.

The following attributes are not mandatory and defaut to empty lists if not specified. They can be used with any type of device model: linear, nonlinear, frequency-defined.

  • If numTerms is set, the parser knows in advance how many external terminals to expect. By default numTerms = 0 and the program makes no assumptions and allows any number of connections.

  • If internal linear VCCS are needed, they are specified using the following format:

    linearVCCS = [((t0, t1), (t2, t3), g), ... ]
    
    
    0  o--------+      +------o 2
                       |
      +               /|\
    Vin              | | | g Vin
      -               \V/
                       |
    1  o--------+      +------o 3

    The format consists on a list of tuples, one per voltage-controlled current source (VCCS). Each tuple has 2 tuples for the control and output ports, respectively and the transconductance goes at the end.

  • The same format is used for linear charge sources (VCQS):

    linearVCQS = [((t0, t1), (t2, t3), c), ... ]
    

Both linearVCCS and linearVCQS may be empty lists and may be modified by process_params() according to paramenter values. Inductors are represented by a combination of VCCS and VCQS (see inductor model as an example).

  • Parameters are listed in a dictionary named paramDict as shown in the sample code. The parameter name is the key. The fields in the description tuple are: long description, unit, type, default value. The default value can be None. Parameters are converted to class attributes after circuit initialization. For this reason parameter names can not be Python keywords (unfortunately is is a keyword). If model is dependent on temperature, the first item should be cir.Element.tempItem, which contains the description for the device temperature parameter (temp).

  • The process_params(self) function is called once the external terminals have been connected and the non-default parameters have been set. This function may be called multiple times for example for paramter sweeps or parameter sensitivity. Make sanity checks here. Internal terminals/devices must also be connected here (see next section).

  • To prevent problems in the calculation of sensitivities using an automatic differentiation (AD) library, avoid the following style of writing conditional statements for float parameters:

    if self.p1:
        # some code

    where p1 is a float-type parameter. Use instead:

    if self.p1 != 0:
        # some code

    The reason for this is that when p1 is set to an AD type, the condition in the first example may be evaluated as True even when is should be False.

Internal Terminals, Local References and Terminal Units

Some models in addition to the external port voltages require additional independent variables that can be be obtained by defining internal terminals. For example, an inductor can be implemented using current sources as shown below:

0  o---------+            +----------------+ til
             | til-tref   |                |
  +         /|\          /^\               |
Vin        ( | )        ( | ) Vin        ----- L
  -         \V/          \|/             -----
             |            |                |
1  o---------+            +----------------+
                                  |
                                 --- tref
                                  V

The additional variable is the inductor current, which in this circuit can be obtained as til - tref. Here Node tref is used as a local reference. Internal references are merged with the global reference in nodal analysis and so do not add additional unknowns. Both nodes til and tref are implemented using internal terminals. Note that terminals in a device are internally numbered consecutively after external terminals. If a model has 2 external terminals (i.e., 0 to 1), the first internal terminal would be 3. Internal terminals are normally created in process_params() as follows:

# This adds one internal terminal. Assume only 2 external
# terminals are connected so far
til = self.add_internal_term('i1', 'A') # til = 2
# Add local reference terminal
tref = self.add_reference_term() # tref = 3

The first argument in add_internal_terms() is the internal variable name and second is the variable unit. Internal terminals can be directly accessed from the terminal list of the device (self.connection). The return value is the internal terminal index. For models that are used as a base class for other devices such as electrothermal models or extrinsic models, the number of external terminals may change. For that reason it is strongly recommended to use the return value from add_internal_terms() and add_reference_term() instead of fixed numbers. Example from BJT model:

# rb is not zero: add internal terminals
tBi = self.add_internal_term('Bi', 'V')
tib = self.add_internal_term('ib', '{0} A'.format(glVar.gyr))
tref = self.add_reference_term()
# Linear VCCS for gyrator(s)
self.linearVCCS = [((1, tBi), (tib, tref), glVar.gyr),
                   ((tib, tref), (1, tBi), glVar.gyr)]

Terminals have an attribute called unit. The unit of any existing terminal variable can be manually changed as follows:

# Set unit for terminal 6
self.connection[6].unit = 'C'

Temperature Dependence

As previously described, most devices should have a temp parameter. Compared with regular parameters, temperature is specially treated: by default all devices take the global temperature defined in the ”.options” card. This can be overriden by the device ”.model” line. In turn that is overriden by the temperature specified in the element line itself. For electrothermal devices, this parameter is ignored and the temperature at the thermal port is used. All temperatures are specified in degrees C.

Temperature-related code is included in the following (optional) function:

def set_temp_vars(self, temp):
    """
    Calculate temperature-dependent variables for temp given in C

    temp: temperature in degree C
    """
    # if device is based on cppaddev, make sure tape is re-generated
    # ad.delete_tape(self)

    # Absolute temperature
    T = const.T0 + temp
    # Thermal voltage
    self.Vt = const.k * T / const.e

Note that linear devices may be temperature-dependent. In that case this function would modify the conductances and capacitances in linearVCCS and linearVCQS lists. This function may be called multiple times and is used to auto-generate electrothermal models.

Nonlinear models

The following attributes are required for nonlinear models:

isNonlinear = True
  • Controlling ports (controlPorts): list here all ports whose voltages are needed to calculate the nonlinear currents / charges in this format: (n1, n2) means that the port voltage is defined as V(n1) - V(n2).

    Example for BJT without intrinsic RC, RB and RE (vbc, vbe):

    controlPorts = [(1, 0), (1, 2)]
    
  • Time-delayed port voltages (nDelays and delayedContPorts): optional, nDelays is the number of delayed control voltages (defaults to zero). delayedContPorts is a list port definitions and corresponding delay in triplet format:

    nDelays = 2
    delayedContPorts = [(n1, n2, delay1), (n3, n4, delay2)]
    
  • An optional attribute, vPortGuess is a numpy vector with a valid set of controlling (plus time-delayed) voltages to be used as an initial guess. If this is not specified, the initial guess is set to zero.

  • Current source output ports (csOutPorts): for each current source in the device, list ports as follows: (n1, n2). Current flows from n1 to n2.

    Example for a 3-terminal BJT with BE and CE current sources, assuming teminals are connected C (0) - B (1) - E (2):

    csOutPorts = [(1, 2), (0, 2)]
    
  • A similar vector is required for output ports of charge sources (qsOutPorts).

Some of these attributes could be empty or can be modified by process_params() according to parameter values.

Nonlinear model equations that are dependent on the control port voltages are implemented in the following function:

def eval_cqs(self, vPort, getOP=False):
    """
    vPort is a vector with control voltages

    Returns tuple with two numpy vectors: one for currents and
    another for charges.

    If getOP = True, only return dictionary with OP variables
    """
    # calculation here
    iVec = np.array([i1, i2])
    qVec = np.array([q1])
    if getOP:
        # calculate and return operating point variables
        return {'var1': value1,
                'var2': value2}
    else:
        return (iVec, qVec)

The getOP argument is optional and may be ommitted if it is never needed. vPort contains control port voltages (or state variables) in the order defined by controlPorts, followed by any voltages defined in csDelayedContPorts.

The variables in iVec are first currents following the order defined in csOutPorts, in qVec are the charges defined in csOutPorts. If there are no currents/charges, return an empty vector.

The following two functions should be present, normally implemented by evaluating the AD tape (they run much faster than eval_cqs()). But they could also be implemented manually by other means:

def eval(self, vPort): same as eval_cqs()
def eval_and_deriv(self, vPort): returns a tuple, (outVec, Jacobian)

To have those automatically implemented using the cppaddev module, add the following to the Device class:

# Use functions directly from cppaddev (imported as ad)
eval_and_deriv = ad.eval_and_deriv
eval = ad.eval

Note on coding models to be used with automatic differentiation

To allow the automatic differentiation library to record all possible operations performed in a model, use the ad.condassign() function provided in cppaddev.py to replace conditional (if) statements dependent on variables related to vPort. For example, suppose the following calculation must be implemented:

if (e > f):
   # Bunch of calculations 1
   result = c
else:
   # Bunch of calculations 2
   result = d

This code can be replaced by a call to ad.condassign() as follows:

# Bunch of calculations 1 (calculates c)
...
# Bunch of calculations 2 (calculates d)
...
#  Returns c if (e-f) > 0, d otherwise
result = ad.condassign(e-f, c, d)

Note that one of c or d may not be valid numbers in the second implementation (depending on the relation between e and f), but a valid value is always assigned to result.

Automatic Eletro-Thermal Models

Automatic electrothermal model generation allows to implement one nonlinear model with two different netlist names: the normal one with electrical terminals only (e.g., “bjt”) and an electrothermal model that has an additional pair of thermal terminals. The voltage in this thermal port is the difference between the device temperature and the ambient temperature. The current is proportional to the power dissipated in the device. The netlist name for the electrothermal model is formed by adding “_t” to the original name (e.g., “bjt_t”).

To implement an automatic electrothermal model, set the following attribute:

makeAutoThermal = True

The process_params() function must be modified to accept an additional argument as follows:

def process_params(self, thermal = False):
    # Set flag to re-add thermal port
    self.__addThermalPorts = True
    ...
    self.csOutPorts = [(tBi, 2), (tBi, 0), (0, 2), (tref, tib)]
    self.controlPorts = [(tBi, 2), (tBi, 0), (tib, tref)]
    ...
    if not thermal:
        # Calculate temperature-dependent variables
        self.set_temp_vars(self.temp)

The thermal flag is set to True for electrothermal devices. In this example the temperature-dependent variables are not calculated during parameter processing if thermal == True since this calculation would be redundant. Set the __addThermalPorts flag to True in this function if one of csOutPorts or controlPorts is changed/reassigned.

In addition, the following function must be implemented:

def power(self, vPort, currV):
    """
    Returns total instantaneous power

    Input: input (vPort) and output vectors in the format from
      eval_cqs()
    """
    vds = vPort[0] - vPort[2]
    # pout = vds*ids + vdb*idb + vsb*isb
    pout = vds*currV[0] + vPort[0] * currV[1] + vPort[2] * currV[2]
    return pout

This function takes the input vector and the results from eval_cqs() and returns the total power dissipated at the nonlinear current sources. This function is overridden in the electrothermal version and thus can be safely used (for example in get_OP) as it always returns the correct value.

Operating Point Information

The get_OP() function generates a dictionary with operating point variables and it should be implemented by all devices. For frequency-dependent devices, f is assumed to be zero. Variable names in the returned dictionary are arbitrary. A simple implementation example for the BJT:

def get_OP(self, vPort):
    """
    Calculates operating point information

    Input: same as eval_cqs
    Output: dictionary with OP variables
    """
    # First we need the Jacobian (transconductances, etc.)
    (outV, jac) = self.eval_and_deriv(vPort)
    # Dissipated power
    power = self.power(vPort, outV)

    opDict = dict(
        VBE = vPort[0],
        VCE = vPort[0] - vPort[1],
        IB = outV[0] + outV[1],
        IC = outV[2] - outV[1],
        IE = - outV[2] - outV[0],
        Temp = self.temp,
        Power = power,
        gm = jac[2,0] - jac[1,0],
        rpi = 1./(jac[0,0] + jac[1,0]),
        )
    return opDict

In some cases it may be better to calculate some operating point parameters directly in the eval_cqs() function. For example in the EKV MOSFET model:

def get_OP(self, vPort):
    """
    Calculates operating point information

    Input:  vPort = [vdb , vgb , vsb]
    Output: dictionary with OP variables
    """
    (outV, jac) = self.eval_and_deriv(vPort)
    # Note that the initial dictionary is returned by eval_cqs()
    opDict = self.eval_cqs(vPort, True)
    power = self.power(vPort, outV)

    # Check things that change if the transistor is reversed
    if opDict['Reversed']:
        gds = jac[0,2]
    else:
        gds = jac[0,0]

    # Save noise variables
    self._Sthermal = opDict['Sthermal']
    self._kSfliker = self.kf * pow(jac[0,1], 2) / self._Cox

    # Use negative index for charges as power may be inserted in
    # between currents and charges by electrothermal model
    opDict.update(dict(VD = vPort[0],
                       VG = vPort[1],
                       VS = vPort[2],
                       IDS = outV[0],
                       IDB = outV[1],
                       ISB = outV[2],
                       QD = outV[-3],
                       QG = outV[-2],
                       QS = outV[-1],
                       Power = power,
                       Temp = self.temp,
                       gm = jac[0,1],
                       gmbs = - jac[0,2] - jac[0,1] - jac[0,0],
                       gds = gds,
                       kSfliker = self._kSfliker))
    return opDict

If the model noise model is dependent on the operating point, this is the place to calculate the corresponding variables as shown above with self._Sthermal and self._kSfliker.

Note for electrothermal models

For models that support an electrothermal version, the save_OP() must be aware that the output vector returned by eval() and eval_and_deriv() may have the output power as the last current component. For example, for the regular EKV model:

outV = [IDS IDB ISB QD QG QS]

the format of the vector in the electrothermal version of the same model is as follows:

outV = [IDS IDB ISB POUT QD QG QS]

For that reason it is recommended to use negative indexes to refer to charges, as shown in the last example.

Noise current spectral density sources

Same format as csOutPorts (for nonlinear devices). Default is an empty tuple.

Example:

# Noise sources: one between drain and source
noisePorts = [(0, 2)]

The get_noise() function in general requires a previous call to get_OP():

def get_noise(self, f):
    """
    Return noise spectral density at frequency f

    Requires a previous call to get_OP()
    """
    s = self._Sthermal + self._kSflicker / pow(f, self.af)
    return np.array([s])

This function should work when given for both scalar and vector frequencies. It should take advantage of the vectorization facilities in numpy. This interface is still experimental and may change.

Independent Sources

Must provide the following arguments/functions:

  1. At least one (perhaps more) of the source flags set to True:

    # isDCSource = True
    # isTDSource = True
    # isFDSource = True
    
  2. The sourceOutput argument that contains tuple with output port. Voltage sources are implemented using a gyrator and a current source. Example:

    sourceOutput = (0, 1) # for a current source
    
  3. Implement at least one of the following source-related functions:

    def get_DCsource(self):
        # used if isDCSource = True
        # return current value
        pass
    
    def get_TDsource(self, ctime):
        """
        ctime is the current time
        """
        # used if isTDSource = True
        # return current at ctime
        pass
    
    def get_FDsource(self):
        """
        Returns a tuple with a frequency and a current phasor vectors
    
                (fvec, currentVec)
    
        """
        # used if isFDSource = True. fvec is defined by the source
        # parameters.
    
        # Example for cos wave:
        fvec = np.array([self.freq])
        currentVec = np.array([self.magnitude], dtype=complex)
        return (fvec, currentVec)
    

    These functions are used with the following conventions:

    • The DC component is the only one that is active for OP or DC analyses.
    • The DC component is always added to the contribution of the other sources. Do not include DC components in the other functions.
    • Some analyses (such as some forms of envelope-following) may require combined time/frequency or multiple time dimensions. The interface may have to be extended to handle that. The safest approach seems to be to define a new function for each case.

    Optionally, some time-domain sources may implement the following function to help controlling time-step size:

    def get_next_event(self, ctime):
        """
        Returns time of next discontinuity in function/derivative
        """
        pass
    

    Also optionally, frequency-domain sources may implement the following function to be used for AC analysis:

    def get_AC(self):
        """
        Returns AC magnitude and phase
        """
        return cm.rect(self._acmag, self._phase)
    

Linear frequency-defined

If the attribute isFreqDefined = True, then the model must also include the following attribute with the port definitions for the frequency-domain part of the device:

fPortsDefinition = [(0, 1), (2, 3)]

The format of this list is one tuple per port. In the example above, there are two ports. The positive terminals are 0 and 2. The other terminals, 1 and 3 are (local) port references.

The Y/G parameters are calculated in the following functions:

def get_Y_matrix(self, fvec):
    """
    Documentation
    fvec is a frequency vector/scalar, but frequency can not be zero
    """
    # For scalar fvec returns Y matrix
    # For vector should return 3-D np.array. The frequency
    # index is the last.
    return ymatrix


def get_G_matrix(self):
    """
    Returns a matrix with the DC G parameters
    """
    return ymatrix

get_ymatrix() should work when given for both scalar and vector frequencies and should take advantage of the vectorization facilities in numpy. It may not work at DC, that is why get_gmatrix() is also needed.

Devices Package Documentation

devices – Device Library

The devices package contains a library with device models. Device classes are imported into a dictionary. Keys are the device types. To create a new device use the following:

devices.devClass['devType']('instancename')

Example (from python):

from devices import devClass
# Create device instance
m1 = devClass['mosacm']('m1n')

Example (from netlist):

mosacm:m1n <nodes> <parameters>

Making a device model to be recognized by this package

Suppose the new model is implemented in a file named newmodel.py. Save this file in the devices directory and edit devices/__init__.py. Add your module name to netElemList as shown below:

# Regular 'netlist' elements must be listed here
netElemList = ['mosACM', 'resistor', 'capacitor', 'inductor', 'idc', 'vdc', 
               'diode', 'svdiode', 'mosEKV', 'bjt', 'svbjt', 'newelem']

That’s all!

Re-Generating Catalogs

Erase one catalog file (for example, device_library.rst) in the documentation directory (doc/) and re-make the documentation. All catalogs should be automatically re-generated.