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

Simulating a device

Verified · scan_callback.rs · linked_calc.rs · demo_scan.py · demo_calc.py · check docs_verify · docs-verify

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

What you'll build

PVs that change on their own — a periodic scan, and computed PVs that recalculate whenever their inputs move. Together these are most of what a test double needs.

Periodic scanning

Rust

#![allow(unused)]
fn main() {
    let server = PvaServer::builder()
        .ai("SIM:TEMPERATURE", 22.5)
        .scan("SIM:TEMPERATURE", Duration::from_millis(100), |_pv| {
            let t = TICK.fetch_add(1, Ordering::Relaxed) as f64;
            ScalarValue::F64(22.5 + (t * 0.1).sin())
        })
        .build();
}

Python

temp = spvirit.ai("SIM:TEMPERATURE", 22.5, units="degC", prec=2)


@temp.scan(period=0.1)
def _simulate(pv):
    return 22.5 + math.sin(time.monotonic())


server = spvirit.Server(pvs=[temp])

@temp.scan(period=0.1) is the decorator form; temp.scan(0.1, fn) is the same thing as a call.

Computed PVs

Rust

#![allow(unused)]
fn main() {
    let server = PvaServer::builder()
        // Writable inputs
        .ao("CALC:A", 0.0)
        .ao("CALC:B", 0.0)
        // Computed outputs (read-only)
        .ai("CALC:SUM", 0.0)
        .ai("CALC:PROD", 0.0)
        .ai("CALC:MEAN", 0.0)
        // Links: recomputed whenever CALC:A or CALC:B changes
        .link("CALC:SUM", &["CALC:A", "CALC:B"], |v| {
            ScalarValue::F64(f64_of(&v[0]) + f64_of(&v[1]))
        })
        .link("CALC:PROD", &["CALC:A", "CALC:B"], |v| {
            ScalarValue::F64(f64_of(&v[0]) * f64_of(&v[1]))
        })
        .link("CALC:MEAN", &["CALC:A", "CALC:B"], |v| {
            ScalarValue::F64((f64_of(&v[0]) + f64_of(&v[1])) / 2.0)
        })
        .build();
}

.link(output, inputs, compute) recomputes output whenever any of inputs changes. There is also Pv::calc(name, &[&inputs], f) on the handle side, which is the same idea with f64 types filled in for you.

Python

a = spvirit.ao("CALC:A", 0.0)
b = spvirit.ao("CALC:B", 0.0)

total = spvirit.calc("CALC:SUM", [a, b], lambda v: v[0] + v[1])
product = spvirit.calc("CALC:PROD", [a, b], lambda v: v[0] * v[1])
mean = spvirit.calc("CALC:MEAN", [a, b], lambda v: (v[0] + v[1]) / 2.0)

server = spvirit.Server(pvs=[a, b, total, product, mean])

What to notice

Scan and calc fail differently, and neither one shouts.

On a callback exception
scanlogs the error, re-posts the last value the scan produced (or the type default — 0.0/False/0/"" — if it has never produced one)
calclogs the error, posts 0.0

So a calc that starts throwing pins its PV at zero, which looks exactly like a real reading of zero. If a computed PV must distinguish "broken" from "zero", set an alarm severity explicitly — see Alarms.

A scan returning None does not mean "leave it alone". It re-posts the last value that scan produced. It does not read the PV's current value, so if something else called pv.set(...) in between, returning None will overwrite that with the scan's own cached value. Return a real value, or call pv.set() and let the scan return None deliberately.

Computed PVs are read-only. .link() and calc produce ai records. Writing to one either fails or is immediately overwritten on the next recomputation.

Recomputation is change-driven, not periodic. Nothing happens until an input changes. A calc over two inputs that never move costs nothing.

Deadbands apply to the output too. If a computed PV has an mdel, its subscribers see the deadbanded stream even though the recomputation ran.

Run it

# Terminal 1
cargo run -p spvirit-server --example linked_calc

# Terminal 2
spput CALC:A 10
spput CALC:B 3
spget CALC:SUM       # 13
spget CALC:PROD      # 30
spget CALC:MEAN      # 6.5
spmonitor CALC:SUM   # live updates as A or B change
$ spput CALC:A 10
CALC:A OK

$ spput CALC:B 3
CALC:B OK

$ spget CALC:SUM
CALC:SUM 2026-08-06 09:13:28.993  13

$ spget CALC:PROD
CALC:PROD 2026-08-06 09:13:28.993  30

$ spget CALC:MEAN
CALC:MEAN 2026-08-06 09:13:28.993 6.5

All three derived PVs carry the same timestamp, because one write to CALC:B recomputed all of them in the same pass.

Leave spmonitor CALC:SUM running first, then do the two puts from a third terminal, and you can watch the recomputation happen:

$ spmonitor CALC:SUM
CALC:SUM 2026-08-06 09:27:29.216   0
CALC:SUM 2026-08-06 09:27:34.375  10
CALC:SUM 2026-08-06 09:27:35.262  13
CALC:SUM 2026-08-06 09:27:36.132  23

0 is the initial value delivered on connect, 10 follows spput CALC:A 10, 13 follows spput CALC:B 3, and 23 is a later spput CALC:A 20. Each input write produces exactly one output update.

Or the scan pair:

python spvirit-py/examples/demo_scan.py     # terminal 1
spmonitor SIM:TEMPERATURE                   # terminal 2
SIM:TEMPERATURE 2026-08-06 09:25:29.221 21.677613
SIM:TEMPERATURE 21.734644
SIM:TEMPERATURE 21.8092
SIM:TEMPERATURE 21.89275
...
SIM:TEMPERATURE 2026-08-06 09:25:30.028 22.347997

Ten updates a second, forever, with no client asking for them. The timestamp is reprinted only when the wall-clock second rolls over.

Next

Arrays and waveforms.