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

A complete IOC

Verified · complete_ioc.rs · demo_complete_ioc.py · check docs_verify · docs-verify

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

What you'll build

One server that uses everything in Part III at once: a scanned readback with units and a deadband, a validated setpoint, a derived PV, an array, and an explicitly-managed alarm. It is small, but it is shaped like a real piece of equipment rather than a demo.

The device is a vacuum system:

PVRecordRole
VAC:PRESSUREaiscanned readback, pumping down
VAC:SETPOINTaotarget pressure, range-checked on write
VAC:ERRORcalcreadback minus setpoint
VAC:RGAaaia 64-point residual-gas spectrum
VAC:LINKaicontroller reachability, severity set by hand

Rust

#![allow(unused)]
fn main() {
    // --- Readback: scanned, with units and a monitor deadband -----------
    let tick = Arc::new(AtomicU64::new(0));
    let t = tick.clone();

    let pressure = Pv::ai("VAC:PRESSURE", 1.0e-6)
        .units("mbar")
        .prec(3)
        .desc("Chamber pressure")
        .mdel(1.0e-8) // suppress sub-nanobar jitter
        .scan(Duration::from_millis(500), move |_pv| {
            let n = t.fetch_add(1, Ordering::Relaxed) as f64;
            // A decaying pump-down curve with a little noise.
            1.0e-6 * (-n / 40.0).exp() + 1.0e-9 * (n * 1.7).sin()
        });

    // --- Setpoint: validated on write -----------------------------------
    let setpoint = Pv::ao("VAC:SETPOINT", 1.0e-6)
        .units("mbar")
        .prec(3)
        .desc("Target pressure")
        .on_put(|pv, value: f64| {
            // Drive limits are advisory, so enforce the range here.
            if !(1.0e-9..=1.0e-3).contains(&value) {
                return Err(format!("{}: {value} outside 1e-9..1e-3", pv.name()));
            }
            println!("{} -> {value:e}", pv.name());
            Ok(())
        });

    // --- Derived: recomputed whenever an input moves ---------------------
    let error = Pv::calc("VAC:ERROR", &[&pressure, &setpoint], |inputs: &[f64]| {
        inputs[0] - inputs[1]
    })
    .units("mbar")
    .desc("Readback minus setpoint");

    // --- Array: a spectrum a client can read but not write ---------------
    let spectrum = PvArray::aai("VAC:RGA", ScalarArrayValue::F64(vec![0.0; 64]));

    // --- Status: severity we set ourselves -------------------------------
    let status = Pv::ai("VAC:LINK", 0.0).desc("Gauge controller link");

    let server = PvaServer::serve([
        pressure.clone(),
        setpoint.clone(),
        error.clone(),
        status.clone(),
    ])
    .pvs([spectrum.clone()])
    .build()
    .await;
}

Everything above is declarative — you describe the records and hand them to the server. Anything the IOC needs to do beyond that is an ordinary Tokio task holding the same handles:

#![allow(unused)]
fn main() {
    // Everything above is declarative. Anything else you want the IOC to do
    // is an ordinary task driving the handles.
    let spec = spectrum.clone();
    tokio::spawn(async move {
        let mut frame = 0u64;
        loop {
            let data: Vec<f64> = (0..64)
                .map(|i| ((i as f64) * 0.2 + frame as f64 * 0.1).sin().abs())
                .collect();
            let _ = spec.set(ScalarArrayValue::F64(data)).await;
            frame += 1;
            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        }
    });

    // The gauge controller is reachable, so clear the alarm explicitly.
    status.set_alarm(0, 0, "").await?;
}

Note the two-call construction. PvaServer::serve() takes one homogeneous iterator, so the four Pv<f64> handles go in the first call and the PvArray goes in through .pvs() (spvirit-server/src/pva_server.rs:724, :741). Chain .pvs() as many times as you have distinct handle types.

Python

The same five records, with three differences worth naming:

# --- Readback: scanned, with units and a monitor deadband ---------------
pressure = spvirit.ai(
    "VAC:PRESSURE",
    1.0e-6,
    units="mbar",
    prec=3,
    desc="Chamber pressure",
    mdel=1.0e-8,  # suppress sub-nanobar jitter
)

_tick = 0


@pressure.scan(period=0.5)
def _pump_down(pv):
    global _tick
    n = float(_tick)
    _tick += 1
    # A decaying pump-down curve with a little noise.
    return 1.0e-6 * math.exp(-n / 40.0) + 1.0e-9 * math.sin(n * 1.7)


# --- Setpoint: validated on write ---------------------------------------
setpoint = spvirit.ao(
    "VAC:SETPOINT", 1.0e-6, units="mbar", prec=3, desc="Target pressure"
)


@setpoint.on_put
def _check_range(pv, value):
    # Drive limits are advisory, so enforce the range here. Raising rejects
    # the PUT and sends the exception text back to the client; returning
    # False also rejects, but with a fixed "rejected by on_put" message.
    if not 1.0e-9 <= value <= 1.0e-3:
        raise ValueError(f"{pv.name}: {value} outside 1e-9..1e-3")
    print(f"{pv.name} -> {value:e}")


# --- Derived: recomputed whenever an input moves -------------------------
# `calc` takes handles, not names, and no metadata keywords — units and
# desc are not settable on a computed PV from Python.
error = spvirit.calc("VAC:ERROR", [pressure, setpoint], lambda vals: vals[0] - vals[1])

# --- Array: a spectrum a client can read but not write -------------------
spectrum = spvirit.aai("VAC:RGA", [0.0] * 64)

# --- Status: severity we set ourselves -----------------------------------
status = spvirit.ai("VAC:LINK", 0.0, desc="Gauge controller link")

# One flat list, whatever the handle types. Callbacks must already be
# attached at this point: the handles are bound here, and `on_put`/`scan`/
# `calc` are ignored on a bound handle.
server = spvirit.Server(pvs=[pressure, setpoint, error, spectrum, status])
server.start()

Server(pvs=[...]) takes one flat list — the two-call split above is a Rust type-system constraint, not a protocol one, so the array handle goes in beside the scalars.

spvirit.calc(name, inputs, callback) accepts no metadata keywords, so VAC:ERROR cannot carry units="mbar" or a description the way the Rust version does. If a computed PV needs metadata, write it through the raw-NT layer with store.put_nt(...) after the server is up (Records vs raw NT).

Array PVs reject the scalar keywords (units, prec, desc, adel, mdel, and both limit pairs) with TypeError, and on_put/scan raise TypeError on them too (spvirit-py/src/pv.rs:307, :365). VAC:RGA is therefore driven from a plain loop rather than a scan callback:

# Everything above is declarative. Anything else you want the IOC to do is
# an ordinary loop driving the handles. `scan` is not available on an array
# PV, so VAC:RGA is updated from here.

# The gauge controller is reachable, so clear the alarm explicitly.
status.set_alarm(0, 0, "")

frame = 0
while True:
    spectrum.set([abs(math.sin(i * 0.2 + frame * 0.1)) for i in range(64)])
    frame += 1
    time.sleep(0.2)

Note the rejection path. Raising from on_put sends the exception's text to the client, matching the Rust Err(String); returning False also rejects but the client sees a fixed rejected by on_put (spvirit-py/src/pv.rs:559).

Run it

# Terminal 1
cargo run -p spvirit-server --example complete_ioc
# or: python spvirit-py/examples/demo_complete_ioc.py

Discover the server, then ask it for its PV list:

$ splist
GUID 0xC4960000E061B581C193C818 version 2: tcp@[ 10.64.23.134:5075 ]

$ splist 127.0.0.1:5075
VAC:ERROR
VAC:LINK
VAC:PRESSURE
VAC:RGA
VAC:SETPOINT
__pvlist

splist with no argument lists servers; splist <target> lists the PVs on one. __pvlist is the server's own introspection channel — it is how that second call works, and it appears in every listing.

Read and write:

$ spget VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:35:23.578 0.000001

$ spput VAC:SETPOINT 5e-4
VAC:SETPOINT OK

$ spget VAC:ERROR
VAC:ERROR 2026-08-04 10:35:56.070 -0.0005

$ spput VAC:SETPOINT 1.0
VAC:SETPOINT ERROR protocol error: PUT failed: VAC:SETPOINT: 1 outside 1e-9..1e-3

The derived PV moved on its own: on_put accepted the setpoint, the store propagated the change through the link graph, and VAC:ERROR recomputed before the next spget arrived.

What to notice

A client PUT always advances the record's timeStamp. The write applies and the record is restamped with server time, distinct from whatever it carried before:

$ spput VAC:SETPOINT 2e-4 && spget VAC:SETPOINT
VAC:SETPOINT 2026-08-04 10:35:11.566 0.0002

$ spput VAC:SETPOINT 3e-4 && spget VAC:SETPOINT
VAC:SETPOINT 2026-08-04 10:35:57.902 0.0003

Each read carries the time of its own PUT, not the record's creation time. RecordInstance::apply_put (spvirit-server/src/apply.rs:546) updates value, alarm, display and control from the client's structure and then always restamps — with the client's timeStamp if the PUT carried a non-default one, so a gateway can forward the originating acquisition time, otherwise with server time — whether or not the value itself changed. That matches EPICS Base, where recGblGetTimeStampSimm() runs unconditionally in process(). Server-driven updates — scan, calc, set — stamp too, which is why VAC:PRESSURE and VAC:ERROR above move on the same rhythm as the client-written VAC:SETPOINT.

The deadband is doing its job. VAC:PRESSURE scans every 500 ms, but a monitor posts roughly once a second:

$ spmonitor VAC:PRESSURE
VAC:PRESSURE 2026-08-04 10:36:09.069   0
VAC:PRESSURE 2026-08-04 10:36:10.065   0

.mdel(1.0e-8) suppresses every tick whose change is smaller than that. Half the scans move less than a nanobar and are dropped. See Monitors.

spget formatting is not the value. Once the chamber pumps below about 1e-7 the display collapses to 0. The wire value is intact — spget -F value and spmonitor read the same double. For a fixed number of digits, use the record's PREC field and a client that honours it, or read the raw field.

Pv::calc takes handles, not names. The signature is calc(name, inputs: &[&Pv<f64>], f) (spvirit-server/src/pv.rs:392), so the input PVs must exist as handles before the derived PV is built. That ordering constraint is what makes the link graph resolvable at construction time instead of at first read.

Validation belongs in on_put, not in DRVL/DRVH. The drive limits are published for GUIs; nothing enforces them. The range check here is the only thing keeping 1.0 out of the record — and because spput retries a rejected write, keep the callback idempotent (Reacting to writes).

Where to go next

You now have every building block. The remaining parts of this book are reference rather than tutorial: