Generated Constructors and External Components ​
The Dyad compiler turns every component into a Julia function that builds a ModelingToolkit System. Most of the time only other generated code calls these functions. This page is for the cases where your own Julia code is involved: when you call a generated constructor from Julia, and when you write an external component whose implementation is hand-written Julia.
Dyad 3.5 (dyad-lang 3.5.0) changed how generated constructors receive overrides. The first half of this page covers that change and how to move a library to it; the second half explains how to write an external component.
The generated constructor ​
Take a small library, RateLib:
component Decay
parameter rate::Real = 1.0
variable x::Real(initial = 1.0)
relations
der(x) = -rate * x
end
component TwoStage
parameter k::Real = 2.0
first = Decay(rate = k)
second = Decay()
relations
endThe compiler generates this signature for TwoStage:
@component function TwoStage(; name = nothing, var"k"=Float64(2.0), __overrides = Dict{String, Any}())The constructor takes two kinds of input:
Declared parameters are keyword arguments.
kis a keyword argument with its Dyad default.Everything else goes in
__overrides. This is aDict{String, Any}keyed by the dotted path you would write in Dyad: a variable value ("x"), a variable attribute ("x.initial","x.guess"), and anything inside a subcomponent ("first.rate","second.x.initial").
The keys use the same dotted spelling as Dyad source, metadata test params, and apply parameter files. When the dictionary and a keyword argument set the same parameter, the dictionary entry wins. The constructor copies the dictionary before using it, so your dictionary is never changed.
A key that does not match anything in the model is an error when the model is constructed:
ArgumentError: overrides: [x__initial] don't match names found in model. These names may exist in the model but could have been conditionally excluded. Override keys are dotted paths as written in Dyad source (e.g. inner.rate).A keyword argument the constructor does not declare is a MethodError. Generated constructors no longer collect unknown keyword arguments.
Calling a generated constructor from Julia ​
using RateLib
# A declared parameter is a keyword; a variable attribute goes in the dictionary.
@named d = Decay(; rate = 2.0, __overrides = Dict{String, Any}("x.initial" => 3.0))
# Reach into subcomponents with dotted paths.
@named s = TwoStage(; k = 7.0, __overrides = Dict{String, Any}(
"second.rate" => 0.25,
"second.x.initial" => 2.0,
))Inside a model, the compiler does the same for you. In Dyad, TwoStage(k = 3.0, second(rate = 0.5)) becomes:
stages_dict = Dict{String, Any}()
stages_dict["second.rate"] = ModelingToolkit.default_to_parentscope(0.5)
merge!(stages_dict, stages_overrides)
push!(__systems, @named stages = RateLib.TwoStage(; k=Float64(3.0), __overrides = stages_dict))The default_to_parentscope wrapper is part of how generated code passes values between levels. Plain values work when you build the dictionary yourself.
Moving a library to Dyad 3.5 ​
Before Dyad 3.5, generated constructors ended in kwargs... and accepted every override as a keyword argument. Nested paths and attributes were joined with __ (second__rate, x__initial). Libraries generated with Dyad 3.5 and libraries generated earlier cannot construct each other's components. You need to do four things:
Regenerate every Dyad library with the Dyad 3.5 compiler, in dependency order.
Release each regenerated library as a breaking version and update
[compat]in its consumers.Update hand-written Julia that calls generated constructors.
Check your external components (see Writing an external component). They keep the same contract.
Regenerate every library, in dependency order ​
Every generated library now declares its constructor API version in generated/internals.jl:
const __dyad_constructor_api = 2Libraries generated before Dyad 3.5 do not define this constant and count as version 1.
A library that depends on another Dyad library checks the dependency's version when it loads. The check runs in the consumer's root module, right after its import:
import RateLib
let api = isdefined(RateLib, :__dyad_constructor_api) ? RateLib.__dyad_constructor_api : 1
api == 2 || error("RateLib was generated with Dyad constructor API version $api, but PlantLib requires version 2 (the `__overrides` dict override channel). Regenerate RateLib with the same Dyad compiler that generated PlantLib.")
endIf you regenerate PlantLib but load it with a RateLib generated by an older compiler, PlantLib fails to precompile:
ERROR: LoadError: RateLib was generated with Dyad constructor API version 1, but PlantLib requires version 2 (the `__overrides` dict override channel). Regenerate RateLib with the same Dyad compiler that generated PlantLib.The fix is to regenerate and release the dependency first. Start with libraries that have no Dyad dependencies, then work up to the libraries that use them.
Only direct Dyad dependencies that contain at least one generated component are checked. A dependency that contains only types, external components, or partial components has no generated constructors, so it has nothing to check.
The case the check cannot catch ​
The check only exists in newly generated code. If an old consumer is loaded with a regenerated dependency, nothing runs the check. The mismatch surfaces at model construction as a MethodError, because the old consumer passes keyword arguments that the new constructor no longer accepts. If the old consumer happens to pass no overrides, it appears to work until someone adds a modification. Version bounds are what prevent this pairing; see the next section.
Release as a breaking change and update compat ​
A library regenerated with Dyad 3.5 is a breaking release, because older generated libraries cannot call it. For each library:
Bump the major version (the minor version for a
0.xpackage).In each consumer, set the
[compat]entry for every regenerated Dyad dependency to the new major version only. For example, useRateLib = "2", notRateLib = "1, 2". Then regenerate and release the consumer, also as a breaking version.
The major-version bump is what protects users from the unchecked case. An already-released consumer has RateLib = "1" in its [compat] section, so the package manager will never pair it with the regenerated RateLib 2.x.
Update hand-written Julia that calls generated constructors ​
Find every call to a generated constructor in Julia code you wrote yourself. Look in dyad/definitions.jl, src/, tests, scripts, and code generators that emit Julia. Declared parameters stay keyword arguments. Variable values, attributes, and anything inside a subcomponent move into __overrides under their dotted path.
Before (Dyad 3.4 and earlier):
@named d = Decay(; rate = 2.0, x__initial = 3.0)
@named s = TwoStage(; k = 7.0, second__rate = 0.25)After (Dyad 3.5):
@named d = Decay(; rate = 2.0, __overrides = Dict{String, Any}("x.initial" => 3.0))
@named s = TwoStage(; k = 7.0, __overrides = Dict{String, Any}("second.rate" => 0.25))If you do not update the old form, you get a MethodError for the keyword argument the constructor no longer accepts:
MethodError: no method matching Decay(; name::Symbol, rate::Float64, x__initial::Float64)
...
Decay(; name, rate, __overrides) got unsupported keyword argument "x__initial"Variables passed by name are the easiest case to miss, because they used to look exactly like parameters. For example, the hand-written World component in MultibodyComponents builds its ground plane with the generated PlaneShape. r, R, length and the others are variables of Shape, so they move into the dictionary, while the parameters render and color stay keywords:
# Before
@named ground = PlaneShape(
render=render_ground, color=ground_color,
r=zeros(3), R=Matrix{Float64}(LinearAlgebra.I, 3, 3),
r_shape=zeros(3),
length_direction=collect(ground_axis_u),
width_direction=collect(ground_axis_v),
length=ground_length_u, width=ground_length_v, height=0.0,
)
# After
@named ground = PlaneShape(
render=render_ground, color=ground_color,
__overrides=Dict{String, Any}(
"r" => zeros(3), "R" => Matrix{Float64}(LinearAlgebra.I, 3, 3),
"r_shape" => zeros(3),
"length_direction" => collect(ground_axis_u),
"width_direction" => collect(ground_axis_v),
"length" => ground_length_u, "width" => ground_length_v, "height" => 0.0,
),
)Dyad source does not change. Decay(rate = 2.0, x(initial = 3.0)) is written the same way and the compiler routes each modification for you.
Writing an external component ​
An external component declares a component's interface in Dyad and leaves its implementation to hand-written Julia. Use it to wrap an existing ModelingToolkit component or to write something the Dyad language cannot express.
The Dyad declaration ​
The declaration lists the component's connectors and parameters, with an empty relations section:
connector RealIn = input Real
connector RealOut = output Real
"First-order lag implemented in Julia: T*der(y) = k*u - y"
external component Lag
u = RealIn()
y = RealOut()
"Static gain"
parameter k::Real = 1.0
"Time constant"
parameter T::Real = 0.1
relations
endAn external component cannot declare variables or contain relations. The compiler rejects both with an unexpected-implementation error, because the Julia implementation provides them.
The generated stub ​
dyad compile does not generate a body for an external component. It declares the function name and exports it, with a comment describing the contract:
# Component Lag is an external component definition. This means that the
# code for this component should be written in native Julia and will not be generated by
# the Dyad compiler. Dyad will only generate a stub that creates the function name.
# The implementation of the function (function Lag(; name, params...))
# must be manually written. Callers deliver overrides to an external component as plain
# keyword arguments (never as an `__overrides` dict), so the hand-written signature's
# keyword arguments are the full override surface.
function Lag end
export LagThe Julia implementation ​
Put the implementation in the library's dyad/definitions.jl file. For a component in a submodule, use dyad/<Submodule>/definitions.jl. The generated code includes this file automatically, after the library's types are defined.
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
@component function Lag(; name, k = 1.0, T = 0.1)
@parameters k = k T = T
@variables u(t) [input = true] y(t) [output = true]
return System([T * D(y) ~ k * u - y], t; name, initial_conditions = [y => 0.0])
endA component that uses it is written like any other:
component LagTest
parameter T0::Real = 0.5
src = Ramp(slope = 2.0)
lag = Lag(k = 3.0, T = T0)
relations
connect(src.y, lag.u)
endThe compiler generates this call:
lag_overrides = __pop_subcomponent_overrides!(__overrides, "lag")
push!(__systems, @named lag = PlantLib.Lag(; k=Float64(3.0), T=T0, __symbol_overrides(lag_overrides)...))__symbol_overrides turns any overrides addressed to lag into ordinary keyword arguments. Calling LagTest(; __overrides = Dict{String, Any}("lag.k" => 10.0)) from Julia therefore arrives in Lag as k = 10.0.
The contract ​
Signature. Write @component function Name(; name, <one keyword per declared parameter>). Use keyword arguments only, and do not declare an __overrides argument. Nothing ever passes one to an external component.
What the function receives:
| Argument | When it arrives |
|---|---|
name | Always, as a Symbol set by @named. |
A declared parameter, such as k | Only when the instance modifies it. The value may be a number, an array, an enum value, or a symbolic expression of the parent's parameters (T = T0 above). |
A promoted enum field, <param>__<field> | When a parent sets a field of an enum-valued structural parameter. See Enum-valued parameters. |
| Any other keyword | Only from hand-written Julia that puts an extra key in the parent's __overrides dictionary. |
Because a parent passes only the parameters it modifies, the Julia default of each keyword must match the Dyad default. In the example, both say k = 1.0 and T = 0.1.
What it returns. A ModelingToolkit System built with name, from a function marked @component. Names in the system must match the Dyad declaration. Connectors and parameters keep their Dyad names (u, y, k, T), because enclosing components refer to lag.u and lag.k.
What it must not do:
Do not expect dotted keys such as
"y.initial". External components have no variables or subcomponents for such a key to address.Avoid a catch-all
params...slurp unless you check what it contains. A misspelled or unexpected key is silently absorbed by a slurp. With explicit keyword arguments, the same mistake is an immediateMethodError.
Enum-valued parameters ​
When an external component has an enum-valued structural parameter, a parent can set a field of the active case from one of its own parameters:
enum Limit =
| Clamp(hi::Real = 1.0)
| Off
external component Limiter
u = RealIn()
y = RealOut()
structural parameter mode::Limit = Limit.Clamp(hi = 1.0)
relations
end
component LimiterTest
parameter top::Real = 2.0
src = Ramp()
lim = Limiter(mode = Limit.Clamp(hi = top))
relations
connect(src.y, lim.u)
endThe parent passes the enum value and, separately, the field value as a keyword named <param>__<field>:
@named lim = PlantLib.Limiter(; mode=PlantLib.Limit.Clamp(; hi=top), mode__hi=top, __symbol_overrides(lim_overrides)...)The stub lists every such keyword and the helper that applies it:
# Callers may additionally pass promoted enum-field overrides as keyword arguments:
# mode__hi (targets mode.hi; apply with __dyad_override_enum_field(mode, :hi, mode__hi))Accept each listed keyword with a default of nothing and apply it with __dyad_override_enum_field. The helper is defined in every generated library:
@component function Limiter(; name, mode = Limit.Clamp(hi = 1.0), mode__hi = nothing)
mode = __dyad_override_enum_field(mode, :hi, mode__hi)
@variables u(t) [input = true] y(t) [output = true]
eqs = mode isa Limit.Clamp ? [y ~ min(u, mode.hi)] : [y ~ u]
return System(eqs, t; name)
end__dyad_override_enum_field(value, field, override) returns value unchanged when override is nothing or when the active case has no such field. Otherwise it returns a copy of value with that field replaced.