ModelingToolkit + matrix variables: defaults & 1×1 scalars

Hi! I’m trying to use ModelingToolkit with matrix variables/parameters. I’ve got it working, but I need to be very careful about how I define things, and a couple behaviors feel non-intuitive. I’m wondering if I’m missing an idiomatic way to do this, or if what I’m seeing is expected.

Minimal example 1 (works, but with quirks)

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

@mtkmodel FOL begin
    @parameters begin
        A1[1:2,1:2] = [-1 0;
                        0 -2]
        v[1:1,1:2]  = [1 2]
        k[1:2,1:1]  = [1; 1]
    end
    @variables begin
        x(t)[1:2,1:1]
        x1(t) = 1
        x2(t) = 1
        u(t)
    end
    @equations begin
        x ~ [x1 x2]'
        D(x) ~ A1 * x * u
        u ~ (v * k)[1,1]   # (v*k) is 1×1, so I index [1,1] to get a scalar for u
    end
end

@mtkcompile fol = FOL()
t2 = 3.0
prob = ODEProblem(fol, Dict(fol.x => [defaults(fol)[fol.x1], defaults(fol)[fol.x2]]), (0.0, t2))
sol = solve(prob, saveat=0.01)

Observations:

  1. Defaults don’t seem to propagate from x1(t),x2(t) to x(t) even though x ~ [x1 x2]'. With scalar variables I’m used to defaults flowing, but with matrix variables I have to manually supply fol.x in the ODEProblem init.
  2. Because v is 1×2 and k is 2×1, v*k is 1×1 (a matrix), not a scalar. I have to do (v*k)[1,1] to feed u(t).

Minimal example 2 (also works, but more plumbing)

If I instead define u(t) as a 1×1 matrix, then I don’t need [1,1] on the RHS—but I have to provide u’s initial condition explicitly and in matrix shape:

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

@mtkmodel FOL begin
    @parameters begin
        A1[1:2,1:2] = [-1 0;
                        0 -2]
        v[1:1,1:2]  = [1 2]
        k[1:2,1:1]  = [1; 1]
    end
    @variables begin
        x(t)[1:2,1:1]
        x1(t) = 1
        x2(t) = 1
        u(t)[1:1,1:1]
    end
    @equations begin
        x ~ [x1 x2]'
        D(x) ~ A1 * x * u
        u ~ (v * k)
    end
end

@mtkcompile fol = FOL()
t2 = 3.0
prob = ODEProblem(
    fol,
    Dict(
        fol.x => [defaults(fol)[fol.x1], defaults(fol)[fol.x2]],
        fol.u => 3 * ones(1,1)  # must supply as 1×1 matrix
    ),
    (0.0, t2)
)
sol = solve(prob, saveat=0.01)

This works, but feels a bit clunky compared to the scalar case. Also now it would fail if I had

     D(x) ~ u * A1 * x

since the matrix dimension would be wrong (even though it works fine math wise since u is a scalar)

Questions

  1. Default propagation for array variables:
    Is it expected that defaults on the scalar components (x1, x2) do not propagate to the array variable (x) when using an equation like x ~ [x1 x2]'? Is there a recommended way to have defaults “flow” into x(t)[…] without manually building the vector for ODEProblem?

  2. Scalar vs 1×1 matrix ergonomics:
    When expressions like v*k naturally produce a 1×1 matrix, what’s the idiomatic way to feed a scalar variable on the LHS? Is (v*k)[1,1] the intended approach?

  3. Defining array variables/parameters idiomatically:
    Is there a best practice for declaring vectors vs 2D “column vectors” in @variables/@parameters? For example, should “state vectors” generally be x(t)[1:2] (1D) rather than x(t)[1:2,1:1] (2D)? Any pitfalls to be aware of when mixing 1D and 2D shapes in equations?

Side note on Julia array syntax (source of confusion)

Just to show where part of my confusion came from:

[1 1]     # 1×2 Matrix
[1,1]     # 2-element Vector
[1;1]     # 2-element Vector
ones(2,1) # 2×1 Matrix

I get why [1 1] vs [1,1] differ, but I still find myself tripping over it when switching between vector and matrix forms in symbolic equations.

Thanks a lot! Any guidance, docs pointers, or examples would be super appreciated.

Yes, though we should probably throw a better error here @cryptic.ax

Yes it’s intended. See “Taking Vector Transposes Seriously” for details about this whole topic.

It’s the same as in Julia. If you want a 1D object you should prefer using the 1D object. From my understanding our symbolic rules match Julia here, and Jiahao’s talk describes a lot of the reasoning behind it.

I think we have a case to flow defaults from [x1, x2] to x, but it’s a heuristic and may not always work. It depends a lot on how the system simplifies.

Yes, though we should probably throw a better error here

What exactly is the error condition? This should already throw an error saying “no initial conditions for x

I get the following if I don’t manually supply the initial values for x(t)[1:2,1:1]:

ERROR: MethodError: Cannot convert an object of type ModelingToolkit.NoValue to an object of type Real
The function convert exists, but no method is defined for this combination of argument types.

Closest candidates are:
convert(::Type{T}, ::Unitful.Quantity) where T<:Real
@ Unitful ~/.julia/packages/Unitful/sxiHA/src/conversion.jl:167
convert(::Type{T}, ::Number) where T<:Number
@ Base number.jl:7
convert(::Type{T}, ::Unitful.Level) where T<:Real
@ Unitful ~/.julia/packages/Unitful/sxiHA/src/logarithm.jl:46

Stacktrace:
[1] setindex!(A::Memory{Real}, x::ModelingToolkit.NoValue, i1::Int64)
@ Base ./genericmemory.jl:243
[2] unsafe_copyto!(dest::Memory{Real}, doffs::Int64, src::Memory{ModelingToolkit.NoValue}, soffs::Int64, n::Int64)
@ Base ./genericmemory.jl:153
[3] unsafe_copyto!
@ ./genericmemory.jl:133 [inlined]
[4] copyto_impl!
@ ./array.jl:308 [inlined]
[5] copyto!
@ ./array.jl:294 [inlined]
[6] copyto!
@ ./array.jl:319 [inlined]
[7] copyto_axcheck!
@ ./abstractarray.jl:1167 [inlined]
[8] Matrix{Real}(x::Matrix{ModelingToolkit.NoValue})
@ Base ./array.jl:626
[9] convert
@ ./array.jl:618 [inlined]
[10] symconvert(::Type{Matrix{Real}}, x::Matrix{ModelingToolkit.NoValue})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/systems/parameter_buffer.jl:4
[11] MTKParameters(sys::System, op::Dict{…}; tofloat::Bool, t0::Nothing, substitution_limit::Int64, floatT::Type, p_constructor::Function, fast_path::Bool)
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/systems/parameter_buffer.jl:124
[12] process_SciMLProblem(constructor::Type, sys::System, op::Dict{…}; build_initializeprob::Bool, implicit_dae::Bool, t::Nothing, guesses::Dict{…}, warn_initialize_determined::Bool, initialization_eqs::Vector{…}, eval_expression::Bool, eval_module::Module, fully_determined::Nothing, check_initialization_units::Bool, u0_eltype::Nothing, tofloat::Bool, u0_constructor::typeof(identity), p_constructor::typeof(identity), check_length::Bool, symbolic_u0::Bool, warn_cyclic_dependency::Bool, circular_dependency_max_cycle_length::Int64, circular_dependency_max_cycles::Int64, substitution_limit::Int64, use_scc::Bool, time_dependent_init::Bool, algebraic_only::Bool, allow_incomplete::Bool, is_initializeprob::Bool, kwargs::@Kwargs{…})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/systems/problem_utils.jl:1445
[13] (NonlinearLeastSquaresProblem{…})(sys::System, op::Dict{…}; check_length::Bool, check_compatibility::Bool, expression::Type, kwargs::@Kwargs{…})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/problems/nonlinearproblem.jl:83
[14] NonlinearLeastSquaresProblem
@ ~/.julia/packages/ModelingToolkit/hlGut/src/problems/nonlinearproblem.jl:77 [inlined]
[15] #
#1044
@ ./none:0 [inlined]
[16] InitializationProblem{…}(sys::System, t::Float64, op::Dict{…}; guesses::Dict{…}, check_length::Bool, warn_initialize_determined::Bool, initialization_eqs::Vector{…}, fully_determined::Nothing, check_units::Bool, use_scc::Bool, allow_incomplete::Bool, algebraic_only::Bool, time_dependent_init::Bool, kwargs::@Kwargs{…})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/problems/initializationproblem.jl:138
[17] InitializationProblem
@ ~/.julia/packages/ModelingToolkit/hlGut/src/problems/initializationproblem.jl:20 [inlined]
[18] #_#1098
@ ./none:0 [inlined]
[19] maybe_build_initialization_problem(sys::System, iip::Bool, op::Dict{…}, t::Float64, defs::Dict{…}, guesses::Dict{…}, missing_unknowns::Set{…}; implicit_dae::Bool, time_dependent_init::Bool, u0_constructor::Function, p_constructor::Function, floatT::Type, initialization_eqs::Vector{…}, use_scc::Bool, kwargs::@Kwargs{…})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/systems/problem_utils.jl:1118
[20] process_SciMLProblem(constructor::Type, sys::System, op::Dict{…}; build_initializeprob::Bool, implicit_dae::Bool, t::Float64, guesses::Dict{…}, warn_initialize_determined::Bool, initialization_eqs::Vector{…}, eval_expression::Bool, eval_module::Module, fully_determined::Nothing, check_initialization_units::Bool, u0_eltype::Nothing, tofloat::Bool, u0_constructor::typeof(identity), p_constructor::typeof(identity), check_length::Bool, symbolic_u0::Bool, warn_cyclic_dependency::Bool, circular_dependency_max_cycle_length::Int64, circular_dependency_max_cycles::Int64, substitution_limit::Int64, use_scc::Bool, time_dependent_init::Bool, algebraic_only::Bool, allow_incomplete::Bool, is_initializeprob::Bool, kwargs::@Kwargs{…})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/systems/problem_utils.jl:1382
[21] (ODEProblem{…})(sys::System, op::Dict{…}, tspan::Tuple{…}; callback::Nothing, check_length::Bool, eval_expression::Bool, expression::Type, eval_module::Module, check_compatibility::Bool, kwargs::@Kwargs{})
@ ModelingToolkit ~/.julia/packages/ModelingToolkit/hlGut/src/problems/odeproblem.jl:81
[22] ODEProblem
@ ~/.julia/packages/ModelingToolkit/hlGut/src/problems/odeproblem.jl:73 [inlined]
[23] ODEProblem
@ ./none:0 [inlined]
[24] ODEProblem(sys::System, op::Dict{Symbolics.Arr{Num, 2}, Matrix{Float64}}, tspan::Tuple{Float64, Float64})
@ ModelingToolkit ./none:0
[25] top-level scope
@ ~/Documents/Adaptive_Control/matrix_sim.jl:26
Some type information was truncated. Use show(err) to see complete types.

And when I saw this
julia> defaults(fol)
Dict{Any, Any} with 27 entries:
k[1, 1] => 1
(x(t))[2, 1] => NoValue()
(Initial(x(t)))[2, 1] => false
(Initial(xˍt(t)))[1, 1] => false
Initial(uˍt(t)) => BasicSymbolic{Real}[(Initial(uˍt(t)))[1, 1];;]
A1[2, 1] => 0
x1(t) => 1
k[2, 1] => 1
Initial(x(t)) => BasicSymbolic{Real}[(Initial(x(t)))[1, 1]; (Initial(x(t)))[2, 1];;]
Initial(u(t)) => BasicSymbolic{Real}[(Initial(u(t)))[1, 1];;]
(Initial(xˍt(t)))[2, 1] => false
(Initial(uˍt(t)))[1, 1] => false
A1[1, 2] => 0
Initial(x1ˍt(t)) => false
Initial(xˍt(t)) => BasicSymbolic{Real}[(Initial(xˍt(t)))[1, 1]; (Initial(xˍt(t)))[2, 1];;]
Initial(x2(t)) => false
v[1, 2] => 2
v[1, 1] => 1
A1[1, 1] => -1
x2(t) => 1
A1[2, 2] => -2
(x(t))[1, 1] => NoValue()
⋮ => ⋮

it made sense to me what was going on with the NoValue(), but it took some lurking around

I see, thanks for the explanation! That makes sense.

But I still would have expected the default values to be mapped automatically, just like with scalars. Wouldn’t that make more sense? I can’t really think of a case where you wouldn’t want the defaults to flow through to x.

It feels a bit confusing when you start adding structure, like combining signals into vectors to keep the math tidy. In small examples it’s fine to handle manually, but in bigger systems it could get messy to keep track of all the different variables and their defaults.

Is there a reason why it works this way today, or is it just a limitation in the current implementation?