ModelingToolkit Integration
NetworkDynamics.jl is compatible with ModelingToolkit.jl (MTK). The general idea is to use MTK to define component models (i.e. edge and vertex dynamics) which are then connected on network scale using NetworkDynamics.
The main entry point for this interop are the constructors
VertexModel(::System, inputs, outputs)
EdgeModel(::System, srcin, dstin, [srscout], dstout)whose docstrings can be found in the Component Models with MTK section in the API.
These constructors will:
- transform the states marked as input to parameters and
mtkcompileing the system, - generate the
fandgfunctions, - generate code for observables,
- port all supported Metadata from MTK symbols to component symbols and
- output a
Vertex-/EdgeModelfunction compatible with NetworkDynamics.jl.
The main usecase for this feature is when you want to build relatively complex component models but interconnect them in a very homogeneous way (i.e. having the same output/input pairings in the whole system).
In theory, you can achieve everything you want to do with plain MTK. The idea of combining the two is, that NetworkDynamics offers far less flexibility when in comes to interconnection of subsystems on the network level. This might allow ND to exploit more knowledge of the structure without very expensive operations such as tearing of thousands of equations.
ModelingToolkit is a fast paced library with lots of functionality and ever growing complexity. As such the provided interface is kinda experimental. Some features of MTK are straight up unsupported, for example events within models or delay differential equations.
RC-Circuit Example
In good MTK tradition, this feature will be explained along a simple RC circuit example. The Gas Network Example or Initialization Tutorial also showcase the MTK constructors.
The system to model is 2 node, 1 edge network. The node output states are the voltage (to ground), the edge output sates are the currents at both ends.
ideal v source Resistor Capacitor
v1 o─←────MMM────→─o v2
│ ┴
(↗) ┬
│ │
⏚ ⏚Obviously there is no need in modeling such a small system using NetworkDynamics, however the method extends quite easily to construct large electrical networks reusing the same fundamental building blocks.
using NetworkDynamics
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using OrdinaryDiffEqTsit5
using CairoMakieAll our components have "terminals", which have a voltage and current. We don't use the @connector from MTK here because our pins mark the interface towards the network and do not follow the MTK connector semantics.
@mtkmodel NWTerminal begin
@variables begin
v(t), [description="Voltage at node"]
i(t), [description="Current flowing into node"]
end
endAn ideal voltage source is just a model which pins its output voltage to a fixed parameter. The source ejects whatever current is necessary. We introduce another variable i(t) to "capture" this current. This variable will be removed during structural simplify, but will be available for plotting through the Observables mechanism. The VertexModel can be generated from an System by providing names of the input and output states:
@mtkmodel VoltageSource begin
@components begin
p = NWTerminal()
end
@parameters begin
V = 1.0
end
@variables begin
i(t), [description="produced current by ideal voltage source (observable)"]
end
@equations begin
i ~ -p.i
p.v ~ V
end
end
@named vs = VoltageSource()
vs_vertex = VertexModel(vs, [:p₊i], [:p₊v]; vidx=1)VertexModel :vs NoFeedForward() @ Vertex 1
├─ 1 input: [p₊i]
├─ 0 states: []
├─ 1 output: [p₊v]
└─ 1 param: [V=1]A capacitor is a slightly more complicated model. Its voltage is defined as an differential equation based on the inflowing current.
@mtkmodel Capacitor begin
@components begin
p = NWTerminal(;v=0)
end
@parameters begin
C = 1.0
end
@equations begin
D(p.v) ~ p.i / C
end
end
@named cap = Capacitor()
cap_vertex = VertexModel(cap, [:p₊i], [:p₊v], vidx=2)VertexModel :cap PureStateMap() @ Vertex 2
├─ 1 input: [p₊i]
├─ 1 state: [p₊v=0]
├─ 1 output: [p₊v=0]
└─ 1 param: [C=1]For the resistor we need two pins, one for the src and one for the dst side. The equations are straight forward.
@mtkmodel Resistor begin
@components begin
src = NWTerminal()
dst = NWTerminal()
end
@parameters begin
R = 1.0
end
@equations begin
dst.i ~ (src.v - dst.v)/R
src.i ~ -dst.i
end
end
@named resistor = Resistor()
resistor_edge = EdgeModel(resistor, [:src₊v], [:dst₊v], [:src₊i], [:dst₊i]; src=:vs, dst=:cap)EdgeModel :resistor PureFeedForward() @ Edge vs=>cap
├─ 1/1 inputs: src=[src₊v] dst=[dst₊v]
├─ 0 states: []
├─ 1/1 outputs: src=[src₊i] dst=[dst₊i]
└─ 1 param: [R=1]Having all those components defined, we can build the network. We don't need to provide a graph object here because we specified the placement in the graph on a per component basis.
nw = Network([vs_vertex, cap_vertex], [resistor_edge])Network with 1 state and 3 parameters
├─ 2 vertices (2 unique types)
└─ 1 edges (1 unique type)
Edge-Aggregation using SequentialAggregator(+)We can see, that NetworkDynamics internally is able to reduce all of the "output" states. We end up with a plain ODE of a single state.
Now we can simulate the system. For that we generate the u0 object. Since the metadata (such as default values) was automatically transferred, we can straight away construct the ODEProblem and solve the system.
u0 = NWState(nw) # generate state based on default values
prob = ODEProblem(nw, uflat(u0), (0, 10.0), pflat(u0))
sol = solve(prob, Tsit5())
# plot the solution
fig, ax1, p = plot(sol; idxs=VIndex(:cap, :p₊v), label="Capacitor Voltage");
axislegend(ax1)
ax2 = Axis(fig[2,1])
plot!(ax2, sol; idxs=VIndex(:vs, :i), label="Current produced by ideal v source")
axislegend(ax2)
Fully Implicit Outputs
When working with MTK systems in NetworkDynamics, you may encounter situations where your desired output variables don't explicitly appear in the equations. This creates fully implicit outputs - variables that are determined by the system's constraints but aren't directly computed.
Introduce "fake" dependencies to your input-forcing equations 0 ~ in + implicit_output(y). Which is mathematically equivalent to 0 ~ in but helps MTK to reason about dependencies.
Consider a system with a fully implicit output:
u┌───────┐y
─→┤ 0 ~ u ├→─
└───────┘Here, $y$ does not appear in the equations at all. In general, that doesn't make too much sense. During simplification, MTK will potentially get rid of the equation as it does not contribute to the system's state.
However, in NetworkDynamics, we're always dealing with open loop models on the equation level, which is not exactly what MTK was made for. If you build a closed loop between a subsystem A which has input forcing and a subsystem B which has input feed forward, the resulting system can be solved:
(system with input forcing)
ua┌─────────┐ya
┌──→┤ 0 ~ ua ├→──┐
│ └─────────┘ │
│ yb┌─────────┐ub │
└──←┤ yb ~ ub ├←──┘
└─────────┘
(system with input feed forward)Since MTK does not know about the closed loop (which is only introduced on the NetworkDynamics level once we leave the equation based domain) we need to help MTK to figure out those dependencies. We can do so by introducing "fake" dependencies using implicit_output. This function is defined as
implicit_output(x) = 0
ModelingToolkit.@register_symbolic implicit_output(x)which makes it numerically equivalent to zero (no effect on the simulation) but is opaque to the Symbolic Simplification.
Example
Consider a "Kirchhoff Node" between multiple resistors:
- the currents through the resistors directly depend on the voltage output of the node (input feed forward) and
- the Kirchhoff node requires the sum of all inflowing currents to be zero (input forcing).
We can model this type of node like this:
@mtkmodel KirchhoffNode begin
@variables begin
v(t), [description="Node voltage", output=true]
i_sum(t), [description="Sum of incoming currents", input=true]
end
@equations begin
0 ~ i_sum + implicit_output(v) # Kirchhoff's current law
end
end
@named kirchhoff = KirchhoffNode()
VertexModel(kirchhoff, [:i_sum], [:v])VertexModel :kirchhoff PureStateMap()
├─ 1 input: [i_sum]
├─ 1 state: [v]
| with diagonal mass matrix [0]
└─ 1 output: [v]where we "trick" MTK into believing that the input forcing equation depends on the output too.
Register Postprocessing Functions
MTK models are "composite" by design, i.e. your toplevel model might consist of multiple internal components. Sometimes, you want to specify behavior of the encapsulating model on a subcomponent level. For that, NetworkDynamics provides the ComponentPostprocessing metadata mechanism.
Let's say you want to implement an integrator with anti windup
__ outMax
/
╭─────╮
in │ K │ out
╶───┤╶───╴├────╴
│ s T │
╰─────╯
outMin __/We could try to implement this using ifelse functions, but the proper way is to do it with callbacks. Since NetworkDynamics cannot understand events defined within MTK models, we need to attach the callbacks on the component level. That's annoying, because the building block might appear in lots of different models. To work around this shortcoming, ND.jl allows you to define postprocessing functions, which will be called on every model which makes use of the subsystem.
In our model, we add two "internal" parameters, to indicate if the system is in lower or upper saturation. Depending on the saturation state, the integrator is "disabled". The model definition looks like this. The important part is the @metadata block at the end.
function attach_limint_callback! end # function needs to exist before model
@mtkmodel LimitedIntegrator begin
@structural_parameters begin
K # Gain
T # Time constant
outMin # Lower limit
outMax # Upper limit
guess=0
end
@parameters begin
_callback_sat_max = 0
_callback_sat_min = 0
end
@variables begin
in(t), [description="Input signal", input=true]
out(t), [guess=guess, description="limited integrator output state", output=true]
min(t), [description="Lower limit"]
max(t), [description="Upper limit"]
forcing(t)
end
@equations begin
min ~ outMin
max ~ outMax
forcing ~ K*in
T*D(out) ~ (1 - _callback_sat_max - _callback_sat_min) * forcing
end
@metadata begin
ComponentPostprocessing = attach_limint_callback!
end
endThe callback to generate is split in three separate conditions:
- if
out - maxregisters an upcrossing, enter upper saturation - if
-out + minregisters an upcrossing, enter lower saturation - if currently in saturation, check for zero crossings in
forcing, if in upper saturation and
forcing turns negative, unsaturate. If in lower saturation and forcing turns positive unsaturate.
The function below generates such a callback for the component at a given namespace:
function attach_limint_callback!(cf, namespace)
# generate the required symbols based on the namespace
min = Symbol(namespace, "₊min")
max = Symbol(namespace, "₊max")
out = Symbol(namespace, "₊out")
forcing = Symbol(namespace, "₊forcing")
satmax = Symbol(namespace, "₊_callback_sat_max")
satmin = Symbol(namespace, "₊_callback_sat_min")
condition = ComponentCondition([min, max, out, forcing], [satmax, satmin]) do _out, u, p, _
insatmax = !iszero(p[satmax])
insatmin = !iszero(p[satmin])
upcrossing_max = u[out] - u[max]
upcrossing_min = -u[out] + u[min]
# enable upper saturation
_out[1] = insatmax ? Inf : upcrossing_max
# enable lower saturation
_out[2] = insatmin ? Inf : upcrossing_min
if insatmax || insatmin
# when in saturation, check for zero crossing of forcing
_out[3] = u[forcing]
else
_out[3] = Inf
end
end
upcrossing_affect = ComponentAffect([], [satmax, satmin]) do u, p, eventidx, ctx
if eventidx == 1
println("$namespace: /⎺ reached upper saturation at $(round(ctx.t, digits=4))s")
p[satmax] = 1.0
p[satmin] = 0.0
elseif eventidx == 2
println("$namespace: \\_ reached lower saturation at $(round(ctx.t, digits=4))s")
p[satmax] = 0.0
p[satmin] = 1.0
elseif eventidx == 3
# upcrossing means, forcing went from negative to positive, i.e. we leave lower saturation
insatmin = !iszero(p[satmin])
if insatmin
println("$namespace: _/ left lower saturation at $(round(ctx.t, digits=4))s")
p[satmin] = 0.0
end
else
error("Unknown event index $eventidx")
end
end
downcrossing_affect = ComponentAffect([],[satmax]) do u, p, eventidx, ctx
if eventidx == 3 # downcrossing means nothing for saturation affects
# downcrossing means, forcing went from positive to negative, i.e. we leave upper saturation
insatmax = !iszero(p[satmax])
if insatmax
println("$namespace: ⎺\\ left upper saturation at $(round(ctx.t, digits=4))s")
p[satmax] = 0.0
end
else
error("Unknown event index $eventidx")
end
end
cb = VectorContinuousComponentCallback(condition, upcrossing_affect, 3; affect_neg! = downcrossing_affect)
# finally add callback to component
NetworkDynamics.add_callback!(cf, cb)
endEven though fairly complex, the above function is a simplified example which may have performance problems and does not add "safety" discrete callbacks. Under discrete jumps, the zero crossing might be skipped so it is good practice to add additional discrete callbacks to detect those cases (i.e. check if out >= max + 1e-10). You'll find a "proper" version of the saturated integrator in the PowerDynamics.jl Library, feel free to copy the code from there.
With the definition above, we can create a model which uses the limited integrator:
@mtkmodel ComponentWithLimInt begin
@components begin
int = LimitedIntegrator(K=1, T=1, outMin=-1, outMax=1)
end
@variables begin
out(t)
in(t)
end
@equations begin
int.in ~ in
out ~ int.out
end
end
@named testcomp = ComponentWithLimInt()When we build this (useless) vertex model
vm = VertexModel(testcomp, [:in], [:out])VertexModel :testcomp PureStateMap()
├─ 1 input: [in]
├─ 1 state: [out]
├─ 1 output: [out]
├─ 2 params: [int₊_callback_sat_max=0, int₊_callback_sat_min=0]
└─ 1 callback: (:int₊min, :int₊max, :int₊out, :int₊forcing, :int₊_callback_sat_max, :int₊_callback_sat_min) affecting (:int₊_callback_sat_max, :int₊_callback_sat_min)we see the callback was generated and attached automatically.