Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
# docstring of collections.abc.Callable:1: WARNING:
# 'any' reference target not found: self [ref.any]
("any", "self"),
# Inherited from pydantic.BaseModel.__init__, which has no intersphinx mapping
("any", "ValidationError"),
# p4p doesn't have intersphinx mapping
("py:class", "p4p.server.StaticProvider"),
("py:class", "p4p.nt.scalar.NTScalar"),
Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "fastcs"
authors = [
{name = "Martin Gaughran", email = "martin.gaughran@diamond.ac.uk"},
{name = "Gary Yendell", email = "gary.yendell@diamond.ac.uk"},
{ name = "Martin Gaughran", email = "martin.gaughran@diamond.ac.uk" },
{ name = "Gary Yendell", email = "gary.yendell@diamond.ac.uk" },
]
classifiers = [
"Development Status :: 3 - Alpha",
Expand Down Expand Up @@ -34,7 +34,10 @@ requires-python = ">=3.11"

[project.optional-dependencies]
demo = ["tickit~=0.4.3"]
epicsca = ["pvi~=0.12.0", "softioc>=4.5.0"]
epicsca = [
"pvi~=0.12.0",
"softioc @ git+https://github.com/DiamondLightSource/pythonSoftIOC.git@master",
]
Comment thread
shihab-dls marked this conversation as resolved.
epicspva = ["p4p", "pvi~=0.12.0"]
epics = ["fastcs[epicsca]", "fastcs[epicspva]"]
tango = ["pytango"]
Expand Down
1 change: 1 addition & 0 deletions src/fastcs/attributes/attr_w.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ async def put(self, setpoint: DType_T, sync_setpoint: bool = False) -> None:
logger.opt(exception=e).error(
"Put failed", attribute=self, setpoint=setpoint
)
raise

if sync_setpoint:
try:
Expand Down
37 changes: 31 additions & 6 deletions src/fastcs/transports/epics/ca/ioc.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import asyncio
from collections import Counter
from collections.abc import Awaitable
from typing import Any, Literal

from softioc import builder, softioc
from softioc import alarm, builder, softioc
from softioc.asyncio_dispatcher import AsyncioDispatcher
from softioc.pythonSoftIoc import RecordWrapper

Expand Down Expand Up @@ -223,13 +224,14 @@ def _create_and_link_write_pv(
attr_name: str,
alias: str | None,
attribute: AttrW[DType_T],
) -> None:
):
pv = f"{pv_prefix}:{pv_name}"

async def on_update(value):
logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value))

await attribute.put(cast_from_epics_type(attribute.datatype, value))
await _run_and_set_alarm(
record, attribute.put(cast_from_epics_type(attribute.datatype, value))
)

async def set_setpoint_without_process(value: DType_T):
tracer.log_event(
Expand Down Expand Up @@ -280,8 +282,7 @@ def _create_and_link_command_pv(

async def wrapped_method(_: Any):
tracer.log_event("Command PV put", topic=method, pv=pv)

await method.fn()
await _run_and_set_alarm(record, method.fn())

record = builder.Action(
f"{pv_prefix}:{pv_name}",
Expand Down Expand Up @@ -335,3 +336,27 @@ def _add_alias(record: RecordWrapper, alias: str | None):
)
else:
record.add_alias(alias)


def _set_alarm(record: RecordWrapper, alarm_state: int):
record.set(
record.get(),
process=False,
severity=alarm_state,
alarm=alarm_state,
)


async def _run_and_set_alarm(record, coro: Awaitable):
"""Await `coro` and update `record`'s alarm state based on the outcome.

On success, clears the alarm (NO_ALARM). On any exception, raises the
record into MAJOR_ALARM. The exception itself is not re-raised or
logged here, since `AttrW.put` already logs it; this function's only
job is to reflect the outcome in the record's alarm status.
"""
try:
await coro
_set_alarm(record, alarm.NO_ALARM)
except Exception:
_set_alarm(record, alarm.MAJOR_ALARM)
Comment thread
shihab-dls marked this conversation as resolved.
68 changes: 51 additions & 17 deletions src/fastcs/transports/epics/pva/_pv_handlers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

import numpy as np
from p4p import Value
from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable
Expand All @@ -7,13 +9,14 @@
from p4p.server.asyncio import SharedPV

from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.datatypes import Enum, Table
from fastcs.datatypes import DataType, Table, Waveform
from fastcs.methods import CommandCallback
from fastcs.tracer import Tracer

from .types import (
MAJOR_ALARM_SEVERITY,
RECORD_ALARM_STATUS,
P4PAlarmState,
Severity,
Status,
cast_from_p4p_value,
cast_to_p4p_value,
make_p4p_type,
Expand Down Expand Up @@ -48,29 +51,36 @@ async def put(self, pv: SharedPV, op: ServerOperation):

tracer.log_event("PV put", topic=self._attr_w, pv=pv, value=cast_value)

if isinstance(self._attr_w.datatype, Enum):
pv.post(cast_to_p4p_value(self._attr_w, cast_value))
else:
pv.post(value)
datatype = self._attr_w.datatype

await self._attr_w.put(cast_value)
op.done()
_post_with_alarm_states(pv, datatype, raw_value, p4p_alarm_states())

try:
await self._attr_w.put(cast_value)
except Exception as e:
error_msg = f"Exception raised during put operation: {e!r}"
op.done(error=error_msg)
alarm_states = p4p_alarm_states(Severity.MAJOR, Status.RECORD, error_msg)
# Raise alarm on failed put
_post_with_alarm_states(pv, datatype, raw_value, alarm_states)

else:
op.done()


class CommandPvHandler:
def __init__(self, command: CommandCallback):
self._command = command
self._task_in_progress = False

async def _run_command(self) -> dict:
async def _run_command(self) -> P4PAlarmState:
self._task_in_progress = True

try:
await self._command()
except Exception as e:
alarm_states = p4p_alarm_states(
MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, str(e)
)
error_msg = f"Exception raised during command put: {e!r}"
alarm_states = p4p_alarm_states(Severity.MAJOR, Status.RECORD, error_msg)
else:
alarm_states = p4p_alarm_states()

Expand All @@ -96,13 +106,25 @@ async def put(self, pv: SharedPV, op: ServerOperation):
blocking = False

# Flip to true once command task starts
pv.post({"value": True, **p4p_timestamp_now(), **p4p_alarm_states()})
pv.post(
{
"value": True,
**p4p_timestamp_now(),
**p4p_alarm_states().model_dump(),
}
)
if not blocking:
op.done()
alarm_states = await self._run_command()
pv.post({"value": False, **p4p_timestamp_now(), **alarm_states})
pv.post(
{"value": False, **p4p_timestamp_now(), **alarm_states.model_dump()}
)
if blocking:
op.done()
# Check if we are in alarm
if msg := alarm_states.model_dump()["alarm"]["message"]:
op.done(error=msg)
else:
op.done()
else:
raise RuntimeError("Commands should only take the value `True`.")

Expand Down Expand Up @@ -153,7 +175,7 @@ async def set_setpoint(value):
def make_command_pv(command: CommandCallback) -> SharedPV:
type_ = NTScalar.buildType("?", display=True, control=True)

initial = Value(type_, {"value": False, **p4p_alarm_states()})
initial = Value(type_, {"value": False, **p4p_alarm_states().model_dump()})

def _wrap(value: dict):
return Value(type_, value)
Expand All @@ -165,3 +187,15 @@ def _wrap(value: dict):
)

return shared_pv


def _post_with_alarm_states(
Comment thread
shihab-dls marked this conversation as resolved.
pv: SharedPV, dtype: DataType, value: Any, alarm_states: P4PAlarmState
):
sub_states = alarm_states.model_dump()["alarm"]
if isinstance(dtype, Table | Waveform):
# NTTable and NTNDArray don't accept 'status'
sub_states.pop("status", None)
pv.post(value=value, **sub_states)
else:
pv.post({"value": value, **alarm_states.model_dump()})
4 changes: 2 additions & 2 deletions src/fastcs/transports/epics/pva/pvi.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _make_p4p_value(
return Value(
p4p_type,
{
**p4p_alarm_states(),
**p4p_alarm_states().model_dump(),
**p4p_timestamp_now(),
**display,
"value": raw_value,
Expand All @@ -61,7 +61,7 @@ def _make_p4p_raw_value(pv_prefix: str, controller_api: ControllerAPI) -> dict:
# Sub-device of a ControllerVector
p4p_raw_value[f"__{int(pv_leaf)}"]["d"] = pv
else:
p4p_raw_value[pv_leaf]["d"] = pv
p4p_raw_value[pv_leaf.lower()]["d"] = pv
for pv_leaf, attribute in controller_api.attributes.items():
# Add attribute entry
pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}"
Expand Down
97 changes: 72 additions & 25 deletions src/fastcs/transports/epics/pva/types.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import math
import time
from enum import IntEnum

import numpy as np
from numpy.typing import DTypeLike
from p4p import Value
from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable
from pydantic import BaseModel

from fastcs.attributes import Attribute, AttrR, AttrW
from fastcs.datatypes import Bool, DType, Enum, Float, Int, String, Table, Waveform
from fastcs.datatypes.datatype import DType_T

P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table)

# https://epics-base.github.io/pvxs/nt.html#alarm-t
RECORD_ALARM_STATUS = 3
NO_ALARM_STATUS = 0
MAJOR_ALARM_SEVERITY = 2
NO_ALARM_SEVERITY = 0

# https://numpy.org/devdocs/reference/arrays.dtypes.html#arrays-dtypes
# Some numpy dtypes don't match directly with the p4p ones
_NUMPY_DTYPE_TO_P4P_DTYPE = {
Expand Down Expand Up @@ -109,22 +105,71 @@ def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T
raise ValueError(f"Unsupported datatype {attribute.datatype}")


class Severity(IntEnum):
"""Alarm severity for a PV, as defined by the PVXS alarm_t normative type.

Whether MINOR or MAJOR applies is context-dependent and can vary per PV.
INVALID indicates the current value should not be treated as a meaningful
reading (e.g. of the underlying measured quantity) and typically reflects
the last known good value rather than a fresh one."""

NO_ALARM = 0
MINOR = 1
MAJOR = 2
INVALID = 3


class Status(IntEnum):
"""Alarm status for a PV.

Identifies which part of the system raised the alarm, as defined by the
PVXS alarm_t normative type.
"""

NO_ALARM = 0
DEVICE = 1
DRIVER = 2
RECORD = 3
DATABASE = 4
CONFIGURATION = 5
UNDEFINED = 6
CLIENT = 7


class AlarmInfo(BaseModel):
"""The alarm state of a PV, following the PVXS alarm_t normative type.

See https://epics-base.github.io/pvxs/nt.html#alarm-t.
"""

severity: Severity = Severity.NO_ALARM
status: Status = Status.NO_ALARM
message: str = ""


class P4PAlarmState(BaseModel):
"""Wraps AlarmInfo as the alarm sub-structure of a p4p NT* Value."""

alarm: AlarmInfo


def p4p_alarm_states(
severity: int = NO_ALARM_SEVERITY,
status: int = NO_ALARM_STATUS,
severity: Severity = Severity.NO_ALARM,
status: Status = Status.NO_ALARM,
message: str = "",
) -> dict:
"""Returns the p4p alarm structure for a given severity, status, and message."""
return {
"alarm": {
"severity": severity,
"status": status,
"message": message,
},
}
) -> P4PAlarmState:
"""Returns the p4p alarm structure for a given severity, status, and message,
validated as a Pydantic model."""
return P4PAlarmState(
alarm=AlarmInfo(
severity=severity,
status=status,
message=message,
)
)
Comment thread
shihab-dls marked this conversation as resolved.


def p4p_timestamp_now() -> dict:
def p4p_timestamp_now() -> dict[str, dict[str, int]]:
"""The p4p timestamp structure for the current time."""
now = time.time()
seconds_past_epoch = int(now)
Expand Down Expand Up @@ -157,27 +202,29 @@ def p4p_display(attribute: Attribute) -> dict:
return {}


def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> dict:
def _p4p_check_numeric_for_alarm_states(
datatype: Int | Float, value: DType
) -> dict[str, dict[str, int | str]]:
low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore
high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore
severity = (
MAJOR_ALARM_SEVERITY
Severity.MAJOR
if high not in (None, False) or low not in (None, False)
else NO_ALARM_SEVERITY
else Severity.NO_ALARM
)
status, message = NO_ALARM_SEVERITY, "No alarm"
status, message = Status.NO_ALARM, "No alarm"
if low:
status, message = (
RECORD_ALARM_STATUS,
Status.RECORD,
f"Below minimum alarm limit: {datatype.min_alarm}",
)
if high:
status, message = (
RECORD_ALARM_STATUS,
Status.RECORD,
f"Above maximum alarm limit: {datatype.max_alarm}",
)

return p4p_alarm_states(severity, status, message)
return p4p_alarm_states(severity, status, message).model_dump()


def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object:
Expand Down
Loading
Loading