Simulation runs
The engine that runs a project on a controller in real time also runs it as a simulation: the same project, the same model packages, the same fixed-step semantics, as fast as the models compute. taktwerk solves no equations itself. Each model advances itself by one step at every tick it is due, and the engine exchanges values at tick boundaries, as in real time.
Pace
--speed decides how ticks follow the wall clock:
taktwerk run project.toml # real time: one tick per tick_ms
taktwerk run project.toml --speed 120 # deadlines 120 times closer together
taktwerk run project.toml --speed max --no-connectors --cycles 100000
| speed | ticks | engine clock | overruns |
|---|---|---|---|
1 | on absolute deadlines every tick_ms | wall clock | counted |
<factor> | on absolute deadlines every tick_ms / factor | engine time | counted |
max | each as soon as the previous one is done | engine time | none |
Model time is start_time + tick × tick_ms at every speed; a model sees the same step size and
the same inputs. What changes is how long a tick takes on the wall clock.
Engine time. Off real time the engine’s clock is engine time: an input’s age is measured in
model time, and a write by a connector (a tunable set from the TUI, say) is stamped with the
engine time of the tick it lands in. So max_age_ms means the same at every speed.
Recorders lose no rows at max. In a paced run a file sink that falls a ring behind loses
rows (see Recording); unpaced, nothing is late, so the cycle
waits for its file sinks instead. A buffer sink is not waited for.
Connectors. --no-connectors starts none: the run serves nothing and binds no port, so
many runs can share a machine. An opcua-client exchanges values with a device in wall time
and is refused off real time. The OPC UA server may stay; a client sees the run go by at its
speed and can still set tunables, which are then stamped and logged at the engine’s tick.
Same project, same inputs: the same rows, at any speed. A run whose inputs only come from its
models (no connector writes while it runs) records byte-identical parts at --speed 1, 120
and max, except for taktwerk.overruns, which counts the machine’s missed deadlines.
Stop conditions
A run ends on SIGINT or SIGTERM, on a model error, or at the first of these:
taktwerk run project.toml --speed max --no-connectors \
--cycles 1000000 --until-time 86400 \
--stop-when 'tank.level > 2.0' --stop-when 'ctrl.alarm'
--cycles N: afterNticks.--until-time S: before the first tick whose engine time is at or afterSseconds, so the last tick is the last one beforeS; withstart_time0 and a 0.1 s tick,--until-time 60runs 600 ticks.--stop-when CONDITION: after the first tick at which the condition holds; that tick is the last recorded one. A condition is<signal> <op> <number>with>,>=,<,<=,==or!=, or a bare signal, which holds when the value is not zero (a flag set).name[i]takes elementiof an array.trueandfalseare1and0. Any numeric signal works, read after the tick: outputs, inputs, tunables, system signals.NaNsatisfies only!=. The option repeats; the first condition that holds ends the run.
Conditions are parsed against the plan at start, so a typo fails the run before the first tick.
The stop event in events.csv names what ended the run: cycle limit reached,
time limit reached, or condition tank.level > 2.0.
Overrides
One project serves many runs. --set, --model and --record-dir change a run without
editing the project file:
taktwerk run project.toml --speed max --no-connectors --until-time 3600 \
--set ctrl.kp=2.5 --set plant.tau='[0.2, 0.5]' --set plant.dims.n=2 \
--set engine.start_time=0 --set plant.u=1.0 \
--model plant=../packages/plant-v2 --record-dir out/run-7
| key | what it sets |
|---|---|
<instance>.<parameter> | the parameter’s value, like parameters in the project |
<instance>.<tunable> | the tunable’s start value |
<instance>.<input> | the initial value of the external signal the input reads |
<instance>.dims.<dimension> | the dimension’s length |
<instance>.every | the instance’s period in ticks |
engine.tick_ms, engine.start_time, engine.input_max_age_ms | the engine setting |
<signal> | a tunable bound to that signal (its start value), or an external input (its initial value) |
- The value is a TOML value:
0.5,3,true,[1.0, 2.0],"model2.txt". Text that is not valid TOML is taken as text, so--set ctrl.mode=fastworks without quotes. A model’s data file, read by the library from a text parameter, is just such a parameter. - Overrides apply in order once the models are loaded, so a later one wins, and they are checked like the project file: an unknown instance, variable or signal, an output, a wired input, or a value of the wrong shape fails the run before it starts.
- An initial value is written into the image before the first tick. Nothing writes the input again in a run without connectors, so it holds for the whole run unless a scenario event changes it.
--model ID=PATHpoints modelIDat another package or FMU of the same kind.--record-dir DIRreplaces thedirof every file recorder, so the run lands inDIR/<project>/<recorder>/<run-id>/. Both paths are relative to the working directory.
Every applied override is recorded in run.json under invocation, with the project’s value it
replaced (was, absent when the project left it to the model), next to the speed, the stop
conditions and whether connectors ran. With the project file’s hash, a record describes its run
completely:
"invocation": {
"speed": "max",
"connectors": false,
"until_time": 3600.0,
"overrides": [
{ "key": "models.plant", "value": "/home/me/packages/plant-v2", "was": "plant" },
{ "key": "ctrl.kp", "value": 2.5, "was": 1.0 }
]
}
Seeds
--seed N gives a run one seed, and every instance that draws random numbers its own seed
derived from it:
taktwerk run project.toml --speed max --no-connectors --until-time 600 --seed 7
- Which variable. An instance takes its seed through a numeric scalar parameter or tunable
named
seed, when its model has one. A model that names it differently is pointed at it in the project,seed_var = "rng_seed"on the[[instance]];seed_var = ""takes none. A model needs no change for this: it already reads its seed from a parameter, now the run sets it. - Which value. Instance
idgetssplitmix64(N xor fnv1a(id)), reduced to what the variable holds: 31 bits for a signed 32-bit integer, 32 for an unsigned one, 53 for a float, 63 for a 64-bit integer. Instances draw independent streams, and a renamed instance draws another. - What wins. The run seed replaces a seed the project file sets;
--set <instance>.seed=…replaces the run seed. Without--seednothing changes. - Recorded.
run.json’sinvocationholdsseedandseeds, the value each instance got.
Same project, same overrides, same seed: byte-identical parts, at any speed.
Scenario events
A scenario file says what happens to a run while it runs: timed events, and the overrides, stops and seed that go with them. One project, many scenarios:
taktwerk run project.toml --speed max --no-connectors --scenario disturbance.toml
seed = 3
[set] # overrides, as --set takes them
"ctrl.sp" = [1.0]
[models] # model packages, relative to this file
# plant = "../packages/plant-v2"
[stop] # as --cycles, --until-time and --stop-when
time_s = 900.0
when = ["plant.level > 2.5"]
[[event]] # a step of the disturbance inflow at 300 s
at_s = 300.0
add = "plant.q_dist"
value = 0.2
[[event]] # the level sensor freezes for a minute
at_s = 400.0
fault = "plant.level_m"
hold = true
for_s = 60.0
[[event]] # a set-point ramp
at_s = 600.0
ramp = "ctrl.sp[0]"
to = 1.5
over_s = 100.0
[[event]] # from 800 s the sensor reads 0.1 m high, to the end
at_s = 800.0
fault = "plant.level_m"
offset = 0.1
An event applies at the first tick whose engine time is at or after at_s, or at tick cycle,
before the models step; events of one tick apply in file order. Each has one action:
| action | values | does |
|---|---|---|
set = "<target>" | value | sets a tunable or external input; a number sets every element (or name[i] one), an array the whole signal |
add = "<target>" | value | adds value: a step |
ramp = "<target>" | to, over_s | moves linearly from the value at the event to to, one step per tick, reaching it over_s later |
fault = "<signal>" | one of the faults below, for_s | changes what the signal’s readers see, for for_s seconds or until cleared |
clear = "<signal>" | ends the fault on the signal |
| fault | readers see |
|---|---|
hold = true | the value the signal had when the fault started |
value = <x> | x (nan allowed: a lost reading) |
offset = <x> | the value plus x: a bias |
scale = <x> | the value times x: a gain error |
noise_sd = <x> | the value plus normal noise of standard deviation x |
drop = true | an external input no longer written: its last value, and with an age limit it goes stale like a lost connection |
- A target of
set,addandrampis a tunable or an external input. A fault acts on an output or an external input, elementname[i]or whole, for every model that reads it. A new fault on a signal replaces the one in force. - Faults act where models read: a recorded output stays what its model produced. A recorded
external input and a recorded composed input are what their model received. An input wired
whole to an output has no signal of its own (its column would be the output), so to see what
a model received there, compose it:
inputs = { pv = ["plant.y"] }records asctrl.pv. - Noise draws from a stream of the run seed and the event’s index, so a seeded run repeats its noise.
- Every applied, started and ended event is a
scenarioevent inevents.csv, with the signal as its source:fault plant.level_m hold for 60 s, and laterend: fault plant.level_m hold for 60 s. - Events are checked against the plan at start: an unknown signal, a target of the wrong kind, a missing or misplaced value fails the run before the first tick.
The file’s overrides apply before --set, its models before --model, and --seed,
--cycles, --until-time win over the file’s; its stop conditions and those of the command
line all apply. run.json’s invocation keeps the file whole: its path, hash and text.