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

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:

LevelUser-facing nameWire/code keyWhat it is
1Projectproject_idThe customer, contract, or product line. Owns many tests.
2Test Methodmethod_idThe standardised recipe: schema, chart views, validation rules. Defined in project.json under test_methods.
3Samplesample_idThe physical object in the machine right now. Operator types it on the setup form; required.
4Test Recordrun_idA 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:

  1. 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)" } ]
            }
          }
        }
      }
    }
    
  2. Generate the typed code:

    acctl codegen-tags
    

    This regenerates control/src/gm.rs (with TestInformationSystem plus one *TestManager per method) and www/src/autocore/tis.ts (with one *Schema per method).

  3. 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);
        }
    }
    }
  4. 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 successful start_test does NOT consume it. The operator can run sample after sample by changing only sample_id and clicking Start. Cancel via tis.clear_staged.

  • tis.start_test — Operator (or the control program) opens a record on disk. Server creates test.json + the empty cycles.jsonl + raw_data/ + filtered_data/ directories. Sets tis.active = true and broadcasts the four tis.active_* scalars. The control program’s tick_with_autostart does this for you.

  • tis.finish_test — Closes the record. Flips tis.active = false. The control program’s end_active(ctx) calls this.

Auto-injected GM scalars (added by Project::normalize() at server load when test_methods is non-empty):

GM variableLinked toType
tis_stagedtis.stagedbool
tis_staged_project_idtis.staged_project_idstring
tis_staged_method_idtis.staged_method_idstring
tis_staged_sample_idtis.staged_sample_idstring
tis_activetis.activebool
tis_active_project_idtis.active_project_idstring
tis_active_method_idtis.active_method_idstring
tis_active_sample_idtis.active_sample_idstring
tis_active_run_idtis.active_run_idstring

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:

RawFiltered
tis.add_raw_datatis.add_filtered_data
tis.read_rawtis.read_filtered
tis.list_rawtis.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 from data.actual_samples and data.sample_rate (no DAQ channel needed).
  • "ni.<daq>.channels.<chan>" — looks up the channel index in project.json’s modules.ni.config.daq[<daq>].channels array and emits a typed pull from DaqCapture.

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

FormMeaning
<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 as if rhs.abs() > f32::EPSILON { lhs / rhs } else { 0.0 }, so traces don’t go to NaN/Inf at 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.

MethodReturnsPurpose
new()SelfConstruct. 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)boolA trace is being built or sent.
record_raw_trace_is_error(&self)boolMost recent trace ended in error.
record_raw_trace_error_message(&self)&strDiagnostic for the last error, or empty string.
run_analysis(&mut self, ctx)AnalysisDispatchFire the active method’s Python analysis script.
is_analysis_busy(&self)boolAn analysis request is in flight.
is_analysis_error(&self)boolMost recent analysis failed.
analysis_error_message(&self)&strDiagnostic 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:

MethodReturnsPurpose
new()SelfConstruct.
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_messageas abovePer-method versions.
run_analysis(&mut self, ctx)AnalysisDispatchPer-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.

TopicCallerSection
Lifecycle
tis.stage_testHMILifecycle
tis.clear_stagedHMILifecycle
tis.start_testControlLifecycle
tis.finish_testControlLifecycle
tis.statusEitherLifecycle
Cycles and traces
tis.add_cycleControlCycles and traces
tis.update_resultsControlCycles and traces
tis.add_raw_dataControlCycles and traces
tis.add_filtered_dataPost-processCycles and traces
Reading runs
tis.list_testsHMIReading runs
tis.read_testHMIReading runs
tis.read_cyclesHMIReading runs
tis.list_rawHMIReading runs
tis.read_rawHMIReading runs
tis.list_filteredHMIReading runs
tis.read_filteredHMIReading runs
Projects
tis.list_projectsHMIProject management
tis.create_projectHMIProject management
tis.read_projectHMIProject management
tis.update_projectHMIProject management
tis.delete_projectHMIProject management
tis.list_methodsHMIProject management
tis.list_schemasHMIProject management
Admin and exports
tis.delete_testHMIAdmin and exports
tis.disk_usageHMIAdmin and exports
tis.export_test_csvHMIAdmin and exports
tis.export_test_data_csvHMIAdmin and exports
tis.export_project_csvHMIAdmin and exports
tis.export_project_zipHMIAdmin 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)

TopicFires whenPayload
tis.stagedstage_test, clear_stagedbool (scalar)
tis.staged_project_idstage_teststring
tis.staged_method_idstage_teststring
tis.staged_sample_idstage_teststring
tis.activestart_test, finish_testbool (scalar)
tis.active_project_idstart_test, finish_teststring
tis.active_method_idstart_test, finish_teststring
tis.active_sample_idstart_test, finish_teststring
tis.active_run_idstart_test, finish_teststring
tis.last_start_errorstart_test succeeds (clears) / fails (sets)string
tis.cycle_addedadd_cycle succeeds{ project_id, method_id, run_id, cycle }
tis.results_updatedupdate_results succeeds{ project_id, method_id, run_id, results }
tis.statusstage_testfull staged record (diagnostic)
tis.project_createdcreate_project succeeds{ project_id }
tis.project_updatedupdate_project succeeds{ project_id }
tis.project_deleteddelete_project succeeds{ project_id }
tis.test_deleteddelete_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 on tis.active = false and 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 the download_url.
  • For administration (deleting a misfired test, freeing disk), the Project Manager tab calls tis.delete_test, tis.delete_project, and reads tis.disk_usage.

Where each piece is documented

StepTopic / APISection
Stage formtis.stage_testLifecycle
AutostartTestInformationSystem::tick_with_autostartControl program API
Cycle rowTestInformationSystem::record_cycletis.add_cycleControl program API, Cycles and traces
Raw traceTestInformationSystem::record_raw_tracetis.add_raw_dataControl program API, Cycles and traces
ResultsTestManager::update_resultstis.update_resultsControl program API, Cycles and traces
FinishTestInformationSystem::end_activetis.finish_testControl program API, Lifecycle
AnalysisTestInformationSystem::run_analysisControl program API
Filtered datatis.add_filtered_dataCycles and traces
Exports / admintis.export_*, tis.delete_*, tis.disk_usageAdmin 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).

ComponentDefault 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 12acctl new-tis-project, acctl add-tis, and acctl codegen-tags.
  • Chapter 16 — Asset Management System (ams.* RPCs, calibration history, surface lanes, the asset_refs/asset_snapshot integration).

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:

  1. Schema Definition: All test structures are defined in project.json.
  2. Code Generation: Auto-generates typed Rust structs and TypeScript interfaces.
  3. Real-Time Collection: The control program pushes cycle data via IPC (non-blocking).
  4. Asynchronous Storage: A dedicated servelet handles disk I/O, UTC timestamping, and checksumming.
  5. Filesystem-Based: Data is stored as standard JSON and JSONL files for maximum portability.

Auto-provided fields in the legacy contract:

FieldDescription
test_idISO-8601 timestamp string assigned on results.start_test. Becomes the directory name under datastore/results/<project_id>/<definition_id>/.
created_atUTC timestamp set when the test record is first created.
completed_atUTC timestamp set when the test is closed.
checksumSHA-256 of the final test.json payload.
schemaSnapshot of the definition used.
project_idSupplied 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_idmethod_id in every payload and the addition of sample_id as a top-level structural field.