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.
RPC reference
Verbs (caller → server, ack/response)
| Topic | Caller | Purpose |
|---|---|---|
tis.stage_test | HMI | { project_id, method_id, sample_id, config } — declare a run intent. Persistent. |
tis.clear_staged | HMI | Drop the staged entry. |
tis.start_test | Control | Open a new run on disk. sample_id taken from the staged record (or supplied directly). |
tis.add_cycle | Control | Append one cycle to cycles.jsonl. |
tis.add_raw_data | Control | Write raw_data/<name>.json. |
tis.add_filtered_data | Post-process | Write filtered_data/<name>.json. |
tis.update_results | Control | Patch the results block of test.json. |
tis.finish_test | Control | Close the active run. |
tis.list_schemas | HMI | { test_methods, default_method_id } from project.json. Called by <TisProvider> on mount. |
tis.list_projects | HMI | List directories under base_directory. |
tis.list_methods | HMI | List method dirs under one project. |
tis.list_tests | HMI | { project_id, method_id? } — method_id optional; omit it to aggregate runs across methods. |
tis.read_test | HMI | Full test.json for one run. |
tis.read_cycles | HMI | Paginated cycles.jsonl. |
tis.read_raw / tis.list_raw | HMI | Read or list raw_data/. |
tis.read_filtered / tis.list_filtered | HMI | Read or list filtered_data/. |
tis.status | Either | Read the staged record (diagnostic; prefer tis.staged* scalars for gating). |
Broadcasts (server → clients, no subscription needed)
| Topic | Fires when | Payload |
|---|---|---|
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.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 |
The scalar broadcasts get auto-linked into GM by normalize(). The
two JSON-payload broadcasts (cycle_added, results_updated) are
consumed directly by <TestDataView> and don’t need to be added to
project.json.
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": [
{ "field": "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.