Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable setting alarm status of Out records #157

Merged
merged 5 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Versioning <https://semver.org/spec/v2.0.0.html>`_.
Unreleased_
-----------

Added:
- `Enable setting alarm status of Out records <../../pull/157>`_

Removed:

- `Remove python3.6 support <../../pull/138>`_
Expand Down
17 changes: 13 additions & 4 deletions docs/reference/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -454,9 +454,10 @@ starting the IOC.
Alarm Value Definitions: `softioc.alarm`
----------------------------------------


The following values can be passed to IN record :meth:`~softioc.device.ProcessDeviceSupportIn.set` and
:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods.
:meth:`~softioc.device.ProcessDeviceSupportIn.set_alarm` methods, and to OUT record
:meth:`~softioc.device.ProcessDeviceSupportOut.set` and
:meth:`~softioc.device.ProcessDeviceSupportOut.set_alarm`.

.. attribute::
NO_ALARM = 0
Expand Down Expand Up @@ -608,14 +609,22 @@ Working with OUT records
``ao``, ``bo``, ``longout``, ``mbbo`` and OUT ``waveform`` records. All OUT
records support the following methods.

.. method:: set(value, process=True)
.. method:: set(value, process=True, severity=NO_ALARM, alarm=UDF_ALARM)

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

.. method:: set_alarm(severity, alarm)

This is exactly equivalent to calling::

rec.set(rec.get(), severity=severity, alarm=alarm)

and triggers an alarm status change without changing the value.

.. method:: get()

Returns the value associated with the record.
Expand Down
66 changes: 46 additions & 20 deletions softioc/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,15 @@ def __init__(self, name, **kargs):
self.__enable_write = True

if 'initial_value' in kargs:
self._value = self._value_to_epics(kargs.pop('initial_value'))
value = self._value_to_epics(kargs.pop('initial_value'))
initial_alarm = alarm.NO_ALARM
else:
self._value = None
value = None
# To maintain backwards compatibility, if there is no initial value
# we mark the record as invalid
initial_alarm = alarm.INVALID_ALARM

self._value = (value, initial_alarm, alarm.UDF_ALARM)
AlexanderWells-diamond marked this conversation as resolved.
Show resolved Hide resolved

self._blocking = kargs.pop('blocking', blocking)
if self._blocking:
Expand All @@ -190,18 +196,22 @@ def init_record(self, record):
'''Special record initialisation for out records only: implements
special record initialisation if an initial value has been specified,
allowing out records to have a sensible initial value.'''
if self._value is None:
if self._value[0] is None:
# Cannot set in __init__ (like we do for In records), as we want
# the record alarm status to be set if no value was provided
# Probably related to PythonSoftIOC issue #53
self._value = self._default_value()
else:
self._write_value(record, self._value)
if 'MLST' in self._fields_:
record.MLST = self._value
record.TIME = time.time()
record.UDF = 0
recGblResetAlarms(record)
value = self._default_value()
self._value = (value, self._value[1], self._value[2])

self._write_value(record, self._value[0])
if 'MLST' in self._fields_:
record.MLST = self._value[0]

record.TIME = time.time()

record.UDF = 0
record.NSEV = self._value[1]
record.NSTA = self._value[2]
recGblResetAlarms(record)
return self._epics_rc_

def __completion(self, record):
Expand All @@ -216,9 +226,14 @@ def _process(self, record):
if record.PACT:
return EPICS_OK

# Ignore memoized value, retrieve it from the VAL field directly later
_, severity, alarm = self._value

self.process_severity(record, severity, alarm)

value = self._read_value(record)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put reading self._value and self._read_value(record) on adjacent lines, then it's much clearer that they're working together and you might not need the comment!

if not self.__always_update and \
self._compare_values(value, self._value):
self._compare_values(value, self._value[0]):
# If the value isn't making a change then don't do anything.
return EPICS_OK

Expand All @@ -227,11 +242,11 @@ def _process(self, record):
not self.__validate(self, python_value):
# Asynchronous validation rejects value, so restore the last good
# value.
self._write_value(record, self._value)
self._write_value(record, self._value[0])
return EPICS_ERROR
else:
# Value is good. Hang onto it, let users know the value has changed
self._value = value
self._value = (value, severity, alarm)
record.UDF = 0
if self.__on_update and self.__enable_write:
record.PACT = self._blocking
Expand All @@ -248,15 +263,26 @@ def _value_to_dbr(self, value):
return self._dbf_type_, 1, addressof(value), value


def set(self, value, process=True):
def set_alarm(self, severity, alarm):
'''Updates the alarm status without changing the stored value. An
update is triggered, and a timestamp can optionally be specified.'''
self._value = (self._value[0], severity, alarm)
self.set(
self.get(),
severity=severity,
alarm=alarm)


def set(self, value, process=True,
severity=alarm.NO_ALARM, alarm=alarm.UDF_ALARM):
'''Special routine to set the value directly.'''
value = self._value_to_epics(value)
try:
_record = self._record
except AttributeError:
# Record not initialised yet. Record the value for when
# Record not initialised yet. Record data for when
# initialisation occurs
self._value = value
self._value = (value, severity, alarm)
else:
# The array parameter is used to keep the raw pointer alive
dbf_code, length, data, array = self._value_to_dbr(value)
Expand All @@ -265,11 +291,11 @@ def set(self, value, process=True):
self.__enable_write = True

def get(self):
if self._value is None:
if self._value[0] is None:
# Before startup complete if no value set return default value
value = self._default_value()
else:
value = self._value
value = self._value[0]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can simplify this, _value[0] is now never None, can now just be

return self._epics_to_value(self._value[0])

return self._epics_to_value(value)


Expand Down
118 changes: 114 additions & 4 deletions tests/test_records.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import asyncio
import subprocess
import sys
import numpy
import os
import pytest
from enum import Enum

from conftest import (
aioca_cleanup,
Expand All @@ -16,8 +15,8 @@
get_multiprocessing_context
)

from softioc import asyncio_dispatcher, builder, softioc
from softioc import alarm
from softioc import alarm, asyncio_dispatcher, builder, softioc
from softioc.builder import ClearRecords
from softioc.device import SetBlocking
from softioc.device_core import LookupRecord, LookupRecordList

Expand All @@ -33,12 +32,14 @@
builder.mbbIn,
builder.stringIn,
builder.WaveformIn,
builder.longStringIn,
]

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

path = str(tmp_path / "records.db")
Expand Down Expand Up @@ -1215,3 +1216,112 @@ async def test_recursive_set(self):
if process.exitcode is None:
process.terminate()
pytest.fail("Process did not finish cleanly, terminating")

class TestAlarms:
"""Tests related to record alarm status"""

# Record creation function and associated PV name
records = [
(builder.aIn, "AI_AlarmPV"),
(builder.boolIn, "BI_AlarmPV"),
(builder.longIn, "LI_AlarmPV"),
(builder.mbbIn, "MBBI_AlarmPV"),
(builder.stringIn, "SI_AlarmPV"),
(builder.WaveformIn, "WI_AlarmPV"),
(builder.longStringIn, "LSI_AlarmPV"),
(builder.aOut, "AO_AlarmPV"),
(builder.boolOut, "BO_AlarmPV"),
(builder.longOut, "LO_AlarmPV"),
(builder.stringOut, "SO_AlarmPV"),
(builder.mbbOut, "MBBO_AlarmPV"),
(builder.WaveformOut, "WO_AlarmPV"),
(builder.longStringOut, "LSO_AlarmPV"),
]

severity = alarm.INVALID_ALARM
status = alarm.DISABLE_ALARM

class SetEnum(Enum):
"""Enum to specify when set_alarm should be called"""
PRE_INIT = 0
POST_INIT = 1

def alarm_test_func(self, device_name, conn, set_enum: SetEnum):
builder.SetDeviceName(device_name)

pvs = []
for record_func, name in self.records:
kwargs = {}
if record_func in [builder.WaveformOut, builder.WaveformIn]:
kwargs["length"] = WAVEFORM_LENGTH

pvs.append(record_func(name, **kwargs))

if set_enum == self.SetEnum.PRE_INIT:
log("CHILD: Setting alarm before init")
for pv in pvs:
pv.set_alarm(self.severity, self.status)

builder.LoadDatabase()
softioc.iocInit()

if set_enum == self.SetEnum.POST_INIT:
log("CHILD: Setting alarm after init")
for pv in pvs:
pv.set_alarm(self.severity, self.status)

conn.send("R") # "Ready"
log("CHILD: Sent R over Connection to Parent")

# Keep process alive while main thread works.
while (True):
if conn.poll(TIMEOUT):
val = conn.recv()
if val == "D": # "Done"
break


@requires_cothread
@pytest.mark.parametrize("set_enum", [SetEnum.PRE_INIT, SetEnum.POST_INIT])
def test_set_alarm_severity_status(self, set_enum):
"""Test that set_alarm function allows setting severity and status"""
ctx = get_multiprocessing_context()
parent_conn, child_conn = ctx.Pipe()

device_name = create_random_prefix()

process = ctx.Process(
target=self.alarm_test_func,
args=(device_name, child_conn, set_enum),
)

process.start()

from cothread.catools import caget, _channel_cache, FORMAT_CTRL

try:
# Wait for message that IOC has started
select_and_recv(parent_conn, "R")

# Suppress potential spurious warnings
_channel_cache.purge()

for _, name in self.records:

ret_val = caget(
device_name + ":" + name,
timeout=TIMEOUT,
format=FORMAT_CTRL
)

assert ret_val.severity == self.severity, \
f"Severity mismatch for record {name}"
assert ret_val.status == self.status, \
f"Status mismatch for record {name}"


finally:
# Suppress potential spurious warnings
_channel_cache.purge()
parent_conn.send("D") # "Done"
process.join(timeout=TIMEOUT)
Loading