Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 tui is one.
  • One TOML project file per engine describes models, instances, wiring and connectors.
taktwerk FMU or C library step thread fixed period process image OPC UA server OPC UA client taktwerk tui, any OPC UA client PLC

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

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:

examplewhat it shows
fmi-state-spacean FMI 3 FMU with structural parameters bound to one state
raw-pia PI controller from a plain C library, multiple instances by handle
raw-filter-banka C library in the recommended shape, its descriptor read unreviewed
closed-loopboth in one engine, wired through the process image
taktwerk.servicea 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:

directionwritten byread by
inputa connectorthe instances that consume it
outputthe instance that produces itconnectors, other instances
tunablea connector, while runningone instance, before its step
systemthe engineconnectors

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:

  1. The external inputs and tunables of every due instance are copied from the image.
  2. If any of those inputs is stale, the tick is skipped (see stale inputs).
  3. Otherwise the due instances step in declared order. Each first copies its wired inputs, so it sees outputs stored earlier in the same tick.
  4. 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):

signaltypemeaning
taktwerk.heartbeatu64advances once per tick whose outputs were published
taktwerk.statusi320 init, 1 running, 2 faulted, 3 stopped
taktwerk.cycleu64every tick, skipped ones included
taktwerk.overrunsu64deadline slots missed
taktwerk.staleu64ticks 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]

keytypedefaultmeaning
tick_msfloatrequiredbase tick, milliseconds, > 0; every period is a multiple of it
start_timefloat0.0model time of the first tick, seconds
input_max_age_msfloatnonedefault age limit of external inputs; none never goes stale
system_prefixstring"taktwerk"prefix of the engine’s own signals; non-empty
realtimetablenonereal-time settings of the cycle thread, below

[engine.realtime]

Applies to the cycle thread only; the I/O threads keep the default scheduling.

keytypedefaultmeaning
policystringrequired"fifo" (SCHED_FIFO) or "rr" (SCHED_RR)
priorityintegerrequired1 to 99
cpuintegernoneCPU the cycle thread is pinned to
lock_memoryboolfalsemlockall 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>]

keytypemeaning
kindstring"fmi" (FMI 2 or 3 co-simulation) or "raw" (C library package)
pathstringan .fmu, an extracted FMU directory, or a raw package directory

[[instance]]

keytypedefaultmeaning
idstringrequiredunique, non-empty, no .
modelstringrequireda key of [models]
everyinteger1period in ticks, ≥ 1
dimstable{}dimension → length or "from-server"
parameterstable{}parameter or tunable → start value
inputstable{}input → signal, { signal, max_age_ms }, name[i] for a scalar, or a list of element sources
outputstable{}output → signal
tunablestable{}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’s min and max.
  • 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 its map.
  • 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]"] }
  • name is 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 element i, 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 check lists 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]]

keytypemeaning
idstringunique
kindstring"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
keydefaultmeaning
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"
anonymoustrueaccept 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_certsfalsetrust 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 example ns=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 as nsu=urn:taktwerk;s=plant.y).
  • The browse path splits the name on . into folders under Objects: plant.y is Objects/plant/y, taktwerk.heartbeat is Objects/taktwerk/heartbeat.
  • Data types: f64 Double, f32 Float, i64 Int64, i32 Int32, i16 Int16, i8 SByte, u64 UInt64, u32 UInt32, u16 UInt16, u8 Byte, bool Boolean.
  • 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 String node. A read gives the bytes up to the first NUL, decoded as UTF-8 (invalid sequences are replaced, not refused). A write takes a String of at most capacity − 1 UTF-8 bytes, copies it and zero-fills the rest; a longer one is refused with BadOutOfRange and one containing NUL with BadInvalidArgument, 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"
keydefaultmeaning
endpointrequiredserver URL, opc.tcp://host:port[/path]
security"none""none", "sign" or "sign-encrypt" (Basic256Sha256)
credentialsanonymous{ user, password } or { user, password_env }
request_timeout_ms1000bound on every request
reconnect_backoff_ms1000first wait before a reconnect; doubles per failure up to 16 times this
sync_period_ms100period 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_certsfalsetrust 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 (String for 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:

settingneeds
policy, priorityLimitRTPRIO ≥ priority, or CAP_SYS_NICE
lock_memoryLimitMEMLOCK=infinity, or CAP_IPC_LOCK
cpua 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:

  1. 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.
  2. 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 optional min, max and default. 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), an output (written every step), a parameter (set once before init) or a tunable (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 declares single; 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> takes start="4" (a literal length), or a valueReference to a structural parameter or to a constant integer variable with a start.
  • 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 / variabilitytaktwerk causality
inputinput
outputoutput
parameter, fixedparameter
parameter, tunabletunable
structuralParametera dimension
local, independent, calculatedParameternot exposed
FMI 3 typetaktwerk type
Float64, Float32f64, f32
Int64, Int32, Int16, Int8i64, i32, i16, i8
UInt64, UInt32, UInt16, UInt8u64, u32, u16, u8
Booleanbool
Enumerationi64
String (FMI 3)text, u8 [capacity]
Binary, Clockrefused 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:

FMIx86_64aarch6432-bit ARM
3x86_64-linuxaarch64-linuxaarch32-linux
2linux64, then x86_64-linuxaarch64-linux, linuxaarch64, linux-aarch64aarch32-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 CoSimulation element; model exchange alone is refused.
  • Every size is a structural parameter with an integer type, a start, and min/max where 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 fmi shows 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.

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. init and step return int; 0 is success, anything else an error. A failed init or step stops the engine (fail-stop); the code is logged. terminate returns int or void.
  • 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>_params and <m>_tunables may be left out, and with them their argument. The struct arguments may come in any order; the handle, time and step_size are where the shape puts them.
  • Sizes in <m>_dims, one int per 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 unit s and description time constant; /* [n]: input */ only a description. Without a colon, one word is a unit (/* m */) and more words a description.
  • const marks what the model reads: dims, params, inputs and tunables are const; outputs are not.
  • State behind the handle. init allocates the instance’s state and stores it through h; step and terminate receive 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 optionally const and optionally followed by one *. A char * 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

  1. taktwerk import-header model.h -o taktwerk-model.toml proposes a descriptor. The importer reads flat typedef 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.
  2. 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 const and outputs otherwise; scalars by value are parameters unless the struct name hints otherwise.
  3. Fix causalities (which values are parameters, which tunables), shapes, matrix layouts (the importer proposes one dimension per pointer), dimension bounds and defaults.
  4. Set abi.confirmed = true. An unconfirmed descriptor is refused at load.
  5. Put the library at lib/<arch>/ next to the descriptor and run taktwerk 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:

argumentC parameterfilled 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" }Tscalar variable v (input, parameter, tunable)
{ dim = "n", type = "int" }integerthe bound length of dimension n
{ builtin = "step_size" }doublethe instance’s step, seconds
{ builtin = "time" }doublethe 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" }Tthe same number on every call
{ phase = { init = 1, step = 0 }, type = "int" }Tone 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 memberholds
{ variable = "v" }T *the address of v’s buffer
{ variable = "v", owner = "library" }T *an address the library stores (see below)
{ variable = "v" }Tscalar 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" }doublethe 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 variable carries 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, const or phase points 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

keymeaning
namemodel 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.confirmedtrue once the developer checked every pointer→length relation
abi.libraryfile name in lib/<arch>/; default: the only .so there
abi.ok_codessuccess return values of int calls; default [0]
abi.init, abi.step, abi.terminatesymbol, returns (int or void), args; init and step may share a symbol
abi.structs.<s>.membersname, 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:

placeholdermeaningexample
<host>the controller’s address192.168.1.10
<user>your login on the controlleradmin
<dir>a writable directory on the controller for everything here/opt/custom/taktwerk
<port>a free TCP port for taktwerk’s OPC UA server4841

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 variant become inputs; a fixed selection is a const member 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 an output.
  • Sizes. Give every array its shape in dimension names or literal lengths. A size the library reports at init is a reported = true dimension member; give that dimension a max, 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 left NULL after 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 in check, 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 endpoint to opc.tcp://<host>:<port> so it listens on the controller’s network address instead of only on 127.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 Running and a rising heartbeat; overruns should 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

messagecause and fix
GLIBC_2.xx not foundthe binary or library was built against a newer glibc than the controller’s: step 3
load: .../lib/<arch>: No such fileno library for this machine’s architecture in the package
abi.confirmed is falsereview 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