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

Records vs raw NT

Verified · nt_put_get.rs · demo_nt_access.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, not this chapter alone.

Everything on the wire is a Normative Type, but Spvirit gives you two levels at which to work — and it pays to know which one you are on. Choosing wrong is the most common source of "why is my timestamp zero" and "why does my monitor fire on every tick".

The comparison

IOC-style recordsRaw NT payloads
You create them withPv<T> handles (Pv::ai(...) …), builder methods (.ai(), .waveform() …), .db filesNtScalar/NtScalarArray/… built by hand; hand-built RecordInstance; custom Source impls
You read/writeplain values: pv.set(21.5), store.set_value(...)whole payloads: store.put_nt(...) / get_nt(...), Notifier posts
Alarm statecomputed for you from HIHI/HIGH/LOW/LOLO limits (compute_alarms), or pv.set_alarm(...)you set alarm on every payload yourself
Timestampsstamped automatically on every update, client PUT includedyours to fill in — an explicit timeStamp is honoured, a zero one is stamped for you
Display/control metadata (EGU, PREC, limits)record fields, visible QSRV-style (PV.EGU, PV.DESC, …)whatever you put in the payload, each update
Monitor deadbands (MDEL/ADEL)applied by the servernot applied — every put_nt/notify posts
Best forsoft IOCs, simulators, anything that should feel like an EPICS recordgateways/bridges, tables, images, PVs whose metadata changes per update

Rule of thumb

Stay IOC-style — Pv<T> handles first, .db files for existing databases — until you need per-update control of the metadata, or a payload shape the record layer does not model. Then drop to put_nt/get_nt, hand-built records, or a custom Source.

The two mix freely in one server. store.get_nt() returns the full payload of an IOC-style record too, so you can start with records and reach through to the raw layer for the one PV that needs it.

The three consequences that bite

Deadbands only exist at the record level. MDEL and ADEL are applied by the server when an IOC-style record's value is set. A put_nt bypasses that entirely: every post goes out. If you have a 1 kHz raw-NT source and a monitor client, you are sending 1000 updates a second, and no amount of MDEL in a .db file will change that.

Timestamps are automatic at both levels, and the rule is the same one. Post a payload — or PUT a record field — with a zero or absent timeStamp and the server stamps it with the current time; supply a real timeStamp and it is honoured instead. That is deliberate, so a gateway can forward the originating acquisition time rather than the time it happened to relay the value.

At the record level this restamp happens on every accepted PUT, whether or not the value itself changed — RecordInstance::apply_put (spvirit-server/src/apply.rs:546) applies value, alarm, display and control, then always calls set_time_stamp. Server-side updates — set_value, a scan callback, a .link() recomputation — restamp the same way. EPICS Base does the same: recGblGetTimeStampSimm() runs unconditionally in process(), independent of whether the value moved. Reading and writing shows it happening.

Alarms are only computed at the record level. compute_alarms walks the HIHI/HIGH/LOW/LOLO thresholds and sets severity. Raw payloads get the severity you put in them, which for a hand-built NtScalar::from_value is NO_ALARM — a silently un-alarming PV.

Building a payload by hand

This is what the raw level actually looks like. In Rust, NtScalar starts from a ScalarValue and the with_* builders each consume and return the payload, so they chain; fields without a builder are plain pub fields you assign:

#![allow(unused)]
fn main() {
/// Build a complete `NtScalar` from scratch: value, metadata, alarm, time.
fn make_temp_nt_with_custom_alarm(temp: f64) -> NtScalar {
    // Custom severity mapping. Nothing computes this for you at the raw-NT
    // level — a payload you build by hand is NO_ALARM until you say otherwise.
    // 0 = NO_ALARM, 1 = MINOR, 2 = MAJOR; status is example-only tagging.
    let (severity, status, message) = if temp >= 22.9 {
        (2, 3, "custom HIHI")
    } else if temp >= 22.7 {
        (1, 1, "custom HIGH")
    } else if temp <= 21.1 {
        (2, 5, "custom LOLO")
    } else if temp <= 21.3 {
        (1, 4, "custom LOW")
    } else {
        (0, 0, "custom OK")
    };

    // The builders are chained on the owned value and each returns `Self`.
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let mut nt = NtScalar::from_value(ScalarValue::F64(temp))
        .with_units("degC".to_string())
        .with_description(format!("Simulated temperature ({temp:.2} degC)"))
        .with_precision(2)
        .with_limits(20.5, 23.5)
        // An explicit timestamp is honoured verbatim; leave it unset and the
        // encoder stamps at encode time instead.
        .with_timestamp(now.as_secs() as i64, now.subsec_nanos() as i32);

    // Anything without a builder is a plain public field.
    nt.alarm_severity = severity;
    nt.alarm_status = status;
    nt.alarm_message = message.to_string();
    nt
}
}

Then the payload goes to the store as a whole, and comes back as a whole:

#![allow(unused)]
fn main() {
            // Lower-level NT write (scalar) with custom alarm logic.
            let temp_nt = make_temp_nt_with_custom_alarm(temp);
            store.put_nt("SIM:TEMP", NtPayload::Scalar(temp_nt)).await;

            // Lower-level NT write (array).
            let samples = (0..8)
                .map(|i| (t * 0.15 + i as f64 * 0.4).sin())
                .collect::<Vec<_>>();
            let array_nt = NtScalarArray::from_value(ScalarArrayValue::F64(samples));
            store
                .put_nt("SIM:SPECTRUM", NtPayload::ScalarArray(array_nt))
                .await;

            // Lower-level NT read: the whole payload, not just the value.
            if let Some(snapshot) = store.get_nt("SIM:TEMP").await {
                println!("SIM:TEMP => {snapshot:?}");
            }
}

Python has no builder chain — the same fields are keyword arguments on the NtScalar constructor:

# Python has no builder chain — every field is a keyword argument on the
# constructor, and anything you leave out keeps its default.
nt_scalar = spvirit.NtScalar(
    value=42.0,
    units="degC",
    display_low=0.0,
    display_high=100.0,
    display_description="Temperature setpoint",
    display_precision=2,
    control_low=5.0,
    control_high=95.0,
)

# Nothing computes alarms for a payload you built yourself: an NtScalar is
# NO_ALARM unless you pass alarm_severity/alarm_status/alarm_message.
warm = spvirit.NtScalar(
    value=96.0,
    units="degC",
    alarm_severity=1,  # 0=NONE 1=MINOR 2=MAJOR 3=INVALID
    alarm_status=4,
    alarm_message="HIGH",
)

# Write the whole payload, metadata included. put_nt coerces to the record's
# existing wire type and never retypes it.
store.put_nt("NT:SETPOINT", nt_scalar)

and the read side:

# get_nt returns the whole payload — get_value returns only the number.
# It works on IOC-style records too, so you can start with handles and
# reach through to the raw layer for the one PV that needs it.
nt = store.get_nt("NT:TEMPERATURE")
if nt is not None:
    print(f"  value  {nt.value} {nt.units}  severity={nt.alarm_severity}")
    print(f"  display {nt.display_low}..{nt.display_high} prec={nt.display_precision}")
    print(f"  control {nt.control_low}..{nt.control_high}")
# A name nothing serves gives None rather than raising.
assert store.get_nt("DOES:NOT:EXIST") is None

NtScalarArray, NtTable and NtNdArray follow the same shape. The Python constructors are spvirit.NtScalar, spvirit.NtScalarArray, spvirit.NtTable, spvirit.NtNdArray, plus Alarm, TimeStamp, Display and Control for the substructures.

Note what neither example gets for free: no deadband, no computed alarm. Both are record-level services, and the payload you built is not a record. That is the trade the table above describes.

Which one am I on?

If you called .ai(), .ao(), Pv::ai(), or loaded a .db, you are on the record level. If you implemented the Source trait or called put_nt, you are on the raw level. If you called store.set_value() on a record, you are still on the record level — that is the record API, and it applies deadbands and stamps time.

Both are covered in Part III: records throughout, and the raw level in Tables and images (payload types the record layer does not model) and Custom data sources (serving PVs without a record store at all).