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, full_size) pair: the served extent and the extent of the
# underlying frame.
builder.nt_ndarray("SIM:IMG", [0] * 16, [(4, 4), (4, 4)], type="ubyte")
Driving both
Neither type accepts a client PUT, so the server updates them with
store.put_nt(...) — the raw-NT level from
Records vs raw NT:
# Neither type is writable over the wire, so the server drives both with
# put_nt. The payload constructors take dimensions as a flat list of sizes,
# where the builder takes (size, full_size) 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, full_size) tuples, while the NtNdArray
constructor takes a flat list of sizes and sets full_size equal to each
size. Offset, binning and reversal are not reachable from the Python
constructor at all; build the payload in Rust if you need a region of
interest.
What to notice
Both types accept a wire PUT. RecordInstance::apply_put
(spvirit-server/src/apply.rs:609) dispatches NtTable and NtNdArray PUTs
to apply_table_put/apply_ndarray_put, so a client write updates the
record and restamps it like any other. The examples above still use
store.put_nt(...) because that is the natural way to drive a
server-to-client payload from Rust or Python code, not because a client PUT
is refused.
No deadband, no alarm computation, no scanning. All of that lives in
the record layer, which these types are not part of. Every put_nt posts
to every subscriber.
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.