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

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