Skip to content
TUTORIAL

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 what the simulator does behind the scenes 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:

  1. infers the clocks — works out which variables belong to which clock, using the annotations and the equations (see Clock sources);

  2. splits the system into a continuous-time partition plus one partition per clock;

  3. 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;

  4. 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 and a clock, which is what the example below does.

Download as a Dyad projectcodegen.zipOpen in Dyad Studio

dyad
"""
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
end

The signature is built in Julia. ClockedInput and ClockedOutput name the variables crossing the boundary, InputClock supplies the clock (it becomes a Bool argument saying "a tick happened"), and ParametersStruct describes how the model's parameters reach the generated code:

julia
using ModelingToolkit
using ModelingToolkit: t_nounits as t
using SynchToolkit
using DiscreteComponents

dt = 0.1

# A purely discrete-time system: the controller and a clock.
@named ctrl  = TinyController(a = 0.3)
@named clock = DiscreteComponents.PeriodicClock(dt = dt)
sys = System([connect(ctrl.y, clock.y)], t; systems = [ctrl, clock], name = :node)

# `generated = true` collects the parameters the node needs into a struct for us.
pars_spec = ParametersStruct(; arg_name = :pars, struct_name = :Pars,
                            generated = true)

cn = stkcompile(sys;
    inputs  = [ClockedInput(ctrl.u), InputClock(ModelingToolkit.Clock(dt)), pars_spec],
    outputs = [ClockedOutput(ctrl.y)])
CompiledNode(top)

The result is a CompiledNode. Its argument types show the signature that was built — the measurement, the clock tick, and the parameter struct:

julia
cn.input_types
Tuple{Float64, Bool, SynchToolkit.var"##SynchRuntime#486".Pars}

Stepping it ​

SynchExecutable turns the compiled node into something callable, and step! advances it one tick. The parameter struct is constructed by calling the ParametersStruct on the node:

julia
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
outputs
6-element Vector{Float64}:
 0.3
 0.51
 0.657
 0.7599
 0.53193
 0.372351

The 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.

Naming inputs and outputs

ClockedInput/ClockedOutput accept a name keyword, but renaming is being removed and currently errors. Leave the names alone and read the outputs positionally from the returned NamedTuple.

Generating C ​

The compiled node can be emitted as C. SynchToolkit.node gets the underlying node out of the CompiledNode, and SynchCompiler.export_c writes the sources:

julia
using SynchCompiler

dir = mktempdir()
SynchCompiler.export_c(dir, SynchToolkit.node(cn))
sort(readdir(dir))
4-element Vector{String}:
 "synchjulia.h"
 "top.c"
 "top.h"
 "top.pc"

top.c holds the node, top.h its interface, and synchjulia.h the runtime types it depends on. An excerpt:

julia
println(join(first(split(read(joinpath(dir, "top.h"), String), '\n'), 25), '\n'))
#ifndef TOP_H
#define TOP_H

#include "synchjulia.h"

typedef struct {
double ctrl_y_t_;
/* presence of ctrl₊y(t); false means absent this tick */
bool has_ctrl_y_t_;
} z3top_t7Float64_t4Bool_t3Ptr_out;

typedef struct {
bool first_tick_3;
double ctrl_y_t_;
} z3top_t7Float64_t4Bool_t3Ptr_mem;

z3top_t7Float64_t4Bool_t3Ptr_out z3top_t7Float64_t4Bool_t3Ptr_step(double ctrl_u_t_, bool clock1, int64_t pars, z3top_t7Float64_t4Bool_t3Ptr_mem* self);
void z3top_t7Float64_t4Bool_t3Ptr_reset(z3top_t7Float64_t4Bool_t3Ptr_mem* self);
extern const size_t z3top_t7Float64_t4Bool_t3Ptr_state_size;
#endif // TOP_H

The exported symbols are derived from a deterministically mangled base name, which you can compute with SynchCompiler.mangle from the node name and its argument types. For a node called top the entry points are:

SymbolPurpose
<mangled>_stepRun one tick. Takes the inputs, the clock flag, a parameter handle and the state; returns the output struct.
<mangled>_resetInitialise the state block before the first step.
<mangled>_memThe state type. Allocate one and zero it.
<mangled>_outThe output struct returned by _step.
<mangled>_state_sizeextern const size_t giving the state size, if you would rather not sizeof the struct.

Two details of the emitted interface are easy to miss, and both are visible in the header above:

  • Each output carries a presence flag. The output struct holds double ctrl_y_t_ and bool has_ctrl_y_t_, commented "presence of …; false means absent this tick". A clocked output does not necessarily produce a value on every call, so check the flag before using the value.

  • The parameter handle is an integer, not a pointer type. _step takes int64_t pars; pass the address of your parameter struct cast through (int64_t)(intptr_t). The struct layout matches the generated declaration, so the values can be baked in at build time or filled in 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 shipping:

julia
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 shape of such a loop is:

c
#include "top.h"

int main(void) {
    <mangled>_mem state;
    memset(&state, 0, sizeof(state));
    <mangled>_reset(&state);

    open_device();
    while (!stop_requested) {
        struct timespec t0;
        clock_gettime(CLOCK_MONOTONIC, &t0);

        double y = read_sensor();
        <mangled>_out out = <mangled>_step(y, true,
                                          (int64_t)(intptr_t)&pars, &state);
        if (out.has_ctrl_y_t_) {
            write_actuator(out.ctrl_y_t_);
        }

        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 knobs, and a concrete analysis binding the model and any modifications of the default parameters applicable to a particular scenario only:

dyad
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
end

The Julia side implements run_analysis for the spec, does the work — 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 analysis sharing the same partial analysis uses 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 the code generation". The debug channel prints the intermediate representation and the generated source:

julia
SynchToolkit.enable_debug!(:sj)     # the synchronous-program stage
SynchToolkit.enable_debug!()        # all stages
SynchToolkit.disable_debug!()       # back to quiet

Groups are :sj, :compile, :codegen, :integration and :init. :sj is the one that dumps the generated module, which is what you want when the failure is in code generation rather than in your model.

Deployment options and their status ​

PathStatus
C sources via export_c, compiled into your own applicationAvailable; subject to limitations on automatic conversion of Julia code to C code.
In-process SynchExecutable, :julia or :c back endAvailable. Used for validation and for running a controller from a host PC.
Statically compiled Julia binaryJuliaC Trimming is not yet fully supported.
FMU exportRejected for models containing events, which includes clocked models.

Limitations ​

  • stkcompile accepts purely discrete-time systems only — see the note above. The ability to automatically segment out clocked partitions is a planned feature.

  • Components whose ports are connectors and whose clock is inferred cannot be compiled directly. Array-connector blocks such as DiscreteStateSpace fall in this category. Attach a clock source and compile the combined system instead.

  • 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 ​