Test Information System
The Test Information System (TIS) is AutoCore’s unified pipeline for
collecting, storing, and exporting test data across the control program
and the HMI. You declare your test schema once in project.json, and
the server hands the control program a typed Rust struct, the HMI a
typed TypeScript schema, and a four-tier filesystem layout that’s the
same on every project.
Historical note. TIS was originally called the “Standardized Results System.” Old IPC topics (
results.*), generated structs (ResultsSystem), and GM variables (results_*) were renamed wholesale during the rebrand. The original reference prose has been moved to the appendix at the bottom of this chapter for historical context — everything before the appendix is the current contract.
The four-level hierarchy
Every recorded test is identified by four canonical keys. The same names are used in the wire format, the generated code, the disk layout, and the HMI:
| Level | User-facing name | Wire/code key | What it is |
|---|---|---|---|
| 1 | Project | project_id | The customer, contract, or product line. Owns many tests. |
| 2 | Test Method | method_id | The standardised recipe: schema, chart views, validation rules. Defined in project.json under test_methods. |
| 3 | Sample | sample_id | The physical object in the machine right now. Operator types it on the setup form; required. |
| 4 | Test Record | run_id | A single test event. Auto-generated ISO timestamp when tis.start_test fires. |
On disk:
datastore/results/
└── <project_id>/
└── <method_id>/
└── <run_id>/
├── test.json ← includes sample_id at the top level
├── cycles.jsonl
├── raw_data/<blob>.json
└── filtered_data/<blob>.json
test.json:
{
"project_id": "plant_a",
"method_id": "translational_traction",
"run_id": "20260422T140231Z",
"start_time": "2026-04-22T14:02:31Z",
"sample_id": "SAMPLE-0042",
"config": { "specimen_notes": "…", "control_load": 1000 },
"results": { "avg_cof": 0.50, "max_cof": 0.55, "min_cof": 0.45 }
}
sample_id lives at the top level — peer of project_id, method_id,
and run_id — because it’s part of the run’s structural identity, not
a user-pickable config field.
Target integration workflow
Adding TIS to a new project takes four steps, no hand-wiring:
-
Declare the test methods in
project.json:{ "test_methods": { "translational_traction": { "project_fields": [ { "name": "customer", "type": "string", "required": true } ], "config_fields": [ { "name": "control_load", "type": "f32", "units": "N" } ], "cycle_fields": [ { "name": "cycle_index", "type": "u32", "source": "gm.cycle_count" }, { "name": "actual_load", "type": "f32", "source": "gm.zforce_load" } ], "results_fields": [ { "name": "avg_load", "type": "f32" } ], "raw_data": { "blob_name": "trace", "columns": { "t": { "source": "time" }, "force": { "source": "ni.traction.channels.tsdr_fz" } }, "units": { "t": "s", "force": "N" } }, "views": { "load_per_cycle": { "type": "cycle_scatter", "x": { "field": "cycle_index", "label": "Cycle" }, "y": [ { "field": "actual_load", "label": "Load (N)" } ] } } } } } -
Generate the typed code:
acctl codegen-tagsThis regenerates
control/src/gm.rs(withTestInformationSystemplus one*TestManagerper method) andwww/src/autocore/tis.ts(with one*Schemaper method). -
In the control program:
#![allow(unused)] fn main() { pub struct MyProgram { tis: TestInformationSystem, daq: DaqCapture, } impl ControlProgram for MyProgram { type Memory = GlobalMemory; fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) { if let Some(_method) = self.tis.tick_with_autostart(ctx) { // first tick of a new run; initialise per-run state here } // ... run the cycle ... if let Err(e) = self.tis.translational_traction.record_raw_trace(&self.daq, ctx) { log::warn!("record_raw_trace failed: {}", e); } self.tis.record_cycle(ctx); self.tis.end_active(ctx); } } } -
In the HMI:
<TisProvider> <TabView> <TabPanel header="Setup"> <TestSetupForm /> </TabPanel> <TabPanel header="Data"> <TestDataView /> </TabPanel> <TabPanel header="History"> <ResultHistoryTable /> </TabPanel> </TabView> </TisProvider>
That’s the entire surface area. No projectId props, no GM tag
indirection, no manual stage_test wiring. acctl new-tis-project <name> lays this skeleton down for you in one command.
Lifecycle in detail
Three IPC verbs drive the lifecycle from the HMI side:
-
tis.stage_test— Operator filled in the form, the form is valid, the rig is ready. Payload:{ project_id, method_id, sample_id, config }. Stage is persistent: a successfulstart_testdoes NOT consume it. The operator can run sample after sample by changing onlysample_idand clicking Start. Cancel viatis.clear_staged. -
tis.start_test— Operator (or the control program) opens a record on disk. Server createstest.json+ the emptycycles.jsonl+raw_data/+filtered_data/directories. Setstis.active = trueand broadcasts the fourtis.active_*scalars. The control program’stick_with_autostartdoes this for you. -
tis.finish_test— Closes the record. Flipstis.active = false. The control program’send_active(ctx)calls this.
Auto-injected GM scalars (added by Project::normalize() at server
load when test_methods is non-empty):
| GM variable | Linked to | Type |
|---|---|---|
tis_staged | tis.staged | bool |
tis_staged_project_id | tis.staged_project_id | string |
tis_staged_method_id | tis.staged_method_id | string |
tis_staged_sample_id | tis.staged_sample_id | string |
tis_active | tis.active | bool |
tis_active_project_id | tis.active_project_id | string |
tis_active_method_id | tis.active_method_id | string |
tis_active_sample_id | tis.active_sample_id | string |
tis_active_run_id | tis.active_run_id | string |
These nine scalars are the contract between the control program and
the server’s lifecycle state. Don’t add them to project.json
yourself — normalize() will inject them, and they’re typed Internal
(not bound to any hardware).
Filtered data mirrors raw data
Every test method that declares a raw_data block automatically gets
a parallel filtered_data blob with the same columns and the same
blob name. The server creates both directories on start_test, and
exposes symmetric IPC verbs:
| Raw | Filtered |
|---|---|
tis.add_raw_data | tis.add_filtered_data |
tis.read_raw | tis.read_filtered |
tis.list_raw | tis.list_filtered |
Codegen emits record_raw_trace(daq, ctx) and
set_filtered_trace(col_a, col_b, ..., ctx) per TestManager. The
control program normally ships raw_data; a post-processing step
(inline, Python, or offline) writes filtered_data. Absent
filtered files are non-errors — consumers degrade gracefully.
raw_data.columns — typed channel sources
raw_data.columns is a map of column_name → { source }. The
codegen reads source and emits the right channel pull for each:
"time"— synthesizes a linear time axis fromdata.actual_samplesanddata.sample_rate(no DAQ channel needed)."ni.<daq>.channels.<chan>"— looks up the channel index inproject.json’smodules.ni.config.daq[<daq>].channelsarray and emits a typed pull fromDaqCapture.
A single record_raw_trace(daq: &DaqCapture, ctx) per TestManager
ships the whole columnar blob:
#![allow(unused)]
fn main() {
match self.tis.active_test {
Some(TestType::TranslationalTraction) =>
self.tis.translational_traction.record_raw_trace(&self.daq, ctx),
Some(TestType::RotationalTraction) =>
self.tis.rotational_traction.record_raw_trace(&self.daq, ctx),
None => Ok(()),
}
}
Failures (DAQ not ready, channel missing, schema error) return
RawTraceError; the control program decides whether to log, retry,
or surface the failure on the HMI.
The legacy string-array form of raw_data.columns (e.g.,
["t", "force"]) is rejected at parse time. Migrate every column
to the explicit { source: "..." } form.
A third source exists for columns computed from other columns —
see the next section.
Derived columns
source: "derived" lets a column be computed from other declared
columns at trace-write time. The control program does not need any
extra wiring: the codegen emits the math into record_raw_trace
alongside the primitive columns, and the resulting blob looks
identical on disk and over the wire.
Declare a derived column with a formula field:
"raw_data": {
"blob_name": "trace",
"columns": {
"t": { "source": "time" },
"tsdr_fx": { "source": "ni.traction.channels.tsdr_fx" },
"tsdr_fz": { "source": "ni.traction.channels.tsdr_fz" },
"enc_x": { "source": "ni.traction.channels.enc_x" },
"cof": { "source": "derived", "formula": "abs(tsdr_fx) / abs(tsdr_fz)" },
"velocity": { "source": "derived", "formula": "ddt(enc_x)" }
}
}
Grammar
| Form | Meaning |
|---|---|
<col> | Bare reference to another column in the same columns map. |
<number> | f32 literal (1000.0, -2.5e3, …). |
<expr> + <expr> / - / * / / | Element-wise arithmetic. |
-<expr> | Unary negation. Same as neg(<expr>). |
abs(<expr>) | f32::abs. |
sqrt(<expr>) | f32::sqrt. |
neg(<expr>) | Unary negation. |
ddt(<col>) | Top-level only. Per-sample derivative (col[i] - col[i-1]) / dt, where dt = 1 / sample_rate. The first sample of the trace seeds to 0.0. The argument must be a bare column reference — nested expressions inside ddt are not supported. |
Common formulas
"abs(tsdr_fx) / abs(tsdr_fz)" # Coefficient of friction
"ddt(enc_x)" # Linear velocity from position
"ddt(enc_c)" # Angular velocity from angle
"sqrt(tsdr_fx*tsdr_fx + tsdr_fy*tsdr_fy)" # In-plane force magnitude
"tsdr_fx / 1000.0" # Newtons → kN
Safety guarantees
- Divide-by-zero is guarded. Every
/in a formula is wrapped at codegen time asif rhs.abs() > f32::EPSILON { lhs / rhs } else { 0.0 }, so traces don’t go toNaN/Infat discontinuities — operators see a zero on the chart and can reason about it. - References are validated at codegen time. Unknown column names fail with a clear “declare it first” error rather than at runtime.
- No self-references and no forward derived→derived references. A derived column may reference any primitive column (Time or NI channel) regardless of declaration order; a derived column that references another derived column must sort alphabetically after it (the codegen emits primitives first, then derived columns in iteration order). Rename or restructure if you hit the ordering error — chains of more than two derived columns are usually a smell.
Units
Don’t forget to add a units entry for each derived column — the
HMI uses it for axis labels and the columnar viewer’s column
header:
"units": {
"cof": "",
"velocity": "mm/s"
}
Control program API
The codegen produces one TestManager struct per declared test
method plus a unified TestInformationSystem aggregator that
dispatches to the active manager. Both live in the generated
gm.rs. Every method below is non-blocking — IPC requests are
queued on a pending_tids deque and drained on each tick().
TestInformationSystem (aggregator)
The high-level entry point your control loop uses. It owns one
<Method>TestManager per declared method and routes calls to the
active one. Auto-injected into GlobalMemory when any
test_methods block exists.
| Method | Returns | Purpose |
|---|---|---|
new() | Self | Construct. Called automatically. |
tick(&mut self, client) | () | Drain pending IPC responses on every manager. Call once per scan cycle. |
tick_with_autostart(&mut self, ctx) | Option<TestType> | tick() + start any staged test whose method_id matches a known manager. Returns Some(TestType) on the tick a new test transitions to active. |
try_start_staged_test(&mut self, ctx) | Option<TestType> | Lower-level: try to start a staged test without first ticking. Use when you need finer control. |
record_cycle(&mut self, ctx) | () | Append one row to cycles.jsonl on the active manager. No-op when no test is active. Only emitted when every method’s cycle_fields are fully source-bound. |
record_raw_trace(&mut self, cycle_index, daq, ctx) | Result<(), RawTraceError> | Ship a DAQ capture as raw_data/<sample>_<blob>_cycleNNNN.json on the active manager. |
record_raw_trace_is_busy(&self) | bool | A trace is being built or sent. |
record_raw_trace_is_error(&self) | bool | Most recent trace ended in error. |
record_raw_trace_error_message(&self) | &str | Diagnostic for the last error, or empty string. |
run_analysis(&mut self, ctx) | AnalysisDispatch | Fire the active method’s Python analysis script. |
is_analysis_busy(&self) | bool | An analysis request is in flight. |
is_analysis_error(&self) | bool | Most recent analysis failed. |
analysis_error_message(&self) | &str | Diagnostic for the last analysis error. |
finish_test(&mut self, ctx) | () | Close the active test record. Equivalent to end_active. |
end_active(&mut self, ctx) | () | Close the active test if any. Idempotent. |
<Method>TestManager (per-method)
The codegen emits one of these per test_methods.<name> entry —
e.g. TranslationalTractionTestManager. They expose the same
methods as TestInformationSystem.record_*, plus a few that only
make sense per-method:
| Method | Returns | Purpose |
|---|---|---|
new() | Self | Construct. |
tick(&mut self, client) | () | Drain pending IPC responses for this manager. |
start_test(&mut self, project_id, client) | () | Open a new run for this method’s method_id. Normally called by the aggregator’s autostart; reach for this directly only when the control program is driving the lifecycle without the HMI’s stage. |
add_cycle(&mut self, ctx) | () | Append one cycle row, sourced from GM per the method’s cycle_fields. |
add_raw_data(&mut self, name, cycle_index, data, client) | () | Manually push a raw_data blob. Prefer record_raw_trace — this is the escape hatch for hand-constructed payloads. |
add_filtered_data(&mut self, name, data, client) | () | Push a filtered_data blob. |
set_filtered_trace(&mut self, col_a, col_b, …, ctx) | () | Strongly-typed wrapper around add_filtered_data with one argument per declared column. Use this from post-processing code (typically the Python analysis), not from the live control loop. |
update_results(&mut self, <results_fields>, ctx) | () | Strongly-typed wrapper around tis.update_results — one argument per declared results_field. |
record_raw_trace(&mut self, cycle_index, daq, ctx) | Result<(), RawTraceError> | Per-method version of the aggregator method. |
record_raw_trace_is_busy / is_error / error_message | as above | Per-method versions. |
run_analysis(&mut self, ctx) | AnalysisDispatch | Per-method version. |
finish_test(&mut self, ctx) | () | Close this method’s active run. |
Plus an associated constant:
#![allow(unused)]
fn main() {
RotationalTractionTestManager::METHOD_ID // → "rotational_traction"
}
RawTraceError
#![allow(unused)]
fn main() {
pub enum RawTraceError {
NotStarted, // start_test wasn't called
DaqNotReady, // capture has no data yet
ChannelMissing { channel: &'static str },
SchemaError(&'static str), // codegen rejected this method's raw_data
Busy, // previous trace still in flight
WorkerDead, // worker thread died — recreate the TestManager
}
}
Busy is the case you’ll hit most often. The typical pattern is to
gate the next record_raw_trace call behind
record_raw_trace_is_busy():
#![allow(unused)]
fn main() {
if !self.tis.record_raw_trace_is_busy() {
match self.tis.record_raw_trace(cycle_index, &self.daq, ctx) {
Ok(()) => self.last_trace_cycle = cycle_index,
Err(e) => log::warn!("record_raw_trace: {}", e),
}
}
}
AnalysisDispatch
#![allow(unused)]
fn main() {
pub enum AnalysisDispatch {
Dispatched, // request sent; poll is_analysis_busy()
Busy, // previous analysis still running
NotConfigured, // this method has no `analysis` block
}
}
run_analysis is fire-and-forget — there’s no return value the
control program needs to read. Once dispatched, the Python script
writes results back via its own tis.update_results or
tis.add_filtered_data calls.
Live broadcasts the control program watches
The auto-injected GM scalars listed in Lifecycle in detail are
your primary observation surface. Two HMI-side broadcasts are
not mirrored to GM and are observed from the React side only —
tis.cycle_added and tis.results_updated. If your control
program needs to react to them (rare), subscribe via the IPC
client’s subscribe() directly.
RPC reference
This section is the full catalog of tis.* IPC commands. The
quick-reference table below lists every command grouped by area;
each subsection that follows documents the request and response
shape with a worked example.
| Topic | Caller | Section |
|---|---|---|
| Lifecycle | ||
tis.stage_test | HMI | Lifecycle |
tis.clear_staged | HMI | Lifecycle |
tis.start_test | Control | Lifecycle |
tis.finish_test | Control | Lifecycle |
tis.status | Either | Lifecycle |
| Cycles and traces | ||
tis.add_cycle | Control | Cycles and traces |
tis.update_results | Control | Cycles and traces |
tis.add_raw_data | Control | Cycles and traces |
tis.add_filtered_data | Post-process | Cycles and traces |
| Reading runs | ||
tis.list_tests | HMI | Reading runs |
tis.read_test | HMI | Reading runs |
tis.read_cycles | HMI | Reading runs |
tis.list_raw | HMI | Reading runs |
tis.read_raw | HMI | Reading runs |
tis.list_filtered | HMI | Reading runs |
tis.read_filtered | HMI | Reading runs |
| Projects | ||
tis.list_projects | HMI | Project management |
tis.create_project | HMI | Project management |
tis.read_project | HMI | Project management |
tis.update_project | HMI | Project management |
tis.delete_project | HMI | Project management |
tis.list_methods | HMI | Project management |
tis.list_schemas | HMI | Project management |
| Admin and exports | ||
tis.delete_test | HMI | Admin and exports |
tis.disk_usage | HMI | Admin and exports |
tis.export_test_csv | HMI | Admin and exports |
tis.export_test_data_csv | HMI | Admin and exports |
tis.export_project_csv | HMI | Admin and exports |
tis.export_project_zip | HMI | Admin and exports |
All TIS commands respond on the same WebSocket frame they arrived on, with a JSON envelope:
{
"topic": "tis.<command>",
"message_type": "Response",
"success": true, // false on error
"error_message": "", // populated when success=false
"data": { /* command-specific */ }
}
The control program’s generated *TestManager methods wrap these
commands so you rarely need to type them by hand. The examples
below are most useful when writing HMI code, debugging via
wscat, or driving TIS from acctl / a test harness.
Lifecycle
tis.stage_test
Declare an intent to run. The stage is persistent — a successful
start_test does not consume it, so the operator can run
sample after sample without re-filling the form. Use
tis.clear_staged to explicitly abandon.
// Request
{ "topic": "tis.stage_test", "data": {
"project_id": "TT-01",
"method_id": "translational_traction",
"sample_id": "SAMPLE-0042",
"config": { "control_load": 500.0 }
}}
// Response
{ "success": true, "data": { "status": "staged" } }
Side effect: broadcasts tis.staged = true plus the three
tis.staged_* scalars.
tis.clear_staged
// Request
{ "topic": "tis.clear_staged", "data": { "project_id": "TT-01", "method_id": "translational_traction" } }
// Response
{ "success": true, "data": { "status": "cleared" } }
tis.start_test
Open a new test record on disk. The server creates
<base>/<project_id>/<method_id>/<run_id>/ (where run_id is a
fresh ISO-8601 UTC timestamp), seeds test.json, and creates
raw_data/ + filtered_data/. Sets tis.active = true and the
four tis.active_* scalars.
// Request
{ "topic": "tis.start_test", "data": {
"project_id": "TT-01",
"method_id": "translational_traction"
// sample_id + config picked up from the staged record;
// can be supplied here directly to bypass the stage.
}}
// Response
{ "success": true, "data": {
"status": "started",
"run_id": "2026-05-13T11:14:22.103Z",
"sample_id": "SAMPLE-0042"
}}
The control program normally never calls this directly — the
codegen’d tick_with_autostart reads the staged scalars from GM
and dispatches to the correct manager.
tis.finish_test
Close the active run. Flips tis.active = false and clears the
four tis.active_* scalars.
{ "topic": "tis.finish_test", "data": { "project_id": "TT-01", "method_id": "translational_traction" } }
tis.status
Diagnostic readback of the staged record. Prefer the
tis.staged_* scalars for any kind of gating — status is
intended for the Are we still staged? sanity dialog during
development.
// Response when staged
{ "success": true, "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"sample_id": "SAMPLE-0042", "ready": true,
"config": { /* ... */ }
}}
// Response when nothing is staged for this pair
{ "success": true, "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"ready": false
}}
Cycles and traces
tis.add_cycle
Append one row to cycles.jsonl. Server assigns a monotonic
cycle_index and timestamps the row. Use the codegen’d
TestManager::add_cycle(ctx) instead of raw IPC — it builds the
payload from GM fields per the method’s cycle_fields.
// Request
{ "topic": "tis.add_cycle", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"cycle_data": {
"cycle_index": 1,
"actual_load": 499.4,
"actual_surface_speed": 0.250,
"friction_coefficient": 0.42
}
}}
// Response
{ "success": true, "data": { "status": "added", "cycle_index": 1 } }
Broadcast on success: tis.cycle_added with
{ project_id, method_id, run_id, cycle }.
tis.update_results
Replace the results block of test.json. Codegen wrapper:
TestManager::update_results(<field_1>, <field_2>, ..., ctx).
// Request
{ "topic": "tis.update_results", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"avg_cof": 0.41, "max_cof": 0.55, "min_cof": 0.32
}}
Broadcast on success: tis.results_updated with
{ project_id, method_id, run_id, results }.
tis.add_raw_data
Write raw_data/<sample_id>_<name>_cycleNNNN.json. Prefer
record_raw_trace (the codegen wrapper) — it threads the
DaqCapture clone through an off-thread worker and adds the cycle
context automatically. add_raw_data is for hand-built blobs
(simulators, replay, etc.).
{ "topic": "tis.add_raw_data", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"name": "trace", "cycle_index": 1,
"data": {
"cycle_index": 1,
"context": { "sample_rate": 5000, "n_samples": 2500 },
"data": { "t": [0.0, 0.0002, 0.0004], "tsdr_fz": [-1.2, -1.3, -1.25] }
}
}}
tis.add_filtered_data
Write filtered_data/<name>.json. One per name per run, no
cycle_index. Typically called by post-processing (the analysis
script).
{ "topic": "tis.add_filtered_data", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"name": "trace",
"data": { "t": [/* ... */], "tsdr_fz_filtered": [/* ... */], "cof_smoothed": [/* ... */] }
}}
Reading runs
tis.list_tests
Run list for a project (or for a project + method pair). method_id
is optional; omit it to aggregate runs across every method. Sorted
newest-first by start_time.
// Request
{ "topic": "tis.list_tests", "data": { "project_id": "TT-01" } }
// Response
{ "success": true, "data": { "tests": [
{ "project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z",
"sample_id": "SAMPLE-0042",
"start_time": "2026-05-13T11:14:22.103Z",
"config": { /* ... */ },
"results": { /* ... */ } }
/* ...next-newest test... */
]}}
tis.read_test
Full test.json for one run.
{ "topic": "tis.read_test", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z"
}}
tis.read_cycles
Paginated read of cycles.jsonl. Defaults: offset=0, limit=200,
order="asc". Pass order="desc" for newest-first.
// Request
{ "topic": "tis.read_cycles", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z",
"offset": 0, "limit": 50, "order": "asc"
}}
// Response
{ "success": true, "data": {
"cycles": [ /* up to `limit` rows */ ],
"offset": 0, "limit": 50, "total": 137
}}
tis.list_raw / tis.list_filtered
{ "topic": "tis.list_raw", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z"
}}
// Response: { "files": ["SAMPLE-0042_trace_cycle0001.json", ...] }
tis.read_raw / tis.read_filtered
Returns the parsed JSON file. Raw blobs are wrapped in a per-cycle
envelope { cycle_index, cycle_fields, context, data: { col: [...] } };
filtered blobs are flat (no envelope).
{ "topic": "tis.read_raw", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z",
"name": "trace",
"cycle_index": 1
}}
Project management
tis.list_projects
{ "topic": "tis.list_projects", "data": {} }
// Response: { "projects": ["TT-01", "TT-02", ...] }
tis.create_project
Creates <base>/<project_id>/ and seeds project.json with the
supplied project_fields. Project IDs must be ASCII
[A-Za-z0-9_-]+; the server rejects path-separator and ..
attempts and refuses to overwrite an existing project.
{ "topic": "tis.create_project", "data": {
"project_id": "TT-01",
"project_fields": { "customer": "ACME", "operator": "alice" }
}}
Broadcast on success: tis.project_created with { project_id }.
tis.read_project
Returns the project.json metadata blob.
// Response data
{ "project_id": "TT-01",
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-05-13T11:14:22Z",
"project_fields": { "customer": "ACME", "operator": "alice" }
}
tis.update_project
Replace project_fields and bump updated_at. The file is rewritten
atomically (write-temp + rename). Reading first to merge by hand is
the caller’s job; this is a replace, not a merge.
{ "topic": "tis.update_project", "data": {
"project_id": "TT-01",
"project_fields": { "customer": "ACME Industries", "operator": "alice" }
}}
Broadcast on success: tis.project_updated with { project_id }.
tis.delete_project
Recursive delete of <base>/<project_id>/. Refuses if any test
is currently active for this project (on any method) — finish or
delete the live run first. Also drops any matching staged entries.
{ "topic": "tis.delete_project", "data": { "project_id": "TT-01" } }
// Response: { "status": "deleted", "project_id": "TT-01" }
Broadcast on success: tis.project_deleted with { project_id }.
tis.list_methods
Lists method directories under one project (i.e., the methods that
actually have data on disk for this project — distinct from
list_schemas, which lists every method declared in project.json).
{ "topic": "tis.list_methods", "data": { "project_id": "TT-01" } }
// Response: { "methods": ["translational_traction", "rotational_traction"] }
tis.list_schemas
Returns the entire test_methods block from project.json plus
the server-suggested default method. Called by <TisProvider> on
mount. The HMI re-uses this for form rendering, view selection,
and schema-driven validation.
// Response data
{ "test_methods": {
"translational_traction": {
"config_fields": [/* ... */],
"cycle_fields": [/* ... */],
"results_fields": [/* ... */],
"raw_data": { /* ... */ },
"views": { /* ... */ }
},
"rotational_traction": { /* ... */ }
},
"default_method_id": "translational_traction"
}
Admin and exports
tis.delete_test
Remove one run directory entirely. Refuses when the target run
is the active test for this (project_id, method_id) — finish it
first. The HMI’s Project Manager drives this with a confirmation
dialog.
{ "topic": "tis.delete_test", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z"
}}
// Response: { "status": "deleted", ... }
Broadcast on success: tis.test_deleted with { project_id, method_id, run_id }.
tis.disk_usage
Linux-only statvfs on the TIS base_directory. Surfaces free /
total space so the Project Manager panel can warn operators
before the disk fills up.
{ "topic": "tis.disk_usage", "data": {} }
// Response
{ "success": true, "data": {
"base_directory": "/srv/autocore/results",
"total_bytes": 1099511627776,
"free_bytes": 512345600000,
"available_bytes": 512345600000,
"used_bytes": 587166027776
}}
tis.export_test_csv
Per-test Report CSV: metadata header (# project_id, sample,
config), [cycles] table, and [results] block. Inline response —
the CSV text lives in data.csv so the HMI can build a blob and
trigger a download without an extra round trip.
// Request
{ "topic": "tis.export_test_csv", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z"
}}
// Response
{ "success": true, "data": {
"filename": "TT-01_translational_traction_SAMPLE-0042_2026-05-13T11:14:22.103Z_report.csv",
"csv": "# project_id: TT-01\n# method_id: translational_traction\n..."
}}
tis.export_test_data_csv
Per-test Data CSV: raw cycles concatenated with filtered columns
prefixed filtered_ (paired by row index against cycle 1). Same
inline-response shape as export_test_csv.
{ "topic": "tis.export_test_data_csv", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"run_id": "2026-05-13T11:14:22.103Z",
"name": "trace"
}}
name defaults to "trace" if omitted.
tis.export_project_csv
Project-wide report — every test in the project, oldest-first,
separated by blank lines. Inline response (CSV in data.csv).
{ "topic": "tis.export_project_csv", "data": { "project_id": "TT-01" } }
// Response
{ "success": true, "data": {
"filename": "TT-01_project_report.csv",
"csv": "...",
"test_count": 137
}}
tis.export_project_zip
ZIP archive of the whole <base>/<project_id>/ tree, written to
the server’s /downloads/ directory. Response contains a URL the
browser can GET to retrieve the file (the ZIP can be hundreds
of MB; we don’t ship it inline).
{ "topic": "tis.export_project_zip", "data": { "project_id": "TT-01" } }
// Response
{ "success": true, "data": {
"download_url": "/downloads/1715600000000_TT-01_project_archive.zip",
"filename": "TT-01_project_archive.zip",
"size": 12345678
}}
Broadcasts (server → clients, no subscription needed)
| Topic | Fires when | Payload |
|---|---|---|
tis.staged | stage_test, clear_staged | bool (scalar) |
tis.staged_project_id | stage_test | string |
tis.staged_method_id | stage_test | string |
tis.staged_sample_id | stage_test | string |
tis.active | start_test, finish_test | bool (scalar) |
tis.active_project_id | start_test, finish_test | string |
tis.active_method_id | start_test, finish_test | string |
tis.active_sample_id | start_test, finish_test | string |
tis.active_run_id | start_test, finish_test | string |
tis.last_start_error | start_test succeeds (clears) / fails (sets) | string |
tis.cycle_added | add_cycle succeeds | { project_id, method_id, run_id, cycle } |
tis.results_updated | update_results succeeds | { project_id, method_id, run_id, results } |
tis.status | stage_test | full staged record (diagnostic) |
tis.project_created | create_project succeeds | { project_id } |
tis.project_updated | update_project succeeds | { project_id } |
tis.project_deleted | delete_project succeeds | { project_id } |
tis.test_deleted | delete_test succeeds | { project_id, method_id, run_id } |
The scalar broadcasts get auto-linked into GM by normalize(). The
JSON-payload broadcasts (cycle_added, results_updated, the
project_* / test_* mutation broadcasts) are consumed directly by
the HMI components and don’t need to be added to project.json.
End-to-end worked example
A complete cycle, from “operator opens the HMI” to “results are
visible on the History tab,” for a translational_traction test
method that declares one DAQ trace and an analysis script:
1. Operator stages the test (HMI)
<TestSetupForm> reads tis.list_schemas, renders the method’s
config_fields, and on validate sends:
{ "topic": "tis.stage_test", "data": {
"project_id": "TT-01", "method_id": "translational_traction",
"sample_id": "SAMPLE-0042",
"config": { "control_load": 500.0 }
}}
Server flips tis.staged = true and the three tis.staged_* scalars.
GM mirrors them: gm.tis_staged, gm.tis_staged_project_id, etc.
2. Control program autostarts (Rust)
The control program is sitting in its tick loop. Once per tick:
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<GlobalMemory>) {
// Drain pending TIS IPC responses and try to start any staged test.
if let Some(started_method) = self.tis.tick_with_autostart(ctx) {
// First tick of a new run — initialise per-run state if needed.
log::info!("Test started: {:?}", started_method);
self.cycle_count = 0;
}
// ... drive the rig, record cycles ...
}
}
Behind the scenes, tick_with_autostart sent tis.start_test. The
server opened the run directory, set tis.active = true, and the
new tis.active_run_id scalar lands in GM as
gm.tis_active_run_id.
3. Control program records a cycle + raw trace (Rust)
Mid-test, the control program completes one mechanical cycle.
#![allow(unused)]
fn main() {
fn on_cycle_complete(&mut self, ctx: &mut TickContext<GlobalMemory>) {
ctx.gm.cycle_count = ctx.gm.cycle_count.saturating_add(1);
ctx.gm.friction_coefficient = compute_cof(&self.daq);
// Append one cycle row. Routes to the active manager.
self.tis.record_cycle(ctx);
// Ship the DAQ capture. Non-blocking — call returns immediately;
// worker thread converts to JSON; tick() drains the response.
if !self.tis.record_raw_trace_is_busy() {
if let Err(e) = self.tis.record_raw_trace(ctx.gm.cycle_count, &self.daq, ctx) {
log::warn!("record_raw_trace failed: {}", e);
}
}
}
}
The HMI’s <TestDataView> is subscribed to tis.cycle_added and
re-renders in place as each cycle lands.
4. Control program finishes the test (Rust)
When the rig hits its end-of-test condition:
#![allow(unused)]
fn main() {
fn on_test_complete(&mut self, ctx: &mut TickContext<GlobalMemory>) {
// Per-test aggregates. Codegen-typed: one arg per results_field.
self.tis.translational_traction.update_results(
self.cof_running_avg,
self.cof_max,
self.cof_min,
ctx,
);
// Close the record. tis.active flips false.
self.tis.end_active(ctx);
// Optional: dispatch the Python analysis script. Non-blocking.
match self.tis.run_analysis(ctx) {
AnalysisDispatch::Dispatched => log::info!("analysis dispatched"),
AnalysisDispatch::Busy => log::warn!("analysis still running from a prior run"),
AnalysisDispatch::NotConfigured => {},
}
}
}
5. Analysis writes filtered data (Python, server-side)
The Python analysis script reads the raw blobs, smooths them, and calls:
ipc.send("tis.add_filtered_data", {
"project_id": project_id,
"method_id": method_id,
"name": "trace",
"data": { "t": t_arr, "tsdr_fz_smoothed": fz_smoothed, ... },
})
6. Operator reviews + exports (HMI)
<ResultHistoryTable>re-renders ontis.active = falseand shows the new run at the top.- The operator clicks the row’s Report button → HMI calls
tis.export_test_csv→ response carries the inline CSV → browser download. - For the whole-project archive at end of shift, the operator
clicks Download Archive → HMI calls
tis.export_project_zip→ server writes the file → HMI follows thedownload_url. - For administration (deleting a misfired test, freeing disk),
the Project Manager tab calls
tis.delete_test,tis.delete_project, and readstis.disk_usage.
Where each piece is documented
| Step | Topic / API | Section |
|---|---|---|
| Stage form | tis.stage_test | Lifecycle |
| Autostart | TestInformationSystem::tick_with_autostart | Control program API |
| Cycle row | TestInformationSystem::record_cycle → tis.add_cycle | Control program API, Cycles and traces |
| Raw trace | TestInformationSystem::record_raw_trace → tis.add_raw_data | Control program API, Cycles and traces |
| Results | TestManager::update_results → tis.update_results | Control program API, Cycles and traces |
| Finish | TestInformationSystem::end_active → tis.finish_test | Control program API, Lifecycle |
| Analysis | TestInformationSystem::run_analysis | Control program API |
| Filtered data | tis.add_filtered_data | Cycles and traces |
| Exports / admin | tis.export_*, tis.delete_*, tis.disk_usage | Admin and exports |
HMI components
The four TIS components self-drive when wrapped in a <TisProvider>.
All props are optional overrides — pass them only when you want to
lock a particular axis (e.g., a per-method history page).
| Component | Default behaviour |
|---|---|
<TestSetupForm> | Renders the schema for selection.methodId. Required Sample ID input alongside Project ID. Stages tests itself via tis.stage_test whenever the form validates. |
<TestDataView> | Reads the run pinned in selection, falls back to the active run, renders cycle scatter + cycle table + results. Header shows Sample ID prominently. |
<TestRawDataView> | Same selection rules; renders any type: "raw_trace" views from the schema. |
<ResultHistoryTable> | Project-scoped (across every method) by default — switching the loaded method on Setup tab doesn’t hide already-recorded runs. Sample ID is the primary column. |
Hooks exposed by <TisProvider>
useTis() // direct access to the whole context value
useTisSchemas() // SchemaRegistry from tis.list_schemas
useTisState() // live readiness scalars (staged*, active*)
useTisSelection() // [{ projectId, methodId, sampleId, runId }, setSelection]
useTisRuns(projectId?, methodId?) // run list + refresh()
useTisRun(runId?) // { meta, cycles, results, rawData, loading }
Selection has two layers: explicit pins (set via
setSelection({ runId: "..." })) and an active follower (each field
auto-tracks its tis.active_* scalar until pinned). Pass null to a
field to clear its pin.
Asset traceability — asset_refs
A test method may declare a list of asset_refs so each test record
captures the active calibration values of the equipment that produced
it. At tis.start_test, AMS resolves the refs and writes the snapshot
into test.json::asset_snapshot:
"test_methods": {
"translational_traction": {
"config_fields": [/* … */],
"asset_refs": [
{ "name": "load_cell_z", "asset_type": "load_cell",
"select": "by_location", "location": "tsdr_z",
"calibration_required": "warn" }
]
}
}
calibration_required is one of ignore, warn (default), or
require. See Chapter 16 for the full AMS
surface.
See also
- Chapter 12 —
acctl new-tis-project,acctl add-tis, andacctl codegen-tags. - Chapter 16 — Asset Management System
(
ams.*RPCs, calibration history, surface lanes, theasset_refs/asset_snapshotintegration).
Appendix: original reference (Standardized Results System)
The pre-rename reference prose follows. It documents the same
filesystem layout and most of the same RPC verbs but uses the legacy
identifiers (results.*, definition_id, ResultsSystem,
results_staged_*). The plumbing has been replaced — code samples
here will not compile against the current control-program API or
match what the server now broadcasts. Treat this section as
historical.
The system is designed for high-performance industrial environments:
- Schema Definition: All test structures are defined in
project.json. - Code Generation: Auto-generates typed Rust structs and TypeScript interfaces.
- Real-Time Collection: The control program pushes cycle data via IPC (non-blocking).
- Asynchronous Storage: A dedicated servelet handles disk I/O, UTC timestamping, and checksumming.
- Filesystem-Based: Data is stored as standard JSON and JSONL files for maximum portability.
Auto-provided fields in the legacy contract:
| Field | Description |
|---|---|
test_id | ISO-8601 timestamp string assigned on results.start_test. Becomes the directory name under datastore/results/<project_id>/<definition_id>/. |
created_at | UTC timestamp set when the test record is first created. |
completed_at | UTC timestamp set when the test is closed. |
checksum | SHA-256 of the final test.json payload. |
schema | Snapshot of the definition used. |
project_id | Supplied by the UI in results.start_test / stage_test. |
Legacy schema example (now test_methods / method_id):
{
"test_definitions": {
"impact_test": {
"config_fields": [
{ "name": "drop_height", "type": "f32", "units": "mm", "source": "gm.drop_height_mm" }
],
"cycle_fields": [
{ "name": "drop_index", "type": "u32", "source": "gm.cycle_count" },
{ "name": "peak_g", "type": "f32", "source": "gm.total_peak_load" },
{ "name": "judgement", "type": "string" }
]
}
}
}
The legacy results.* IPC verbs (results.stage_test,
results.start_test, results.add_cycle, results.add_raw_data,
results.update_results, results.finish_test, etc.) and broadcast
topics (results.staged, results.active, results.cycle_added,
results.results_updated) have been replaced 1:1 by their tis.*
counterparts, with definition_id → method_id in every payload
and the addition of sample_id as a top-level structural field.