Code Generation and Deployment ​
A discrete-time synchronous program written in Dyad can be turned into a standalone program: a Julia function you can step yourself, or C source you can compile into an embedded application. This page covers both workflows, and the compilation steps that the simulator performs before simulating models containing synchronous programs.
See Discrete-Time Modeling for clocks, and Julia Functions, Side Effects and Hardware for the device-facing side of a deployed controller.
What happens when you simulate a clocked model ​
A model containing clocks is not compiled the way a purely continuous-time model is. An extra compiler pass runs, and it:
infers the clocks — works out which variables belong to which clock, using the annotations and the equations (see Clock sources);
splits the system into a continuous-time partition plus one partition per clock;
translates each clocked partition into a synchronous program: a node with explicit inputs, outputs and state, whose body runs once per tick of its associated clock;
attaches the nodes to the continuous-time problem as timed callbacks, so the differential-equation solver advances continuous-time state between ticks and the node runs at each tick.
TransientAnalysis selects this pass automatically when the model contains clocks, which is why none of the examples in these tutorials have to ask for it.
The translation step that occurs under point 3 is what makes code generation possible: the node is already a self-contained imperative program, the compiler emits the code for this program as either C or Julia code.
Compiling a synchronous program on its own ​
stkcompile compiles a system into a standalone node. It takes the system plus a description of the node's signature — which variables are inputs, which are outputs, which clock drives it, and how parameters are supplied.
Purely discrete-time systems only
stkcompile rejects a system with a continuous-time partition. You cannot currently pass a model containing both a continuous-time plant model and a controller and ask for the code for the controller. Instead build a system containing only the controller, which is what the example below does.
Download as a Dyad projectcodegen.zipOpen in Dyad Studio
"""
A first-order discrete-time filter, standing in for a controller. Nothing about it
is specific to code generation — it is an ordinary clock-agnostic component.
"""
component TinyController@[input clk extends Discrete]
"Measurement in"
u = Dyad.RealInput@[clk]()
"Command out"
y = Dyad.RealOutput@[clk]()
"Filter coefficient"
parameter a::Real = 0.3
relations
initial y@(clk-1) = 0.0
y@clk = (1 - a) * y@(clk-1) + a * u@clk
endThe signature is built in Julia. ClockedInput and ClockedOutput select the variables crossing the boundary, and their name keyword sets the name of the corresponding argument or output in the generated code. The clock keyword of ClockedInput assigns a clock to the input, which determines the clock of the clock-agnostic TinyController. InputClock makes that clock an argument of the node (it becomes a Bool argument indicating that a tick occurred), and ParametersStruct describes how the model's parameters reach the generated code:
using ModelingToolkit
using ModelingToolkit: t_nounits as t
using SynchToolkit
dt = 0.1
# A purely discrete-time system containing only the controller.
@named ctrl = TinyController(a = 0.3)
sys = System(Equation[], t; systems = [ctrl], name = :node)
# `generated = true` collects the parameters that the node requires into a struct.
pars_spec = ParametersStruct(; arg_name = :pars, struct_name = :Pars,
generated = true)
cn = stkcompile(sys;
inputs = [ClockedInput(ctrl.u; name = :measurement, clock = ModelingToolkit.Clock(dt)),
InputClock(; clockof = ctrl.u), pars_spec],
outputs = [ClockedOutput(ctrl.y; name = :command)])CompiledNode(top)Alternatively, a clock source such as DiscreteComponents.PeriodicClock may be connected to the controller in the system, and the clock passed as InputClock(ModelingToolkit.Clock(dt)).
The result is a CompiledNode. Its argument types show the signature that was built — the measurement, the clock tick, and the parameter struct:
cn.input_typesTuple{Float64, Bool, SynchToolkit.var"##SynchRuntime#1114".Pars}Stepping it ​
SynchExecutable wraps the compiled node in a callable executable, and step! advances it one tick. The parameter struct is constructed by calling the ParametersStruct on the node:
using SynchJulia
pars = pars_spec(cn)
exe = SynchExecutable(cn; backend = :julia)
outputs = Float64[]
for u in [1.0, 1.0, 1.0, 1.0, 0.0, 0.0]
out = step!(exe, u, true, pars)
push!(outputs, only(values(out)))
end
outputs6-element Vector{Float64}:
0.3
0.51
0.657
0.7599
0.53193
0.372351The second argument to step! is the clock input: true means that the clock ticked at this call. When multiple clocks are present, not all clocks may tick at any one point in time. The step response rises towards the input and then decays once the input returns to zero, exactly as the same component does inside a simulation.
step! returns a NamedTuple whose keys are the output names, here command. Without a name keyword, an input or output is named after its variable, for example ctrl₊y(t).
Generating C ​
The compiled node can be emitted as C. SynchToolkit.node extracts the underlying node from the CompiledNode, and SynchJulia.export_c writes the sources. The name keyword sets the base name of the files and of the exported symbols:
dir = mktempdir()
SynchJulia.export_c(dir, SynchToolkit.node(cn); name = "controller")
sort(readdir(dir))4-element Vector{String}:
"controller.c"
"controller.h"
"controller.pc"
"synchjulia.h"controller.c holds the node, controller.h its interface, and synchjulia.h the runtime types it depends on. The header:
println(read(joinpath(dir, "controller.h"), String))#ifndef SYNCHJULIA_controller_H
#define SYNCHJULIA_controller_H
#include "synchjulia.h"
typedef struct Pars Pars;
/* mutable struct SynchToolkit.var"##SynchRuntime#1114".Pars */
struct Pars {
double ctrl_a;
};
typedef struct {
double command;
/* presence of command; false means absent this tick */
bool has_command;
} controller_out;
typedef struct {
bool first_tick_3;
double command;
} controller_mem;
/* controller_step: implementation step for the in-process runtime
* and existing callers. Takes generated value structs and returns
* controller_out by value. C callers should use controller_step_c. */
controller_out controller_step(double measurement, bool clock1, Pars * pars, controller_mem* self);
// clang-format off
/* controller_step_c: C interface. Parameters follow declaration order:
* inputs, outputs, then state:
* measurement : Float64 -> double measurement (input value)
* clock1 : Bool -> bool clock1 (input value)
* pars : SynchToolkit.var"##SynchRuntime#1114".Pars -> Pars *pars (input object, by pointer)
* command : Float64 -> double *command (output, written through the pointer)
* command presence -> bool *has_command (output; false means absent this tick)
* state -> controller_mem *self (reset with controller_reset before the first tick)
*
* Storage parameters (buffers, inputs read through a pointer, output and presence
* destinations, and state) must be non-null, aligned for their type, hold the
* stated number of elements, and remain valid until the call returns. Outputs,
* presence flags and the state must not overlap each other or input storage.
* The call copies buffers into the node's value types, runs controller_step
* once, and copies present outputs back. The adapter retains no buffer pointers.
* A clocked output's presence flag is always written; its payload only when
* present. An absent tick leaves the destination unchanged.
* Object inputs preserve identity and mutability.
* The node may dereference, modify or retain signal pointers and object references,
* including those inside aggregates. Their referents must remain valid for those
* accesses, which must also obey the non-overlap requirements above.
*/
// clang-format on
void controller_step_c(double measurement, bool clock1, Pars * pars, double * command, bool * has_command, controller_mem * self);
void controller_reset(controller_mem* self);
extern const size_t controller_state_size;
#endif // SYNCHJULIA_controller_HWithout name, the files are called top.*, and the base name of the symbols is derived from the node name and its argument types by SynchJulia.mangle:
SynchJulia.mangle("top", cn.input_types.parameters...)"top_f64_bool_Pars"With name = "controller" the entry points are:
| Symbol | Purpose |
|---|---|
controller_step_c | Run one tick. Takes the inputs, the clock flag and a pointer to the parameter struct, then a pointer to the destination of each output followed by its presence flag, and finally the state. This is the interface intended for C callers. |
controller_step | Run one tick and return the output struct by value. Used by the in-process runtime. |
controller_reset | Initialise the state block before the first step. |
controller_mem | The state type. |
controller_out | The output struct returned by controller_step. |
controller_state_size | extern const size_t giving the size of the state. |
Two details of the emitted interface are visible in the header above:
Each output carries a presence flag.
controller_step_cwrites aboolfor every output, and the output struct holdshas_commandnext tocommand. A clocked output does not necessarily produce a value on every call; when the flag is false, the output destination is left unchanged.The parameters are passed as a pointer to a generated struct. The header declares
struct Parswith one field per parameter, and the step functions takePars *pars. The values can be set at build time or at start-up.
The same node can also be run through the C back end in-process, which is the standard way to check that the two back ends agree before deployment. This requires a C compiler: add Clang_unified_jll to the environment and load it.
using Clang_unified_jll
exe_c = SynchExecutable(cn; backend = :c)Driving hardware from the generated C ​
The generated node has no notion of time — it computes one tick when asked. Timing, I/O and logging belong to a loop around it, which you write. A typical structure of such a loop is:
#include "controller.h"
int main(void) {
Pars pars = { .ctrl_a = 0.3 };
controller_mem state;
controller_reset(&state);
open_device();
while (!stop_requested) {
struct timespec t0;
clock_gettime(CLOCK_MONOTONIC, &t0);
double y = read_sensor();
double command;
bool has_command;
controller_step_c(y, true, &pars, &command, &has_command, &state);
if (has_command) {
write_actuator(command);
}
sleep_remainder_of_period(&t0, TS);
}
write_actuator(0.0);
close_device();
return 0;
}Packaging an experiment as an analysis ​
A deployment step is worth capturing as a Dyad analysis, so it is reproducible and parameterized rather than a script. The pattern is a partial analysis holding the configurable parameters, and a concrete analysis binding the model and any modifications of the default parameters applicable to a particular scenario only:
partial analysis ExportCBase
extends Analysis
model::Dyad.EmptyComponent = Dyad.EmptyComponent()
"Directory to write the generated C into"
parameter output_dir::String = "generated_c"
"Controller sample time [s]"
parameter Ts::Real = 0.01
"Compile and run the generated loop on the hardware after exporting"
parameter run::Boolean = false
"Duration [s] of the hardware run when `run = true`"
parameter Tf::Real = 10.0
relations
end
analysis ExportC
extends ExportCBase(run = false)
model = MyController()
relations
endThe Julia side implements run_analysis for the spec, performs the steps — such as computing gains, stkcompile, export_c, optionally compile and run — and returns artifacts such as a table of generated files, the build log and the run log. See Analyses and Write a custom analysis for the mechanics.
Give each analysis implementation its own partial analysis
A concrete analysis forwards to the spec of the root of its extends chain. Two concrete analyses sharing the same partial analysis use the same run_analysis implementation.
Debugging code generation ​
When code generation fails, the error may be generic — for example "Failed to evaluate the generated runtime module … This is a bug in SynchToolkit's code generation". The debug channel prints the intermediate representation and the generated source:
SynchToolkit.enable_debug!(:sj) # the synchronous-program stage
SynchToolkit.enable_debug!() # all stages
SynchToolkit.disable_debug!() # disable debug outputGroups are :sj, :compile, :codegen, :integration and :init. :sj prints the generated module, which is the relevant output when the failure is in code generation rather than in the model. See Debug Logging.
Deployment options and their status ​
| Path | Status |
|---|---|
C sources via export_c, compiled into your own application | Available; subject to limitations on automatic conversion of Julia code to C code. |
In-process SynchExecutable, :julia or :c back end | Available. Used for validation and for running a controller from a host PC. |
| Statically compiled Julia binary | JuliaC Trimming is not yet fully supported. |
| FMU export | Rejected for models containing events, which includes clocked models. |
Limitations ​
stkcompileaccepts purely discrete-time systems only — see the note above. The ability to automatically segment out clocked partitions is a planned feature.Components with array connectors require a clock source. A clock specified on
ClockedInputis sufficient for a component with scalar connectors, but not for an array-connector block such asDiscreteStateSpace. Attach a clock source and compile the combined system instead, passing the whole connector array toClockedInputandClockedOutput, e.g.ClockedInput(ss.u).A registered function that returns a vector is evaluated once per element of its result in the compiled node, rather than once per tick. Simulation is not affected.
Everything is compiled into one top-level node. There is no per-component node reuse, so a large model produces one large node.
Struct-valued parameters are not currently available.
See also ​
Discrete-Time Modeling — clocks, partitions and reading results
Sampled-Data Systems — combining discrete-time and continuous-time models
Julia Functions, Side Effects and Hardware — device I/O, call ordering and logging
Array Variables in Clocked Components — why array-connector blocks are harder to compile
Analyses and Write a custom analysis — packaging a deployment step
FMU generation — the other export route, and its limits
QuanserComponents.jl — a complete worked example: a discrete-time swing-up and stabilizing controller for a rotary inverted pendulum, compiled to C and to a standalone binary, with the real-time loop and the export packaged as Dyad analyses