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