taktwerk
taktwerk runs compiled models at a fixed step on Linux, beside or on a PLC, and exchanges their signals over OPC UA.
- Models are FMI 3 or FMI 2 co-simulation FMUs, or plain C libraries described by a small descriptor. A model is size-generic; an instance binds its sizes.
- The engine owns a process image. A real-time thread steps every model on absolute deadlines and reads a snapshot of the image at each tick; connectors sync the image with the outside in the background.
- An own OPC UA server exposes every signal, the engine status and a heartbeat, so any
OPC UA client doubles as a UI.
taktwerk tuiis one. - One TOML project file per engine describes models, instances, wiring and connectors.
What it is not
- Not hard real time: a fixed step on stock Linux, with optional
SCHED_FIFO, CPU pinning and locked memory. Jitter is measured on the target, never assumed. - Not a safety controller: models run in the engine’s process. Whoever consumes the outputs, typically the PLC, owns the safe state once the heartbeat stops (see Concepts).
Where to go next
- Getting started: build, scaffold, check and run a project.
- Concepts: process image, ticks, instances, wiring, staleness, fail-stop.
- Making a model taktwerk-compatible: for model developers.
taktwerk is licensed under MIT or Apache-2.0 and published on crates.io.
Getting started
Install
taktwerk needs a Rust toolchain (1.88 or newer); the examples also need a C compiler.
cargo install taktwerk # installs `taktwerk` into ~/.cargo/bin
From a checkout: cargo install --path crates/taktwerk.
For a target other than the build host, see cross-building.
A project in four commands
taktwerk inspect plant.fmu --kind fmi # dimensions, variables, shapes
taktwerk new plant.fmu --kind fmi -o plant.toml # scaffold a project file
taktwerk check plant.toml # load, resolve, bind; print the plan
taktwerk run plant.toml # until SIGINT or SIGTERM
new writes a project with one instance, every dimension at the model’s default, every input,
output and tunable mapped to its default signal name, commented-out parameters, and an OPC UA
server on opc.tcp://127.0.0.1:4840. Edit it, then check it: check loads every model,
binds every dimension, resolves every signal and connects every connector, so it fails on
anything run would fail on at init. It prints the plan:
tick 10 ms
instance model period dims
filt lowpass 10 ms n=3
signal direction type shape max age
taktwerk.heartbeat system u64 scalar -
taktwerk.status system i32 scalar -
taktwerk.cycle system u64 scalar -
taktwerk.overruns system u64 scalar -
taktwerk.stale system u64 scalar -
filt.y output f64 [3] -
filt.u input f64 [3] -
filt.gain tunable f64 scalar -
ok: 1 instance(s), 8 signal(s), 1 connector(s) bound
run logs to stderr (RUST_LOG filters, default info), stops on SIGINT or SIGTERM, prints a
run summary and exits non-zero when a model or connector failed.
Watch and edit with the TUI
taktwerk tui opc.tcp://127.0.0.1:4840
The TUI is an OPC UA client of the engine’s own server. It shows status, heartbeat and counters,
and every signal with its value and age. Keys: ↑/↓ or j/k select, PgUp/PgDn,
Home/End (g/G) jump, Enter edits the selected input or tunable and Enter again
writes it, Esc cancels, q quits. If the project changes namespace or system_prefix, pass
--namespace and --prefix.
The examples
The examples/ run
from the repository root:
| example | what it shows |
|---|---|
fmi-state-space | an FMI 3 FMU with structural parameters bound to one state |
raw-pi | a PI controller from a plain C library, multiple instances by handle |
raw-filter-bank | a C library in the recommended shape, its descriptor read unreviewed |
closed-loop | both in one engine, wired through the process image |
taktwerk.service | a systemd unit (see Running in production) |
# The FMU is the Reference FMU `StateSpace`, built for this host (needs git, network and cc).
crates/taktwerk-fmi/tests/reference-fmus.sh target/tmp/reference-fmus
taktwerk run examples/fmi-state-space/project.toml
# The PI controller is built from its C source.
examples/raw-pi/build.sh
taktwerk run examples/raw-pi/project.toml
# The filter bank likewise; its descriptor is `taktwerk import-header lowpass.h --shape`.
examples/raw-filter-bank/build.sh
taktwerk run examples/raw-filter-bank/project.toml
# Both: the controller closes the loop around the plant.
taktwerk run examples/closed-loop/project.toml
taktwerk tui opc.tcp://127.0.0.1:4840 # edit ctrl.sp, watch plant.y follow
Concepts
Process image
The engine owns one value per signal, the process image. Every value lives in a typed buffer sized at init and never resized. Connectors write inputs and tunables and read outputs from their own threads; the cycle thread copies what it needs at its tick and stores outputs back. Each signal is locked only for one copy.
A signal has a direction:
| direction | written by | read by |
|---|---|---|
| input | a connector | the instances that consume it |
| output | the instance that produces it | connectors, other instances |
| tunable | a connector, while running | one instance, before its step |
| system | the engine | connectors |
Element types are f64, f32, i64, i32, i16, i8, u64, u32, u16, u8 and bool.
A signal is a scalar or an array of any rank; matrices are stored row-major or column-major as
the model declares.
Ticks and periods
One real-time thread wakes on an absolute deadline grid (clock_nanosleep with
TIMER_ABSTIME) every engine.tick_ms. Each instance has a period of every ticks and is due
at every tick whose number is a multiple of every, so all instances are due at tick 0.
Per tick:
- The external inputs and tunables of every due instance are copied from the image.
- If any of those inputs is stale, the tick is skipped (see stale inputs).
- Otherwise the due instances step in declared order. Each first copies its wired inputs, so it sees outputs stored earlier in the same tick.
- Outputs are stored, the heartbeat advances and the tick is published: connectors now see a complete, consistent set of outputs.
Model time is start_time + tick × tick_ms; an instance advances by its step size
every × tick_ms per step. A tick that ends after the next deadline skips the missed slots
and counts each in overruns; the tick counter stays contiguous, so after an overrun model time
lags wall time by the skipped slots. Nothing on the tick path allocates.
Instances and dimensions
A model is size-generic: its variables have symbolic shapes (u: f64[n], A: f64[nx, nx])
over named dimensions with an optional min, max and default. An instance references a
model and binds every dimension, from the project file, from the model’s default, or
"from-server" (the length a connector finds outside). The bound sizes are structural: they
are fixed for the run, and a change needs a restart.
One model serves any number of instances. Whether one loaded library can host several is the
model’s instances capability: multiple libraries keep their state per instance; for a
single library (state in globals), each further instance loads a private copy of the file.
Parameters are set once before init. Tunables are set before init and change while running; a changed value is delivered before the instance’s next step. A tunable that no connector has written yet shows the model’s own start value after init, so writing one tunable never zeroes another.
Signals and wiring
An unmapped input, output or tunable v of instance i is the signal i.v. Mapping a
variable in the project file names its signal:
- An input mapped to a signal that some instance outputs is wired to it. It reads what the producer stored last: the same tick when the producer runs earlier in declared order, else the previous one. A wired input never goes stale.
- An input nobody outputs is an external input, written by a connector.
- Several instances may consume one signal.
- A one-dimensional input may be composed element by element from scalar signals and
elements of other instances’ outputs, each element wired or external as above; its value is
the read-only signal
i.v. A scalar input may take one element,x = "j.y[2]", the same way. See Project file.
Errors at init: two outputs on one signal, a tunable on an input or output signal, one signal
used with two types, shapes or layouts, a mapping onto one of the engine’s own signals, or a
signal name with [ or ] that is no element of another instance’s output.
Init
Before the first tick every instance is initialised once, in declared order. Its init sees its dimension lengths and parameters, its tunables as the image holds them (a connector’s write, else the start value), and its inputs as follows:
- A wired input whose producer comes earlier in declared order holds the producer’s output as it stood after the producer’s init, so a library may seed its state from it.
- A wired input whose producer comes later (a feedback loop) is zero.
- An external input holds what a connector has written so far, zero if nothing yet. No first read is awaited and no age limit is checked at init.
- The elements of a composed input follow the same rules, element by element.
Outputs written by an init are not stored in the image: connectors and other instances see outputs from tick 0 on, and every wired input is copied again before each step.
Stale inputs
Each external input may carry a maximum age, max_age_ms on the binding or
engine.input_max_age_ms for all. Without one, an input never goes stale. With one, an input
is stale when its last write is older than that at the tick, or when it was never written.
A stale input skips the whole tick: no instance steps, outputs and heartbeat are held, status
becomes faulted and stale counts the tick. The fault is published at once. When inputs are
fresh again the next tick runs normally and status returns to running.
An OPC UA client connector that loses its server stops writing its inputs, so they age and the engine turns them stale.
Heartbeat, status and the safety contract
The engine publishes its own signals under engine.system_prefix (default taktwerk):
| signal | type | meaning |
|---|---|---|
taktwerk.heartbeat | u64 | advances once per tick whose outputs were published |
taktwerk.status | i32 | 0 init, 1 running, 2 faulted, 3 stopped |
taktwerk.cycle | u64 | every tick, skipped ones included |
taktwerk.overruns | u64 | deadline slots missed |
taktwerk.stale | u64 | ticks skipped for a stale input |
Models run inside the engine’s process; there is no process isolation. Whoever consumes the
outputs, typically the PLC, owns the safe state. It watches the heartbeat (map it to a PLC
node with the OPC UA client) and falls back to its safe state
when the heartbeat stops advancing or status is not 1. The heartbeat advances only on a tick
whose outputs were published, so a held heartbeat means the outputs are not current.
Fail-stop
A model error (a non-success return code, or a failed init) terminates every instance and ends
the run with that error; taktwerk run exits non-zero. A connector that fails beyond its own
reconnect logic stops the run the same way. The engine does not retry: restart is the service
manager’s job (see Running in production).
Project file
One TOML file per engine is the source of truth. Unknown keys are errors. Relative paths are resolved against the project file’s directory.
[engine]
tick_ms = 10.0
input_max_age_ms = 100.0
[engine.realtime]
policy = "fifo"
priority = 80
cpu = 3
lock_memory = true
[models.pid]
kind = "raw" # or "fmi"
path = "models/pid" # package directory, .fmu, or extracted FMU directory
[[instance]] # instances run in declared order
id = "loop1"
model = "pid"
every = 2 # period = every × tick_ms
dims = { n = 3, m = "from-server" }
parameters = { kp = 1.5, ki = [0.1, 0.2, 0.3] }
inputs = { y = { signal = "plant.y", max_age_ms = 50.0 }, sp = "loop1.sp" }
outputs = { u = "plant.u" }
tunables = { kp = "tuning.kp" }
[[connector]]
id = "ua"
kind = "opcua-server"
[engine]
| key | type | default | meaning |
|---|---|---|---|
tick_ms | float | required | base tick, milliseconds, > 0; every period is a multiple of it |
start_time | float | 0.0 | model time of the first tick, seconds |
input_max_age_ms | float | none | default age limit of external inputs; none never goes stale |
system_prefix | string | "taktwerk" | prefix of the engine’s own signals; non-empty |
realtime | table | none | real-time settings of the cycle thread, below |
[engine.realtime]
Applies to the cycle thread only; the I/O threads keep the default scheduling.
| key | type | default | meaning |
|---|---|---|---|
policy | string | required | "fifo" (SCHED_FIFO) or "rr" (SCHED_RR) |
priority | integer | required | 1 to 99 |
cpu | integer | none | CPU the cycle thread is pinned to |
lock_memory | bool | false | mlockall before the first tick |
A refused setting (missing privilege, CPU out of range) stops the engine at start. See Running in production for the limits it needs.
[models.<id>]
| key | type | meaning |
|---|---|---|
kind | string | "fmi" (FMI 2 or 3 co-simulation) or "raw" (C library package) |
path | string | an .fmu, an extracted FMU directory, or a raw package directory |
[[instance]]
| key | type | default | meaning |
|---|---|---|---|
id | string | required | unique, non-empty, no . |
model | string | required | a key of [models] |
every | integer | 1 | period in ticks, ≥ 1 |
dims | table | {} | dimension → length or "from-server" |
parameters | table | {} | parameter or tunable → start value |
inputs | table | {} | input → signal, { signal, max_age_ms }, name[i] for a scalar, or a list of element sources |
outputs | table | {} | output → signal |
tunables | table | {} | tunable → signal |
Every key in dims, parameters, inputs, outputs and tunables must name a dimension or a
variable of that causality in the model.
dims
n = 3: a fixed length, checked against the dimension’sminandmax.n = "from-server": the length is discovered at init. The model needs a one-dimensional variable of shape[n]; the engine asks each connector for the length of the external item mapped to that variable’s signal. Today the OPC UA client answers for nodes in itsmap.- Unbound: the model’s
default; without one, init fails.
parameters
Start values of parameters and tunables. Unset ones keep the model’s value: the FMU’s start value, or zero for a raw library.
- A scalar takes a number or a boolean. Integers convert to floats; floats never convert to integers.
- An array takes nested arrays, one level per dimension, first index outermost:
A = [[1.0, 0.0], [0.0, 1.0]]. The engine stores it in the variable’s layout. - An array also takes one flat array of the full length, taken as already stored in the
variable’s layout:
A = [1.0, 0.0, 0.0, 1.0].
inputs, outputs, tunables
Each maps a variable to a signal name. Unmapped variables get <instance>.<variable>. An input
binding is either a signal name or a table:
inputs = { y = "plant.y" } # signal only
inputs = { y = { signal = "plant.y", max_age_ms = 50.0 } } # with an age limit
max_age_ms overrides engine.input_max_age_ms for that input. It is refused on a wired input
(one some instance outputs). When several instances consume one external signal, the explicit
limits they set must agree; one explicit limit wins over the default. See
Concepts for wiring rules.
A one-dimensional input may instead be composed element by element from a list with one source per element, in element order:
inputs = { y = ["plc.p", "est.mu", { signal = "plc.t", max_age_ms = 50.0 }, "est.v[1]"] }
nameis a scalar signal of the input’s type: another instance’s scalar output (wired) or an external input, created when no one else uses it.name[i]is elementi, counted from 0, of another instance’s one-dimensional output of the input’s type.{ signal, max_age_ms }sets an age limit on one external element, as above.- The list’s length must equal the input’s bound length. Multi-dimensional and text inputs, outputs and tunables cannot be composed.
- The composed value is the signal
<instance>.<input>, read-only, stored each time the instance is about to step (and before its init), so a connector shows what the model received.taktwerk checklists every composed input with its element sources.
A scalar input takes one element the same way, without a list:
inputs = { n_st = "mpc.u[0]" }
It is a composed input of one element, with the same rules and the same read-only signal
<instance>.<input>. A name with [ or ] that is not an element of another instance’s
one-dimensional output is refused: no signal name contains brackets, so it never becomes an
external input or a connector’s node mapping.
[[connector]]
| key | type | meaning |
|---|---|---|
id | string | unique |
kind | string | "opcua-server" or "opcua-client" |
All other keys belong to the connector kind; see Connectors. A project may have several connectors of one kind, for example one client per PLC.
Changes
Everything in the project file is read at start. Structural changes (models, dimensions, wiring, connectors) need a restart. While running, only tunables change, through a connector.
Connectors
A connector syncs part of the process image with the outside. At init the engine builds each
connector from its [[connector]] table, asks it for "from-server" lengths, builds the image
and lets each connector verify its mapping (fail-closed). While running, connectors work on
their own I/O threads; the cycle thread never waits for them.
Two kinds exist: the engine’s own OPC UA server and an OPC UA client.
OPC UA server
kind = "opcua-server" exposes every signal of the image, the engine’s own signals included.
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://0.0.0.0:4840"
namespace = "urn:taktwerk"
security = ["none"]
anonymous = true
users = [{ user = "op", password_env = "UA_PASSWORD" }]
pki_dir = "./pki"
trust_client_certs = false
| key | default | meaning |
|---|---|---|
endpoint | "opc.tcp://0.0.0.0:4840" | listen URL, opc.tcp://host:port |
namespace | "urn:taktwerk" | namespace URI of the signal nodes |
security | ["none"] | offered message security, one endpoint each: "none", "sign", "sign-encrypt" |
anonymous | true | accept anonymous sessions |
users | [] | accepted users: { user, password } or { user, password_env } |
pki_dir | "./pki" | server certificate and key, trusted and rejected client certificates |
trust_client_certs | false | trust every client certificate, not only those in <pki_dir>/trusted |
"sign" and "sign-encrypt" use the Basic256Sha256 policy. The server certificate is generated
in pki_dir when a secure endpoint needs one. A user’s password comes from exactly one of
password (inline) and password_env (the name of an environment variable); prefer
password_env so no secret sits in the project file.
The server is open by default: anonymous, no encryption, all interfaces. On any network
other than a closed machine network, bind it to one interface, turn off anonymous, add users
and offer only secure endpoints.
Nodes
- Every signal is a variable node with NodeId
ns=<index of namespace>;s=<signal name>, for examplens=2;s=plant.y(the index is assigned by the server; read it from the server’s namespace array, or address the node by URI asnsu=urn:taktwerk;s=plant.y). - The browse path splits the name on
.into folders underObjects:plant.yisObjects/plant/y,taktwerk.heartbeatisObjects/taktwerk/heartbeat. - Data types:
f64Double,f32Float,i64Int64,i32Int32,i16Int16,i8SByte,u64UInt64,u32UInt32,u16UInt16,u8Byte,boolBoolean. - A scalar has ValueRank −1. An array has its rank and
ArrayDimensions; matrix elements are sent in row-major order whatever the model’s storage layout. - A text signal is a scalar
Stringnode. A read gives the bytes up to the first NUL, decoded as UTF-8 (invalid sequences are replaced, not refused). A write takes aStringof at most capacity − 1 UTF-8 bytes, copies it and zero-fills the rest; a longer one is refused withBadOutOfRangeand one containing NUL withBadInvalidArgument, never truncated. - Outputs and system signals are read-only. Inputs and tunables are writable; a write goes to the image and is picked up at the next tick.
- Every node follows each published tick, with the time the value was written into the image as
its source timestamp. A value never written has status
UncertainInitialValue.
OPC UA client
kind = "opcua-client" syncs mapped signals with another OPC UA server, typically a PLC’s.
[[connector]]
id = "plc"
kind = "opcua-client"
endpoint = "opc.tcp://192.168.0.10:4840"
security = "none"
credentials = { user = "op", password_env = "PLC_PASSWORD" }
request_timeout_ms = 1000
reconnect_backoff_ms = 1000
sync_period_ms = 100
stamp = "receive"
[connector.map]
"plant.u" = "ns=4;s=Plant.u" # input: read from the PLC
"plant.y" = "nsu=urn:example:plc;s=Plant.y" # output: written to the PLC
"taktwerk.heartbeat" = "ns=4;s=Plant.heartbeat" # the PLC watches it
"taktwerk.status" = "ns=4;s=Plant.status"
| key | default | meaning |
|---|---|---|
endpoint | required | server URL, opc.tcp://host:port[/path] |
security | "none" | "none", "sign" or "sign-encrypt" (Basic256Sha256) |
credentials | anonymous | { user, password } or { user, password_env } |
request_timeout_ms | 1000 | bound on every request |
reconnect_backoff_ms | 1000 | first wait before a reconnect; doubles per failure up to 16 times this |
sync_period_ms | 100 | period of the input read |
stamp | "receive" | time an input read is stamped with: "receive" or "source" |
pki_dir | "./pki-client" | client certificate and trusted server certificates (secure sessions only) |
trust_server_certs | false | trust every server certificate, not only those in <pki_dir>/trusted |
map | {} | signal name → NodeId |
Map
Each key is a signal of the image; each value a NodeId: ns=<index>;s=<id>, nsu=<uri>;s=<id>
(the namespace is resolved by URI on every session), or the i=, g=, b= forms. Unmapped
signals are not synced.
- Inputs and tunables are read from their nodes, one bulk Read every
sync_period_ms. - Outputs and system signals are written to theirs, one bulk Write per published tick.
Verification
Every session is verified before it syncs: the first at init, every reconnect again. For each mapped node the client reads DataType, ValueRank, ArrayDimensions, AccessLevel and the value, and refuses the session listing every mismatch:
- the node exists and its DataType is the signal’s type (
Stringfor a text signal); - a scalar signal maps to a scalar node, an array signal to an array node with the same element
count (from
ArrayDimensions, or the current value when the server reports none); - the node is readable, and writable when the engine writes it.
A text signal maps to a scalar String node, typically a PLC STRING(n), and follows the
server’s rules: a remote string longer than capacity − 1 bytes is not read into the image (the
input ages, the problem is logged), and the PLC refuses an output longer than its own n.
Array elements travel in the signal’s storage order. At init a refusal, or an unreachable
server, stops the engine before the first tick. After a reconnect a refusal is logged and
retried with backoff.
Reconnect and staleness
A lost session is closed and reconnected with exponential backoff. While disconnected, inputs
are not written, so they age; with a max_age_ms the engine turns them stale and skips ticks
until reads resume. An input read with a bad status, or a value that does not fit the signal,
leaves that signal aging as well; the problem is logged once until it clears.
Stamp
stamp = "receive" stamps an input with the time the read response arrived: an input is as
fresh as the last successful read. stamp = "source" uses the server’s source timestamp (or
the receive time when it sends none). Servers that stamp a value only when it changes make a
constant input look old under "source".
from-server
For an instance dimension bound "from-server", the client answers with the length of the node
mapped to the dimension’s signal (its ArrayDimensions, else its current value’s length).
Running in production
systemd
Run one engine per unit. The service manager owns restarts: taktwerk stops on a model error
(fail-stop) and exits non-zero, and Restart=on-failure brings it back.
[Unit]
Description=taktwerk model engine
After=network-online.target
Wants=network-online.target
[Service]
User=taktwerk
WorkingDirectory=/srv/taktwerk
ExecStart=/usr/local/bin/taktwerk run /srv/taktwerk/project.toml
Restart=on-failure
RestartSec=2
Environment=RUST_LOG=info
# Let [engine.realtime] apply SCHED_FIFO, a priority and mlockall without root.
LimitRTPRIO=90
LimitMEMLOCK=infinity
# Optional: keep the rest of the process (OPC UA I/O) off the cycle thread's CPU.
# CPUAffinity=0-2
[Install]
WantedBy=multi-user.target
sudo install -m 0644 taktwerk.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now taktwerk
journalctl -u taktwerk -f
The same unit is in the repository as
examples/taktwerk.service.
WorkingDirectory matters for relative paths in connector settings such as pki_dir; model
paths are relative to the project file. Passwords referenced by password_env go into the unit
with Environment= or an EnvironmentFile= readable only by root.
Real time
[engine.realtime] applies to the cycle thread only:
[engine.realtime]
policy = "fifo" # SCHED_FIFO; or "rr"
priority = 80 # 1..=99, at most the unit's LimitRTPRIO
cpu = 3 # pin the cycle thread
lock_memory = true # mlockall before the first tick
What each setting needs:
| setting | needs |
|---|---|
policy, priority | LimitRTPRIO ≥ priority, or CAP_SYS_NICE |
lock_memory | LimitMEMLOCK=infinity, or CAP_IPC_LOCK |
cpu | a CPU the process’s cgroup allows |
If a setting is refused the engine does not start. A dedicated CPU helps most when nothing else
runs on it: keep it free of other work (isolcpus= on the kernel command line, or a cpuset),
start the process on the other CPUs with the unit’s CPUAffinity=, and let cpu pin the cycle
thread to the free one. The I/O threads stay where CPUAffinity= put them.
Measure on the target: taktwerk run prints the mean and longest tick and the overrun count when
it stops, and taktwerk.overruns is live on the OPC UA server.
Cross-building
taktwerk runs on aarch64, x86_64 and 32-bit hard-float ARM (armv7) Linux. Build on a development machine for the target with
cargo-zigbuild, which links against an older
glibc than the host’s so one binary runs on older target systems:
cargo install --locked cargo-zigbuild # also needs `zig` on the PATH
rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu armv7-unknown-linux-gnueabihf
# The suffix after the target is the glibc version to link against: the oldest your targets run.
cargo zigbuild --release -p taktwerk --target aarch64-unknown-linux-gnu.<glibc>
cargo zigbuild --release -p taktwerk --target x86_64-unknown-linux-gnu.<glibc>
cargo zigbuild --release -p taktwerk --target armv7-unknown-linux-gnueabihf.<glibc>
The binaries land in target/<target>/release/taktwerk. taktwerk has no native dependencies
beyond libc; its OPC UA security is pure Rust.
Model libraries are separate files and must run on the target too: build raw libraries per
architecture into lib/aarch64/, lib/x86_64/ and lib/arm/ (zig cc -target aarch64-linux-gnu.<glibc> or arm-linux-gnueabihf.<glibc> works for C), and ship FMUs with a binary for the target platform
(see FMI 3).
CLI reference
taktwerk logs to stderr. RUST_LOG sets the filter; the default is info for run and
warn otherwise. Exit status is non-zero when a command fails, when check finds a problem,
and when run ends on a model or connector failure.
The sections below are the program’s own --help output.
taktwerk
Fixed-step model execution engine for Linux with its own OPC UA server
Usage: taktwerk <COMMAND>
Commands:
check Load a project, its models and connectors, resolve and bind it, print the plan
run Run a project until SIGINT or SIGTERM
inspect Print a model's interface: dimensions, variables, shapes
new Scaffold a project file for one model; prints it unless asked to write
import-header Read a raw model descriptor from a C header, or propose one; prints it unless asked to write
fmu-wrap Wrap a confirmed raw model package as an FMI 3 co-simulation FMU; a dry run unless asked to write
tui Monitor a running engine over OPC UA and edit its inputs and tunables
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
taktwerk check
Load a project, its models and connectors, resolve and bind it, print the plan
Usage: taktwerk check <PROJECT>
Arguments:
<PROJECT> Project file
Options:
-h, --help Print help
In the signal table, type is the element type, or text for a text signal; its shape is then
the capacity in bytes, NUL included (text [32]). inspect prints variables the same way.
taktwerk run
Run a project until SIGINT or SIGTERM
Usage: taktwerk run <PROJECT>
Arguments:
<PROJECT> Project file
Options:
-h, --help Print help
taktwerk inspect
Print a model's interface: dimensions, variables, shapes
Usage: taktwerk inspect --kind <KIND> <MODEL>
Arguments:
<MODEL>
Model path: an .fmu, an extracted FMU or a raw package directory
Options:
--kind <KIND>
Model kind
Possible values:
- fmi: FMI 2 or FMI 3 co-simulation FMU (`.fmu` or an extracted directory)
- raw: Raw C library package (`taktwerk-model.toml` and libraries)
-h, --help
Print help (see a summary with '-h')
taktwerk new
Scaffold a project file for one model; prints it unless asked to write
Usage: taktwerk new [OPTIONS] --kind <KIND> <MODEL>
Arguments:
<MODEL>
Model path
Options:
--kind <KIND>
Model kind
Possible values:
- fmi: FMI 2 or FMI 3 co-simulation FMU (`.fmu` or an extracted directory)
- raw: Raw C library package (`taktwerk-model.toml` and libraries)
-o, --output <OUTPUT>
Write the project to this file instead of printing it
--write
Write the project to `taktwerk.toml` (or `--output`)
--force
Overwrite an existing file
--tick-ms <TICK_MS>
Base tick, milliseconds
[default: 10]
--port <PORT>
Port of the own OPC UA server
[default: 4840]
-h, --help
Print help (see a summary with '-h')
taktwerk import-header
Read a raw model descriptor from a C header, or propose one; prints it unless asked to write
Usage: taktwerk import-header [OPTIONS] <HEADER>
Arguments:
<HEADER> C header declaring the model's functions and structs
Options:
-o, --output <OUTPUT> Write the descriptor to this file instead of printing it
--force Overwrite an existing file
--entry <ENTRY> The one function that serves as init and step (single entry point)
--arg-struct <PARAM=STRUCT> Which struct an opaque `char *`/`void *` parameter of the entry carries, as `<param>=<struct>`; repeatable
--shape Require the recommended shape: fail with every deviation instead of proposing an unconfirmed descriptor
-h, --help Print help
taktwerk fmu-wrap
Wrap a confirmed raw model package as an FMI 3 co-simulation FMU; a dry run unless asked to write
Usage: taktwerk fmu-wrap [OPTIONS] <PACKAGE>
Arguments:
<PACKAGE>
The raw model package (directory with `taktwerk-model.toml` and `lib/<arch>/`)
Options:
-o, --output <OUTPUT>
The `.fmu` to write; default `<modelIdentifier>.fmu` in the working directory
--target <ARCH>
Target architecture (`aarch64`, `x86_64`), repeatable; default the host.
Another target needs `zig` on PATH (`zig cc -target <arch>-linux-gnu.2.25`) and the package's library for it.
--bundle <LIB>
A further library to ship beside the model's, repeatable.
A bare file name is taken from the package's `lib/<arch>/` per target, a path is copied as is; both land in `binaries/<arch>-linux/`.
--write
Build and write the FMU instead of printing what it would contain
-h, --help
Print help (see a summary with '-h')
taktwerk tui
Monitor a running engine over OPC UA and edit its inputs and tunables
Usage: taktwerk tui [OPTIONS] <ENDPOINT>
Arguments:
<ENDPOINT> The engine's server, e.g. `opc.tcp://127.0.0.1:4840`
Options:
--namespace <NAMESPACE> Namespace URI of the signal nodes [default: urn:taktwerk]
--prefix <PREFIX> The engine's `system_prefix` [default: taktwerk]
-h, --help Print help
Text signals show quoted ("tank 3") and are edited as strings: type the text, or quote it to
keep leading or trailing spaces; inside quotes \", \\, \n and \t are escapes. The
server refuses text longer than the signal’s capacity − 1 bytes.
Examples
The examples ship in the release archive with their libraries prebuilt. Their sources are below;
each project file runs from the directory that holds examples/.
raw-pi
A PI controller from a plain C library, multiple instances by handle.
pi.h:
/* A PI controller over n channels, one state per instance behind a handle. */
#ifndef PI_H
#define PI_H
typedef struct {
double kp;
double ki;
} pi_gains;
int pi_init(void **handle, int n, double dt);
int pi_step(void *handle, const pi_gains *gains, const double *sp, const double *pv, double *u);
void pi_terminate(void *handle);
#endif
pi.c:
#include <stdlib.h>
#include "pi.h"
typedef struct {
int n;
double dt;
double integral[];
} pi_state;
int pi_init(void **handle, int n, double dt) {
pi_state *s;
int i;
if (n < 1 || dt <= 0.0) return -1;
s = malloc(sizeof *s + sizeof(double) * (size_t)n);
if (!s) return -2;
s->n = n;
s->dt = dt;
for (i = 0; i < n; i++) s->integral[i] = 0.0;
*handle = s;
return 0;
}
int pi_step(void *handle, const pi_gains *gains, const double *sp, const double *pv, double *u) {
pi_state *s = handle;
int i;
if (!s) return -1;
for (i = 0; i < s->n; i++) {
double e = sp[i] - pv[i];
s->integral[i] += gains->ki * e * s->dt;
u[i] = gains->kp * e + s->integral[i];
}
return 0;
}
void pi_terminate(void *handle) {
free(handle);
}
build.sh:
#!/usr/bin/env sh
# Build the PI library into this package for the running architecture.
set -eu
cd "$(dirname "$0")"
arch=$(uname -m)
case "$arch" in arm*) arch=arm ;; esac # 32-bit ARM packages use lib/arm
mkdir -p "lib/$arch"
cc -shared -fPIC -O2 -Wall -o "lib/$arch/libpi.so" pi.c
taktwerk-model.toml:
# Descriptor of pi.h, proposed by `taktwerk import-header pi.h` and confirmed by hand:
# the gains became tunables, everything else is as proposed.
name = "pi"
instances = "multiple"
[[dimensions]]
name = "n"
min = 1
default = 1
[[variables]]
name = "kp"
causality = "tunable"
type = "f64"
description = "proportional gain"
[[variables]]
name = "ki"
causality = "tunable"
type = "f64"
unit = "1/s"
description = "integral gain"
[[variables]]
name = "sp"
causality = "input"
type = "f64"
shape = ["n"]
description = "setpoint"
[[variables]]
name = "pv"
causality = "input"
type = "f64"
shape = ["n"]
description = "process value"
[[variables]]
name = "u"
causality = "output"
type = "f64"
shape = ["n"]
description = "manipulated variable"
[abi]
confirmed = true
[abi.init]
symbol = "pi_init"
args = [{ handle = "out" }, { dim = "n", type = "int" }, { builtin = "step_size" }]
[abi.step]
symbol = "pi_step"
args = [{ handle = "in" }, { struct = "pi_gains" }, { array = "sp" }, { array = "pv" }, { array = "u" }]
[abi.terminate]
symbol = "pi_terminate"
returns = "void"
args = [{ handle = "in" }]
[abi.structs.pi_gains]
members = [
{ name = "kp", type = "double", variable = "kp" },
{ name = "ki", type = "double", variable = "ki" },
]
project.toml:
# A PI controller from a plain C library. Build it first:
# examples/raw-pi/build.sh
# Then, from the repository root:
# taktwerk run examples/raw-pi/project.toml
# taktwerk tui opc.tcp://127.0.0.1:4840 # edit pi.sp and pi.pv, watch pi.u
[engine]
tick_ms = 10.0
[models.pi]
kind = "raw"
path = "."
[[instance]]
id = "pi"
model = "pi"
dims = { n = 1 }
parameters = { kp = 2.0, ki = 1.0 }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
raw-filter-bank
lowpass.h:
/* A bank of first-order low-pass filters over n channels, in the recommended shape. */
#ifndef LOWPASS_H
#define LOWPASS_H
typedef struct {
int n; /* 1..64 = 3 */
} lowpass_dims;
typedef struct {
const double *tau; /* [n] s: time constant */
const double *y0; /* [n]: initial state */
} lowpass_params;
typedef struct {
const double *u; /* [n]: filter input */
} lowpass_inputs;
typedef struct {
double gain; /* output gain */
} lowpass_tunables;
typedef struct {
double *y; /* [n]: gain times the filter state */
} lowpass_outputs;
int lowpass_init(void **h, const lowpass_dims *dims, const lowpass_params *params,
double step_size);
int lowpass_step(void *h, double time, const lowpass_inputs *in,
const lowpass_tunables *tun, lowpass_outputs *out);
int lowpass_terminate(void *h);
#endif
lowpass.c:
#include <stdlib.h>
#include "lowpass.h"
typedef struct {
int n;
double *alpha; /* [n], dt / (tau + dt) */
double *x; /* [n], filter state */
} lowpass_state;
int lowpass_init(void **h, const lowpass_dims *dims, const lowpass_params *params,
double step_size) {
lowpass_state *s;
int i;
if (dims->n < 1 || step_size <= 0.0) return 1;
s = calloc(1, sizeof *s);
if (!s) return 2;
s->n = dims->n;
s->alpha = calloc((size_t)s->n, sizeof *s->alpha);
s->x = calloc((size_t)s->n, sizeof *s->x);
if (!s->alpha || !s->x) {
lowpass_terminate(s);
return 2;
}
for (i = 0; i < s->n; i++) {
if (params->tau[i] < 0.0) {
lowpass_terminate(s);
return 3;
}
s->alpha[i] = step_size / (params->tau[i] + step_size);
s->x[i] = params->y0[i];
}
*h = s;
return 0;
}
int lowpass_step(void *h, double time, const lowpass_inputs *in,
const lowpass_tunables *tun, lowpass_outputs *out) {
lowpass_state *s = h;
int i;
(void)time;
for (i = 0; i < s->n; i++) {
s->x[i] += s->alpha[i] * (in->u[i] - s->x[i]);
out->y[i] = tun->gain * s->x[i];
}
return 0;
}
int lowpass_terminate(void *h) {
lowpass_state *s = h;
if (s) {
free(s->alpha);
free(s->x);
free(s);
}
return 0;
}
build.sh:
#!/usr/bin/env sh
# Build the filter bank library into this package for the running architecture.
set -eu
cd "$(dirname "$0")"
arch=$(uname -m)
case "$arch" in arm*) arch=arm ;; esac # 32-bit ARM packages use lib/arm
mkdir -p "lib/$arch"
cc -shared -fPIC -O2 -Wall -Wextra -o "lib/$arch/liblowpass.so" lowpass.c
taktwerk-model.toml:
# Read by `taktwerk import-header` from the recommended shape of the header: every
# role, shape and bound is stated there, so the descriptor is confirmed as read.
name = "lowpass"
instances = "multiple"
[[dimensions]]
name = "n"
min = 1
max = 64
default = 3
[[variables]]
name = "tau"
causality = "parameter"
type = "f64"
shape = ["n"]
layout = "row-major"
unit = "s"
description = "time constant"
[[variables]]
name = "y0"
causality = "parameter"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "initial state"
[[variables]]
name = "u"
causality = "input"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "filter input"
[[variables]]
name = "gain"
causality = "tunable"
type = "f64"
shape = []
layout = "row-major"
description = "output gain"
[[variables]]
name = "y"
causality = "output"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "gain times the filter state"
[abi]
confirmed = true
ok_codes = [0]
[abi.init]
symbol = "lowpass_init"
returns = "int"
[[abi.init.args]]
handle = "out"
[[abi.init.args]]
struct = "lowpass_dims"
[[abi.init.args]]
struct = "lowpass_params"
[[abi.init.args]]
builtin = "step_size"
[abi.step]
symbol = "lowpass_step"
returns = "int"
[[abi.step.args]]
handle = "in"
[[abi.step.args]]
builtin = "time"
[[abi.step.args]]
struct = "lowpass_inputs"
[[abi.step.args]]
struct = "lowpass_tunables"
[[abi.step.args]]
struct = "lowpass_outputs"
[abi.terminate]
symbol = "lowpass_terminate"
returns = "int"
[[abi.terminate.args]]
handle = "in"
[[abi.structs.lowpass_dims.members]]
name = "n"
type = "int32_t"
dim = "n"
[[abi.structs.lowpass_inputs.members]]
name = "u"
type = "double *"
variable = "u"
[[abi.structs.lowpass_outputs.members]]
name = "y"
type = "double *"
variable = "y"
[[abi.structs.lowpass_params.members]]
name = "tau"
type = "double *"
variable = "tau"
[[abi.structs.lowpass_params.members]]
name = "y0"
type = "double *"
variable = "y0"
[[abi.structs.lowpass_tunables.members]]
name = "gain"
type = "double"
variable = "gain"
project.toml:
# A low-pass filter bank from a C library in the recommended shape. Its descriptor is read from
# the header without review (`taktwerk import-header lowpass.h --shape -o taktwerk-model.toml`).
# Build the library first:
# examples/raw-filter-bank/build.sh
# Then, from the repository root:
# taktwerk run examples/raw-filter-bank/project.toml
# taktwerk tui opc.tcp://127.0.0.1:4840 # edit filt.u and filt.gain, watch filt.y
[engine]
tick_ms = 10.0
[models.lowpass]
kind = "raw"
path = "."
[[instance]]
id = "filt"
model = "lowpass"
dims = { n = 3 }
parameters = { tau = [0.1, 0.5, 1.0], y0 = [0.0, 0.0, 0.0], gain = 1.0 }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
raw-single-entry
io_model.h:
/* A model behind a single entry point: one function for init and step, called with an id and
* two opaque byte pointers that the library casts to its input and output structs. The library
* keeps no state; every call re-reads the sizes it reported from the caller's output struct. */
#ifndef IO_MODEL_H
#define IO_MODEL_H
#include <stdint.h>
#define IO_ID 7
#define IO_OK 0
#define IO_NULL_ARGUMENT (-1)
#define IO_UNKNOWN_VARIANT (-2)
#define IO_UNKNOWN_ID (-3)
#define IO_NOT_INITIALISED (-4)
#define IO_BAD_FLAG (-5)
/* What a successful call writes into `ready`: a true byte that is not 1. */
#define IO_TRUE_BYTE 2
typedef struct {
int32_t *init_flag; /* 1 on the first call (init), 0 on every step */
int32_t variant; /* by value between two pointers: which size set to report */
double *k; /* scalar gain */
double *u; /* nu inputs */
int32_t *counter; /* step counter, written back by the library */
char *title; /* text the caller supplies, NUL-terminated */
} io_input;
typedef struct {
char *label; /* the title echoed back, or the library's own name if empty */
double *y; /* ny outputs: y = k * gain * u */
double *gain; /* ny x nu, row-major, filled at init */
double *p; /* np parameters, filled at init */
char *names; /* names written at init */
int32_t *dim_nu; /* reported at init */
int32_t *dim_ny; /* reported at init */
int32_t *dim_np; /* reported at init */
uint8_t *ready; /* IO_TRUE_BYTE after every successful call */
} io_output;
int io_call(int id, char *in, char *out);
#endif
io_model.c:
#include "io_model.h"
#include <stddef.h>
#include <string.h>
static int sizes(int32_t variant, int32_t *nu, int32_t *ny, int32_t *np) {
switch (variant) {
case 1: *nu = 2; *ny = 3; *np = 4; return 0;
case 2: *nu = 1; *ny = 1; *np = 2; return 0;
default: return -1;
}
}
static void copy_text(char *dst, size_t cap, const char *src) {
size_t n = strlen(src);
if (n >= cap) n = cap - 1;
memcpy(dst, src, n);
dst[n] = '\0';
}
int io_call(int id, char *in_bytes, char *out_bytes) {
if (id != IO_ID) return IO_UNKNOWN_ID;
if (in_bytes == NULL || out_bytes == NULL) return IO_NULL_ARGUMENT;
io_input *in = (io_input *)in_bytes;
io_output *out = (io_output *)out_bytes;
if (in->init_flag == NULL || in->k == NULL || in->u == NULL || in->counter == NULL ||
out->y == NULL || out->gain == NULL || out->p == NULL || out->dim_nu == NULL ||
out->dim_ny == NULL || out->dim_np == NULL || out->ready == NULL)
return IO_NULL_ARGUMENT;
int32_t nu, ny, np;
if (sizes(in->variant, &nu, &ny, &np) != 0) return IO_UNKNOWN_VARIANT;
if (*in->init_flag == 1) {
*out->dim_nu = nu;
*out->dim_ny = ny;
*out->dim_np = np;
for (int i = 0; i < ny; i++) {
out->y[i] = 0.0;
for (int j = 0; j < nu; j++) out->gain[i * nu + j] = (i + 1) + 0.1 * j;
}
for (int i = 0; i < np; i++) out->p[i] = 0.5 * i;
if (out->label != NULL)
copy_text(out->label, 32, (in->title != NULL && in->title[0] != '\0') ? in->title
: "io");
if (out->names != NULL) copy_text(out->names, 24, "k;u;y;p");
*in->counter = 0;
*out->ready = IO_TRUE_BYTE;
return IO_OK;
}
if (*in->init_flag != 0) return IO_BAD_FLAG;
if (*out->dim_nu != nu || *out->dim_ny != ny || *out->dim_np != np) return IO_NOT_INITIALISED;
for (int i = 0; i < ny; i++) {
double acc = 0.0;
for (int j = 0; j < nu; j++) acc += out->gain[i * nu + j] * in->u[j];
out->y[i] = *in->k * acc;
}
*in->counter += 1;
*out->ready = IO_TRUE_BYTE;
return IO_OK;
}
build.sh:
#!/usr/bin/env sh
# Build the single-entry library into this package for the running architecture.
set -eu
cd "$(dirname "$0")"
arch=$(uname -m)
case "$arch" in arm*) arch=arm ;; esac # 32-bit ARM packages use lib/arm
mkdir -p "lib/$arch"
cc -shared -fPIC -O2 -Wall -Wextra -o "lib/$arch/libio.so" io_model.c
taktwerk-model.toml:
# Descriptor of io_model.h, proposed by
# taktwerk import-header io_model.h --entry io_call --arg-struct in=io_input --arg-struct out=io_output
# and confirmed by hand: the id and `variant` became constants (7, 1), `k` a tunable, `counter`
# an output (the library writes it back), `title`, `label` and `names` text of a fixed capacity,
# `gain` the ny x nu matrix, and each reported dimension got a max.
name = "io"
[[dimensions]]
name = "nu"
min = 1
max = 8
[[dimensions]]
name = "ny"
min = 1
max = 8
[[dimensions]]
name = "np"
min = 1
max = 8
[[variables]]
name = "k"
causality = "tunable"
type = "f64"
[[variables]]
name = "u"
causality = "input"
type = "f64"
shape = ["nu"]
[[variables]]
name = "counter"
causality = "output"
type = "i32"
[[variables]]
name = "title"
causality = "parameter"
type = "u8"
shape = [16]
[[variables]]
name = "label"
causality = "output"
type = "u8"
shape = [32]
[[variables]]
name = "y"
causality = "output"
type = "f64"
shape = ["ny"]
[[variables]]
name = "gain"
causality = "output"
type = "f64"
shape = ["ny", "nu"]
[[variables]]
name = "p"
causality = "output"
type = "f64"
shape = ["np"]
[[variables]]
name = "names"
causality = "output"
type = "u8"
shape = [24]
[[variables]]
name = "ready"
causality = "output"
type = "u8"
[abi]
confirmed = true
[abi.init]
symbol = "io_call"
args = [{ const = 7, type = "int" }, { struct = "io_input" }, { struct = "io_output" }]
[abi.step]
symbol = "io_call"
args = [{ const = 7, type = "int" }, { struct = "io_input" }, { struct = "io_output" }]
[abi.structs.io_input]
members = [
{ name = "init_flag", type = "int32_t *", phase = { init = 1, step = 0 } },
{ name = "variant", type = "int32_t", const = 1 },
{ name = "k", type = "double *", variable = "k" },
{ name = "u", type = "double *", variable = "u" },
{ name = "counter", type = "int32_t *", variable = "counter" },
{ name = "title", type = "char *", variable = "title" },
]
[abi.structs.io_output]
members = [
{ name = "label", type = "char *", variable = "label" },
{ name = "y", type = "double *", variable = "y" },
{ name = "gain", type = "double *", variable = "gain" },
{ name = "p", type = "double *", variable = "p" },
{ name = "names", type = "char *", variable = "names" },
{ name = "dim_nu", type = "int32_t *", dim = "nu", reported = true },
{ name = "dim_ny", type = "int32_t *", dim = "ny", reported = true },
{ name = "dim_np", type = "int32_t *", dim = "np", reported = true },
{ name = "ready", type = "uint8_t *", variable = "ready" },
]
project.toml:
# A library behind a single entry point: one function for init and step, an id and two opaque
# struct pointers (io_model.h). Build it first:
# examples/raw-single-entry/build.sh
# Then, from the repository root:
# taktwerk check examples/raw-single-entry/project.toml
# taktwerk run examples/raw-single-entry/project.toml
# taktwerk tui opc.tcp://127.0.0.1:4840 # edit io.u and io.k, watch io.y = k * gain * u
# io.label reads "tank 3" (the title echoed back) and io.names "k;u;y;p": text signals, String
# nodes on the OPC UA server.
[engine]
tick_ms = 10.0
[models.io]
kind = "raw"
path = "."
# `variant = 1` in the descriptor makes the library report nu = 2, ny = 3, np = 4.
[[instance]]
id = "io"
model = "io"
dims = { nu = 2, ny = 3, np = 4 }
parameters = { title = "tank 3", k = 1.0 }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
fmi-state-space
project.toml:
# A first-order lag, y' = (u - y) / 1 s, from the Reference FMU `StateSpace` (FMI 3) with its
# structural parameters bound to one state, one input and one output.
#
# Build the FMU for this host first (needs git, network and cc):
# crates/taktwerk-fmi/tests/reference-fmus.sh target/tmp/reference-fmus
# Then, from the repository root:
# taktwerk check examples/fmi-state-space/project.toml
# taktwerk run examples/fmi-state-space/project.toml
# taktwerk tui opc.tcp://127.0.0.1:4840 # edit plant.u, watch plant.y follow
[engine]
tick_ms = 10.0
[models.lag]
kind = "fmi"
path = "../../target/tmp/reference-fmus/fmi3/StateSpace"
[[instance]]
id = "plant"
model = "lag"
dims = { m = 1, n = 1, r = 1 }
parameters = { A = [[-1.0]], B = [[1.0]], C = [[1.0]], D = [[0.0]], x0 = [0.0] }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
closed-loop
project.toml:
# The PI controller of `raw-pi` closing the loop around the first-order lag of `fmi-state-space`.
# Build both models first:
# examples/raw-pi/build.sh
# crates/taktwerk-fmi/tests/reference-fmus.sh target/tmp/reference-fmus
# Then, from the repository root:
# taktwerk run examples/closed-loop/project.toml
# taktwerk tui opc.tcp://127.0.0.1:4840 # edit ctrl.sp, watch plant.y follow
[engine]
tick_ms = 10.0
[models.pi]
kind = "raw"
path = "../raw-pi"
[models.lag]
kind = "fmi"
path = "../../target/tmp/reference-fmus/fmi3/StateSpace"
# The controller runs first and reads the plant output of the previous tick.
[[instance]]
id = "ctrl"
model = "pi"
dims = { n = 1 }
parameters = { kp = 2.0, ki = 1.0 }
inputs = { pv = "plant.y" }
[[instance]]
id = "plant"
model = "lag"
dims = { m = 1, n = 1, r = 1 }
parameters = { A = [[-1.0]], B = [[1.0]], C = [[1.0]], D = [[0.0]], x0 = [0.0] }
inputs = { u = "ctrl.u" }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
taktwerk.service
# systemd unit for one engine. Install as /etc/systemd/system/taktwerk.service, then
# systemctl daemon-reload && systemctl enable --now taktwerk
# The service manager restarts the engine after a model error (fail-stop).
[Unit]
Description=taktwerk model engine
After=network-online.target
Wants=network-online.target
[Service]
User=taktwerk
WorkingDirectory=/srv/taktwerk
ExecStart=/usr/local/bin/taktwerk run /srv/taktwerk/project.toml
Restart=on-failure
RestartSec=2
Environment=RUST_LOG=info
# Real time: `[engine.realtime]` in the project asks for SCHED_FIFO, a CPU and mlockall on the
# cycle thread only. These limits let it do so without root.
LimitRTPRIO=90
LimitMEMLOCK=infinity
# Optional: keep the rest of the process (OPC UA I/O) off the cycle thread's CPU; the project's
# `cpu` then names the CPU left free, ideally one isolated with `isolcpus=` or a cpuset.
# CPUAffinity=0-2
[Install]
WantedBy=multi-user.target
Making a model taktwerk-compatible
taktwerk has no ABI of its own. It hosts two kinds of model:
- FMI co-simulation FMUs (FMI 3, also FMI 2). FMI 3 is the recommended format: any tool that exports FMI 3 co-simulation works, and structural parameters carry the sizes.
- Plain C shared libraries, described by a descriptor (
taktwerk-model.toml) next to the library. New C models follow the recommended shape; existing other C interfaces are hosted through a hand-confirmed descriptor.
The model convention
Whatever the format, a taktwerk model is:
- Size-generic and plant-agnostic. Variables have symbolic shapes over named dimensions
(
y: f64[ny],A: f64[nx, nx]) with optionalmin,maxanddefault. The model learns each size at init. A fixed-size model is one whose dimensions are literals. - Explicit about roles. Each variable is an
input(read every step), anoutput(written every step), aparameter(set once before init) or atunable(set before init, changeable while running, delivered before the next step). - Allocation-free in its step. The engine owns every buffer the model exchanges: it sizes them at init and passes pointers that stay valid for the instance’s lifetime. A library may allocate its own state at init, never per step.
- Honest about instances. A library that keeps state per instance (behind a handle, or in
structs the engine owns per instance) declares
multiple. A library with state in globals declaressingle; taktwerk then loads a private copy of the file for each further instance. - Explicit about matrix layout: row-major (C order) or column-major.
- Fail-loud. A step that cannot continue returns an error code; the engine stops (fail-stop) and the service manager restarts it. Never return success with garbage outputs.
The project file then binds every dimension, sets parameters and maps signals per instance; one model serves many instances. See Concepts.
Checking a model
taktwerk inspect model.fmu --kind fmi # or: taktwerk inspect path/to/package --kind raw
taktwerk new model.fmu --kind fmi -o try.toml
taktwerk check try.toml
taktwerk run try.toml
inspect prints dimensions and variables exactly as the engine sees them; it is the quickest
way to confirm sizes, causalities, types and units came across.
Wrapping a raw model as an FMU
A confirmed package can also leave taktwerk as a standard FMI 3 co-simulation FMU, for any importer:
taktwerk fmu-wrap path/to/package # dry run: variables, structural parameters, targets
taktwerk fmu-wrap path/to/package --write -o model.fmu # build for the host
taktwerk fmu-wrap path/to/package --target aarch64 --target x86_64 --write # cross-build with zig
fmu-wrap builds for aarch64 and x86_64.
The generated C wrapper does what the raw adapter does, in C: it allocates the buffers from the
bound structural parameters (one UInt64 per dimension, with the descriptor’s min, max and
default as start), fills the struct members per call, loads the library itself (a single
library as a private copy per instance), checks ok_codes and reported lengths. The structs are
emitted for the target compiler and checked against taktwerk’s layout at compile time. Text
buffers appear as String variables; a library told its step size (builtin = "step_size") is
initialised on the first fmi3DoStep, which fixes the communication step. The FMU carries the
library and any --bundled dependency under binaries/<arch>-linux/ and the wrapper source
under sources/.
FMI 3 (recommended)
taktwerk runs FMI 3 and FMI 2 co-simulation FMUs. Export from any tool that writes FMI 3
co-simulation for Linux; the FMU then runs unchanged in taktwerk and in every other FMI
importer. A kind = "fmi" model path is an .fmu archive (extracted to a private temporary
directory at load) or an already extracted FMU directory.
Sizes: structural parameters
A size-generic FMI 3 model declares each size as a structural parameter and sizes its arrays
with <Dimension valueReference="…"/>. Each structural parameter becomes a taktwerk dimension:
its name is the dimension’s name, start its default, min and max its bounds.
<ModelVariables>
<UInt64 name="n" valueReference="1" causality="structuralParameter"
variability="fixed" start="1" min="1" max="64"/>
<Float64 name="tau" valueReference="2" causality="parameter" variability="fixed"
start="1.0" unit="s">
<Dimension valueReference="1"/>
</Float64>
<Float64 name="gain" valueReference="3" causality="parameter" variability="tunable"
start="1.0"/>
<Float64 name="u" valueReference="4" causality="input" start="0">
<Dimension valueReference="1"/>
</Float64>
<Float64 name="y" valueReference="5" causality="output">
<Dimension valueReference="1"/>
</Float64>
</ModelVariables>
$ taktwerk inspect filter.fmu --kind fmi
dimension min max default
n 1 64 1
variable causality type shape unit description
tau parameter f64 [n] s
gain tunable f64 scalar -
u input f64 [n] -
y output f64 [n] -
- Structural parameters must have an integer type.
- A
<Dimension>takesstart="4"(a literal length), or avalueReferenceto a structural parameter or to a constant integer variable with astart. - At instantiation taktwerk enters configuration mode, sets every structural parameter to the instance’s bound length, and leaves it. Then it sets the instance’s parameters and tunables.
- Arrays are row-major, as FMI 3 defines them.
Variables and types
| FMI causality / variability | taktwerk causality |
|---|---|
input | input |
output | output |
parameter, fixed | parameter |
parameter, tunable | tunable |
structuralParameter | a dimension |
local, independent, calculatedParameter | not exposed |
| FMI 3 type | taktwerk type |
|---|---|
Float64, Float32 | f64, f32 |
Int64, Int32, Int16, Int8 | i64, i32, i16, i8 |
UInt64, UInt32, UInt16, UInt8 | u64, u32, u16, u8 |
Boolean | bool |
Enumeration | i64 |
String (FMI 3) | text, u8 [capacity] |
Binary, Clock | refused when exposed |
A String is text of a fixed capacity, NUL included: 256 bytes, or what a taktwerk
annotation gives (<Annotation type="taktwerk"><text capacity="32"/></Annotation>). It is
exchanged through fmi3SetString/fmi3GetString; a longer string from the FMU is cut to the
capacity. Connectors present it as a string, like raw text.
Units come from unit or the declared type’s unit; description is kept. Both show in
inspect and are informational.
FMI 2 FMUs are scalar only: Real is f64, Integer and Enumeration are i32, Boolean
is bool; other types are refused.
Calling sequence
Per instance: instantiate (co-simulation, no event mode, the FMU’s resources/ directory as
resource path), configuration mode for structural parameters, parameters set, initialization
mode at engine.start_time (no tolerance, no stop time) with the inputs described under
Init, outputs read back, then one DoStep per period of
every × tick_ms. Inputs are set before every step, changed tunables
before the next step; outputs are read after it. OK and Warning are success; any other
status, or a step that requests termination, stops the engine. Logging goes to the engine log
at debug level.
Single vs multiple instances
canBeInstantiatedOnlyOncePerProcess="true" marks a single-instance FMU. taktwerk still runs
several instances of it: each further instance loads a private copy of the shared library, since
one loaded file shares its globals. Without the flag, all instances share one loaded library.
Prefer FMUs that keep all state in the instance.
Platform binaries
taktwerk loads binaries/<platform>/<modelIdentifier>.so for the running architecture:
| FMI | x86_64 | aarch64 | 32-bit ARM |
|---|---|---|---|
| 3 | x86_64-linux | aarch64-linux | aarch32-linux |
| 2 | linux64, then x86_64-linux | aarch64-linux, linuxaarch64, linux-aarch64 | aarch32-linux |
An FMU without a binary for the target is refused at load, naming the platforms it has. Build the binary against a glibc no newer than the target’s (see cross-building).
Checklist
- FMI 3 (or FMI 2), with a
CoSimulationelement; model exchange alone is refused. - Every size is a structural parameter with an integer type, a
start, andmin/maxwhere the model has limits. - Exposed variables use numeric or Boolean types only.
- Parameters that may change while running are
variability="tunable". - A Linux binary for every target architecture, in the platform folder above.
- No allocation and no blocking I/O in
DoStep. -
taktwerk inspect model.fmu --kind fmishows the dimensions and variables you expect.
Hand-written C
A C model is a shared library plus a descriptor, taktwerk-model.toml, in a package directory:
lowpass/
taktwerk-model.toml the descriptor
lib/aarch64/liblowpass.so
lib/x86_64/liblowpass.so
lib/<arch>/ is named by the running architecture: aarch64, x86_64, or arm for 32-bit
hard-float ARM. It holds one .so, or set
abi.library in the descriptor to pick one by file name.
The recommended shape
New C models use this shape, for a model <m>:
typedef struct { int nx; /* 1..64 = 4 */ int nu; } <m>_dims; /* one int per dimension */
typedef struct { const double *A; /* [nx][nx] */ double k; } <m>_params;
typedef struct { const double *u; /* [nu] m/s: inflow */ } <m>_inputs;
typedef struct { double gain; } <m>_tunables;
typedef struct { double *x; /* [nx] */ double y; } <m>_outputs;
int <m>_init(void **h, const <m>_dims *dims, const <m>_params *params, double step_size);
int <m>_step(void *h, double time, const <m>_inputs *in, const <m>_tunables *tun,
<m>_outputs *out);
int <m>_terminate(void *h); /* or void */
taktwerk import-header reads a header in this shape completely and writes a descriptor that is
already confirmed: nothing is guessed, so there is nothing to review. The rules:
- Return codes.
initandstepreturnint;0is success, anything else an error. A failed init or step stops the engine (fail-stop); the code is logged.terminatereturnsintorvoid. - Roles by struct name. The suffix of the type name says what a struct holds:
_dims,_params,_inputs,_tunables,_outputs. Every member is a variable of its struct’s role.<m>_paramsand<m>_tunablesmay be left out, and with them their argument. The struct arguments may come in any order; the handle,timeandstep_sizeare where the shape puts them. - Sizes in
<m>_dims, oneintper dimension, named like the dimension. A trailing comment may bound it and give a default:/* 1..64 */,/* 1.. */,/* 1..64 = 4 */. - Arrays are pointers whose memory the engine owns. Every pointer member carries its shape
in a trailing comment on its line, in dimension names or literal lengths:
/* [n] */,/* [nx][nu] */,/* [3] */. Matrices are row-major. Pointers stay valid for the instance’s lifetime, so init may keep them. - Scalars by value inside the structs: a scalar parameter, input, tunable or output is a
plain
double(or another admitted type) member without a shape. - Units and descriptions may follow the shape in the same comment:
/* [n] s: time constant */is unitsand descriptiontime constant;/* [n]: input */only a description. Without a colon, one word is a unit (/* m */) and more words a description. constmarks what the model reads: dims, params, inputs and tunables areconst; outputs are not.- State behind the handle.
initallocates the instance’s state and stores it throughh;stepandterminatereceive it. No globals, so any number of instances share one loaded library (instances = "multiple"). - No allocation in step, no blocking I/O, no printing. Allocate everything at init and free
it in
terminate. - Admitted C types:
double,float,bool,char, and the fixed-width and plain integer types at their 64-bit Linux widths, each optionallyconstand optionally followed by one*. Achar *with a literal length (/* [32] */) carries text, NUL included.
step advances from time by step_size. The engine fills in and tun before each step and
reads out after it; a changed tunable is in tun from the next step on.
Example: a low-pass filter bank
A first-order low-pass filter per channel over n channels, y = gain · x, with
x ← x + α (u − x) and α = dt / (tau + dt).
This is
examples/raw-filter-bank.
lowpass.h:
/* A bank of first-order low-pass filters over n channels, in the recommended shape. */
#ifndef LOWPASS_H
#define LOWPASS_H
typedef struct {
int n; /* 1..64 = 3 */
} lowpass_dims;
typedef struct {
const double *tau; /* [n] s: time constant */
const double *y0; /* [n]: initial state */
} lowpass_params;
typedef struct {
const double *u; /* [n]: filter input */
} lowpass_inputs;
typedef struct {
double gain; /* output gain */
} lowpass_tunables;
typedef struct {
double *y; /* [n]: gain times the filter state */
} lowpass_outputs;
int lowpass_init(void **h, const lowpass_dims *dims, const lowpass_params *params,
double step_size);
int lowpass_step(void *h, double time, const lowpass_inputs *in,
const lowpass_tunables *tun, lowpass_outputs *out);
int lowpass_terminate(void *h);
#endif
lowpass.c:
#include <stdlib.h>
#include "lowpass.h"
typedef struct {
int n;
double *alpha; /* [n], dt / (tau + dt) */
double *x; /* [n], filter state */
} lowpass_state;
int lowpass_init(void **h, const lowpass_dims *dims, const lowpass_params *params,
double step_size) {
lowpass_state *s;
int i;
if (dims->n < 1 || step_size <= 0.0) return 1;
s = calloc(1, sizeof *s);
if (!s) return 2;
s->n = dims->n;
s->alpha = calloc((size_t)s->n, sizeof *s->alpha);
s->x = calloc((size_t)s->n, sizeof *s->x);
if (!s->alpha || !s->x) {
lowpass_terminate(s);
return 2;
}
for (i = 0; i < s->n; i++) {
if (params->tau[i] < 0.0) {
lowpass_terminate(s);
return 3;
}
s->alpha[i] = step_size / (params->tau[i] + step_size);
s->x[i] = params->y0[i];
}
*h = s;
return 0;
}
int lowpass_step(void *h, double time, const lowpass_inputs *in,
const lowpass_tunables *tun, lowpass_outputs *out) {
lowpass_state *s = h;
int i;
(void)time;
for (i = 0; i < s->n; i++) {
s->x[i] += s->alpha[i] * (in->u[i] - s->x[i]);
out->y[i] = tun->gain * s->x[i];
}
return 0;
}
int lowpass_terminate(void *h) {
lowpass_state *s = h;
if (s) {
free(s->alpha);
free(s->x);
free(s);
}
return 0;
}
Build it into the package for the running architecture:
mkdir -p "lib/$(uname -m)"
cc -shared -fPIC -O2 -Wall -Wextra -o "lib/$(uname -m)/liblowpass.so" lowpass.c
Descriptor: read from the header
taktwerk import-header reads the header and writes the descriptor:
taktwerk import-header lowpass.h --shape -o taktwerk-model.toml
The header is in the recommended shape, so the descriptor comes out with abi.confirmed = true
and the engine loads it as it is. --shape makes any deviation an error; without it, a header
that is not quite in the shape still gets a proposal (see below). For lowpass.h:
name = "lowpass"
instances = "multiple"
[[dimensions]]
name = "n"
min = 1
max = 64
default = 3
[[variables]]
name = "tau"
causality = "parameter"
type = "f64"
shape = ["n"]
layout = "row-major"
unit = "s"
description = "time constant"
[[variables]]
name = "y0"
causality = "parameter"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "initial state"
[[variables]]
name = "u"
causality = "input"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "filter input"
[[variables]]
name = "gain"
causality = "tunable"
type = "f64"
shape = []
layout = "row-major"
description = "output gain"
[[variables]]
name = "y"
causality = "output"
type = "f64"
shape = ["n"]
layout = "row-major"
description = "gain times the filter state"
[abi]
confirmed = true
# … init, step and terminate calls, one struct per role
The full format is in the descriptor reference.
When the header deviates
A header that is not completely in the shape gets the heuristic proposal of
other C interfaces, written with abi.confirmed = false, which the
engine refuses to load. Its leading comment lists every deviation from the shape first, then
every guess. Leave out the shape comment of tau, for example:
# - not the recommended shape: lowpass_params: tau is a pointer without a shape comment such as `/* [n] */`
Either fix the header and import again, or review the proposal, fix causalities and shapes by
hand and set confirmed = true. That flag states that you checked which pointer takes which
dimension: lengths are never inferred at run time, and a wrong shape means the library reads or
writes out of bounds.
Run it
[engine]
tick_ms = 10.0
[models.lowpass]
kind = "raw"
path = "lowpass"
[[instance]]
id = "filt"
model = "lowpass"
dims = { n = 3 }
parameters = { tau = [0.1, 0.5, 1.0], y0 = [0.0, 0.0, 0.0], gain = 1.0 }
[[connector]]
id = "ua"
kind = "opcua-server"
endpoint = "opc.tcp://127.0.0.1:4840"
taktwerk inspect lowpass --kind raw
taktwerk check project.toml
taktwerk run project.toml
taktwerk tui opc.tcp://127.0.0.1:4840 # edit filt.u and filt.gain, watch filt.y
A second instance with another n is one more [[instance]] block on the same model.
Other C interfaces: the descriptor
An existing C library that does not follow the recommended shape is hosted as it is, through a descriptor that says how to call it. The engine owns every buffer, lays structs out by C’s rules for the running target, fills pointers once per call and copies inputs in and outputs out around each step.
Workflow
taktwerk import-header model.h -o taktwerk-model.tomlproposes a descriptor. The importer reads flattypedef struct { … } Name;blocks and function prototypes over the admitted types; it is not a C parser (no macro expansion) and skips, with a note, anything it does not cover. A header in the recommended shape comes out confirmed; any other gets a proposal whose notes start with what deviates from that shape.- Read the notes at the top of the proposal. Function roles (init, step, terminate), dimension
members and pointer→length relations are guessed by name; every guess is listed. Pointers are
inputs when
constand outputs otherwise; scalars by value are parameters unless the struct name hints otherwise. - Fix causalities (which values are parameters, which tunables), shapes, matrix layouts (the importer proposes one dimension per pointer), dimension bounds and defaults.
- Set
abi.confirmed = true. An unconfirmed descriptor is refused at load. - Put the library at
lib/<arch>/next to the descriptor and runtaktwerk inspect <dir> --kind raw.
Package
<package>/
taktwerk-model.toml the descriptor
lib/aarch64/<name>.so one library per architecture; the running one is picked
lib/x86_64/<name>.so
lib/arm/<name>.so 32-bit hard-float ARM
Calls
A library is called through at most three functions, or one function serving as both init and step (see single entry point):
[abi.init]once per instance, in declared order, after the engine has allocated and filled the buffers (dimension lengths, parameters, tunables, inputs as described under Init);[abi.step]once per step, after inputs and changed tunables were copied in; outputs are copied out afterwards;[abi.terminate](optional) once when the instance is released, also after an error.
Each function returns int (returns = "int", the default), where any value in ok_codes
(default [0]) is success and anything else fails the call, or void (returns = "void").
Arguments are listed in C order; each is exactly one of:
| argument | C parameter | filled with |
|---|---|---|
{ struct = "s" } | s * | the address of the engine’s image of s |
{ array = "v" } | T * | the address of variable v’s buffer |
{ value = "v" } | T | scalar variable v (input, parameter, tunable) |
{ dim = "n", type = "int" } | integer | the bound length of dimension n |
{ builtin = "step_size" } | double | the instance’s step, seconds |
{ builtin = "time" } | double | the time the call advances from |
{ handle = "out" } | void ** | init only: the library stores its handle |
{ handle = "in" } | void * | the stored handle |
{ const = 7, type = "int" } | T | the same number on every call |
{ phase = { init = 1, step = 0 }, type = "int" } | T | one number for init, another for step and terminate |
type (default int) applies to dim, const and phase. Written as a pointer (int *),
the argument is the address of engine-owned storage holding the value. A const or phase
number is an integer literal (any type that holds it) or a float literal (double, float
only). Handles need
instances = "multiple"; init has at most one handle = "out". A value argument cannot be an
output or a shaped variable. Up to eight pointer/integer and eight floating-point arguments are
passed per call; larger interfaces go through a struct.
Structs
A struct in [abi.structs.<s>] lists its members with their C type in declaration order;
each member maps to at most one of:
member (name, type omitted) | C member | holds |
|---|---|---|
{ variable = "v" } | T * | the address of v’s buffer |
{ variable = "v", owner = "library" } | T * | an address the library stores (see below) |
{ variable = "v" } | T | scalar v (see below) |
{ dim = "n" } | int/int * | the bound length of n |
{ dim = "n", reported = true } | int/int * | a length the library writes (see below) |
{ builtin = "step_size" } | double | the step size or the time |
{ const = 9 } | T/T * | the same number on every call |
{ phase = { init = 1, step = 0 } } | T/T * | one number for init, another afterwards |
- A scalar member mapped to a
variablecarries the value: inputs, parameters and tunables are written before each call, outputs read after it. A shaped variable needs a pointer member. - A pointer member may point at a scalar variable of any causality. One the library writes back (a counter in an input struct) is mapped to an output variable, whose buffer the engine never overwrites.
- A pointer member carrying a
dim,constorphasepoints at engine-owned storage. - Unmapped pointer members are
NULL, unmapped scalars zero.
A variable member’s C type must match the variable’s type, with one exception: a uint8_t or
uint8_t * member may carry a bool variable; the engine keeps the bytes and reads any non-zero
byte as true.
Reported lengths. A dim member with reported = true is a length the library knows and
writes itself. It is zeroed before init, then compared with the bound length after init and
after every step; a difference fails the call with both values. The check runs after the call
returned, so a library loaded with the wrong sizes may already have written at its own size:
every reported dimension therefore needs a max, and the engine allocates each buffer shaped by
it at that max while exchanging only the bound length (text and byte buffers keep their
literal capacity). The project file stays the size authority.
Library-owned outputs. Some libraries allocate their output buffers themselves at init and
store the addresses into the output struct’s pointer members (out->y = y_buf;), then write
only their own buffers. Mark such a member owner = "library":
{ name = "y", type = "double *", variable = "y", owner = "library" }
The engine writes NULL into the member before init and never writes it again. After init and
after every step it reads the address from the struct and copies the variable’s bound length
(text and byte buffers: their literal capacity) from there into its own buffer, byte-wise, so
the address needs no alignment. A member still NULL after init, or set back to NULL by a
step, fails that call with the library left <struct>.<member> NULL. Only a pointer member
mapped to an output variable can be library-owned, and no other member or argument may carry
that variable. The engine trusts the address: the library must keep at least the bound length
readable there for the instance’s lifetime, the same contract as every pointer the engine hands
it. The default, owner = "engine", is the engine’s buffer as above.
Text is a char * member mapped to a u8 variable with a literal shape, its capacity with
the terminating NUL included (shape = [32]). Such a variable is marked text: the image and the
library see its bytes, while connectors, check, inspect and the TUI present a string (the
OPC UA server a String node, see Connectors). A uint8_t * or
unsigned char * member stays a byte buffer. The caller supplies text through a parameter
(a TOML string in the project file) or an input; the library writes it into an output.
Each instance owns one image per struct, the same in every call, so a library may keep a struct pointer it got at init.
C types
Admitted are double, float, bool, char and the fixed-width and plain integer types at
their 64-bit Linux widths (int is i32, long and size_t are 64-bit, char is a byte
that marks text),
each optionally const and optionally followed by one *. Pointers to pointers are refused,
except the void ** of handle = "out".
Keys
| key | meaning |
|---|---|
name | model name, informational |
instances | "single" (default; state in globals) or "multiple" |
[[dimensions]] | name, optional min, max, default |
[[variables]] | name, causality (input, output, parameter, tunable), type, shape (dimension names or literal lengths; empty for a scalar), layout (row-major default, or column-major), optional unit, description, text (implied by a char * member) |
abi.confirmed | true once the developer checked every pointer→length relation |
abi.library | file name in lib/<arch>/; default: the only .so there |
abi.ok_codes | success return values of int calls; default [0] |
abi.init, abi.step, abi.terminate | symbol, returns (int or void), args; init and step may share a symbol |
abi.structs.<s>.members | name, type (C spelling), at most one of variable (with optional owner: engine default, or library), dim (with optional reported), builtin, const, phase |
A single library keeps its state in globals, so each further instance loads a private copy of
the file; a multiple library carries its state behind a handle or inside a struct the engine
owns per instance.
Example
A discrete state-space model x' = A x + b u, y = k cᵀx, size-generic in nx, with this C
interface:
typedef struct { int nx; const double *A; const double *b; const double *c; double k; } ss_params;
typedef struct { double u; double *x; double y; } ss_io;
int ss_init(const ss_params *p, double dt);
int ss_step(const ss_params *p, ss_io *io, double t);
void ss_terminate(void);
name = "state-space"
instances = "single"
[[dimensions]]
name = "nx"
min = 1
max = 64
[[variables]]
name = "A"
causality = "parameter"
type = "f64"
shape = ["nx", "nx"]
layout = "row-major"
[[variables]]
name = "b"
causality = "parameter"
type = "f64"
shape = ["nx"]
[[variables]]
name = "c"
causality = "parameter"
type = "f64"
shape = ["nx"]
[[variables]]
name = "k"
causality = "tunable"
type = "f64"
[[variables]]
name = "u"
causality = "input"
type = "f64"
[[variables]]
name = "x"
causality = "output"
type = "f64"
shape = ["nx"]
[[variables]]
name = "y"
causality = "output"
type = "f64"
[abi]
confirmed = true
ok_codes = [0]
[abi.init]
symbol = "ss_init"
args = [{ struct = "ss_params" }, { builtin = "step_size" }]
[abi.step]
symbol = "ss_step"
args = [{ struct = "ss_params" }, { struct = "ss_io" }, { builtin = "time" }]
[abi.terminate]
symbol = "ss_terminate"
returns = "void"
[abi.structs.ss_params]
members = [
{ name = "nx", type = "int", dim = "nx" },
{ name = "A", type = "const double *", variable = "A" },
{ name = "b", type = "const double *", variable = "b" },
{ name = "c", type = "const double *", variable = "c" },
{ name = "k", type = "double", variable = "k" },
]
[abi.structs.ss_io]
members = [
{ name = "u", type = "double", variable = "u" },
{ name = "x", type = "double *", variable = "x" },
{ name = "y", type = "double", variable = "y" },
]
A library with a handle instead declares instances = "multiple" and, for example,
init.args = [{ handle = "out" }, { dim = "n" }, { builtin = "step_size" }] with
step.args = [{ handle = "in" }, { array = "sp" }, { array = "pv" }, { array = "out" }]. The
repository’s
examples/raw-pi is such a
library, with its confirmed descriptor.
Single entry point
A common legacy style exports one function for every call, taking an integer id and two opaque pointers the library casts to its input and output structs. Which call runs is a flag the caller sets, and the library reports its own sizes at init:
typedef struct { int32_t *first; int32_t variant; double *k; double *u; int32_t *count; } io_in;
typedef struct { double *y; char *label; int32_t *n_u; int32_t *n_y; } io_out;
int model_call(int id, char *in, char *out); /* 0 on success */
name = "single-entry"
[[dimensions]]
name = "nu"
[[dimensions]]
name = "ny"
[[variables]]
name = "k"
causality = "tunable"
type = "f64"
[[variables]]
name = "u"
causality = "input"
type = "f64"
shape = ["nu"]
[[variables]]
name = "count"
causality = "output"
type = "i32"
[[variables]]
name = "y"
causality = "output"
type = "f64"
shape = ["ny"]
[[variables]]
name = "label"
causality = "output"
type = "u8"
shape = [32]
[abi]
confirmed = true
[abi.init]
symbol = "model_call"
args = [{ const = 7, type = "int" }, { struct = "io_in" }, { struct = "io_out" }]
[abi.step]
symbol = "model_call"
args = [{ const = 7, type = "int" }, { struct = "io_in" }, { struct = "io_out" }]
[abi.structs.io_in]
members = [
{ name = "first", type = "int32_t *", phase = { init = 1, step = 0 } },
{ name = "variant", type = "int32_t", const = 1 },
{ name = "k", type = "double *", variable = "k" },
{ name = "u", type = "double *", variable = "u" },
{ name = "count", type = "int32_t *", variable = "count" },
]
[abi.structs.io_out]
members = [
{ name = "y", type = "double *", variable = "y" },
{ name = "label", type = "char *", variable = "label" },
{ name = "n_u", type = "int32_t *", dim = "nu", reported = true },
{ name = "n_y", type = "int32_t *", dim = "ny", reported = true },
]
init and step name the same symbol; the phase member first tells the two calls apart,
const fills the id and a fixed variant, the library writes the step counter back through
count (an output), and n_u, n_y are reported lengths checked against the instance’s bound
nu and ny.
taktwerk import-header model.h --entry model_call --arg-struct in=io_in --arg-struct out=io_out
imports such a header when told the entry point and the struct behind each opaque parameter; it
then also guesses a flag-like integer as a phase, size-like integer pointers in an output
struct as reported lengths, and a leading integer argument as a const whose value is left to
the developer. Set each reported dimension’s max before confirming. A header does not say
who sets an output pointer: if the library points them at its own buffers at init, add
owner = "library" to those members.
A runnable package in this style, with its header, source and confirmed descriptor:
examples/raw-single-entry.
Calling convention
Supported targets are aarch64, x86_64 and 32-bit hard-float ARM Linux, with the platform’s
standard C calling convention. On 32-bit ARM a float or a 64-bit integer by value is refused
(pass a double, or go through a pointer or struct), and long and size_t are 32 bits.
Tutorial: a single-entry library on a controller
You have a model compiled as a C shared library that exports one function for init and step, and you want it running on a Linux controller next to a PLC, stepping at a fixed period and reachable over OPC UA. This tutorial takes it there with taktwerk, from your workstation over ssh.
The library looks like this; names differ, the shape is what matters:
typedef struct { int32_t *init_flag; int32_t variant; double *k; double *u; char *title; } my_in;
typedef struct { double *y; char *label; int32_t *n_u; int32_t *n_y; } my_out;
int my_entry(int id, char *in, char *out); /* 0 on success */
One function, an integer id, and two opaque pointers the library casts to its input and output
structs. A flag in the input struct tells init from step, and the library may report its own
sizes at init. examples/raw-single-entry
is a complete library of this kind to practise on.
Placeholders used below:
| placeholder | meaning | example |
|---|---|---|
<host> | the controller’s address | 192.168.1.10 |
<user> | your login on the controller | admin |
<dir> | a writable directory on the controller for everything here | /opt/custom/taktwerk |
<port> | a free TCP port for taktwerk’s OPC UA server | 4841 |
1. Look at the controller
ssh <user>@<host> 'uname -m; ldd --version | head -1; ss -ltn'
Note three things: the architecture (x86_64 or aarch64), the glibc version, and which ports
are taken. A controller with its own OPC UA server usually holds 4840; pick another <port>.
2. Install taktwerk on your workstation
On a Linux workstation with Rust 1.88 or newer:
cargo install --locked taktwerk
taktwerk --version
This gives you import-header, new, inspect, check and tui locally.
3. Build taktwerk for the controller
If the workstation has the controller’s architecture and a glibc no newer than the
controller’s, the binary you just installed runs there too: use ~/.cargo/bin/taktwerk.
Otherwise cross-build it from the published crate with
cargo-zigbuild, which links against an older
glibc than the workstation’s. Name a glibc version at or below the controller’s:
cargo install --locked cargo-zigbuild # also needs `zig` on the PATH
rustup target add x86_64-unknown-linux-gnu # or aarch64-unknown-linux-gnu, armv7-unknown-linux-gnueabihf
curl -sSfL https://static.crates.io/crates/taktwerk/taktwerk-0.5.2.crate | tar xz
cd taktwerk-0.5.2
cargo zigbuild --release --locked --target x86_64-unknown-linux-gnu.2.35
cd ..
cp taktwerk-0.5.2/target/x86_64-unknown-linux-gnu/release/taktwerk ./taktwerk-target
Your library has the same constraint: build it for the controller’s architecture and against
a glibc at or below the controller’s (zig cc -target x86_64-linux-gnu.2.35 -shared -fPIC ...
works for C).
4. Make a model package
A package is a directory with the library under lib/<arch>/ and a descriptor that tells
taktwerk how to call it. The header produces the descriptor:
mkdir -p mymodel/lib/x86_64
cp libmymodel.so mymodel/lib/x86_64/
taktwerk import-header my_model.h --entry my_entry \
--arg-struct in=my_in --arg-struct out=my_out -o mymodel/taktwerk-model.toml
--entry names the one function; each --arg-struct says which struct an opaque parameter
carries, by the parameter’s name in the header. The result is a proposal: it starts with
notes, one per guess, and abi.confirmed = false. Go through the notes and fix the
descriptor:
- The id. The leading integer argument is a
const; set the value the library expects. - The init flag. A flag-like member is guessed as
phase = { init = 1, step = 0 }; check the values. - By-value members such as
variantbecome inputs; a fixed selection is aconstmember instead. - Roles. Pointer members become inputs or outputs by their struct. A value you want to
change while running without it being an input is a
tunable; a member of the input struct the library writes back is anoutput. - Sizes. Give every array its shape in dimension names or literal lengths. A size the
library reports at init is a
reported = truedimension member; give that dimension amax, because buffers are allocated at it. - Output pointers the library sets. If the library allocates its own output buffers at
init and stores their addresses into the output struct’s members, the header cannot show it:
add
owner = "library"to those members, or the outputs stay zero. A pointer leftNULLafter init then fails init with a message naming the member. - Text. A
char *member is text: give its variable a literal capacity with the NUL included,shape = [32]. Text shows as a string incheck, on OPC UA and in the TUI. - Matrices need two shape entries and their
layout.
Then set abi.confirmed = true. The descriptor
has every key; the example’s
taktwerk-model.toml
shows a confirmed one and the command that proposed it.
5. Write the project
The project file says which models run, at which period, with which sizes, and where the OPC UA server listens:
taktwerk new mymodel --kind raw --port <port> -o project.toml --write
Open project.toml and:
- bind every dimension under
[instance.dims]; - set start values under
[instance.parameters](text as a TOML string:title = "tank 3"); - set the server’s
endpointtoopc.tcp://<host>:<port>so it listens on the controller’s network address instead of only on127.0.0.1.
inspect, new and check load the library of the machine they run on (lib/<uname -m>/).
On a workstation of another architecture, run them on the controller in step 6 instead.
taktwerk inspect mymodel --kind raw # the interface as taktwerk reads it
taktwerk check project.toml # load, resolve, bind; prints the signal table
6. Ship to the controller
ssh <user>@<host> mkdir -p <dir>
scp -r taktwerk-target <user>@<host>:<dir>/taktwerk
scp -r mymodel project.toml <user>@<host>:<dir>/
The model path in project.toml is relative to the project file, so keep the two side by
side as they are on the workstation.
7. Run and test
On the controller:
ssh <user>@<host>
cd <dir>
./taktwerk check project.toml
./taktwerk run project.toml
check loads the library and verifies the plan without calling the model. run calls the
entry once with the init phase, checks the sizes the library reported against the project,
then calls it once per tick with the step phase. A non-zero return stops the engine with the
code in the log.
From the workstation, in a second terminal:
taktwerk tui opc.tcp://<host>:<port>
- The header shows
status Runningand a risingheartbeat;overrunsshould stay 0. - Select an input or a tunable and set a value; the outputs follow from the next tick.
- Text signals show as strings and are edited as strings.
Any OPC UA client works the same way: every signal is a node under the namespace
urn:taktwerk.
Stop the engine with Ctrl+C. It prints a summary:
cycles 5999 published 5999 overruns 0 stale 0 cycle mean 0.059 ms max 0.073 ms
max against the tick is your margin. A process started from an ssh session usually ends when
that session closes; for anything longer than a test, run it as a service.
8. Keep it running
Running in production has the systemd unit, the real-time settings
(SCHED_FIFO, a CPU, locked memory) and what each needs. On a controller that also runs a PLC
runtime, keep the cycle thread’s priority below the runtime’s own tasks.
To connect the model to the PLC’s variables instead of driving it by hand, add an
opcua-client connector that maps signals to the PLC’s OPC UA nodes; taktwerk new writes a
commented one into the project. See Connectors.
When something fails
| message | cause and fix |
|---|---|
GLIBC_2.xx not found | the binary or library was built against a newer glibc than the controller’s: step 3 |
load: .../lib/<arch>: No such file | no library for this machine’s architecture in the package |
abi.confirmed is false | review the proposal and set abi.confirmed = true |
the library reports … but dimension … is bound to … | the project’s dims do not match what the library reports at init |
Address already in use | <port> is taken: pick another in the server’s endpoint |