ModelingToolkit.jl and type-based equation deciding: best practices?

What do people recommend for ModelingToolkit.jl with variable equations that change depending on some input “type” specification? By that I mean that I have some sort of physical process that has a couple of different ways it can be written with in equation depending on the assumptions I impose on my model. As far as I see, there are two ways to do it:

  1. Decide the equation using traditional multiple dispatch and the Symbolic.jl function registration. I.e., Have something like

abstract type AbstractLCL end
struct ExactLCL <: AbstractLCL end
struct ApproximateLCL <: AbstractLCL end
struct ConstantLCL <: AbstractLCL end

zb ~ lifting_condensation_level(z, s, q, p₀, LCLtype)

function lifting_condensation_level(z, s, q, p₀, LCLtype::ConstantLCL)
    # actual math here. Three functions exist for each LCLtype
end

@register_symbolic lifting_condensation_level(z, s, q, p₀, LCLtype::ExactLCL)
@register_symbolic lifting_condensation_level(z, s, q, p₀, LCLtype::ApproximateLCL) false
@register_symbolic lifting_condensation_level(z, s, q, p₀, LCLtype::ConstantLCL) false
  1. Use a string and and if statement and define three functions, so I would have
if LCLtype == "ExactLCL"
zb ~ function_exact(a,b,c)
elseif LCLtype == "ApproxLCL"
zb ~ function_approximate(a,b,c)
# etc.
end
  1. Use a type like in 1. but make the type a “functor”. So that the type itself is a function. Having
struct ExactLCL <: AbstractLCL end
struct ApproximateLCL <: AbstractLCL end

I would define

function (e::ExactLCL)(z, s, q, p0)
# code
end

and write my equation as

lifting_condensation_level = ExactLCL()
zb ~ lifting_condensation_level(z, s, q, p₀)

What’s of these 3 is a best practice and why? My concerns are also with performance. The second version has no problems with performance because the equation is processed as-is. The first or third version may have type instability problems if the ExactLCL() type is not “captured” appropriately.

TLDR; I would use the third approach.

The first approach is super nice ( and makes everything more readable), but if you need to use symbolic derivatives it might become nasty given that you need to define diffrules along with it (anyone with more insight is welcome to correct me on that :smiley: ).

The second approach is just personal preference. I dislike using strings on the lower levels ( might be useful to add it as user user-facing API and convert it to types though ).

The third approach gives you the flexibility you want within reasonable performance bounds. I think the symbolic post-processing ( function building, gradients, etc. ) will have a higher overhead than anything else, but you could also look into UnitTyper.jl.

They allocate. Just use Symbol instead in any case like this.

In 3, it will trace the function and completely remove the higher level dispatch so there’s no type instability. But it will have a difference in compile time. I think any of the 3 are useful depending on context, where the second is changed to branch on something that is simpler than a string like an enum or symbol.

Great, thanks a lot for your input guys!