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

Asset Management System

The Asset Management System (AMS) is AutoCore’s record-keeper for the physical equipment in your machine: load cells, encoders, springs, and project-specific items like 3830’s traction surfaces. AMS owns three concerns:

  1. What’s installed — a registry of every asset, identified by a server-generated asset_id.
  2. What state it’s in — the active calibration values plus a full audit-trail of past calibrations.
  3. How much it has been used — cycle counts, hours run, and any custom counters declared per asset type.

AMS is intentionally a separate subsystem from TIS. Test records (TIS) grow per-test-run and are immutable once written. Asset records (AMS) persist across years and accumulate calibration / usage events over the equipment’s whole life. The integration point is one-way: when tis.start_test fires, the TIS servelet snapshots the active asset state into the test record, so traceability survives later recalibration.

Quick start. If you already have a project and just want to flip AMS on, skip ahead to Adding AMS to an existing project below.

The three-level data model

Every asset in AMS lives at the intersection of three keys:

LevelUser-facing nameWire/code keyWhat it is
1Asset Typeasset_typeThe shape of the equipment — built-in (load_cell, linear_encoder, spring) or custom (surface, etc.). Defined in code or in project.json::asset_types.
2Assetasset_idOne physical instance, with a serial number and install location. Server-generated identifier.
3Calibration Recordcal_idOne calibration event for an asset. Append-only.

On disk under <datastore>/assets/:

datastore/assets/
├── registry.json
├── load_cell/
│   └── LC-20251015T130022/
│       ├── asset.json
│       ├── calibrations/
│       │   ├── 20251015T130122.json
│       │   └── 20260301T091548.json
│       ├── usage.json
│       └── usage_log.jsonl    ← created on first usage reset
└── surface/
    └── SURF-20260114T093400/
        ├── asset.json         ← includes the lanes sub_locations
        ├── calibrations/
        └── usage.json

Syncing AMS data across machines

The same project.json is typically deployed to several physical machines, but datastore/assets/ is machine-local: it records the transducer actually installed in this machine, its calibration certificates, and its usage counters. That state must not travel with the shared project definition.

So acctl sync treats datastore/assets/ as pull-only — it brings a fresher copy down from the server, but never pushes the local copy up (the same rule it applies to autocore_gnv.ini). This stops one machine from overwriting another’s assets on the shared server during a routine sync.

When you do want to publish this machine’s AMS state — e.g. after registering or recalibrating an asset that the other machines should see — push it deliberately:

acctl push assets

This uploads datastore/assets/ and then calls ams.reinitialize so the running server reloads the registry from disk (otherwise its next AMS write would clobber the pushed files from stale in-memory state). The push is additive — it does not delete server-side assets that were removed locally; for full reconciliation use acctl ams export / import (see Backup and restore below).

Asset IDs are server-generated

Asset IDs are always assigned by the server. The format is <prefix><YYYYMMDDTHHMMSS> — for example, LC-20260301T091548. The trailing timestamp is the asset’s creation time (UTC, second resolution). On collision (rare; bulk import inside one second) the server bumps by one second until the path is free.

The prefix comes from the asset_type’s id_prefix:

Asset typeDefault prefix
load_cellLC-
linear_encoderENC-
springSP-
Custom typesA- (override via id_prefix)

The manufacturer’s serial number is stored as a separate free-form field on asset.json. It’s recorded for traceability but not used as a unique key — vendor serials collide and reuse across product lines and we have zero control over their assignment policy.

Built-in asset types

Three asset types ship with the server. You don’t need to declare them in project.json to use them.

load_cell

Force-measuring transducer. Two field sets — nameplate values (transducer specifications, stamped on the device at manufacture) and calibration values (per-cert measurements that change over the transducer’s life).

Nameplate fieldTypeRequiredNotes
capacityf32yesFull-scale load, expressed in the units given by capacity_units. Feeds NI bridge max_val and EL3356 0x8000:24 (converted to N at the consumer if capacity_units"N").
capacity_unitsstringyesEngineering units the capacity value is expressed in (e.g., "N", "lbf", "kg"). Also drives the calibration’s output units — calibrations produce values in this unit.
compression_sensitivity_mv_vf32yesNameplate output at full compressive load, in mV/V. Feeds NI bridge prescaled_max and EL3356 0x8000:23.
tension_sensitivity_mv_vf32noNameplate output at full tensile load, in mV/V. Optional — leave unset for compression-only cells. Many strain-gauge cells have asymmetric compression/tension response; record both when the data sheet provides them.
bridge_resistance_ohmf32yesWheatstone bridge resistance, in Ω. Feeds NI nominal_bridge_resistance.
excitation_vf32noData-sheet recommended bridge excitation, in V. Optional — the actual excitation comes from the amplifier / DAQ card configuration, not the cell. Use this for cross-check at install time and for projects that drive voltage_excit_val from the asset; projects that prefer to hardcode excitation at the channel config can leave this blank.
Calibration fieldTypeRequiredNotes
scalef32yesCounts-to-engineering-units multiplier. Output is in the cell’s nameplate capacity_units.
offsetf32yesZero-load counts.
rangef32noOptional override of nameplate capacity for the measurable range under this calibration (rarely differs).

Note on capacity_units. Units live on the nameplate rather than the calibration record: the physical cell is rated in a single set of units and recalibration doesn’t change that. EL3356-driven assets are assumed to want "N" at the SDO interface — if capacity_units"N", the consumer is responsible for converting before writing 0x8000:24.

Nameplate values feed module configs via the AMS placeholder resolver (see Placeholder resolver below) — projects that wire their NI bridge channels and EL3356 SDOs through ${ams.by_location.*} never inline these numbers, so a load-cell swap is just a registry edit.

linear_encoder

Position-measuring transducer. Tracks counts-per-mm and accumulated travelled distance.

Calibration fieldTypeNotes
counts_per_mmf32Required.
offset_mmf32Required. Zero-position offset.
directionstring"+" or "-" — sign of increasing counts.

Extra usage counter: total_distance_mm (f64).

spring

Mechanical compression/extension spring. “Recalibration” usually means a replacement; the calibration record serves as the install record.

Calibration fieldTypeNotes
stiffness_n_per_mmf32Required.
free_length_mmf32Required.
preload_nf32Optional.

Restricting which built-ins appear (enabled_builtin_asset_types)

By default all three built-ins show up in the HMI’s Add Asset type picker. A machine that only uses load cells doesn’t want operators scrolling past spring and linear_encoder — so project.json accepts an optional allowlist:

{
  "enabled_builtin_asset_types": ["load_cell"]
}
ValueEffect
omitted / absentAll built-ins available — the historical default.
["load_cell", …]Only the named built-ins are offered. The others are dropped from ams.list_schemas (so they vanish from the picker) and create_asset rejects them as unknown.

Custom asset_types are never filtered by this list — if you declared a type, it’s in use by definition. And a built-in that’s already used by a registered asset is kept regardless, so trimming this list can never strip the schema (labels, nameplate fields) from existing assets — you can only hide types you have no assets of.

Adding AMS to an existing project

acctl add-ams
acctl push project --restart
acctl codegen

acctl add-ams writes an empty asset_types: {} block to your project.json. That single change flips three things:

  1. Project::normalize() injects the baseline AMS GM scalars (ams_asset_count, ams_alert_calibration_overdue, ams_alert_lane_unavailable).
  2. The next acctl codegen regenerates control/src/gm.rs with typed calibration structs (e.g., LoadCellCalibration with scale: f32, offset: f32) and writes www/src/autocore/ams.ts with mirrored TypeScript types and the asset_type catalog.
  3. The <AmsProvider> and HMI components below have something to render against.

Re-running acctl add-ams on a project that already has AMS enabled is a no-op.

Defining a custom asset type

Custom types extend the catalog with project-specific equipment. The 3830 traction tester uses a surface type with twelve testable lanes:

{
  "asset_types": {
    "surface": {
      "id_prefix": "SURF-",
      "label": "Traction Surface",
      "description": "Serialized test surface installed in the bay.",
      "fields": [
        { "name": "material",  "type": "string", "required": true,  "label": "Material" },
        { "name": "thickness", "type": "f32",    "units": "mm",      "label": "Thickness" }
      ],
      "calibration_fields": [],
      "sub_locations": {
        "name": "lanes",
        "count": 12,
        "per_location_state": [
          { "name": "status",      "type": "enum", "values": ["available", "in_use", "worn", "retired"] },
          { "name": "cycles_used", "type": "u64" }
        ]
      }
    }
  }
}

When you create a surface asset, AMS auto-materializes twelve lane records (lane_01 through lane_12) with default state — available status and cycles_used: 0. Operators can update individual lanes via ams.update_sub_location (or via the <SubLocationPicker> UI).

The name field on sub_locations is the user-facing label; the per- location IDs are formed by trimming the trailing s (laneslane) and appending _NN. Use a singular name if your equipment doesn’t pluralize naturally (channelchannel_01).

Multi-axis assets (keyed-fields sub_locations)

A six-axis force/moment transducer is one physical asset with one calibration cert but carries six independent rows of nameplate values and six pairs of calibration scale/offset. Modelling it as six separate load_cell records would lie about the physical reality (you don’t replace one axis); modelling it as one asset with loose JSON would lose schema validation.

The keyed-fields sub_locations shape is the answer. Declare it once on the asset type:

{
  "asset_types": {
    "triaxial_transducer": {
      "extends":     "builtin",
      "id_prefix":   "TSDR-",
      "label":       "Triaxial Transducer",
      "fields": [
        { "name": "manufacturer", "type": "string", "label": "Manufacturer" },
        { "name": "model",        "type": "string", "label": "Model" }
      ],
      "sub_locations": {
        "label":     "Axes",
        "key_label": "Axis",
        "keys":      ["fx", "fy", "fz", "mx", "my", "mz"],
        "fields": [
          { "name": "capacity",                     "type": "f32",    "required": true },
          { "name": "capacity_units",               "type": "string", "required": true },
          { "name": "compression_sensitivity_mv_v", "type": "f32", "units": "mV/V", "required": true },
          { "name": "bridge_resistance_ohm",        "type": "f32", "units": "Ω",    "required": true }
        ],
        "calibration_fields": [
          { "name": "scale",  "type": "f32", "required": true },
          { "name": "offset", "type": "f32", "required": true }
        ]
      }
    }
  }
}

Two distinct sub_locations shapes are recognised, distinguished by the presence of keys:

  • Keyed-fields (above) — fixed set of named sub-locations, each carrying the same per-key field schema. Used for multi-axis transducers, multi-channel amplifiers, and similar fixed-channel- count parts. AIS renders the Add dialog as a row-per-key matrix.
  • Positional (the surface-lanes example earlier) — count + per_location_state. Used for assets with mutable per-position state like a surface’s lanes. AIS renders these with <SubLocationPicker>.

For a keyed-fields type the on-disk asset record looks like:

{
  "asset_id": "TSDR-20260301T091548",
  "asset_type": "triaxial_transducer",
  "location": "tsdr",
  "custom": { "manufacturer": "Interface", "model": "T-3300" },
  "sub_locations": {
    "fx": { "capacity": 13431, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.49885, "bridge_resistance_ohm": 709 },
    "fy": { "capacity": 13245, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.501583,"bridge_resistance_ohm": 710 },
    "fz": { "capacity": 22322, "capacity_units": "N",  "compression_sensitivity_mv_v": 1.39277, "bridge_resistance_ohm": 708 },
    "mx": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.243281,"bridge_resistance_ohm": 360 },
    "my": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.232061,"bridge_resistance_ohm": 360 },
    "mz": { "capacity":   341, "capacity_units": "Nm", "compression_sensitivity_mv_v": 1.967126,"bridge_resistance_ohm": 361 }
  }
}

And the calibration record’s values carries the matching per-axis shape:

{
  "cal_id": "20260301T091548",
  "values": {
    "fx": { "scale": 9.81234, "offset": -0.0042 },
    "fy": { "scale": 9.81000, "offset":  0.0017 },
    "fz": { "scale": 9.80000, "offset":  0.0001 },
    "mx": { "scale": 0.50000, "offset":  0.0000 },
    "my": { "scale": 0.50100, "offset":  0.0000 },
    "mz": { "scale": 0.49900, "offset":  0.0000 }
  }
}

ams.create_asset, ams.update_asset, and ams.add_calibration validate the posted shape against the schema and reject with a per-key, per-field problem list when a required field is missing or an axis is absent. No corrupt half-records.

Placeholder resolver: ${ams.by_location.*}

Module configs (NI channels, EtherCAT SDOs, anything with free-form JSON in project.modules.<name>.config) can substitute AMS values at module-start time via placeholders. The server walks each module’s config just before launching the child process, replaces every ${ams.*} string with a concrete value pulled from the on-disk registry, and hands the resolved JSON to the spawned process. Hardware modules stay AMS-agnostic — autocore-ni and autocore-ethercat never have to talk to AMS themselves.

Grammar:

${ams.by_location.<location>.<field>}
${ams.by_location.<location>.<field> | hex<bits>}
${ams.by_location.<location>.<field> | neg}
${ams.by_location.<location>.sub.<key>.<field>}
${ams.by_location.<location>.cal.<field>}
${ams.by_location.<location>.cal.<key>.<field>}
  • <field> resolves against asset.custom.<field> (the nameplate values declared by the asset_type’s fields) or one of the top-level Asset keys (asset_id, serial, location, install_date, current_calibration_id).
  • cal.<field> reads from the asset’s active calibration’s values.<field>.
  • sub.<key>.<field> and cal.<key>.<field> are the per-axis forms used by multi-axis types — they index into asset.sub_locations.<key>.<field> and calibration.values.<key>.<field> respectively.

Render modifiers (chain left-to-right with |):

  • | hex<bits> — zero-padded uppercase hex with bits/4 digits (bits must be a multiple of 8 — matches the EtherCAT SDO value parser, which is byte-width-padded). Negative values and floats are rejected; overflow is rejected.
  • | neg — flip the sign. Useful for paired bridge min_val/max_val where min = -capacity.
  • | default <json_literal> — bring-up escape hatch. When the lookup would fail (no asset, no current calibration, field absent, value is null), substitute the literal value instead of hard-erroring. Argument is a JSON literal so numbers, quoted strings, booleans, and null all work. Composes with the others — see below.

A value is “missing” if any of these is true: no active asset at the location, no current calibration when the path reads cal.*, the requested field absent from custom / sub_locations, or the resolved value is JSON null. All four cases trigger the inline default if present.

Example — an NI bridge channel for a six-axis transducer’s fx axis:

{
  "name": "tsdr_fx",
  "physical_channel": "cdaq-tt-mod1/ai0",
  "create_function": "CreateAIForceBridgeTwoPointLinChan",
  "create_args": {
    "bridge_config": 10182,
    "max_val":   "${ams.by_location.tsdr.sub.fx.capacity}",
    "min_val":   "${ams.by_location.tsdr.sub.fx.capacity | neg}",
    "nominal_bridge_resistance":
                 "${ams.by_location.tsdr.sub.fx.bridge_resistance_ohm}",
    "prescaled_max":
                 "${ams.by_location.tsdr.sub.fx.compression_sensitivity_mv_v}",
    "prescaled_min": 0,
    "scaled_max": "${ams.by_location.tsdr.sub.fx.capacity}",
    "scaled_min": 0,
    "voltage_excit_source": 10200,
    "voltage_excit_val": 10
  }
}

An EtherCAT EL3356 SDO entry rendering capacity as a 32-bit hex value:

{ "index": "0x8000", "sub_index": "0x24", "bits": 32,
  "value": "${ams.by_location.z_load.capacity | hex32}" }

Whole-string placeholders preserve native types. A field whose entire value is one placeholder resolves to a JSON number when the asset field is numeric; the wire format that hits the NI module matches what its arg_f64 reader expects without any string-coercion gymnastics. Embedded placeholders (placeholder inside a larger string, like a comment) splice as strings.

Bring-up defaults

On a fresh install AIS is empty — every ${ams.*} placeholder would hard-error and no module would start. The | default <value> modifier lets the project author declare a sensible bring-up number right next to the use:

"max_val":  "${ams.by_location.tsdr.sub.fx.capacity | default 13431}",
"min_val":  "${ams.by_location.tsdr.sub.fx.capacity | default 13431 | neg}",
"value":    "${ams.by_location.z_load.capacity     | default 2000  | hex32}"

Resolution order:

  1. Live asset value (if registered, field set, and not null).
  2. Inline | default <json> modifier on the placeholder, if specified.
  3. Schema-level default declared on the asset_type field definition.
  4. Hard-error.

default substitutes the value; subsequent modifiers transform it just as they would a real lookup. So default 5000 | hex32 resolves to "00001388", exactly what you’d get if the asset were registered with capacity: 5000. The same applies to schema-level defaults — ${... | neg} against a schema default of 13431 produces -13431.

The argument is parsed as a JSON literal — quote strings, omit quotes for numbers/booleans/null:

Modifier textSubstituted value
default 5000the number 5000
default -500the number -500
default 3.14the number 3.14
default "Interface"the string Interface
default truethe boolean true
default nullJSON null

Schema-level field defaults

For values that don’t change across every placeholder that references them — a load cell’s nameplate capacity, a bridge resistance, an encoder ppr — drop the default next to the field definition in the asset_type schema instead of repeating it inline. The resolver picks it up automatically when the live value is missing AND the placeholder didn’t supply its own | default ....

"asset_types": {
  "triaxial_transducer": {
    "extends": "builtin",
    "sub_locations": {
      "keys": ["fx", "fy", "fz", "mx", "my", "mz"],
      "fields": [
        { "name": "capacity", "type": "f32", "default": 13431,
          "label": "Capacity" },
        { "name": "capacity_units", "type": "string", "default": "N",
          "label": "Capacity Units" },
        { "name": "compression_sensitivity_mv_v", "type": "f32", "default": 1.5,
          "label": "Compression Sensitivity", "units": "mV/V" },
        { "name": "bridge_resistance_ohm", "type": "f32", "default": 710,
          "label": "Bridge Resistance", "units": "Ω" }
      ]
    }
  }
}

With that schema, channel configs can drop the | default … modifier entirely:

"max_val":  "${ams.by_location.tsdr.sub.fx.capacity}",
"min_val":  "${ams.by_location.tsdr.sub.fx.capacity | neg}",

Precedence is what you’d expect: a live registered value wins; the inline | default X modifier wins over the schema default; the schema default wins over hard-error. acctl validate flags a schema default whose JSON type doesn’t match the field’s declared type (e.g. "default": "x" on an f32 field).

Field defaults are supported in all four field arrays: fields, calibration_fields, sub_locations.fields, and sub_locations.calibration_fields. Schema defaults only apply to locations that some project.asset_refs entry declares — the resolver uses the asset_ref to learn which asset_type’s schema to consult.

Placeholder failure policy

Two layers, with deliberately different severity:

  1. At project push (acctl sync): ams.placeholder findings are warnings — the project file lands on the server, the operator gets a yellow report listing every unresolved reference. Sync is not blocked. The reasoning is that an unresolved placeholder is an operator-fixable asset-record state, not a structural project problem: refusing the push would leave the operator without a way to drive into the AIS UI to fix the underlying record.

  2. At module spawn (system.start_control): an unresolved placeholder in a specific module’s config is a hard error for that module only. The supervisor’s start_module returns a typed SupervisorError::UnresolvedAmsPlaceholders listing every offending placeholder so the operator can fix them all in one pass:

    Module 'ni' refused to start — 2 unresolved AMS placeholder(s) in its config.
    Register the missing asset(s) in the AIS UI and try again:
      • ${ams.by_location.tsdr_fx.capacity} at /daq/0/channels/0/create_args/max_val
        — no active asset registered at location `tsdr_fx`
      • ${ams.by_location.tsdr_fy.capacity} at /daq/0/channels/1/create_args/max_val
        — no active asset registered at location `tsdr_fy`
    

    Other modules whose configs are clean still spawn. The system comes up partially while the offending asset gets fixed in the UI; once resolved, the next start of the affected module picks up the new value.

The split lets project edits propagate quickly during commissioning (don’t get stuck at sync because of a stale asset record) while still preventing a module from starting with placeholder strings actually landing in its serialized config (which would be very confusing to debug from inside the module).

When a project’s module config has placeholders but no AMS data directory is configured on the server, the failure points at the missing ams_base_directory config key instead of the asset, so the operator fixes the right thing.

Pre-flight verification: ams.diagnose_placeholders

The AIS <PlaceholderHealthPanel> calls this IPC and shows one row per ${ams.by_location.*} reference in every enabled module’s config, with green/red status and either the resolved value or the typed reason. Use this before powering hardware up — the panel catches a missing calibration in one click instead of a failed module spawn three minutes later.

Live asset-update callbacks (AssetWatch)

Module-start binding is one half of the story; recalibration during operation is the other. Every AMS mutation broadcasts on a per- location topic:

ams.asset_updated.<location>

with payload { asset_id, asset_type, location, trigger, asset, current_calibration }. Triggers are one of created, nameplate_updated, status_changed, location_changed, or calibration_added.

Control programs subscribe via autocore_std::AssetWatch:

#![allow(unused)]
fn main() {
use autocore_std::{AssetWatch, AssetWatchTrigger};

pub struct ForcePlateAxis {
    watch: AssetWatch,
}

impl ForcePlateAxis {
    pub fn new(client: &mut autocore_std::CommandClient) -> Self {
        Self { watch: AssetWatch::new("tsdr_z", client) }
    }

    pub fn tick(&mut self, ctx: &mut autocore_std::TickContext<GlobalMemory>) {
        for update in self.watch.pump(ctx.client) {
            match update.trigger {
                AssetWatchTrigger::CalibrationAdded
                | AssetWatchTrigger::NameplateUpdated
                | AssetWatchTrigger::InitialSync => {
                    // Re-fire EL3356 SDOs using update.asset.custom.*
                    // and update.current_calibration.values.*.
                    // The control program owns timing — defer when
                    // a test cycle is in flight.
                }
                AssetWatchTrigger::StatusChanged
                | AssetWatchTrigger::LocationChanged
                | AssetWatchTrigger::InitialSyncEmpty => {
                    // Asset retired or moved — flip the subsystem to
                    // inoperative and refuse to start tests against
                    // this role until a fresh asset is registered.
                }
                _ => {}
            }
        }
    }
}
}

AssetWatch::new does two things at construction: subscribes the CommandClient to the topic and issues a one-shot ams.list_assets scoped to the location, so the first pump() always begins with a baseline snapshot (InitialSync or InitialSyncEmpty). Broadcasts that land while the initial-sync response is still in flight stay queued; they arrive after the bootstrap event in arrival order.

The server never writes hardware. The control program is the only place that knows whether the slave is in PreOp / OP and whether a test cycle is in flight. A canonical worked example for an EL3356-0010 force terminal — including which CoE objects to re-fire on each trigger and the state-machine gating — lives in doc/ams_asset_watch.md in the repo root.

Gating control behaviour on asset presence

Control programs frequently need to refuse to enter auto mode when the load cell isn’t registered, or fall through into a safe state when an asset is retired. Two layered ways to query this.

AssetWatch::is_active() (event-driven, in the control loop)

The AssetWatch from the section above already tracks the latest lifecycle state internally. Three accessors expose it:

#![allow(unused)]
fn main() {
let tsdr = AssetWatch::new("tsdr", ctx.client);
// later, in process_tick:
let _ = tsdr.pump(ctx.client);  // drains events; updates internal state

if !tsdr.is_active() {
    ctx.gm.process_state = ProcessState::CantRunNoAsset as i32;
    return;
}
let _ = tsdr.active_asset_id();         // Option<&str> — "running against X"
match tsdr.active_status() {
    AssetWatchStatus::Active        => { /* run */ }
    AssetWatchStatus::Retired
    | AssetWatchStatus::OutForService
    | AssetWatchStatus::Missing     => { /* refuse */ }
}
}

The watcher’s state starts Missing until the first pump() lands the initial-sync result. Initial sync is the watcher asking ams.list_assets for the location; subsequent pumps fold every ams.asset_updated.<location> broadcast into the same state machine.

ams_active_<field>_present GM scalar (always-on, no subscription)

For control programs that don’t want to hold an AssetWatch and would rather check a bool in shared memory, Project::normalize() auto-injects three scalars per asset_ref:

GM variableTypeSource
ams_active_<field>_asset_idstringtis.start_test (test-time view)
ams_active_<field>_calibration_idstringtis.start_test (test-time view)
ams_active_<field>_presentboolAMS (any time the registry changes)

The _asset_id and _calibration_id scalars carry the resolved asset and calibration ids for the currently running test (set when tis.start_test resolves the method’s refs). The _present scalar is the AMS-time signal: it’s published by the AMS servelet on init and after every mutation (create_asset, update_asset that changes status or location, delete_asset).

#![allow(unused)]
fn main() {
// Control program check, with no subscription bookkeeping:
if !ctx.gm.ams_active_tsdr_present {
    refuse_auto();
}
}

Pick whichever fits your control program’s style. AssetWatch gives you the trigger-by-trigger event stream too; the scalar is the simpler “is there one or isn’t there” gate.

Reading current calibration from the control program

acctl codegen emits one <TypeName>Calibration Rust struct per asset type, plus a from_ams_response helper that parses the values out of an ams.read_calibration response. The control program reads the current calibration for an asset by fetching it once at startup and stashing it on the program struct:

#![allow(unused)]
fn main() {
use autocore_std::CommandClient;

pub struct MyProgram {
    load_cell: Option<LoadCellCalibration>,
    // …
}

impl MyProgram {
    pub fn new() -> Self {
        MyProgram { load_cell: None }
    }

    pub fn load_calibrations(&mut self, client: &mut CommandClient, asset_id: &str) {
        if let Some(asset) = client.invoke("ams.read_asset", json!({ "asset_id": asset_id })) {
            if let Some(cal_id) = asset.data.get("current_calibration_id").and_then(|v| v.as_str()) {
                if let Some(resp) = client.invoke("ams.read_calibration",
                    json!({ "asset_id": asset_id, "cal_id": cal_id }))
                {
                    self.load_cell = Some(LoadCellCalibration::from_ams_response(&resp.data));
                }
            }
        }
    }

    pub fn process_tick(&mut self, ctx: &mut TickContext<GM>) {
        if let Some(cal) = &self.load_cell {
            let raw_counts = ctx.gm.load_cell_z_raw as f32;
            let force_n = cal.scale * raw_counts + cal.offset;
            ctx.gm.zforce_load = force_n;
        }
    }
}
}

For testing methods that declare an asset_refs list, the control program can read the resolved asset_id from the auto-injected GM scalar — ctx.gm.ams_active_load_cell_z_asset_id — instead of hard-coding the ID.

Wiring usage counters

Phase 4 of AMS ships RPC-driven usage counters. Call ams.tick_usage from your control program’s record_cycle flow:

#![allow(unused)]
fn main() {
// Inside your program's tick:
self.tis.record_cycle(ctx);

if let Some(asset_id) = ctx.gm.ams_active_load_cell_z_asset_id.as_str_opt() {
    client.invoke("ams.tick_usage", json!({
        "asset_id":     asset_id,
        "delta_cycles": 1,
        "delta_hours":  ctx.dt.as_secs_f64() / 3600.0,
    }));
}
}

For surface lanes specifically, where each lane has its own cycles_used counter under sub_locations.items[*], update the lane directly:

#![allow(unused)]
fn main() {
client.invoke("ams.update_sub_location", json!({
    "asset_id":    surface_id,
    "location_id": active_lane_id,
    "partial":     { "cycles_used": new_count },
}));
}

Audit trail for resets. ams.reset_usage zeros the counters and appends an entry to usage_log.jsonl next to usage.json so the history of “we rebuilt this asset on date X” survives the reset.

TIS ↔ AMS integration: asset_refs and asset_snapshot

A project declares its system hardware once, at the top level. Every test method picks the same snapshot up automatically — load cells don’t appear and disappear between methods, so they shouldn’t be re-declared per method.

{
  "asset_refs": [
    { "name": "load_cell_z",
      "asset_type": "load_cell",
      "select": "by_location", "location": "tsdr_z",
      "calibration_required": "warn",
      "defaults": { "capacity": 13431, "capacity_units": "N" } },
    { "name": "encoder_x",
      "asset_type": "linear_encoder",
      "select": "by_location", "location": "x_axis",
      "calibration_required": "require" },
    { "name": "surface",
      "asset_type": "surface",
      "select": "by_id_field", "from": "config.surface_asset_id",
      "calibration_required": "ignore" }
  ],
  "test_methods": {
    "translational_traction": { /* no asset_refs needed; inherits */ },
    "rotational_traction":    { /* same */ }
  }
}

Methods may still declare their own asset_refs for method-specific accessories (e.g., an extra reference standard mounted only for a calibration test). When a project-level and a method-level ref share the same name, the method wins — the explicit per-test declaration is treated as an override. This is the documented escape hatch; in practice, leave system hardware at the project level.

by_id_field refs are also project-level. The from path resolves against the active method’s staged config — so the same surface ref above reads config.surface_asset_id from translational_traction’s config when that method is active and from rotational_traction’s when that one is.

Each asset_ref has:

  • name: the role identifier. Written as the key into test.json::asset_snapshot, and the suffix on the auto-injected ams.active_<name>_* broadcast topics.
  • asset_type: which catalog entry to look up.
  • select: how to pick the right asset.
    • by_location — pick the unique active asset whose location matches location below.
    • by_id_field — read the asset_id directly from a config field (dotted from path into the staged payload).
  • calibration_required: stage-time policy.
    • ignore — never blocks, never warns.
    • warn (default) — surfaces a warning on stage but lets the test start.
    • require — blocks staging with a clear error.

When tis.start_test fires, AMS resolves each ref and writes the snapshot into test.json:

{
  "project_id": "plant_a",
  "method_id":  "translational_traction",
  "run_id":     "20260422T140231Z",
  "sample_id":  "S-0042",
  "asset_snapshot": {
    "load_cell_z": {
      "asset_id":        "LC-20251015T130022",
      "asset_type":      "load_cell",
      "calibration_id":  "20260301T091548",
      "values":          { "scale": 9.81234, "offset": -0.0042, "units": "N" }
    },
    "encoder_x": { /* … */ },
    "surface":   { /* … */ }
  }
}

This is the audit trail. When someone re-calibrates LC-20251015T130022 in 2027, the 2026 test results still show the calibration that was active when they were taken.

For each asset_ref, AMS auto-injects two GM scalars so the control program and HMI can react:

  • ams_active_<name>_asset_id
  • ams_active_<name>_calibration_id

(For example: ams_active_load_cell_z_asset_id.)

Per-role defaults

An asset_ref may carry a defaults map of <field_name>: <value> pairs. Two consumers read it:

  1. Placeholder resolution. When ${ams.by_location.<location>.<field>} resolves and no live asset value is on disk (and the placeholder has no | default X modifier), the asset_ref’s default for <field> is substituted. This works on built-in asset_types too — load_cell and friends have no schema-level default slot, so the asset_ref is the per-role hook for declaring expected nameplate values.
  2. AIS form pre-fill. When the operator opens the create-asset form for this role, AIS seeds the nameplate inputs from the defaults map. The operator confirms or edits — values are never enforced, never replace a missing registration.
{
  "name": "f1",
  "asset_type": "load_cell",
  "select": "by_location", "location": "tsdr1",
  "label": "Force-plate load cell 1",
  "calibration_required": "warn",
  "defaults": {
    "capacity": 13431,
    "capacity_units": "N",
    "bridge_resistance_ohm": 350
  }
}

Every key in defaults must name a field on the role’s asset_type schema — validation rejects typoed keys at project load (with a “did you mean” hint), so misspelled defaults can’t silently no-op. Value types must coerce to the field’s declared type (number for f32/f64, string for string, etc.).

Default precedence (when no live asset value is on disk):

placeholder `| default X`  >  asset_ref defaults  >  asset_type schema default  >  unresolved

The placeholder-author’s | default is the most local escape hatch and wins; asset_ref defaults are per-role intent; schema defaults (custom asset_types only) are per-type intent.

Building an HMI for asset management

Drop <AmsProvider> once at the top of your HMI. The components below are zero-prop and read from context.

import {
    AmsProvider,
    AssetRegistryTable,
    AssetDetailView,
    SubLocationPicker,
    PlaceholderHealthPanel,
    MissingAssetsBanner,
} from '@adcops/autocore-react';

function AssetsTab() {
    return (
        <AmsProvider>
            <MissingAssetsBanner />
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
                <AssetRegistryTable />
                <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
                    <AssetDetailView />
                    <SubLocationPicker />
                </div>
            </div>
            <PlaceholderHealthPanel />
        </AmsProvider>
    );
}
ComponentResponsibility
<MissingAssetsBanner>Warning panel listing every by_location asset_ref declared in project.json that has no active asset registered. Each row gets a “Register” button that pre-populates <AssetRegistryTable>’s Add dialog with the right asset_type + location. Hides itself when zero missing.
<AssetRegistryTable>List every asset with type/status filters and an “Add Asset” button. The Add dialog reads the asset_type’s fields schema and renders one input per top-level field; when the type declares a keyed-fields sub_locations schema, a per-key matrix is rendered below — required cells gate Create. Click a row to pin selection. Listens for the banner’s prefill event.
<AssetDetailView>Show the selected asset’s header (id, type, serial, location, status), the top-level nameplate panel, the per-axis matrix (when applicable), the current calibration’s per-axis values (when applicable), the calibration history table, and three action buttons: Retire (active assets), Delete (retired assets — permanent), and + Calibration. Modules consuming this asset’s role are called out as “Feeds module configs: ni — their ${ams.by_location.<loc>.*} placeholders will resolve to the values below.”
<CalibrationEntryDialog>Form generated from the asset_type’s calibration_fields. Renders a flat key/value form for single-cell types and a per-axis grid for asset types that declare per-axis calibration_fields under sub_locations. The dialog posts the right wire shape automatically — operators never hand-edit JSON.
<SubLocationPicker>Grid view for asset types using the positional surface-lanes shape. Click a cell to mark a lane available / in_use / worn / retired. (Keyed-fields sub_locations are rendered inside <AssetRegistryTable> and <AssetDetailView>; <SubLocationPicker> stays focused on the mutable-state positional path.)
<PlaceholderHealthPanel>Pre-flight check for ${ams.*} resolution. Calls ams.diagnose_placeholders and shows one row per reference with green/red status, the resolved value, the asset_id it bound to, and the config path. Auto-refreshes on assets mutations so a fresh registration flips a row from red to green without manual reload.

The provider also exposes hooks for custom UI:

import { useAms, useAmsAlerts, useAmsAssets } from '@adcops/autocore-react';

function CalibrationStatusBadge() {
    const { calibrationOverdue } = useAmsAlerts();
    if (calibrationOverdue === 0) return null;
    return (
        <Tag value={`${calibrationOverdue} cal overdue`} severity="warning" />
    );
}

Calibration alerts

AMS publishes three derived counters on every mutation:

Broadcast topicWhat it counts
ams.asset_countNumber of active assets in the registry.
ams.alert_calibration_overdueActive assets whose current calibration’s expires_at is in the past.
ams.alert_lane_unavailableSub_location entries marked worn or retired across all active assets.

The <AmsProvider> keeps these in useAmsAlerts() automatically. Control programs can link to them as GM scalars (ams_alert_*) and gate features accordingly — for example, refusing to start a test when overdue calibrations exist on require-policy refs.

Backup and restore

The full AMS dataset round-trips through a single JSON document.

Export

acctl ams export --output ams_backup_2026-04-29.json

Pulls the registry, every asset’s full record, every calibration, and usage counters from the running server. The output is a single JSON file with this shape:

{
  "version":     1,
  "exported_at": "2026-04-29T16:00:00Z",
  "registry":    { "assets": [/* registry entries */] },
  "assets": [
    {
      "asset":        { /* full asset.json */ },
      "calibrations": [/* every cal record */],
      "usage":        { /* usage.json */ }
    }
  ]
}

Import

# Dry-run first to see what would change.
acctl ams import --input ams_backup_2026-04-29.json --dry-run

# Apply it.
acctl ams import --input ams_backup_2026-04-29.json

Default behaviour is merge: assets that already exist on the target server are left in place; assets that don’t are created with their original IDs preserved; calibrations are appended (the server honours the explicit cal_id in the document); usage counters take the max of existing-and-imported, so a stale backup never decreases counts.

--dry-run walks the document and prints what would happen without writing anything.

Round-trip fidelity. Asset_id and cal_id are preserved on import, which means the audit trail in test.json::asset_snapshot still resolves correctly after a restore.

Deleting an asset

Assets can be permanently removed from AMS in a two-step flow:

  1. Retire. ams.update_asset with status: "retired". The asset stays on disk but is excluded from active queries, the placeholder resolver, and any by_location snapshot. The AIS UI surfaces a Retire button on <AssetDetailView> for active assets.

  2. Delete. ams.delete_asset is the hard purge. It refuses with a clear “retire first” message if the asset is still active. Files removed: asset.json, calibrations/, usage.json, usage_log.jsonl, the entire <datastore>/assets/<asset_type>/<asset_id>/ directory. The registry entry goes too. The AIS UI surfaces a Delete button on retired assets, gated behind a confirmation dialog.

Why the audit trail survives. test.json::asset_snapshot was already enriched to carry the full asset and calibration records inline at tis.start_test time. Deleting the source asset from AMS later has no impact on historical test records — they can be read in isolation. The trade-off: test records are larger (an extra few KB per test for the asset and cal payloads).

Broadcasts on delete:

  • ams.asset_changed (so the registry refreshes)
  • ams.asset_deleted with { asset_id, asset_type } (subscribers can filter on type if they care)
  • ams.asset_count (the alert counter recomputes)
  • ams.active_<field>_present is re-published for every role whose location lost an asset (so control programs’ _present GM scalars flip false on the next tick)

Migration: how this differs from a hand-rolled equipment registry

If you’re migrating from an ad-hoc spreadsheet or Linear-tracked list of “what’s installed”:

  1. Run acctl add-ams to enable the subsystem.
  2. Use <AssetRegistryTable>’s “Add Asset” dialog (or the ams.create_asset RPC directly) to register each piece of equipment. Record the manufacturer serial under the serial field for traceability.
  3. For each asset, add the most recent known calibration via <CalibrationEntryDialog>. Backfill historical calibrations only if you need the audit trail; otherwise just enter the current one.
  4. Update each test method’s asset_refs block to point at the new asset locations. Run acctl codegen to refresh the GM scalars.

The same add- family retrofits TIS into a project that started without it: acctl add-tis. Both commands are idempotent.