Run record format
A run record is what one recorder writes for one run of an engine: a directory of plain files
that any tool can read with gzip, CSV and JSON. The engine’s own file sink and
taktwerk record write the same files; taktwerk serve, the Python package and
taktwerk-record read them. This page specifies format 1, the format in run.json. How a
recorder chooses what to record is in Recording.
Layout
<dir>/<project>/<recorder>/<run-id>/
run.json what was recorded and how the run was started
part-0001.csv.gz closed parts: never change again
part-0002.csv.gz.open the part being written; renamed without .open once closed
events.csv the run's events
SHA256SUMS one line per closed file
CLOSED empty; written last, once the run is closed
<dir>is the recorder’sdir(or--record-dir), or--diroftaktwerk record.<project>is the project file’s name without extension;<recorder>the recorder’sid(ASCII letters, digits,_,-).<run-id>isYYYYMMDDTHHMMSSZ-<hash>: the recorder host’s UTC when the run opened and the first 8 hex digits of the project file’s SHA-256. A second run of the same second gets-2, then-3, and so on.- Parts are numbered from
0001, four digits at least; the order of the numbers is the order of the rows.
Life of a run
- The run opens: the directory,
run.json(withoutend),events.csvwith its header andpart-0001.csv.gz.open. - Rows go into the open part. When it closes (rotation or the end of the run) it is finished,
synced to disk and renamed to
part-NNNN.csv.gz, and<sha256> part-NNNN.csv.gzis appended toSHA256SUMS; the next part opens. - The run closes: the last part closes,
run.jsonis replaced withend, the lines forevents.csvandrun.jsonare appended toSHA256SUMS, andCLOSEDis created.
run.jsonis written asrun.json.tmpand renamed, never rewritten in place.SHA256SUMSlines are<64 lowercase hex> <file name>, the formatsha256sum -cchecks.- A run without
CLOSEDis still recording, or was cut off (SIGKILL, a crash, power). Its last part keeps the.openname and ends at its last flush. - Retention deletes closed parts of a recorder’s runs, oldest first; their lines stay in
SHA256SUMS(sha256sum -c --ignore-missing).run.json,events.csvandSHA256SUMSare never deleted. - Files ending in
.tmpare temporary: readers and sync tools ignore them.
run.json
One JSON object, pretty-printed.
| field | type | meaning |
|---|---|---|
run_id | string | the directory name |
host | string | the recorder host’s name |
source | string | local for an engine’s own file sink, else the endpoint pulled |
start | object | cycle (of the first row), t_engine (seconds), utc (RFC 3339, the recorder host’s clock) |
end | object | once closed: cycle (of the last row; null without rows), rows, parts, utc, and truncated: true when a puller lost the rows after cycle |
format | integer | 1 |
project | string | the project name, as in the path |
project_hash | string | sha256:<hex> of the project file |
taktwerk | string | the engine’s taktwerk version |
engine_run | string | random id of the engine process; another on every start |
recorder | string | the recorder id |
tick_s | number | the base tick, seconds |
start_time | number | engine time of cycle 0, seconds |
every | integer | ticks per row |
buffer_s | number | seconds of engine time the engine’s ring holds |
rotate | object | size_mb, hours |
retain | object | max_gb, days |
models | array | per model: id, kind, path (as the project gives it), name (the interface’s), hash |
columns | array | per column, in row order: name (its header), signal, type (element type or text), unit (when declared), words (1, or a text column’s capacity in bytes) |
invocation | object | how the run was started, when not plainly from the project file; below |
A model’s hash is sha256:<hex> over the package: of the file for an .fmu; for a directory,
over the files that make the package in path order, each as its relative path, a NUL byte and
its bytes. A raw package’s files are taktwerk-model.toml and everything below lib/; an
extracted FMU’s are modelDescription.xml and everything below binaries/ and resources/.
Other files in the directory are not part of the package.
invocation:
| field | meaning |
|---|---|
speed | 1, a factor, or max |
connectors | whether the project’s connectors ran |
cycles, until_time, stop_when | the stop limits and conditions, as given |
overrides | every override in order: key, value, and was, the project’s value it replaced (absent when the project left it to the model); a --model is models.<id> |
seed, seeds | the run seed and the seed each instance got, by instance id |
scenario | the scenario file: path, hash (sha256:<hex>), text |
write | connectors whose writes --write turned on |
resume | --resume: tick and time the run started at, max_age_s, and per instance id what it restored or why it starts from init |
Absent fields were not used. Device clocks are not trusted: rows carry cycles and engine time, and every UTC timestamp comes from the host that wrote the record.
Parts
A part is a gzip file of CSV text (RFC 4180 quoting, \n line ends): a header line, then one
line per row. The gzip header carries no file name and modification time 0.
cycle,t_engine,filt.y[0],filt.y[1],filt.y[2],filt.gain,taktwerk.status
0,0.0,0.9090909090909091,1.9607843137254901,2.9702970297029703,1.0,1
1,0.01,0.8264462809917356,1.9223375624759707,2.9408881482207625,1.0,1
- Header:
cycle,t_engine, then each column’snamefromrun.json, in that order. Every part repeats it. A scalar’s name is its signal; an array element is<signal>[i], a matrix element<signal>[i,j]with logical indices, quoted since it holds a comma; a text signal is one column named like its signal. cycle: the tick, an unsigned integer. Consecutive rows differ byevery; a larger step is a loss (see Gaps and drops). Skipped ticks have rows.t_engine:start_time + cycle · tick_s, seconds.- Floats (
f64,f32): the shortest text that reads back to the same value:0.1,1e-7,1.0,NaN,inf,-inf. - Integers: decimal. Booleans:
0or1. - Text: the bytes up to the first NUL, as UTF-8 (invalid sequences replaced), quoted when they hold a comma, a quote, CR or LF.
- The stream is flushed every 10 s of engine time and at close, never on wall time, so the same rows give the same bytes.
- A part still being written, or cut off, ends at its last flush without the gzip trailer: a reader decodes until the stream ends and keeps the complete lines.
events.csv
CSV with the header cycle,t_engine,kind,source,detail; one line per event, in the order the
engine logged them. A puller’s gap line is written when the gap is found, so it may follow
an event of a later cycle. source and detail are quoted when they need it. Flushed about once a
second on wall time, so a run still recording may end in half a line.
| kind | source | detail |
|---|---|---|
start | engine | the instances: 2 instances: ctrl plant |
stop | engine | requested, failed, cycle limit reached, time limit reached or condition <condition> |
tunable | the signal | start <value>, <old> -> <new> by <writer> or refused <value> by <writer>: <reason> |
fault | the instance | step failed: returned <code>, step failed: interface mismatch or step failed: load error; re-init failed: … for a failed re-init; ; outputs held or ; safe outputs when the run goes on, else the engine stopped |
reinit | the instance | re-initialised: a faulted instance runs again |
resume | the instance | run --resume: restored from tick <n> (saved <utc>), tunables and inputs restored …; the model keeps no state …, or starts from init: <why> |
stale | the signal | older than <n> ms or never written; once when the fault starts |
recovered | engine | <n> ticks skipped |
gap | the recorder | cycles <a>..=<b> lost (<n> rows), events <a>..=<b> lost, or cycles from <n> not pulled: …; written by a puller |
dropped | the recorder | <n> rows lost: cycles <a> to <b>; written by a file sink |
scenario | the signal | what the scenario event does; end: … when a fault or ramp ends |
gate | the connector | open, or closed: <reason> |
A value in a detail is written as in the parts; an array is [a, b, c] in storage order,
text is in double quotes. A reader should accept kinds it does not know.
Tunables
events.csv holds the value of every tunable at every cycle:
- At init, before the first tick, one
start <value>per tunable, in signal order. - Every change:
<old> -> <new> by <writer>, the writer the id of the connector that wrote it. A write of the value in force logs nothing. - Every refused write:
refused <value> by <writer>: <reason>(wrong type, length or text capacity, outside the tunable’s range, or rejected by a client’s mapping). The value in force stays. - A scenario’s
set,addorrampof a tunable is ascenarioevent; the scenario is inrun.json.
The cycle of a start or a change is the first tick that fetches the value: an instance
stepping at that cycle or later steps with it. The value of a tunable at cycle c is that of
its last start or change line with a cycle of at most c; the lines of one tunable are in
the order of its values.
Reading a record
- Read
run.json; checkformat. - List
part-*.csv.gzandpart-*.csv.gz.open, sort by name, and decode each as far as its gzip stream goes, keeping complete lines. Check each header againstcolumns. - Read
events.csvup to its last line break. - A run is complete when
CLOSEDexists; its closed files check withsha256sum -c --ignore-missing SHA256SUMS.
examples/recording/read_run.py does steps 1 and 2 with the
Python standard library.
Pull interface
taktwerk record reads a running engine’s recorders through its OPC UA server; another puller
can do the same. Per recorder, next to its counters, in the signal namespace (s= NodeIds):
<prefix>.recorder.<id>.manifest, aStringvariable:run.jsonwithout the run’s own fields (run_id,host,source,start,end), as JSON.<prefix>.recorder.<id>.read_rows(from_cycle: UInt64, max_rows: UInt32)returnsengine_run(String),oldest_cycle,next_cycle(UInt64) androws(ByteString). Rows start at the first row at or afterfrom_cyclestill held,0meaning the oldest. Each row is its cycle and then one word per column word (wordsin the manifest), all little-endianu64: a float’s bits (f32in the low 32), an integer sign- or zero-extended, a bool0or1, a text byte per word. A reply holds at most 8 MiB. Ask again fromnext_cycle; a jump in the cycles is a gap.<prefix>.recorder.<id>.read_events(from_seq: UInt64, max: UInt32)returnsengine_run,oldest_seq,next_seq, thencycles(UInt64[]),kinds,sourcesanddetails(String[]), one entry per event, at most 4096 per call.
<prefix> is the engine’s system_prefix. The methods are called on the recorder’s folder
object, NodeId s=<prefix>.recorder.<id>/. A changed engine_run means the engine restarted
and its cycles count from 0 again.