Records vs raw NT
✅ Verified ·
nt_put_get.rs·demo_nt_access.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, 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 records | Raw NT payloads | |
|---|---|---|
| You create them with | Pv<T> handles (Pv::ai(...) …), builder methods (.ai(), .waveform() …), .db files | NtScalar/NtScalarArray/… built by hand; hand-built RecordInstance; custom Source impls |
| You read/write | plain values: pv.set(21.5), store.set_value(...) | whole payloads: store.put_nt(...) / get_nt(...), Notifier posts |
| Alarm state | computed for you from HIHI/HIGH/LOW/LOLO limits (compute_alarms), or pv.set_alarm(...) | you set alarm on every payload yourself |
| Timestamps | stamped automatically on every update, client PUT included | yours 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 server | not applied — every put_nt/notify posts |
| Best for | soft IOCs, simulators, anything that should feel like an EPICS record | gateways/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).