A complete IOC
✅ Verified ·
complete_ioc.rs·demo_complete_ioc.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, 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:
| PV | Record | Role |
|---|---|---|
VAC:PRESSURE | ai | scanned readback, pumping down |
VAC:SETPOINT | ao | target pressure, range-checked on write |
VAC:ERROR | calc | readback minus setpoint |
VAC:RGA | aai | a 64-point residual-gas spectrum |
VAC:LINK | ai | controller 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:
- Command-line tools — the
sp*family in detail - Developer guide — internals, protocol notes, and the crate-level API