Alarms and severity
✅ Verified ·
alarms.rs·demo_alarms.py· checkdocs_verify·The badge reports the whole
docs-verifysuite, 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
| How | Evaluated by | |
|---|---|---|
| Computed | LOW/HIGH/LOLO/HIHI in a .db file, plus .compute_alarms(true) | the server, on every write |
| Explicit | set_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.