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

Tables and images

✅ Verified · exotic_nt.rs · demo_table.py · check docs_verify · docs-verify

The badge reports the whole docs-verify suite, 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.

Next

Custom data sources.