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: a gain sweep as a campaign

A controller is tuned by running its loop many times: other gains, other plants, the same disturbance. This tutorial takes the level loop of examples/campaign from one simulated hour to a campaign of 24 runs and a ranking of the gains, with the engine that would run the same loop on a controller. Everything runs on a workstation, from the repository root, with a C compiler, taktwerk and uv.

1. The loop

The plant is a tank drained through an outlet, filled by a pump and by a disturbance inflow; its level sensor adds noise from a seed. The controller is the PI of raw-pi, reading the measured level and driving the pump. Both are raw C packages; build them:

sh examples/raw-pi/build.sh
sh examples/campaign/tank/build.sh

tank.h is in the recommended shape: taktwerk import-header tank.h --shape wrote its descriptor, confirmed. Its parameter seed makes it a seeded model, nothing more is needed for seeds.

2. One run

The set point ctrl.sp is an external input; nothing writes it unless something sets it. Run an hour of engine time as fast as the models compute, without OPC UA, with a set point of 1 m:

taktwerk run examples/campaign/project.toml --speed max --no-connectors \
  --until-time 3600 --set 'ctrl.sp=[1.0]' --seed 5

36 000 ticks of 0.1 s took about half a second with a release build on a four-core aarch64 board. The recorder main wrote the run to examples/campaign/records/project/main/<run-id>/; run.json records under invocation the speed, the time limit, the override and the seed each instance got. Read it in Python:

import taktwerk

run = taktwerk.read_run("examples/campaign/records/project/main")
print(run.stop, run.seed, run["plant.level"][-1])

Run it again with --seed 5: the parts are the same bytes.

3. A disturbance

disturbance.toml is a scenario: the set point, a time limit of 20 minutes, a step of 0.2 m³/s in the disturbance inflow at 300 s, and the level sensor frozen for a minute at 700 s.

taktwerk run examples/campaign/project.toml --speed max --no-connectors \
  --scenario examples/campaign/disturbance.toml

events.csv shows each event at its tick:

cycle,t_engine,kind,source,detail
0,0.0,start,engine,2 instances: ctrl plant
3000,300.0,scenario,plant.q_dist,add 0.2 to plant.q_dist
7000,700.0,scenario,plant.level_m,fault plant.level_m hold for 60 s
7600,760.0,scenario,plant.level_m,end: fault plant.level_m hold for 60 s
12000,1200.0,stop,engine,time limit reached

The fault acts where the controller reads the sensor: the recorded plant.level_m is what the sensor produced, the controller’s input (record ctrl.pv to see it) is what it received.

4. The campaign

gains.toml sweeps ctrl.kp over 0.5 to 4 and ctrl.ki over 0.01 to 0.2, for the tank as built and for one with a smaller outlet (plant.k_out = 0.4), all over the disturbance scenario. Look at the plan first:

taktwerk campaign examples/campaign/gains.toml
runs     24 (0 done), 2 at a time, speed max

run               seed                ctrl.ki  ctrl.kp  plant.k_out  done
nominal-g00       627405149472732430  0.01     0.5      -            no
nominal-g01       627405149472732430  0.01     1.0      -            no
...

Every run has the same seed: they differ only in case and grid point, so they all see the same sensor noise and the comparison is between the gains, not between noise draws. Run it:

taktwerk campaign examples/campaign/gains.toml --write

Each run is a taktwerk run process of its own, two at a time, and lands in examples/campaign/out/gains/runs/<name>/: its run.toml (the scenario it ran), status.json (status, stop, wall time, the hash of every record file), its log and its records. runs.csv lists them all. Stop it halfway with Ctrl-C and run the same command again: what is done is skipped. Any run reruns alone:

taktwerk run examples/campaign/project.toml --speed max --no-connectors \
  --scenario examples/campaign/out/gains/runs/nominal-g08/run.toml

5. The analysis

analyze.py reads the campaign with the Python package and ranks the runs of each case by the integral of the absolute level error after the disturbance step, next to the largest error after the step and around the frozen sensor:

uv run --with taktwerk python examples/campaign/analyze.py examples/campaign/out/gains
nominal
  run                   kp     ki  IAE [m s]  max err [m]  frozen [m]
  nominal-g08          0.5    0.2      1.525       0.1859      0.5398
  nominal-g09          1.0    0.2      1.640       0.1305      0.5179
  ...
  nominal-g00          0.5   0.01     18.925       0.2446      0.0383

The strongest integral action rejects the step best and is worst while the sensor is frozen: it integrates an error that no longer changes. The sweep shows the trade-off in one table; a [sample] axis would add plant uncertainty to it, replicates other noise draws.