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:
- What’s installed — a registry of every asset, identified by a
server-generated
asset_id. - What state it’s in — the active calibration values plus a full audit-trail of past calibrations.
- 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:
| Level | User-facing name | Wire/code key | What it is |
|---|---|---|---|
| 1 | Asset Type | asset_type | The shape of the equipment — built-in (load_cell, linear_encoder, spring) or custom (surface, etc.). Defined in code or in project.json::asset_types. |
| 2 | Asset | asset_id | One physical instance, with a serial number and install location. Server-generated identifier. |
| 3 | Calibration Record | cal_id | One 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 type | Default prefix |
|---|---|
load_cell | LC- |
linear_encoder | ENC- |
spring | SP- |
| Custom types | A- (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 field | Type | Required | Notes |
|---|---|---|---|
capacity | f32 | yes | Full-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_units | string | yes | Engineering 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_v | f32 | yes | Nameplate output at full compressive load, in mV/V. Feeds NI bridge prescaled_max and EL3356 0x8000:23. |
tension_sensitivity_mv_v | f32 | no | Nameplate 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_ohm | f32 | yes | Wheatstone bridge resistance, in Ω. Feeds NI nominal_bridge_resistance. |
excitation_v | f32 | no | Data-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 field | Type | Required | Notes |
|---|---|---|---|
scale | f32 | yes | Counts-to-engineering-units multiplier. Output is in the cell’s nameplate capacity_units. |
offset | f32 | yes | Zero-load counts. |
range | f32 | no | Optional 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 — ifcapacity_units≠"N", the consumer is responsible for converting before writing0x8000: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 field | Type | Notes |
|---|---|---|
counts_per_mm | f32 | Required. |
offset_mm | f32 | Required. Zero-position offset. |
direction | string | "+" 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 field | Type | Notes |
|---|---|---|
stiffness_n_per_mm | f32 | Required. |
free_length_mm | f32 | Required. |
preload_n | f32 | Optional. |
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"]
}
| Value | Effect |
|---|---|
| omitted / absent | All 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:
Project::normalize()injects the baseline AMS GM scalars (ams_asset_count,ams_alert_calibration_overdue,ams_alert_lane_unavailable).- The next
acctl codegenregeneratescontrol/src/gm.rswith typed calibration structs (e.g.,LoadCellCalibrationwithscale: f32, offset: f32) and writeswww/src/autocore/ams.tswith mirrored TypeScript types and the asset_type catalog. - 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 (lanes → lane)
and appending _NN. Use a singular name if your equipment doesn’t
pluralize naturally (channel → channel_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 againstasset.custom.<field>(the nameplate values declared by the asset_type’sfields) or one of the top-levelAssetkeys (asset_id,serial,location,install_date,current_calibration_id).cal.<field>reads from the asset’s active calibration’svalues.<field>.sub.<key>.<field>andcal.<key>.<field>are the per-axis forms used by multi-axis types — they index intoasset.sub_locations.<key>.<field>andcalibration.values.<key>.<field>respectively.
Render modifiers (chain left-to-right with |):
| hex<bits>— zero-padded uppercase hex withbits/4digits (bitsmust be a multiple of 8 — matches the EtherCAT SDOvalueparser, which is byte-width-padded). Negative values and floats are rejected; overflow is rejected.| neg— flip the sign. Useful for paired bridgemin_val/max_valwheremin = -capacity.| default <json_literal>— bring-up escape hatch. When the lookup would fail (no asset, no current calibration, field absent, value isnull), substitute the literal value instead of hard-erroring. Argument is a JSON literal so numbers, quoted strings, booleans, andnullall 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:
- Live asset value (if registered, field set, and not
null). - Inline
| default <json>modifier on the placeholder, if specified. - Schema-level
defaultdeclared on the asset_type field definition. - 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 text | Substituted value |
|---|---|
default 5000 | the number 5000 |
default -500 | the number -500 |
default 3.14 | the number 3.14 |
default "Interface" | the string Interface |
default true | the boolean true |
default null | JSON 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:
-
At project push (
acctl sync):ams.placeholderfindings 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. -
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’sstart_modulereturns a typedSupervisorError::UnresolvedAmsPlaceholderslisting 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 variable | Type | Source |
|---|---|---|
ams_active_<field>_asset_id | string | tis.start_test (test-time view) |
ams_active_<field>_calibration_id | string | tis.start_test (test-time view) |
ams_active_<field>_present | bool | AMS (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_usagezeros the counters and appends an entry tousage_log.jsonlnext tousage.jsonso 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 intotest.json::asset_snapshot, and the suffix on the auto-injectedams.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 whoselocationmatcheslocationbelow.by_id_field— read the asset_id directly from a config field (dottedfrompath 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_idams_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:
- Placeholder resolution. When
${ams.by_location.<location>.<field>}resolves and no live asset value is on disk (and the placeholder has no| default Xmodifier), the asset_ref’s default for<field>is substituted. This works on built-in asset_types too —load_celland friends have no schema-leveldefaultslot, so the asset_ref is the per-role hook for declaring expected nameplate values. - AIS form pre-fill. When the operator opens the create-asset
form for this role, AIS seeds the nameplate inputs from the
defaultsmap. 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>
);
}
| Component | Responsibility |
|---|---|
<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 topic | What it counts |
|---|---|
ams.asset_count | Number of active assets in the registry. |
ams.alert_calibration_overdue | Active assets whose current calibration’s expires_at is in the past. |
ams.alert_lane_unavailable | Sub_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_snapshotstill resolves correctly after a restore.
Deleting an asset
Assets can be permanently removed from AMS in a two-step flow:
-
Retire.
ams.update_assetwithstatus: "retired". The asset stays on disk but is excluded from active queries, the placeholder resolver, and anyby_locationsnapshot. The AIS UI surfaces a Retire button on<AssetDetailView>for active assets. -
Delete.
ams.delete_assetis 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_deletedwith{ asset_id, asset_type }(subscribers can filter on type if they care)ams.asset_count(the alert counter recomputes)ams.active_<field>_presentis re-published for every role whose location lost an asset (so control programs’_presentGM 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”:
- Run
acctl add-amsto enable the subsystem. - Use
<AssetRegistryTable>’s “Add Asset” dialog (or theams.create_assetRPC directly) to register each piece of equipment. Record the manufacturer serial under theserialfield for traceability. - 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. - Update each test method’s
asset_refsblock to point at the new asset locations. Runacctl codegento refresh the GM scalars.
The same add- family retrofits TIS into a project that started
without it: acctl add-tis. Both commands are idempotent.