Skip to content

Commit 2ff3460

Browse files
Enable setting alarm status of Out records
1 parent 7babcde commit 2ff3460

4 files changed

Lines changed: 176 additions & 28 deletions

File tree

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Versioning <https://semver.org/spec/v2.0.0.html>`_.
1010
Unreleased_
1111
-----------
1212

13+
Added:
14+
- `Enable setting alarm status of Out records <../../pull/157>`_
15+
1316
Removed:
1417

1518
- `Remove python3.6 support <../../pull/138>`_

docs/reference/api.rst

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -454,9 +454,10 @@ starting the IOC.
454454
Alarm Value Definitions: `softioc.alarm`
455455
----------------------------------------
456456

457-
458457
The following values can be passed to IN record :meth:`~softioc.device.ProcessDeviceSupportIn.set` and
459-
:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods.
458+
:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods, and to OUT record
459+
:meth:`~softioc.device.ProcessDeviceSupportOut.set` and
460+
:meth:`~softioc.device.ProcessDeviceSupportOut.set_alarm`.
460461

461462
.. attribute::
462463
NO_ALARM = 0
@@ -608,14 +609,22 @@ Working with OUT records
608609
``ao``, ``bo``, ``longout``, ``mbbo`` and OUT ``waveform`` records. All OUT
609610
records support the following methods.
610611

611-
.. method:: set(value, process=True)
612+
.. method:: set(value, process=True, severity=NO_ALARM, alarm=UDF_ALARM)
612613

613-
Updates the value associated with the record. By default this will
614+
Updates the stored value and severity status. By default this will
614615
trigger record processing, and so will cause any associated `on_update`
615616
and `validate` methods to be called. If ``process`` is `False`
616617
then neither of these methods will be called, but the value will still
617618
be updated.
618619

620+
.. method:: set_alarm(severity, alarm)
621+
622+
This is exactly equivalent to calling::
623+
624+
rec.set(rec.get(), severity=severity, alarm=alarm)
625+
626+
and triggers an alarm status change without changing the value.
627+
619628
.. method:: get()
620629

621630
Returns the value associated with the record.

softioc/device.py

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,15 @@ def __init__(self, name, **kargs):
176176
self.__enable_write = True
177177

178178
if 'initial_value' in kargs:
179-
self._value = self._value_to_epics(kargs.pop('initial_value'))
179+
value = self._value_to_epics(kargs.pop('initial_value'))
180+
initial_alarm = alarm.NO_ALARM
180181
else:
181-
self._value = None
182+
value = None
183+
# To maintain backwards compatibility, if there is no initial value
184+
# we mark the record as invalid
185+
initial_alarm = alarm.INVALID_ALARM
186+
187+
self._value = (value, initial_alarm, alarm.UDF_ALARM)
182188

183189
self._blocking = kargs.pop('blocking', blocking)
184190
if self._blocking:
@@ -190,18 +196,22 @@ def init_record(self, record):
190196
'''Special record initialisation for out records only: implements
191197
special record initialisation if an initial value has been specified,
192198
allowing out records to have a sensible initial value.'''
193-
if self._value is None:
199+
if self._value[0] is None:
194200
# Cannot set in __init__ (like we do for In records), as we want
195201
# the record alarm status to be set if no value was provided
196-
# Probably related to PythonSoftIOC issue #53
197-
self._value = self._default_value()
198-
else:
199-
self._write_value(record, self._value)
200-
if 'MLST' in self._fields_:
201-
record.MLST = self._value
202-
record.TIME = time.time()
203-
record.UDF = 0
204-
recGblResetAlarms(record)
202+
value = self._default_value()
203+
self._value = (value, self._value[1], self._value[2])
204+
205+
self._write_value(record, self._value[0])
206+
if 'MLST' in self._fields_:
207+
record.MLST = self._value[0]
208+
209+
record.TIME = time.time()
210+
211+
record.UDF = 0
212+
record.NSEV = self._value[1]
213+
record.NSTA = self._value[2]
214+
recGblResetAlarms(record)
205215
return self._epics_rc_
206216

207217
def __completion(self, record):
@@ -216,9 +226,14 @@ def _process(self, record):
216226
if record.PACT:
217227
return EPICS_OK
218228

229+
# Ignore memoized value, retrieve it from the VAL field directly later
230+
_, severity, alarm = self._value
231+
232+
self.process_severity(record, severity, alarm)
233+
219234
value = self._read_value(record)
220235
if not self.__always_update and \
221-
self._compare_values(value, self._value):
236+
self._compare_values(value, self._value[0]):
222237
# If the value isn't making a change then don't do anything.
223238
return EPICS_OK
224239

@@ -227,11 +242,11 @@ def _process(self, record):
227242
not self.__validate(self, python_value):
228243
# Asynchronous validation rejects value, so restore the last good
229244
# value.
230-
self._write_value(record, self._value)
245+
self._write_value(record, self._value[0])
231246
return EPICS_ERROR
232247
else:
233248
# Value is good. Hang onto it, let users know the value has changed
234-
self._value = value
249+
self._value = (value, severity, alarm)
235250
record.UDF = 0
236251
if self.__on_update and self.__enable_write:
237252
record.PACT = self._blocking
@@ -248,15 +263,26 @@ def _value_to_dbr(self, value):
248263
return self._dbf_type_, 1, addressof(value), value
249264

250265

251-
def set(self, value, process=True):
266+
def set_alarm(self, severity, alarm):
267+
'''Updates the alarm status without changing the stored value. An
268+
update is triggered, and a timestamp can optionally be specified.'''
269+
self._value = (self._value[0], severity, alarm)
270+
self.set(
271+
self.get(),
272+
severity=severity,
273+
alarm=alarm)
274+
275+
276+
def set(self, value, process=True,
277+
severity=alarm.NO_ALARM, alarm=alarm.UDF_ALARM):
252278
'''Special routine to set the value directly.'''
253279
value = self._value_to_epics(value)
254280
try:
255281
_record = self._record
256282
except AttributeError:
257-
# Record not initialised yet. Record the value for when
283+
# Record not initialised yet. Record data for when
258284
# initialisation occurs
259-
self._value = value
285+
self._value = (value, severity, alarm)
260286
else:
261287
# The array parameter is used to keep the raw pointer alive
262288
dbf_code, length, data, array = self._value_to_dbr(value)
@@ -265,11 +291,11 @@ def set(self, value, process=True):
265291
self.__enable_write = True
266292

267293
def get(self):
268-
if self._value is None:
294+
if self._value[0] is None:
269295
# Before startup complete if no value set return default value
270296
value = self._default_value()
271297
else:
272-
value = self._value
298+
value = self._value[0]
273299
return self._epics_to_value(value)
274300

275301

tests/test_records.py

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import asyncio
2-
import subprocess
3-
import sys
42
import numpy
53
import os
64
import pytest
5+
from enum import Enum
76

87
from conftest import (
98
aioca_cleanup,
@@ -16,8 +15,8 @@
1615
get_multiprocessing_context
1716
)
1817

19-
from softioc import asyncio_dispatcher, builder, softioc
20-
from softioc import alarm
18+
from softioc import alarm, asyncio_dispatcher, builder, softioc
19+
from softioc.builder import ClearRecords
2120
from softioc.device import SetBlocking
2221
from softioc.device_core import LookupRecord, LookupRecordList
2322

@@ -33,12 +32,14 @@
3332
builder.mbbIn,
3433
builder.stringIn,
3534
builder.WaveformIn,
35+
builder.longStringIn,
3636
]
3737

3838
def test_records(tmp_path):
3939
# Ensure we definitely unload all records that may be hanging over from
4040
# previous tests, then create exactly one instance of expected records.
4141
from sim_records import create_records
42+
ClearRecords()
4243
create_records()
4344

4445
path = str(tmp_path / "records.db")
@@ -1215,3 +1216,112 @@ async def test_recursive_set(self):
12151216
if process.exitcode is None:
12161217
process.terminate()
12171218
pytest.fail("Process did not finish cleanly, terminating")
1219+
1220+
class TestAlarms:
1221+
"""Tests related to record alarm status"""
1222+
1223+
# Record creation function and associated PV name
1224+
records = [
1225+
(builder.aIn, "AI_AlarmPV"),
1226+
(builder.boolIn, "BI_AlarmPV"),
1227+
(builder.longIn, "LI_AlarmPV"),
1228+
(builder.mbbIn, "MBBI_AlarmPV"),
1229+
(builder.stringIn, "SI_AlarmPV"),
1230+
(builder.WaveformIn, "WI_AlarmPV"),
1231+
(builder.longStringIn, "LSI_AlarmPV"),
1232+
(builder.aOut, "AO_AlarmPV"),
1233+
(builder.boolOut, "BO_AlarmPV"),
1234+
(builder.longOut, "LO_AlarmPV"),
1235+
(builder.stringOut, "SO_AlarmPV"),
1236+
(builder.mbbOut, "MBBO_AlarmPV"),
1237+
(builder.WaveformOut, "WO_AlarmPV"),
1238+
(builder.longStringOut, "LSO_AlarmPV"),
1239+
]
1240+
1241+
severity = alarm.INVALID_ALARM
1242+
status = alarm.DISABLE_ALARM
1243+
1244+
class SetEnum(Enum):
1245+
"""Enum to specify when set_alarm should be called"""
1246+
PRE_INIT = 0
1247+
POST_INIT = 1
1248+
1249+
def alarm_test_func(self, device_name, conn, set_enum: SetEnum):
1250+
builder.SetDeviceName(device_name)
1251+
1252+
pvs = []
1253+
for record_func, name in self.records:
1254+
kwargs = {}
1255+
if record_func in [builder.WaveformOut, builder.WaveformIn]:
1256+
kwargs["length"] = WAVEFORM_LENGTH
1257+
1258+
pvs.append(record_func(name, **kwargs))
1259+
1260+
if set_enum == self.SetEnum.PRE_INIT:
1261+
log("CHILD: Setting alarm before init")
1262+
for pv in pvs:
1263+
pv.set_alarm(self.severity, self.status)
1264+
1265+
builder.LoadDatabase()
1266+
softioc.iocInit()
1267+
1268+
if set_enum == self.SetEnum.POST_INIT:
1269+
log("CHILD: Setting alarm after init")
1270+
for pv in pvs:
1271+
pv.set_alarm(self.severity, self.status)
1272+
1273+
conn.send("R") # "Ready"
1274+
log("CHILD: Sent R over Connection to Parent")
1275+
1276+
# Keep process alive while main thread works.
1277+
while (True):
1278+
if conn.poll(TIMEOUT):
1279+
val = conn.recv()
1280+
if val == "D": # "Done"
1281+
break
1282+
1283+
1284+
@requires_cothread
1285+
@pytest.mark.parametrize("set_enum", [SetEnum.PRE_INIT, SetEnum.POST_INIT])
1286+
def test_set_alarm_severity_status(self, set_enum):
1287+
"""Test that set_alarm function allows setting severity and status"""
1288+
ctx = get_multiprocessing_context()
1289+
parent_conn, child_conn = ctx.Pipe()
1290+
1291+
device_name = create_random_prefix()
1292+
1293+
process = ctx.Process(
1294+
target=self.alarm_test_func,
1295+
args=(device_name, child_conn, set_enum),
1296+
)
1297+
1298+
process.start()
1299+
1300+
from cothread.catools import caget, _channel_cache, FORMAT_CTRL
1301+
1302+
try:
1303+
# Wait for message that IOC has started
1304+
select_and_recv(parent_conn, "R")
1305+
1306+
# Suppress potential spurious warnings
1307+
_channel_cache.purge()
1308+
1309+
for _, name in self.records:
1310+
1311+
ret_val = caget(
1312+
device_name + ":" + name,
1313+
timeout=TIMEOUT,
1314+
format=FORMAT_CTRL
1315+
)
1316+
1317+
assert ret_val.severity == self.severity, \
1318+
f"Severity mismatch for record {name}"
1319+
assert ret_val.status == self.status, \
1320+
f"Status mismatch for record {name}"
1321+
1322+
1323+
finally:
1324+
# Suppress potential spurious warnings
1325+
_channel_cache.purge()
1326+
parent_conn.send("D") # "Done"
1327+
process.join(timeout=TIMEOUT)

0 commit comments

Comments
 (0)