ModelingToolkitParameters - Introduction and Motivation
Currently the standard way to build parameters up in ModelingToolkit models is with the following pattern of keyword arguments with default values.
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using OrdinaryDiffEq
@component function Motor(; name, k = 0.1, r = 0.01, l = 1e-3)
pars = @parameters begin
k = k
r = r
l = l
end
vars = @variables begin
v(t)
dphi(t) = 0
i(t) = 0
di(t)
end
eqs = Equation[
D(i) ~ di
v ~ i * r + l * di + dphi * k
D(dphi) ~ k * i
v ~ sin(t)
]
return ODESystem(eqs, t, vars, pars; name)
endThis patern heavily relies on the defaults mechanism of the model to actually set the parameters of the model. Let say we want k, r, l to be 1, 2, 3. One way we can do this is
@mtkbuild sys = Motor(k=1, r=2, l=3)This is simple enough, but what if we want another instance with k, r, l to be 4, 5, 6? Should we fully rebuild the model? Instead it's better to set parameters at the ODEProblem level...
@mtkbuild sys = Motor()
prob = ODEProblem(sys, [], (0,1))
prob1 = remake(prob; p = [sys.k => 1, sys.r => 2, sys.l => 3])
prob2 = remake(prob; p = [sys.k => 4, sys.r => 5, sys.l => 6])Note, now we are not using the keyword arguments of the model constructor. Instead we are building the parameter map from scratch. Therefore, the cons of the keyword model construction pattern are:
- makes the model interface more complicated. As the list of parameters grows, the keyword list becomes un-manageable
- it's essentially useless if more than one model instance is needed
- building a parameter map for
remakestill needs to be done from scratch. (We could get thedefaultsdictionary of the model, but we still need to index into each parameter withsys.k,sys.r, etc.)
The additional problems with this pattern are:
- there are no ways to enforce parameter settings rules with useful error messages, for example ensuring postive numbers
- parameter maps are not easy to work with since they are flat
- parameter maps are not printed with heirarcy and are therefore not easily saved/retrieved to/from file using TOML or JSON
A Better Way
ModelingToolkitParameters.jl exports an abstract type Params that can be used to build parameter maps using Julia structs. The Motor component can now be defined as follows where a mutable Julia struct MotorParams is defined with the same names as the Motor component parameters.
using ModelingToolkitParameters
@kwdef mutable struct MotorParams <: Params
k::Float64 = 0.1
r::Float64 = 0.01
l::Float64 = 1e-3
end
@component function Motor(; name)
pars = @parameters begin
k
r
l
end
vars = @variables begin
v(t)
dphi(t) = 0
i(t) = 0
di(t)
end
eqs = Equation[
D(i) ~ di
v ~ i * r + l * di + dphi * k
D(dphi) ~ k * i
v ~ sin(t)
]
return ODESystem(eqs, t, vars, pars; name)
endNow we can build the parameter map by asking for the pair (=>): sys::ModelingToolkit.System => p::MotorParams, for example
@mtkbuild sys = Motor()
motor_pars = MotorParams(k=1,r=2,l=3)
pmap = sys => motor_parsGives...
3-element Vector{Pair}:
k => 1.0
r => 2.0
l => 3.0Now we can easily modify parameters using the Julia parameter struct motor_pars. Like
prob = ODEProblem(sys, pmap, (0.1))
motor_pars2 = copy(motor_pars)
motor_pars2.k = 4.0
prob2 = remake(prob; p = sys => motor_pars2)Now that our parameters are generated from a Julia struct we have many additional benefits, we can:
- use
getpropertyto set rules - define
printrules (and save/load from file easily) - define
copy - define constructors
Model Heirarchy and Catalogs
As we move into heirarcal models, we can continue the patern of defining a struct that maps to the component, however we now add the parameters and the child systems. We will build a MassSpringDamper component that has the child systems Mass, Spring, and Damper. The Active Suspension model seen here https://github.com/bradcarman/ActiveSuspensionModel/tree/main/ActiveSuspensionModel.jl defines the simple mass, spring, damper components like
Base.@kwdef mutable struct MassParams <: Params
m::Real
end
@component function Mass(; name)
pars = @parameters begin
m
end
vars = @variables begin
s(t)
v(t)
f(t)
a(t)
end
systems = @named begin
globals = Globals()
flange = MechanicalPort()
end
@unpack g = globals
eqs = [
s ~ flange.x
f ~ flange.f
D(s) ~ v
D(v) ~ a
m*a ~ f + m*g
]
return System(eqs, t, vars, pars; name, systems)
end
# ------------------------------------------------
Base.@kwdef mutable struct SpringParams <: Params
k::Real
end
@component function Spring(; name)
pars = @parameters begin
k
initial_stretch=missing, [guess=0]
end
vars = @variables begin
delta_s(t)
f(t)
end
systems = @named begin
flange_a = MechanicalPort()
flange_b = MechanicalPort()
end
eqs = [
delta_s ~ (flange_a.x - flange_b.x) + initial_stretch
f ~ k * delta_s
flange_a.f ~ +f
flange_b.f ~ -f
]
return System(eqs, t, vars, pars; name, systems)
end
# ------------------------------------------------
Base.@kwdef mutable struct DamperParams <: Params
d::Real
end
@component function Damper(; name)
pars = @parameters begin
d
end
vars = @variables begin
delta_s(t), [guess=0]
f(t), [guess=0]
end
systems = @named begin
flange_a = MechanicalPort()
flange_b = MechanicalPort()
end
eqs = [
delta_s ~ flange_a.x - flange_b.x
f ~ D(delta_s) * d
flange_a.f ~ +f
flange_b.f ~ -f
]
return System(eqs, t, vars, pars; name, systems)
endNow, we can build a composite component MassSpringDamper
@component function MassSpringDamper(;name)
systems = @named begin
damper = Damper()
body = Mass()
spring = Spring()
port_m = MechanicalPort()
port_sd = MechanicalPort()
end
eqs = [
connect(damper.flange_a, spring.flange_a, body.flange, port_m)
connect(port_sd, spring.flange_b, damper.flange_b)
]
return System(eqs, t, [], []; systems, name)
endThis component's parameter struct is then comprised of matching names of the child systems
Base.@kwdef mutable struct MassSpringDamperParams <: Params
# systems
damper::DamperParams = DamperParams()
body::MassParams = MassParams()
spring::SpringParams = SpringParams()
endThis model will require several MassSpringDampers representing the wheels, the car and suspension, and the seat and passanger. We can build a Catalog of these components easily like
const seat = MassSpringDamperParams(;body=MassParams(m=100), spring=SpringParams(k=1000), damper=DamperParams(d=1))
const car = MassSpringDamperParams(;body=MassParams(m=1000), spring=SpringParams(k=1e4), damper=DamperParams(d=10))
const wheel = MassSpringDamperParams(;body=MassParams(m=25), spring=SpringParams(k=1e2), damper=DamperParams(d=1e4))Now when we build the top level model
@component function Model(; name)
systems = @named begin
seat = MassSpringDamper()
car_and_suspension = MassSpringDamper()
wheel = MassSpringDamper()
road_data = Road()
road = Position()
force = Force()
pid = Controller()
err = Add()
set_point = Constant()
seat_pos = PositionSensor()
flip = Gain()
end
eqs = [
# mechanical model
connect(road.s, road_data.output)
connect(road.flange, wheel.port_sd)
connect(wheel.port_m, car_and_suspension.port_sd)
connect(car_and_suspension.port_m, seat.port_sd, force.flange_a)
connect(seat.port_m, force.flange_b, seat_pos.flange)
# controller
connect(err.input1, seat_pos.output)
connect(err.input2, set_point.output)
connect(pid.err_input, err.output)
connect(pid.ctr_output, flip.input)
connect(flip.output, force.f)
]
return System(eqs, t, [], []; systems, name)
endThe parameter struct can be easily created from this catalog
Base.@kwdef mutable struct ModelParams <: Params
# parameters
g::Real = g
# systems
seat::MassSpringDamperParams = seat
car_and_suspension::MassSpringDamperParams = car
wheel::MassSpringDamperParams = wheel
road_data::RoadParams = RoadParams()
pid::ControllerParams = ControllerParams()
err::AddParams = subtract
set_point::ConstantParams = ConstantParams()
flip::GainParams = GainParams(k=-1)
endSpeed
As mentioned previously, using the keyword default patern for model parameter setting is not a good way to build several model variations, as this requires fully compiling/simplifying the model from scratch each time. A better way was shown with ModelingToolkitParameters.jl using remake and proving an updated parameter map. However, this way is still not the fastest. The most efficient approach is to use SymbolicIndexingInterface.jl.
using ModelingToolkit
using ModelingToolkitParameters
using ActiveSuspensionModel
using SciMLBase
using BenchmarkTools
@mtkcompile model = ActiveSuspensionModel.Model()
model_pars = ActiveSuspensionModel.ModelParams()
prob = ODEProblem(model, model=>model_pars, (0, 10))
# Slow Option
model_pars.seat.body.m = 200 # change parameters
@btime prob2 = remake(prob; p = model => model_pars); # remake ODEProblem
# Fast Option
model_setters = cache(model, ActiveSuspensionModel.ModelParams);# build cache (one time only)
model_pars.seat.body.m = 300 # change parameters
@btime prob3 = remake(prob, model_setters, model => model_pars);# remake ODEProblemODEProblem with uType Vector{Float64} and tType Int64. In-place: true
Initialization status: FULLY_DETERMINED
Non-trivial mass matrix: false
timespan: (0, 10)
u0: 7-element Vector{Float64}:
0.5
1.0
0.0
1.5
0.0
0.0
-0.0