Acausal Component-Based Modeling

In this tutorial, we will build a hierarchical acausal component-based model of the RC circuit. The RC circuit is a simple example where we connect a resistor and a capacitor. Kirchhoff's laws are then applied to state equalities between currents and voltages. This specifies a differential-algebraic equation (DAE) system, where the algebraic equations are given by the constraints and equalities between different component variables. We then simplify this to an ODE by eliminating the equalities before solving. Let's see this in action.

Note

This tutorial teaches how to build the entire RC circuit from scratch. However, to simulate electric components with more ease, check out the ModelingToolkitStandardLibrary.jl which includes a tutorial for simulating RC circuits with pre-built components

Copy-Paste Example

using ModelingToolkit, Plots, OrdinaryDiffEq, Setfield
using ModelingToolkit: t_nounits as t, D_nounits as D

# Define the Pin connector
function Pin(; name)
    @variables v(t) i(t) [connect = Flow]
    sys = System(Equation[], t, [v, i], []; name)
    sys = @set sys.connector_type = ModelingToolkit.connector_type(sys)
    return sys
end

# Define Ground component
function Ground(; name)
    @named g = Pin()
    eqs = [g.v ~ 0]
    System(eqs, t, [], []; systems = [g], name)
end

# Define OnePort base component
function OnePort(; name)
    @named p = Pin()
    @named n = Pin()
    @variables v(t) i(t)
    eqs = [
        v ~ p.v - n.v
        0 ~ p.i + n.i
        i ~ p.i
    ]
    System(eqs, t, [v, i], []; systems = [p, n], name)
end

# Define Resistor component
function Resistor(; name, R = 1.0)
    @named oneport = OnePort()
    @unpack v, i = oneport
    @parameters R = R # Sets the default resistance
    eqs = [v ~ i * R]
    extend(System(eqs, t, [], [R]; name), oneport)
end

# Define Capacitor component
function Capacitor(; name, C = 1.0)
    @named oneport = OnePort()
    @unpack v, i = oneport
    @parameters C = C
    eqs = [D(v) ~ i / C]
    extend(System(eqs, t, [], [C]; name), oneport)
end

# Define ConstantVoltage source
function ConstantVoltage(; name, V = 1.0)
    @named oneport = OnePort()
    @unpack v = oneport
    @parameters V = V
    eqs = [V ~ v]
    extend(System(eqs, t, [], [V]; name), oneport)
end

# Build the RC circuit
@named resistor = Resistor(R = 2.0)
@named capacitor = Capacitor(C = 1.0)
@named source = ConstantVoltage(V = 1.0)
@named ground = Ground()

rc_eqs = [
    connect(source.p, resistor.p)
    connect(resistor.n, capacitor.p)
    connect(capacitor.n, source.n)
    connect(capacitor.n, ground.g)
]

@named rc_model = System(rc_eqs, t; systems = [resistor, capacitor, source, ground])
rc_model = mtkcompile(rc_model)

u0 = [
    rc_model.capacitor.v => 0.0
]
prob = ODEProblem(rc_model, u0, (0, 10.0))
sol = solve(prob)
plot(sol)
Example block output

Explanation

We wish to build the following RC circuit by building individual components and connecting the pins:

Building the Component Library

For each of our components, we define a function that returns a System. At the top, we start with defining the fundamental qualities of an electric circuit component. At every input and output pin, a circuit component has two values: the current at the pin and the voltage. Thus we define the Pin connector to simply be the values there. Whenever two Pins in a circuit are connected together, the system satisfies Kirchhoff's laws, i.e. that currents sum to zero and voltages across the pins are equal. [connect = Flow] informs MTK that currents ought to sum to zero, and by default, variables are equal in a connection.

function Pin(; name)
    @variables v(t) i(t) [connect = Flow]
    sys = System(Equation[], t, [v, i], []; name)
    sys = @set sys.connector_type = ModelingToolkit.connector_type(sys)
    return sys
end
Pin (generic function with 1 method)

Note that this is an incompletely specified System: it cannot be simulated on its own because the equations for v(t) and i(t) are unknown. Instead, this just gives a common syntax for receiving this pair with some default values. One can then construct a Pin using the @named helper macro:

@named mypin1 = Pin()
Model mypin1:
Equations (2):
  2 connecting: see equations(expand_connections(mypin1))
Unknowns (2): see unknowns(mypin1)
  v(t)
  i(t)

Next, we build our ground node. A ground node is just a pin that is connected to a constant voltage reservoir, typically taken to be V = 0. Thus to define this component, we generate a System with a Pin subcomponent and specify that the voltage in such a Pin is equal to zero. This gives:

function Ground(; name)
    @named g = Pin()
    eqs = [g.v ~ 0]
    System(eqs, t, [], []; systems = [g], name)
end
Ground (generic function with 1 method)

Next we build a OnePort: an abstraction for all simple electric component with two pins. The voltage difference between the positive pin and the negative pin is the voltage of the component, the current between two pins must sum to zero, and the current of the component equals to the current of the positive pin.

function OnePort(; name)
    @named p = Pin()
    @named n = Pin()
    @variables v(t) i(t)
    eqs = [
        v ~ p.v - n.v
        0 ~ p.i + n.i
        i ~ p.i
    ]
    System(eqs, t, [v, i], []; systems = [p, n], name)
end
OnePort (generic function with 1 method)

Next we build a resistor. A resistor is an object that has two Pins, the positive and the negative pins, and follows Ohm's law: v = i*r. The voltage of the resistor is given as the voltage difference across the two pins, while by conservation of charge we know that the current in must equal the current out, which means (no matter the direction of the current flow) the sum of the currents must be zero. This leads to our resistor equations:

function Resistor(; name, R = 1.0)
    @named oneport = OnePort()
    @unpack v, i = oneport
    @parameters R = R # Sets the default resistance
    eqs = [v ~ i * R]
    extend(System(eqs, t, [], [R]; name), oneport)
end
Resistor (generic function with 1 method)

Notice that we have created this system with a default parameter R for the resistor's resistance. By doing so, if the resistance of this resistor is not overridden by a higher level default or overridden at ODEProblem construction time, this will be the value of the resistance. Also, note the use of extend. For the Resistor, we want to simply inherit OnePort's equations and unknowns and extend them with a new equation. Note that v, i are unpacked from oneport using @unpack.

Using our knowledge of circuits, we similarly construct the Capacitor:

function Capacitor(; name, C = 1.0)
    @named oneport = OnePort()
    @unpack v, i = oneport
    @parameters C = C
    eqs = [D(v) ~ i / C]
    extend(System(eqs, t, [], [C]; name), oneport)
end
Capacitor (generic function with 1 method)

Now we want to build a constant voltage electric source term. We can think of this as similarly being a two pin object, where the object itself is kept at a constant voltage, essentially generating the electric current. We would then model this as:

function ConstantVoltage(; name, V = 1.0)
    @named oneport = OnePort()
    @unpack v = oneport
    @parameters V = V
    eqs = [V ~ v]
    extend(System(eqs, t, [], [V]; name), oneport)
end
ConstantVoltage (generic function with 1 method)

Connecting and Simulating Our Electric Circuit

Now we are ready to simulate our circuit. Let's build our four components: a resistor, capacitor, source, and ground term. For simplicity, we will make all of our parameter values 1.0, except the resistor which we set to 2.0.

@named resistor = Resistor(R = 2.0)
@named capacitor = Capacitor(C = 1.0)
@named source = ConstantVoltage(V = 1.0)
@named ground = Ground()

rc_eqs = [
    connect(source.p, resistor.p)
    connect(resistor.n, capacitor.p)
    connect(capacitor.n, source.n)
    connect(capacitor.n, ground.g)
]

@named rc_model = System(rc_eqs, t; systems = [resistor, capacitor, source, ground])
rc_model = mtkcompile(rc_model)
Model rc_model:
Equations (1):
  1 standard: see equations(rc_model)
Unknowns (1): see unknowns(rc_model)
  capacitor₊v(t)
Parameters (3): see parameters(rc_model)
  resistor₊R
  capacitor₊C
  source₊V
Observed (19): see observed(rc_model)

This model is acausal because we have not specified anything about the causality of the model. We have simply specified what is true about each of the variables. This forms a system of differential-algebraic equations (DAEs) which define the evolution of each unknown of the system. The equations are:

equations(expand_connections(rc_model))
1-element Vector{Equation}:
 Differential(t, 1)(capacitor₊v(t)) ~ capacitor₊i(t) / capacitor₊C

the unknowns are:

unknowns(rc_model)
1-element Vector{SymbolicUtils.BasicSymbolicImpl.var"typeof(BasicSymbolicImpl)"{SymReal}}:
 capacitor₊v(t)

and the parameters are:

parameters(rc_model)
3-element Vector{SymbolicUtils.BasicSymbolicImpl.var"typeof(BasicSymbolicImpl)"{SymReal}}:
 resistor₊R
 capacitor₊C
 source₊V

The observed equations are:

observed(rc_model)
19-element Vector{Equation}:
 ground₊g₊i(t) ~ 0
 ground₊g₊v(t) ~ 0
 source₊v(t) ~ source₊V
 capacitor₊n₊v(t) ~ 0
 capacitor₊p₊v(t) ~ capacitor₊v(t)
 resistor₊v(t) ~ source₊v(t) - capacitor₊v(t)
 source₊p₊v(t) ~ capacitor₊n₊v(t) + source₊v(t)
 source₊n₊v(t) ~ capacitor₊n₊v(t)
 resistor₊n₊v(t) ~ capacitor₊p₊v(t)
 resistor₊i(t) ~ resistor₊v(t) / resistor₊R
 resistor₊p₊v(t) ~ source₊p₊v(t)
 capacitor₊i(t) ~ resistor₊i(t)
 source₊n₊i(t) ~ capacitor₊i(t)
 source₊p₊i(t) ~ -capacitor₊i(t)
 resistor₊p₊i(t) ~ capacitor₊i(t)
 capacitor₊p₊i(t) ~ capacitor₊i(t)
 source₊i(t) ~ source₊p₊i(t)
 resistor₊n₊i(t) ~ -capacitor₊p₊i(t)
 capacitor₊n₊i(t) ~ -capacitor₊p₊i(t)

Solving this System

We are left with a system of only two equations with two unknown variables. One of the equations is a differential equation, while the other is an algebraic equation. We can then give the values for the initial conditions of our unknowns, and solve the system by converting it to an ODEProblem in mass matrix form and solving it with an ODEProblem mass matrix DAE solver. This is done as follows:

u0 = [rc_model.capacitor.v => 0.0]

prob = ODEProblem(rc_model, u0, (0, 10.0))
sol = solve(prob)
plot(sol)
Example block output

By default, this plots only the unknown variables that had to be solved for. However, what if we wanted to plot the timeseries of a different variable? Do not worry, that information was not thrown away! Instead, transformations like mtkcompile simply change unknown variables into observables which are defined by observed equations.

observed(rc_model)
19-element Vector{Equation}:
 ground₊g₊i(t) ~ 0
 ground₊g₊v(t) ~ 0
 source₊v(t) ~ source₊V
 capacitor₊n₊v(t) ~ 0
 capacitor₊p₊v(t) ~ capacitor₊v(t)
 resistor₊v(t) ~ source₊v(t) - capacitor₊v(t)
 source₊p₊v(t) ~ capacitor₊n₊v(t) + source₊v(t)
 source₊n₊v(t) ~ capacitor₊n₊v(t)
 resistor₊n₊v(t) ~ capacitor₊p₊v(t)
 resistor₊i(t) ~ resistor₊v(t) / resistor₊R
 resistor₊p₊v(t) ~ source₊p₊v(t)
 capacitor₊i(t) ~ resistor₊i(t)
 source₊n₊i(t) ~ capacitor₊i(t)
 source₊p₊i(t) ~ -capacitor₊i(t)
 resistor₊p₊i(t) ~ capacitor₊i(t)
 capacitor₊p₊i(t) ~ capacitor₊i(t)
 source₊i(t) ~ source₊p₊i(t)
 resistor₊n₊i(t) ~ -capacitor₊p₊i(t)
 capacitor₊n₊i(t) ~ -capacitor₊p₊i(t)

These are explicit algebraic equations which can then be used to reconstruct the required variables on the fly. This leads to dramatic computational savings because implicitly solving an ODE scales like O(n^3), so making there be as few unknowns as possible is good!

The solution object can be accessed via its symbols. For example, let's retrieve the voltage of the resistor over time:

sol[rc_model.resistor.v]
15-element Vector{Float64}:
 1.0
 0.9999500012499791
 0.9994501512222747
 0.9944653727971767
 0.9469527040251715
 0.8312914349204991
 0.6767789419497787
 0.5208174605055126
 0.3694346757039162
 0.24258326500098903
 0.14523568116402397
 0.07897257468522012
 0.03841787869050861
 0.016483226760988345
 0.006741257427527181

or we can plot the timeseries of the resistor's voltage:

plot(sol, idxs = [rc_model.resistor.v])
Example block output

Although it may be more confusing than helpful here, we can of course also plot all unknown and observed variables together:

plot(sol, idxs = [unknowns(rc_model); observables(rc_model)])
Example block output