Tables and images
✅ Verified ·
exotic_nt.rs·demo_table.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, not this chapter alone.
What you'll build
An NtTable — columnar data, like a scan result or a device inventory —
and an NtNdArray — a framed image.
These are the two places where spvirit steps outside the EPICS record
model. As Records vs raw NT
explains, there is no table record in EPICS Base. An NtTable is a
payload, not a processing entity: nothing scans it, nothing computes
alarms from it, and there is no .db syntax that produces one. You are
using PVAccess as a transport for structured data.
NtTable
A table is a set of equal-length named columns plus display labels:
labels: string[] # what to show in a header row
value:
x: double[] # one array per column
y: double[]
Rust
#![allow(unused)] fn main() { let table_nt = NtTable { labels: vec!["X".to_string(), "Y".to_string()], columns: vec![ NtTableColumn { name: "x".to_string(), values: ScalarArrayValue::F64(xs), }, NtTableColumn { name: "y".to_string(), values: ScalarArrayValue::F64(ys), }, ], descriptor: Some("SIM table demo".to_string()), alarm: None, time_stamp: None, }; store.put_nt("SIM:TBL", NtPayload::Table(table_nt)).await; }
Python
# An NTTable is a dict of equal-length columns.
# Labels default to the column names.
builder.nt_table("SIM:TBL", {"x": [0.0] * 8, "y": [0.0] * 8})
Python's builder takes a plain {name: list} dict and infers each column's
wire type; types= overrides that per column. labels defaults to the
column names.
NtNdArray
An image is flat data plus a dimension list. The dimensions carry offset, binning and reversal so a client can reconstruct a region of interest without a second PV.
Rust
#![allow(unused)] fn main() { let ndarray_nt = NtNdArray { value: ScalarArrayValue::U8(pixels), codec: NdCodec { name: "none".to_string(), parameters: HashMap::new(), }, compressed_size: 16, uncompressed_size: 16, dimension: vec![ NdDimension { size: 4, offset: 0, full_size: 4, binning: 1, reverse: false, }, NdDimension { size: 4, offset: 0, full_size: 4, binning: 1, reverse: false, }, ], unique_id: tick as i32, data_time_stamp: Default::default(), attribute: vec![], descriptor: Some("SIM 4x4 image".to_string()), alarm: None, time_stamp: None, display: None, }; store .put_nt("SIM:IMG", NtPayload::NdArray(ndarray_nt)) .await; }
Python
The Python builder is the compact form of the same thing:
# An NTNDArray is flat data plus dimensions. On the builder each dimension
# is a (size, offset) pair: the served extent and where it starts in the
# underlying frame.
builder.nt_ndarray("SIM:IMG", [0] * 16, [(4, 0), (4, 0)], type="ubyte")
Driving both
The server updates both with store.put_nt(...), the raw-NT level from
Records vs raw NT:
# A client can write the table over the wire; a wire PUT to the image applies
# its metadata but not the image value. Here the server drives both with
# put_nt. The payload constructors take dimensions as a flat list of sizes,
# where the builder takes (size, offset) pairs.
for tick in range(20):
xs = [float(i) for i in range(8)]
ys = [math.sin(v * 0.7 + tick * 0.15) for v in xs]
store.put_nt("SIM:TBL", spvirit.NtTable({"x": xs, "y": ys}, labels=["X", "Y"]))
frame = [(i * 16 + tick * 8) % 256 for i in range(16)]
store.put_nt("SIM:IMG", spvirit.NtNdArray(frame, [4, 4], type="ubyte"))
time.sleep(0.5)
Watch the dimension argument, which is the one place the two APIs disagree:
the builder takes (size, offset) tuples (full_size is set equal to
size, binning 1, not reversed), while the NtNdArray constructor takes a
flat list of sizes (offset 0). Binning, reversal and a full_size different
from size are not reachable from Python; build the payload in Rust if you
need them.
What to notice
NTTable accepts a wire PUT; NTNDArray only partly does.
RecordInstance::apply_put (spvirit-server/src/apply.rs:569) dispatches
NtTable and NtNdArray PUTs to apply_table_put/apply_ndarray_put. A
table PUT updates the record and restamps it. An NTNDArray PUT applies the
metadata fields (codec, sizes, uniqueId, dimension, …), but the
union-shaped image value a client sends is not applied (see
Known gaps). The examples use
store.put_nt(...), which is the natural way to drive both from server
code.
No deadband, no alarm computation, no scanning. All of that lives in
the record layer, which these types are not part of. There is no MDEL
deadband on put_nt: any payload that differs from the current one is
posted. An identical payload is dropped.
spget prints them structurally.
$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, ...], y=[0.000000, 0.644218, ...]}
$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, ...]}
The ubyteValue field name is not decoration — NTNDArray's value is a
union, and the field name identifies which arm is populated. An int16
image would come back as shortValue.
There is no table viewer in the toolbox. spget prints the shape,
and that is the extent of it. sptable is a
server, not a client — an interactive spreadsheet IOC that serves an
NtTable — so it is the right tool for producing test data, not for
inspecting someone else's PV.
Element types are fixed at creation. Writing an f64 column into a
table created with int columns coerces to the record's type rather than
retyping the record. The record is the authority.
Python needs lists, not numpy arrays. Call .tolist() — bytes is
also accepted for ubyte data.
Run it
# Terminal 1
cargo run -p spvirit-server --example exotic_nt
# or: python spvirit-py/examples/demo_table.py
# Terminal 2
spget SIM:TBL
spget SIM:IMG
$ spget SIM:TBL
SIM:TBL 2026-08-06 09:14:32.958 {x=[0.000000, 1.000000, 2.000000, 3.000000,
4.000000, 5.000000, 6.000000, 7.000000], y=[0.000000, 0.644218, 0.985450,
0.863209, 0.334988, -0.350783, -0.871576, -0.982453]}
$ spget SIM:IMG
SIM:IMG 2026-08-06 09:14:32.958 {ubyteValue=[0, 16, 32, 48, 64, 80, 96, 112,
128, 144, 160, 176, 192, 208, 224, 240]}
Both are one long line, wrapped here. y is sin(x) and the image is a
4×4 greyscale ramp — small enough to read, which is the whole point of the
example. Note what spget does not print: the table's labels
(["X", "Y"]), and the image's dimension array. Both are on the wire;
spget renders the value field only.
spinfo SIM:IMG shows why NTNDArray is the most involved normative type in
the book — value is a union of twelve typed arrays, and the shape lives
in a separate dimension structure array:
$ spinfo SIM:IMG
SIM:IMG:
struct epics:nt/NTNDArray:1.0
value: union
booleanValue: array
byteValue: array
shortValue: array
intValue: int[]
...
stringValue: string[]
codec: structure
name: string
parameters: any
compressedSize: long
uncompressedSize: long
dimension: structure[]
size: int
offset: int
fullSize: int
binning: int
reverse: boolean
uniqueId: int
dataTimeStamp: structure
...
attribute: structure[]
name: string
value: any
descriptor: string
sourceType: int
source: string
descriptor: string
alarm: structure
...
Only the arm you filled in is transmitted, so a ubyte image does not pay
for the eleven other array types.