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

Tutorial: from a package to a running device

A model package arrives, from a modelling tool or as an FMU from any FMI tool. This tutorial takes one through its whole life: inspected and simulated on a workstation, tested with a fault and a small campaign, bundled, deployed to an edge device, connected to a PLC and a plant data system, and operated there. Every step ends in commands to run and a result to check.

flowchart LR
  P[package] --> S[project, check]
  S --> W[workbench: run, scenario, campaign]
  W --> B[bundle]
  B --> D[edge device]
  D <--> PL[PLC, plant data system]

The example is examples/plant-loop, a level loop: a PI controller drives a tank’s pump through a plant data system’s JSON API, from the level that system measures; the set point and an interlock come from a PLC, which also mirrors the loop and lets an operator tune the gains. A supervisor model turns the interlock and an implausible level into status bits that hold the writes. For the steps before the plant, a tank model stands in for it; for the plant, the example carries stand-ins of the PLC and the plant data system.

What you need

  • A Linux shell on the workstation (on Windows, WSL2) with a C compiler, python3, curl and uv, and a checkout of the taktwerk repository.
  • taktwerk with the workbench: the desktop app’s own taktwerk on Linux (Installing the workbench), or a build from the checkout, cargo install --locked --path crates/taktwerk --features ui.
  • For steps 5 and 7, a device: any Linux machine with SSH and glibc 2.25 or newer, such as a single-board computer, a VM or WSL2; a login user with sudo, and python3 for the stand-in plant data system.

Every command on this page runs in CI: examples/plant-loop/tutorial.sh runs them in order, headless, and checks each result; the device steps run against the CI machine itself over SSH.

1. The package

A package is a C library with its descriptor (taktwerk-model.toml and lib/<arch>/), or an FMU. Here build.sh builds them from the repository’s C sources: the PI of raw-pi and a tank model into packages/, as a modelling tool releases a package, and in place the example’s supervisor and the stand-in PLC.

cd examples/plant-loop
sh build.sh
taktwerk inspect --kind raw packages/pi
model pi (raw)
instances multiple per process

  dimension  min  max  default
  n          1    -    1

  variable  causality  type  shape   unit  description
  kp        tunable    f64   scalar  -     proportional gain
  ki        tunable    f64   scalar  1/s   integral gain
  sp        input      f64   [n]     -     setpoint
  pv        input      f64   [n]     -     process value
  u         output     f64   [n]     -     manipulated variable

That is what a project binds: the dimension n, the tunables, the inputs and the output. An FMU is inspected the same way with --kind fmi; what makes a package is in Making a model taktwerk-compatible. new prints a project for one package to start from, one instance with a signal per variable and an OPC UA server:

taktwerk new --kind raw packages/pi
taktwerk inspect --kind raw packages/tank

sim.toml is such a project grown to the loop, with no connector: the PI as ctrl, the supervisor as sup and the tank as plant, the PI reading the tank’s measured level and driving its pump. The PLC’s set point and interlock are external inputs, zero until something sets them.

# The level loop of `project.toml` on a simulated plant, with no connector: the tank of
# `packages/tank` in place of the plant data system, so the PI reads the tank's measured level
# and drives its pump. The PLC's set point and interlock stay external inputs, zero until
# something sets them: an override, a scenario event, the workbench. Build first (`build.sh`),
# then from this folder:
#   taktwerk run sim.toml --speed max --until-time 600 --set plc.level_sp=1.0

[engine]
tick_ms = 100.0

[models.pi]
kind = "raw"
path = "packages/pi"

[models.supervisor]
kind = "raw"
path = "supervisor"

[models.tank]
kind = "raw"
path = "packages/tank"

# The tank runs first: the PI and the supervisor read the level it measured this tick, and it
# takes the pump flow the PI asked for at the previous one.
[[instance]]
id = "plant"
model = "tank"
parameters = { area = 10.0, k_out = 0.5, q_max = 1.5, level0 = 0.5, noise_sd = 0.002, seed = 1 }
inputs = { q_set = "ctrl.u[0]" }

[[instance]]
id = "ctrl"
model = "pi"
dims = { n = 1 }
parameters = { kp = 1.0, ki = 0.1 }
inputs = { sp = ["plc.level_sp"], pv = ["plant.level_m"] }

[[instance]]
id = "sup"
model = "supervisor"
parameters = { level_lo = 0.05, level_hi = 2.0 }
inputs = { interlock = "plc.interlock", level = "plant.level_m" }

[[recorder]]
id = "main"
signals = ["@io"]
taktwerk check sim.toml

check loads every model, resolves the wiring and prints the plan: the instances, every signal with its direction and type, the composed inputs (ctrl.pv from plant.level_m, plant.q_set from ctrl.u[0]) and the recorder. It ends in ok: 3 instance(s), 25 signal(s), 0 connector(s) checked.

2. A run in the workbench

taktwerk serve .

Open the URL it prints. In Runs, pick sim.toml, add the override plc.level_sp=1.0 and start it at speed 1. Live shows plant.level_m rising from 0.5 m to the set point; pick it and ctrl.u for the trend. Write ctrl.kp = 2 in its row: the pump answers harder. Stop the run. The recorder wrote it to records/sim/main/<run-id>/, which Records lists with its events and columns (Workbench, Recording).

report.py reads the same record with the Python package: how the run ended, its events, the range of every column.

uv run --with ../../python python report.py records/sim/main

Check its events: tunable ctrl.kp 1.0 -> 2.0 by http, the write from the Live screen, and stop: requested.

3. A fault, a campaign, two runs compared

fault.toml is a scenario: a set point, a time limit and timed events, among them a fault on the level sensor.

# Ten minutes of the simulated loop at a set point of 1 m: the set point steps to 1.5 m at 120 s,
# the level sensor reads 2.5 m for 10 s from 300 s (outside the supervisor's plausible range),
# and the PLC's interlock is set from 450 s to 480 s.

[set]
"plc.level_sp" = 1.0

[stop]
time_s = 600.0

[[event]]
at_s = 120.0
set = "plc.level_sp"
value = 1.5

[[event]]
at_s = 300.0
fault = "plant.level_m"
value = 2.5
for_s = 10.0

[[event]]
at_s = 450.0
set = "plc.interlock"
value = 1

[[event]]
at_s = 480.0
set = "plc.interlock"
value = 0
taktwerk run sim.toml --speed max --scenario fault.toml
uv run --with ../../python python report.py records/sim/main
records/sim/main/20261030T014249Z-db921f1b: 6000 rows, 0.0 to 599.9 s, stop: time limit reached
     120.0 s  scenario  plc.level_sp                 set plc.level_sp = 1.5
     300.0 s  scenario  plant.level_m                fault plant.level_m value 2.5 for 10 s
     310.0 s  scenario  plant.level_m                end: fault plant.level_m value 2.5 for 10 s
     450.0 s  scenario  plc.interlock                set plc.interlock = 1
     480.0 s  scenario  plc.interlock                set plc.interlock = 0
  ...
  plant.level                      0.4965 .. 1.593
  ...
  ctrl.u[0]                        -1.388 .. 1.008
  sup.status                            0 .. 2
  ...

--speed max runs the ten minutes as fast as the models compute. The fault acts where the readers copy their inputs: the PI and the supervisor see 2.5 m, so sup.status is 2 for those 10 s and the PI drives its output below zero; the recorded plant.level_m stays what the tank measured. The interlock sets bit 1. In the loop on the plant, either bit closes the write gates (step 6). The workbench starts the same run with the scenario picked in Runs.

gains.toml is a campaign of three runs over the fault scenario, one per gain, all with one seed:

# The PI's gain over the fault scenario on the simulated plant: three runs, all with one seed.
#   taktwerk campaign gains.toml            # the plan
#   taktwerk campaign gains.toml --write    # run it

project = "sim.toml"
scenario = "fault.toml"
out = "out/gains"
jobs = 2
seed = 1

[grid]
"ctrl.kp" = [0.5, 1.0, 2.0]
taktwerk campaign gains.toml
taktwerk campaign gains.toml --write

The first prints the plan, runs 3 (0 done), 2 at a time, speed max and a row per run (g0, g1, g2); the second runs it into out/gains/, and again skips what is done. In the workbench, Campaigns lists the runs; pick g0 and g2 to plot them together and compare them in the records view. In the shell:

uv run --with ../../python python report.py out/gains/runs/g0 out/gains/runs/g2
largest difference per column:
  plant.q_dist                      equal
  plc.level_sp                      equal
  plc.interlock                     equal
  plant.level                      0.3653 at 319.7 s
  ...
  ctrl.u[0]                         1.499 at 309.9 s
  ...

The inputs are equal, so the gain makes the difference, largest around the end of the fault. Larger sweeps, analyses and a ranking are in Campaign studies and the gain sweep tutorial.

4. The bundle

A device runs a bundle: this folder, with its projects and the packages they use.

# The level loop as a bundle for a device, three projects: the simulated plant (selected on the
# first activation), the loop on the plant as a dry run, and the closed loop.
#   taktwerk deploy . --device edge
name = "plant-loop"
projects = ["sim.toml", "project.toml", "closed-loop.toml"]
exclude = ["records", "out", "stand-in", "tutorial.sh"]
  • sim.toml, the simulated plant, comes first: a device’s first activation selects it.
  • project.toml is the loop on the plant with both connectors in dry run: they read and log what they would write.
  • closed-loop.toml is project.toml with writes on. A device runs a project as it is, with no --write, so the closed loop is a project of its own.
grep -n '^writes' project.toml closed-loop.toml
taktwerk check closed-loop.toml
project.toml:55:writes = "dry-run"
project.toml:77:writes = "dry-run"
closed-loop.toml:41:writes = "on"
closed-loop.toml:63:writes = "on"

Every file below the folder belongs to the bundle except what exclude names; stand-in/ is a bundle of its own (step 7). The rules are in Devices and bundles.

5. The edge device

On the device, once: an install root owned by the login user, and the fingerprint of its host key, read on the device itself rather than over the network you are about to trust.

sudo install -d -o "$USER" /opt/taktwerk
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

On the workstation, with the device’s address and login user:

DEVICE=192.168.1.20 DEVICE_USER=me
taktwerk device add edge --host "$DEVICE" --user "$DEVICE_USER" --privilege sudo
taktwerk device verify edge
edge: 127.0.0.1:22
  host key   SHA256:…
  arch       x86_64
  glibc      2.39 (floor 2.25)
  systemd    yes
  root       /opt/taktwerk (exists, writable)
  free       11570 MiB
  engines    none

That is the CI machine as the device. verify shows the host key it was offered and pins it after a yes, when it matches the fingerprint read on the device (--accept-host-key SHA256:… pins it without asking). Then it probes: the architecture, glibc against the floor 2.25, whether systemd runs (without it the agent’s own supervisor runs the bundles, with the same commands), the install root and its free space. A problem stops a deploy, a note does not.

taktwerk deploy . --device edge
taktwerk deploy . --device edge --yes
taktwerk device install-unit edge --yes
taktwerk device start edge plant-loop --yes
taktwerk device engine edge plant-loop status
taktwerk device engine edge plant-loop set plc.level_sp 1.0 --yes
taktwerk device tunnel edge plant-loop
  • The plan names the engine to install, the files to send against the active release and the bytes against the free space. The libraries must be built for the device’s architecture: for another one, run build.sh with CC and ARCH first (Cross-building), and the engine comes from the desktop app’s bundled engines or --engine (Deploying a release).
  • install-unit and start run through sudo; the engine starts with sim.toml.
  • status passes when the engine runs and its heartbeat advances; set writes the set point and reads it back.
  • tunnel prints http://127.0.0.1:<port>/, the engine’s live view and API on the device, until Ctrl-C.

The workbench’s Devices page does all of this on the same registry: verify, the plan, deploy, start, Live through the same port forward (Workbench). Stop the engine before the next step:

taktwerk device stop edge plant-loop --yes

6. A PLC and a plant data system

project.toml replaces the tank with the plant: the rest connector lab reads the level from a plant data system and writes the pump set point and a watchdog; the opcua-client connector plc reads the set point and the interlock from a PLC and mirrors the loop into its program.

# A level loop on a plant, taktwerk alone: the PI of `packages/pi` drives the pump through a
# plant data system's REST API, from the level that system measures, with the set point and an
# interlock from a PLC, which also mirrors the loop and lets an operator tune it.
#
#   ctrl  PI: pump flow from the set point `plc.level_sp` and the measured level `lab.level`
#   sup   status bits for the write gates: 1 the PLC's interlock is set, 2 the level is
#         outside [level_lo, level_hi]
#
# `lab.level` goes stale 3 s after the last good read: the engine then skips its ticks and both
# gates close, so the plant data system and the PLC keep their last set points. Both connectors
# start as a dry run; `closed-loop.toml` is this file with writes on. Build the models first
# (`build.sh`). Then, from this folder, with the addresses below set to the plant's, or the
# stand-ins of `stand-in/` running:
#   taktwerk check --live project.toml
#   taktwerk run project.toml                         # reads, logs the writes
#   taktwerk run project.toml --write plc --write lab # closes the loop

[engine]
tick_ms = 100.0

[models.pi]
kind = "raw"
path = "packages/pi"

[models.supervisor]
kind = "raw"
path = "supervisor"

[[instance]]
id = "ctrl"
model = "pi"
dims = { n = 1 }
parameters = { kp = 1.0, ki = 0.1 }
inputs = { sp = ["plc.level_sp"], pv = [{ signal = "lab.level", max_age_ms = 3000.0 }] }

[[instance]]
id = "sup"
model = "supervisor"
parameters = { level_lo = 0.05, level_hi = 2.0 }
inputs = { interlock = "plc.interlock", level = "lab.level" }

# Every external input as written and every output each tick; tunable changes, gate changes
# and refused writes are events in events.csv.
[[recorder]]
id = "main"
signals = ["@io"]

# The plant data system: the measured level in, the pump set point and a watchdog out.
[[connector]]
id = "lab"
kind = "rest"
base_url = "http://127.0.0.1:8080/api/"
period_ms = 500
refresh_s = 10
writes = "dry-run"

[connector.gate]
open_after = 3
conditions = [{ signal = "sup.status", mask = 3 }]

[connector.map]
"lab.level" = { get = "values", pointer = "/level", min = 0, max = 5, max_unchanged_s = 60 }
"ctrl.u[0]" = { put = "setpoints/pump", pointer = "/value", min = 0, max = 1.5, clamp = true }
"taktwerk.heartbeat" = { put = "setpoints/watchdog", pointer = "/value" }

# The PLC: set point and interlock in; the engine's signals, the loop and the gains mirrored
# into its program, the gains synced both ways so an operator tunes them there. The map is what
#   taktwerk plc-mirror project.toml --node-prefix 'nsu=urn:example:plc;s=' \
#     --program mirror --from-plc 'plc.*' --all-tunables ctrl --sync-tunables
# generates (with the program `mirror`), edited: the set point and interlock read from the
# PLC's own `hmi` program, three engine signals left out, limits and an ungated status added.
[[connector]]
id = "plc"
kind = "opcua-client"
endpoint = "opc.tcp://127.0.0.1:4840"
sync_period_ms = 200
writes = "dry-run"
sync_epoch = "nsu=urn:example:plc;s=mirror.taktwerk_sync_epoch"

[connector.gate]
open_after = 3
conditions = [{ signal = "sup.status", mask = 3 }]

[connector.map]
"plc.level_sp" = { node = "nsu=urn:example:plc;s=hmi.level_sp", min = 0, max = 2 }
"plc.interlock" = "nsu=urn:example:plc;s=hmi.interlock"
"taktwerk.heartbeat" = "nsu=urn:example:plc;s=mirror.taktwerk_heartbeat"
"taktwerk.status" = "nsu=urn:example:plc;s=mirror.taktwerk_status"
"lab.level" = { node = "nsu=urn:example:plc;s=mirror.lab_level", mirror = true }
"ctrl.u" = { node = "nsu=urn:example:plc;s=mirror.ctrl_u", min = 0, max = 1.5, clamp = true }
"sup.status" = { node = "nsu=urn:example:plc;s=mirror.sup_status", gated = false }
"ctrl.kp" = { node = "nsu=urn:example:plc;s=mirror.ctrl_kp", min = 0, max = 10, sync = true }
"ctrl.ki" = { node = "nsu=urn:example:plc;s=mirror.ctrl_ki", min = 0, max = 1, sync = true }

The example carries stand-ins for both, on loopback:

  • stand-in/lab.py: the plant data system, a tank behind a JSON API, standard library only.
  • stand-in/plc.toml: the PLC, a taktwerk project whose OPC UA server offers the nodes project.toml maps; its model (stand-in/plc/plc.h) holds the operator’s set point and interlock as tunables, takes what the engine mirrors and watches the engine’s heartbeat.

Start them in two more terminals, from this folder, then verify the map against them; check --live writes nothing:

python3 stand-in/lab.py
taktwerk run stand-in/plc.toml --http 127.0.0.1:8490 --http-writes
taktwerk check --live project.toml

It lists every node with its role and type, ok per row, and ends in verified against the live endpoints, nothing written. Now the dry run: it reads, evaluates the gates and logs what it would write.

taktwerk run project.toml --http 127.0.0.1:8480

Its log shows dry run, not written: ctrl.u[0] (PUT setpoints/pump#/value) = … once a second. In another terminal:

curl -s 127.0.0.1:8080/api/setpoints
taktwerk engine read 'taktwerk.connector.*.gate_open' sup.status

The plant data system got nothing ({"writes": 0}), and both gates are open: a write would go out. Stop the run with Ctrl-C. Writes on, for one run: verify that every written node is writable, then run with --write:

taktwerk check --live --write plc --write lab project.toml
taktwerk run project.toml --write plc --write lab --http 127.0.0.1:8480

curl -s 127.0.0.1:8080/api/setpoints now shows the pump set point and the watchdog arriving; the stand-in’s tank follows the pump. Set the interlock on the PLC:

taktwerk engine --url 127.0.0.1:8490 set hmi.interlock 1 --yes

sup.status is 1 and both gates close: the pump set point holds where it was, the watchdog keeps coming, so both sides see the engine alive. Clear the interlock and the gates open after three good ticks. An operator tunes the PI on the PLC; the synced gain reaches the engine:

taktwerk engine --url 127.0.0.1:8490 set hmi.interlock 0 --yes
taktwerk engine --url 127.0.0.1:8490 set mirror.ctrl_kp 2 --yes
taktwerk engine read ctrl.kp

Stop the run and both stand-ins with Ctrl-C. The record holds each gate change and the gain:

uv run --with ../../python python report.py records/project/main

Check for gate lab closed: sup.status 1 has masked bits 1 and tunable ctrl.kp 1.0 -> 2.0 by plc. On a real plant the addresses and the map are the plant’s, generated with plc-mirror, credentials come from the environment, and the same order applies: Commissioning. The connectors are in Connectors (dry run, write gate, tunable sync), the setups in A controller beside a PLC and A closed loop with a plant data system.

7. Operating it on the device

The stand-ins go to the device as a bundle of their own, the PLC’s project as its engine and lab.py as a companion process (stand-in/taktwerk-bundle.toml); then the closed loop, started at a set time:

taktwerk deploy stand-in --device edge --yes
taktwerk device install-unit edge --yes
taktwerk device start edge stand-in --yes
taktwerk device start edge plant-loop --project closed-loop.toml --in 20s --yes
taktwerk device status edge plant-loop
select project closed-loop.toml in /opt/taktwerk/bundles/plant-loop/env
systemd-run --unit=taktwerk-start@plant-loop --on-active=20000ms: systemctl start taktwerk@plant-loop.service
taktwerk@plant-loop.service starts at 2026-10-30 01:53:57 (this computer's clock)
taktwerk-start@plant-loop.timer: active (waiting)

install-unit runs again because the stand-in bundle brings a companion. start --in selects closed-loop.toml and arms a start once, 20 s later, on the device’s monotonic clock; --at 06:00 names a time by the workstation’s clock, --cancel disarms it. Once it runs:

taktwerk device engine edge plant-loop status --expect 'taktwerk.connector.lab.gate_open'
taktwerk device engine edge plant-loop read lab.level ctrl.u --for 10
taktwerk device engine edge plant-loop set ctrl.kp 2 --yes
taktwerk device engine edge stand-in read mirror.ctrl_kp mirror.engine_ok

status passes while the engine runs, its heartbeat advances and the plant data system’s gate is open; read --for gives each signal’s count, mean, standard deviation, minimum and maximum over 10 s; set reads the gain back and reports its events.csv line. The PLC follows the new gain, and mirror.engine_ok is 1 while it sees the heartbeat. The commands and their reports are in Operating a running engine.

Stop both, copy the records home and read them:

taktwerk device stop edge plant-loop --yes
taktwerk device stop edge stand-in --yes
taktwerk device records edge plant-loop
uv run --with ../../python python report.py records/edge/plant-loop/closed-loop/main

records copies what is new into records/edge/plant-loop/, where the workbench’s Records lists it too, and only reads on the device (Records). The report shows the gates opening and tunable ctrl.kp 1.0 -> 2.0 by http, the write of set.

For a plant: set the addresses in project.toml and closed-loop.toml to the plant’s, put credentials into the bundle’s env file on the device, commission with the dry run first, and device enable edge plant-loop --yes to start it at boot. Running for weeks: Production setup.

report.py:

"""A run of the level loop in a few lines, or two runs compared.

    uv run --with ../../python python report.py <run> [<other run>]

A run is a run directory (with `run.json`) or any directory above runs: then its newest run.
Prints how the run ended, its events but the tunables' start values, and the range of every
recorded number; given two runs, the largest difference per column instead.
"""

import math
import sys
from pathlib import Path

import taktwerk


def newest(path: str) -> Path:
    root = Path(path)
    if (root / "run.json").is_file():
        return root
    runs = [p.parent for p in root.rglob("run.json")]
    if not runs:
        sys.exit(f"{path}: no run below it")
    return max(runs, key=lambda p: (p / "run.json").stat().st_mtime)


def numbers(run: taktwerk.Run, column: str) -> list[float]:
    return [float(v) for v in run[column] if isinstance(v, (int, float)) and math.isfinite(v)]


def summary(path: Path, details: bool = True) -> taktwerk.Run:
    run = taktwerk.read_run(path)
    t = run.t_engine
    print(f"{path}: {len(t)} rows, {t[0]:.1f} to {t[-1]:.1f} s, stop: {run.stop}")
    if not details:
        return run
    for e in run.events:
        if e.kind not in ("start", "stop") and not e.detail.startswith("start "):
            print(f"  {e.t_engine:8.1f} s  {e.kind:9} {e.source:28} {e.detail}")
    for column in run.columns:
        values = numbers(run, column)
        if values:
            print(f"  {column:28} {min(values):10.4g} .. {max(values):<10.4g}")
    return run


def compare(a: taktwerk.Run, b: taktwerk.Run) -> None:
    print("largest difference per column:")
    for column in a.columns:
        if column not in b.columns:
            continue
        pairs = list(zip(a.t_engine, a[column], b[column]))
        diffs = [
            (abs(float(x) - float(y)), t)
            for t, x, y in pairs
            if isinstance(x, (int, float)) and isinstance(y, (int, float))
        ]
        diffs = [d for d in diffs if math.isfinite(d[0]) and d[0] > 0]
        if diffs:
            d, t = max(diffs)
            print(f"  {column:28} {d:10.4g} at {t:.1f} s")
        else:
            print(f"  {column:28} {'equal':>10}")


def main() -> None:
    if len(sys.argv) not in (2, 3):
        sys.exit(__doc__)
    if len(sys.argv) == 2:
        summary(newest(sys.argv[1]))
    else:
        first = summary(newest(sys.argv[1]), details=False)
        compare(first, summary(newest(sys.argv[2]), details=False))


if __name__ == "__main__":
    main()