Best practices for large model composition in ModelingToolkit.jl

I’m building fairly large models with ModelingToolkit.jl. While I think the library is very cool, I’m stumbling through how to set up large models effectively, both from a numerical perspective and from a developer perspective (making it easy to use the models I build). Looking for some guidance here. I keep reading articles about MTK being used for 10,000 variables and equations and I’m wondering “How?”–so clearly I’m missing something.

Requirements for my models:

  • @extend only works for one layer, so I resorted to model composition to build nested components, i.e., define the physics in one model and build specializations on top of that model;
  • I only want to define core physics in one place, same goes for geometry definitions. I want to be able to take those core definitions and then use them in more specialized places.
  • I need adjoints with respect to the parameters in the geometry (A in this case).

I have tried two approaches.

Option 1: Define geometry as a model
A MWE with two nested models:

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

@component function ConduitGeometry(;
        s_nodes = [0.0, 1.0], A_nodes = [1.0, 1.0], name = :ConduitGeometry)
    N = length(s_nodes)

    function trapz(sv, Av)
        sum(0.5 * (Av[i] + Av[i + 1]) * (sv[i + 1] - sv[i]) for i in 1:(length(sv) - 1))
    end

    params = @parameters begin
        s[1:N] = s_nodes
        A[1:N] = A_nodes
        A_start = A[1]
        A_end = A[end]
        H = s[end] - s[1]
        V_total = trapz(s_nodes, A_nodes)
    end

    System(Equation[], t, Num[], params; name)
end

@mtkmodel HydroCore begin
    @components begin
        geometry = ConduitGeometry(; s_nodes, A_nodes)  # base owns the one geometry
    end
    @parameters begin
        k_out = 0.2
    end
    @variables begin
        h(t)
        q_in(t)
        q_out(t)
    end
    @equations begin
        q_out ~ k_out * h
        D(h) ~ (q_in - q_out) / geometry.A_start
    end
end

@mtkmodel TankUnit begin
    @components begin
        core = HydroCore(; geometry__s_nodes, geometry__A_nodes)
    end
    @parameters begin
        Qin = 0.01
    end
    @equations begin
        core.q_in ~ Qin
    end
end

@mtkmodel PlantSystem begin
    @components begin
        unit = TankUnit(; Qin, core__geometry__s_nodes, core__geometry__A_nodes)
    end
end

@mtkcompile sys = PlantSystem(;
    unit.core.geometry.s_nodes = [0.0, 7.0],
    unit.core.geometry.A_nodes = [0.1, 0.1],
    unit.Qin = 8e-3
)

u0 = [sys.unit.core.h => 0.0]
tspan = (0.0, 50.0)
sol = solve(ODEProblem(sys, u0, tspan), Rodas4(); reltol = 1e-6, abstol = 1e-9)

Option 2: Represent geometry as a non-numeric parameter and pass through
Another alternative I thought about was making the geometry a non-numeric parameter in each model and pass them through (how you would do with a regular scalar), but I can’t get that to work at all. Note that I am using @parameters here because I want adjoints.

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

function trapz(sv, Av)
    sum(0.5 * (Av[i] + Av[i + 1]) * (sv[i + 1] - sv[i]) for i in 1:(length(sv) - 1))
end

struct Geometry
    s_m::Vector{Float64}
    A_m2::Vector{Float64}
    height_m::Float64
    volume_m3::Float64
    function Geometry(s_m, A_m2)
        height_m = s_m[end] - s_m[1]
        volume_m3 = trapz(s_m, A_m2)
        new(s_m, A_m2, height_m, volume_m3)
    end
end

@mtkmodel BaseStage begin
    @parameters begin
        geometry::Geometry = Geometry([0.0, 7.0], [0.1, 0.1])
        A0_m2 = 0.1
        H_m = 7.0
        kout = 0.2
    end
    @variables begin
        h(t)
        qin(t)
        qout(t)
    end
    @equations begin
        qout ~ kout * h
        D(h) ~ (qin - qout) / A0_m2
    end
end

@mtkmodel MidStage begin
    @parameters begin
        geometry::Geometry = Geometry([0.0, 7.0], [0.1, 0.1])
        Qin = 0.01
    end
    @components begin
        base = BaseStage(;
            geometry = geometry,
        )
    end
    @equations base.qin ~ Qin
end

@mtkmodel TopSystem begin
    @parameters begin
        geometry::Geometry = Geometry([0.0, 7.0], [0.1, 0.1])
    end
    @components begin
        mid = MidStage(;
            geometry,
        )
    end
end

@mtkcompile sys = TopSystem(
    geometry = Geometry([0.0, 9.0], [0.1, 0.1]),
    mid.Qin = 8e-3
)
u0 = [sys.mid.base.h => 0.0]
sol = solve(ODEProblem(sys, u0, (0.0, 50.0)), Rodas4(); reltol = 1e-6, abstol = 1e-9)

The error I get is

ERROR: LoadError: MethodError: Cannot `convert` an object of type Symbol to an object of type Expr
The function `convert` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  Expr(::Any...)
   @ Core boot.jl:271
  convert(::Type{T}, ::T) where T
   @ Base Base.jl:126
  convert(::Type{Expr}, ::Revise.RelocatableExpr)
   @ Revise ~/.julia/packages/Revise/7QZSP/src/relocatable_exprs.jl:22

Stacktrace:
 [1] push!(a::Vector{Expr}, item::Symbol)
   @ Base ./array.jl:1260
 [2] parse_equations!(exprs::Vector{Any}, eqs::Vector{Expr}, dict::Dict{Symbol, Any}, body::Expr)
   @ ModelingToolkit ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:1107
 [3] parse_model!(exprs::Vector{…}, comps::Vector{…}, ext::Vector{…}, eqs::Vector{…}, icon::Base.RefValue{…}, vs::Vector{…}, ps::Vector{…}, sps::Vector{…}, c_evts::Vector{…}, d_evts::Vector{…}, cons::Vector{…}, costs::Vector{…}, dict::Dict{…}, mod::Module, arg::Expr, kwargs::OrderedCollections.OrderedSet{…}, where_types::Vector{…})
   @ ModelingToolkit ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:644
 [4] _model_macro(mod::Module, fullname::Symbol, expr::Expr, isconnector::Bool)
   @ ModelingToolkit ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:82
 [5] var"@mtkmodel"(__source__::LineNumberNode, __module__::Module, fullname::Union{Expr, Symbol}, body::Any)
   @ ModelingToolkit ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:33
 [6] include(fname::String)
   @ Main ./sysimg.jl:38
 [7] top-level scope
   @ REPL[1]:1

My questions specifically are:

  1. Option 1 “works” (compiles and runs), but having to pass those keywords up through each subsequent model feels hacky. Is that really the best way of doing this (I’m just imaging this with a 10-level nested model for example)?
  2. Option 2 does not work. Is this a better option if we could get it to work? I like the syntax and set up of it more. Also not sure how that would work with adjoints.
1 Like

I am also interested in learning how to do this in the best possible way. What do you think about the model with standard Julia functions and passing geometry around like this?

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

function ConduitGeometry(; s_nodes = [0.0, 1.0], A_nodes = [1.0, 1.0], name = :geometry)
    N = length(s_nodes)
    trapz(sv, Av) = sum(0.5 * (Av[i] + Av[i + 1]) * (sv[i + 1] - sv[i]) for i in 1:(length(sv) - 1))
    pars = @parameters begin
        s[1:N] = s_nodes
        A[1:N] = A_nodes
        A_start = A[1]
        A_end = A[end]
        H = s[end] - s[1]
        V_total = trapz(s_nodes, A_nodes)
    end
    geometry = System(Equation[], t, Num[], pars; name)
    return geometry
end

function HydroCore(geometry; name = :core)
    pars = @parameters k_out = 0.2
    vars = @variables h(t) q_in(t) q_out(t)
    eqs = [
        q_out ~ k_out * h
        D(h) ~ (q_in - q_out) / geometry.A_start
    ]
    core = System(eqs, t; name)
    return compose(core, geometry)
end

function TankUnit(geometry; name = :unit)
    core = HydroCore(geometry)
    pars = @parameters Qin = 0.01
    eqs = [
        core.q_in ~ Qin
    ]
    unit = System(eqs, t, Num[], pars; name)
    return compose(unit, core)
end

function PlantSystem(geometry; name = :sys)
    unit = TankUnit(geometry)
    sys = System(Equation[], t, Num[], Num[]; name)
    return compose(sys, unit)
end

@named geometry = ConduitGeometry(s_nodes = [0.0, 7.0], A_nodes = [0.1, 0.1])
@named sys = PlantSystem(geometry)
sys = mtkcompile(sys)

u0 = [sys.unit.core.h => 0.0]
tspan = (0.0, 50.0)
prob = ODEProblem(sys, u0, tspan)
sol = solve(prob, Rodas4(); reltol = 1e-6, abstol = 1e-9)

One potential problem I can think of is that if, say, TankUnit also refers to a geometry parameter, I think it would get re-scoped inside TankUnit. You would maybe have to look into scoping to resolve that, for example by GlobalScope()-ing all parameters in ConduitGeometry (if acceptable).

I guess the idea is similar to your alternative 2. At least it seems to run.

I’ve been using the following design pattern to solve this problem..

using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D

# -------------------------------------------------------------
# Setup Params Type
# -------------------------------------------------------------
abstract type Params end

function Base.Pair(model::ODESystem, pars::T) where T <: Params 

  prs = Pair[]
  for nm in fieldnames(T)
    if hasproperty(model, nm)
      x = getproperty(model,nm) => getproperty(pars,nm)
      if x isa Vector
        append!(prs, x)
      else
        push!(prs, x)
      end
    end
  end

  return prs
end
# ---------------------------------------------------------------




function trapz(sv, Av)
    sum(0.5 * (Av[i] + Av[i + 1]) * (sv[i + 1] - sv[i]) for i in 1:(length(sv) - 1))
end

struct Geometry
    s_m::Vector{Float64}
    A_m2::Vector{Float64}
    height_m::Float64
    volume_m3::Float64
    function Geometry(s_m, A_m2)
        height_m = s_m[end] - s_m[1]
        volume_m3 = trapz(s_m, A_m2)
        new(s_m, A_m2, height_m, volume_m3)
    end
end



@kwdef mutable struct BaseStageParams <: Params
    geometry::Geometry = Geometry([0.0, 7.0], [0.1, 0.1])
    A0_m2 = 0.1
    H_m = 7.0
    kout = 0.2
end

@mtkmodel BaseStage begin
    @parameters begin
        geometry::Geometry
        A0_m2
        H_m
        kout
    end
    @variables begin
        h(t)
        qin(t)
        qout(t)
    end
    @equations begin
        qout ~ kout * h
        D(h) ~ (qin - qout) / A0_m2
    end
end


@kwdef mutable struct MidStageParams <: Params
    Qin = 0.01
    base::BaseStageParams = BaseStageParams()
end

@mtkmodel MidStage begin
    @parameters begin
        Qin
    end
    @components begin
        base = BaseStage()
    end
    @equations begin
        base.qin ~ Qin
    end 
end


@kwdef mutable struct TopSystemParams <: Params
    mid::MidStageParams = MidStageParams()
end

@mtkmodel TopSystem begin
    @components begin
        mid = MidStage()
    end
end

@mtkcompile sys = TopSystem()
u0 = [sys.mid.base.h => 0.0]
p = TopSystemParams()
prob = ODEProblem(sys, u0, (0.0, 50.0), sys => p)

Essentially what this does is remove the use of keywords all together for building up models and instead moves all that handling to Julia structs, where we store the actual values consumed by the model. You can see at the very end, the argument sys => p is supplied to the ODEProblem which builds the parameter map for the model, which looks like this…

julia> sys => p
5-element Vector{Pair}:
           mid₊Qin => 0.01
 mid₊base₊geometry => Geometry([0.0, 7.0], [0.1, 0.1], 7.0, 0.7)
    mid₊base₊A0_m2 => 0.1
      mid₊base₊H_m => 7.0
     mid₊base₊kout => 0.2

My understanding from your requirement is you would like to specify geometry at the top level and this should be passed down to a component several layers deep. The beauty of working with Julia structs is you can now implement all the power of Julia to implement that requirement. First off, you can easily update geometry as so…

julia> p.mid.base.geometry = Geometry([0.0, 10.0], [0.5, 0.5])
Geometry([0.0, 10.0], [0.5, 0.5], 10.0, 5.0)

julia> sys => p
5-element Vector{Pair}:
           mid₊Qin => 0.01
 mid₊base₊geometry => Geometry([0.0, 10.0], [0.5, 0.5], 10.0, 5.0)
    mid₊base₊A0_m2 => 0.1
      mid₊base₊H_m => 7.0
     mid₊base₊kout => 0.2

Or additionally, you can generate a constructor for TopSystemParams that specifies geometry at the top level like:

function TopSystemParams(geometry::Geometry; Qin = 0.01, A0_m2 = 0.1, H_m = 7.0, kout = 0.2)
 p = TopSystemParams(;
        mid = MidStageParams(;
            Qin,
            base = BaseStageParams(; 
                geometry,
                A0_m2,
                H_m,
                kout
            )
        )
    )
 
 return p
end

julia> sys => TopSystemParams(Geometry([0.0, 10.0], [0.5, 0.5]))
5-element Vector{Pair}:
           mid₊Qin => 0.01
 mid₊base₊geometry => Geometry([0.0, 10.0], [0.5, 0.5], 10.0, 5.0)
    mid₊base₊A0_m2 => 0.1
      mid₊base₊H_m => 7.0
     mid₊base₊kout => 0.2

There are several drawbacks to the keyword style model generation that this pattern solves:

  1. Model keywords are really only useful for the generation of the 1st model. But if you want to preserve the structural simplification pass, which is important for big models, then updating parameters needs to happen at the ODEProblem level or by using remake. In this case, keyword arguments for model construction are useless.
  2. Using Julia structs to hold model parameters means you now can enforce rules, enable easy copy operation, write parameters to file, etc. This also enables the ability to generate a library, say you have several geometries, you can now build that up.

Hope this helps!

3 Likes

Oh I like this pattern. A few follow-ups on this pattern:

  1. I imagine you could then use callable parameters on the geometry object?
  2. I was unable to use any of the parameters in the Geometry object within the equations. I’m probably doing something incorrectly, example shown below. (And I apologize for a typo in the MWE which meant that this error didn’t get triggered).
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using OrdinaryDiffEq

# -------------------------------------------------------------
# Setup Params Type
# -------------------------------------------------------------
abstract type Params end

function Base.Pair(model::ODESystem, pars::T) where {T <: Params}
    prs = Pair[]
    for nm in fieldnames(T)
        if hasproperty(model, nm)
            x = getproperty(model, nm) => getproperty(pars, nm)
            if x isa Vector
                append!(prs, x)
            else
                push!(prs, x)
            end
        end
    end

    return prs
end
# ---------------------------------------------------------------

function trapz(sv, Av)
    sum(0.5 * (Av[i] + Av[i + 1]) * (sv[i + 1] - sv[i]) for i in 1:(length(sv) - 1))
end

struct Geometry
    s_m::Vector{Float64}
    A_m2::Vector{Float64}
    height_m::Float64
    volume_m3::Float64
    function Geometry(s_m, A_m2)
        height_m = s_m[end] - s_m[1]
        volume_m3 = trapz(s_m, A_m2)
        new(s_m, A_m2, height_m, volume_m3)
    end
end

@kwdef mutable struct BaseStageParams <: Params
    geometry::Geometry = Geometry([0.0, 7.0], [0.1, 0.1])
    kout = 0.2
end

@mtkmodel BaseStage begin
    @parameters begin
        geometry::Geometry
        kout
    end
    @variables begin
        h(t)
        qin(t)
        qout(t)
    end
    @equations begin
        qout ~ kout * h
        D(h) ~ (qin - qout) / geometry.A_m2[1]
    end
end

@kwdef mutable struct MidStageParams <: Params
    Qin = 0.01
    base::BaseStageParams = BaseStageParams()
end

@mtkmodel MidStage begin
    @parameters begin
        Qin
    end
    @components begin
        base = BaseStage()
    end
    @equations begin
        base.qin ~ Qin
    end
end

@kwdef mutable struct TopSystemParams <: Params
    mid::MidStageParams = MidStageParams()
end

@mtkmodel TopSystem begin
    @components begin
        mid = MidStage()
    end
end

@mtkcompile sys = TopSystem()
u0 = [sys.mid.base.h => 0.0]
p = TopSystemParams(
    mid = MidStageParams(
    Qin = 0.05,
    base = BaseStageParams(
        geometry = Geometry([0.0, 11.0], [0.25, 0.25]),
        kout = 0.2
    )
)
)
prob = ODEProblem(sys, u0, (0.0, 50.0), sys => p)
@time sol = solve(prob, Rodas4())
@info "solution" sol[sys.mid.base.h]

Which causes the error:

ERROR: LoadError: type Sym has no field A_m2.
Stacktrace:
  [1] error(s::String)
    @ Base ./error.jl:35
  [2] throw_no_field(::Val{:Sym}, s::Symbol)
    @ Unityper ~/.julia/packages/Unityper/BiodR/src/compactify.jl:505
  [3] getproperty
    @ ~/.julia/packages/Unityper/BiodR/src/compactify.jl:310 [inlined]
  [4] 
    @ Main ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:909
  [5] (::ModelingToolkit.Model{typeof(__BaseStage__), Dict{Symbol, Any}})(; kw::@Kwargs{name::Symbol})
    @ ModelingToolkit ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:25
  [6] macro expansion
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/abstractsystem.jl:2105 [inlined]
  [7] __MidStage__(; name::Symbol, Qin::ModelingToolkit.NoValue)
    @ Main ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:909
  [8] __MidStage__
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:144 [inlined]
  [9] #_#423
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:25 [inlined]
 [10] macro expansion
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/abstractsystem.jl:2105 [inlined]
 [11] __TopSystem__(; name::Symbol)
    @ Main ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:144
 [12] __TopSystem__
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:144 [inlined]
 [13] #_#423
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/model_parsing.jl:25 [inlined]
 [14] top-level scope
    @ ~/.julia/packages/ModelingToolkit/vyvd9/src/systems/abstractsystem.jl:2105
 [15] include(fname::String)
    @ Main ./sysimg.jl:38
 [16] top-level scope
    @ REPL[1]:1

Unfortunately using fields from a struct in a symbolic equation will give this error. Instead you have 2 options:

  1. you can define functions to retrieve these values and register the functions as symbolic
  2. you can setup a ModelingToolkit System with these parameters, and then you can access them symbolicly

Probably for this case option 1 is best. Here is what that looks like…

get_area(geometry::Geometry) = geometry.area_m3
@register_symbolic get_area(geometry::Geometry)

@mtkmodel BaseStage begin
    @parameters begin
        geometry::Geometry
        H_m
        kout
    end
    @variables begin
        h(t)
        qin(t)
        qout(t)
    end
    @equations begin
        qout ~ kout * h 
        D(h) ~ (qin - qout) / get_area(geometry)
    end
end

.
.
.
@mtkcompile sys = TopSystem()
julia> equations(sys)
1-element Vector{Equation}:
 Differential(t)(mid₊base₊h(t)) ~ (-mid₊base₊qout(t) + mid₊base₊qin(t)) / get_area(mid₊base₊geometry)
1 Like

Here’s how you could access the fields of a struct:

Define these

Symbolics.getelements(s::Type{Geometry}) = fieldnames(s)
Symbolics.getelementtypes(s::Type{Geometry}) = fieldtypes(s)
myfirst(s) = first(s)
@register_symbolic myfirst(s)
...

Then change the equation to

D(h) ~ (qin - qout) / myfirst(Symbolics.symbolic_getproperty(geometry, :A_m2))

and then it works

julia> @info "solution" sol[sys.mid.base.h]
┌ Info: solution
│   sol[sys.mid.base.h] =
│    26-element Vector{Float64}:
│     0.0
│     1.9999200021332894e-5
│     0.00021990322838842067
...

This is all very clunky though, so I suggest sticking with Brad’s suggestion of using only the registered function

2 Likes

@Brad_Carman Thank you for explaining that, that makes sense and is quite elegant. All running great now. @baggepinnen clever trick for sure, but agree that Brad’s solution is the way to go.

A follow-up with the non-callable parameters. I had mentioned I was interested in adjoints and getting gradients from the solution (say sys.mid.base.h[end] with respect to sys.mid.base.geometry.A_m2). Is that possible with this approach?

Thanks all for the suggestions, these were super helpful. I’ve created a new topic to specifically ask about adjoints for non-numeric types: Adjoint sensitivities for non-numeric types in ModelingToolkit.jl.