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

Alarms and severity

Verified · alarms.rs · demo_alarms.py · check docs_verify · docs-verify

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

What you'll build

PVs that tell a client not just what the value is but whether it should be worried about it.

The alarm structure

Every NtScalar carries an alarm block alongside its value:

alarm:
  severity: int      # 0 NONE, 1 MINOR, 2 MAJOR, 3 INVALID
  status:   int      # EPICS status code
  message:  string   # free text

Severity is the part clients act on. A control screen turns a widget yellow on MINOR and red on MAJOR; an archiver may record the transition even when the value itself is inside the deadband.

Alarm transitions always post. The MDEL deadband gates value changes only — a severity change reaches every subscriber regardless of how small the value moved (spvirit-server/src/simple_store.rs:545).

Two ways to get a severity

HowEvaluated by
ComputedLOW/HIGH/LOLO/HIHI in a .db file, plus .compute_alarms(true)the server, on every write
Explicitset_alarm(severity, status, message)you

Computed, from a .db file

record(ao, "DEMO:SETPOINT") {
    field(LOW,  "10")     # MINOR below this
    field(HIGH, "40")     # MINOR above this
    field(LOLO, "5")      # MAJOR below this
    field(HIHI, "45")     # MAJOR above this
}

With .compute_alarms(true) the server re-evaluates on every write:

$ spput DEMO:SETPOINT 41 && spget DEMO:SETPOINT
DEMO:SETPOINT  41 MINOR READ HIGH

$ spput DEMO:SETPOINT 46 && spget DEMO:SETPOINT
DEMO:SETPOINT  46 MAJOR READ HIHI

$ spput DEMO:SETPOINT 3 && spget DEMO:SETPOINT
DEMO:SETPOINT   3 MAJOR READ LOLO

$ spput DEMO:SETPOINT 25 && spget DEMO:SETPOINT
DEMO:SETPOINT  25

compute_alarms defaults to false. Without it the limits are published but never compared against anything.

Explicit

Rust

#![allow(unused)]
fn main() {
    let pressure = Pv::ao("SIM:PRESSURE", 50.0)
        .units("bar")
        .desc("Vessel pressure")
        // lolo, low, high, hihi — published to clients, not evaluated.
        .alarm_limits(5.0, 10.0, 90.0, 110.0);

    let link = Pv::ai("SIM:LINK", 0.0).desc("Device link health");

    let server = PvaServer::serve([pressure.clone(), link.clone()])
        .compute_alarms(true)
        .build()
        .await;
}
#![allow(unused)]
fn main() {
    // Alarms you decide yourself, independent of the value. This is the
    // route that works for handle-built PVs.
    // severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID
    link.set_alarm(3, 17, "device unreachable").await?;
}

Python

Same two halves. alarm_limits=(lolo, low, high, hihi) is a keyword argument rather than a builder call:

pressure = spvirit.ai(
    "SIM:PRESSURE",
    50.0,
    units="bar",
    desc="Vessel pressure",
    # lolo, low, high, hihi — published to clients, not evaluated.
    alarm_limits=(5.0, 10.0, 90.0, 110.0),
)

link = spvirit.ai("SIM:LINK", 0.0, desc="Device link health")

server = spvirit.Server(pvs=[pressure, link], compute_alarms=True)
server.start()
# severity: 0=NONE 1=MINOR 2=MAJOR 3=INVALID
# status is an EPICS status code; the message is free text.
link.set_alarm(3, 17, "device unreachable")

# Deciding severity yourself, from the value. This is the route that works
# for handle-built PVs, since the limits above are never compared.
for reading in (50.0, 95.0, 120.0):
    pressure.set(reading)
    if reading >= 110.0:
        pressure.set_alarm(2, 4, "HIHI")
    elif reading >= 90.0:
        pressure.set_alarm(1, 4, "HIGH")
    else:
        pressure.set_alarm(0, 0, "")
    print(f"SIM:PRESSURE = {reading}")
    time.sleep(1)

What to notice

.alarm_limits() is published but not evaluated. This is the trap in this chapter. Calling .alarm_limits(lolo, low, high, hihi) on a Pv handle — or passing alarm_limits=(...) in Python — fills in the valueAlarm structure, and clients can read those limits back with spinfo. But the value is never compared against them, even with .compute_alarms(true):

$ spget SIM:PRESSURE
SIM:PRESSURE  50

$ spput SIM:PRESSURE 95 && spget SIM:PRESSURE
SIM:PRESSURE  95          # no MINOR, despite high = 90

The cause is two parallel sets of fields. Pv::alarm_limits writes nt.value_alarm_* (spvirit-server/src/pv.rs:329), while the evaluator update_alarm_from_value reads nt.alarm_low/alarm_high/alarm_lolo/ alarm_hihi (spvirit-types/src/lib.rs:285) — and only the .db loader populates those (spvirit-server/src/db.rs:360).

So today: computed alarms require a .db file. For handle-built PVs, call set_alarm yourself, as the examples above do.

INVALID is for "I don't know", not "bad value". Severity 3 means the reading cannot be trusted — the device is unreachable, the scan threw, the link is down. A value that is simply too high is MAJOR, not INVALID. This matters because clients treat INVALID as "ignore this number".

A failing calc posts 0.0 with severity NONE. It looks like a genuine reading of zero. If a computed PV must distinguish broken from zero, set INVALID explicitly — see Simulating a device.

set_alarm bypasses the deadband and does not evaluate links. It is a direct write to the alarm block.

Run it

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

# Terminal 2
spget SIM:LINK          # INVALID device unreachable
spmonitor SIM:PRESSURE
$ spget SIM:LINK
SIM:LINK 2026-08-06 09:14:45.114   0 INVALID SIMM device unreachable

$ spget SIM:PRESSURE
SIM:PRESSURE 2026-08-06 09:14:45.114  50

spget appends severity, status and message after the value when the alarm is not NO_ALARM — and prints nothing extra when it is, which is why SIM:PRESSURE at 50 looks like a plain reading.

The Rust alarms example is static, so spmonitor SIM:PRESSURE shows one line and then waits. Use the Python example to watch the limits fire:

python spvirit-py/examples/demo_alarms.py   # terminal 1
spmonitor SIM:PRESSURE                      # terminal 2
SIM:PRESSURE 2026-08-06 09:26:17.437  50
SIM:PRESSURE 2026-08-06 09:26:18.438  95
SIM:PRESSURE {alarm={severity=1, status=4, message="HIGH"}} MINOR HIGH
SIM:PRESSURE 2026-08-06 09:26:19.439 120
SIM:PRESSURE {alarm={severity=2, status=4, message="HIHI"}} MAJOR HIGH HIHI

Two things worth pausing on. The value and the alarm arrive as separate updates — the server changed value first, then the record layer evaluated the limits and changed alarm. And the second line of each pair shows only the field that changed, because a monitor sends a change bitset; spmonitor renders the changed subtree and then the decoded severity.

Next

Serving a .db file.