Skip to content
Merged
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
76 changes: 62 additions & 14 deletions src/fastcs/transport/epics/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pvi.device import (
LED,
ButtonPanel,
CheckBox,
ComboBox,
ComponentUnion,
Device,
Expand All @@ -13,6 +14,8 @@
SignalW,
SignalX,
SubScreen,
TableRead,
TableWrite,
TextFormat,
TextRead,
TextWrite,
Expand All @@ -25,9 +28,18 @@
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW
from fastcs.controller_api import ControllerAPI
from fastcs.cs_methods import Command
from fastcs.datatypes import Bool, Enum, Float, Int, String, Waveform
from fastcs.datatypes import (
Bool,
DataType,
Enum,
Float,
Int,
String,
Table,
Waveform,
)
from fastcs.exceptions import FastCSException
from fastcs.util import snake_to_pascal
from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal

from .options import EpicsGUIFormat, EpicsGUIOptions

Expand All @@ -40,12 +52,13 @@ def __init__(self, controller_api: ControllerAPI, pv_prefix: str) -> None:
self._pv_prefix = pv_prefix

def _get_pv(self, attr_path: list[str], name: str):
attr_prefix = ":".join([self._pv_prefix] + attr_path)
attr_prefix = ":".join(
[self._pv_prefix] + [snake_to_pascal(node) for node in attr_path]
)
return f"{attr_prefix}:{snake_to_pascal(name)}"

@staticmethod
def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion | None:
match attribute.datatype:
def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None:
match fastcs_datatype:
case Bool():
return LED()
case Int() | Float():
Expand All @@ -59,17 +72,16 @@ def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion | None:
case datatype:
raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}")

@staticmethod
def _get_write_widget(attribute: AttrW) -> WriteWidgetUnion | None:
match attribute.datatype:
def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | None:
match fastcs_datatype:
case Bool():
return ToggleButton()
case Int() | Float():
return TextWrite()
case String():
return TextWrite(format=TextFormat.string)
case Enum():
return ComboBox(choices=attribute.datatype.names)
return ComboBox(choices=fastcs_datatype.names)
case Waveform():
return None
case datatype:
Expand All @@ -82,8 +94,8 @@ def _get_attribute_component(
name = snake_to_pascal(name)
match attribute:
case AttrRW():
read_widget = self._get_read_widget(attribute)
write_widget = self._get_write_widget(attribute)
read_widget = self._get_read_widget(attribute.datatype)
write_widget = self._get_write_widget(attribute.datatype)
if write_widget is None or read_widget is None:
return None
return SignalRW(
Expand All @@ -94,12 +106,12 @@ def _get_attribute_component(
read_widget=read_widget,
)
case AttrR():
read_widget = self._get_read_widget(attribute)
read_widget = self._get_read_widget(attribute.datatype)
if read_widget is None:
return None
return SignalR(name=name, read_pv=pv, read_widget=read_widget)
case AttrW():
write_widget = self._get_write_widget(attribute)
write_widget = self._get_write_widget(attribute.datatype)
if write_widget is None:
return None
return SignalW(name=name, write_pv=pv, write_widget=write_widget)
Expand Down Expand Up @@ -188,3 +200,39 @@ def extract_api_components(self, controller_api: ControllerAPI) -> Tree:
components.append(Group(name=name, layout=Grid(), children=children))

return components


class PvaEpicsGUI(EpicsGUI):
"""For creating gui in the PVA EPICS transport."""

def _get_pv(self, attr_path: list[str], name: str):
return f"pva://{super()._get_pv(attr_path, name)}"

def _get_read_widget(self, fastcs_datatype: DataType) -> ReadWidgetUnion | None:
if isinstance(fastcs_datatype, Table):
fastcs_datatypes = [
numpy_to_fastcs_datatype(datatype)
for _, datatype in fastcs_datatype.structured_dtype
]

base_get_read_widget = super()._get_read_widget
widgets = [base_get_read_widget(datatype) for datatype in fastcs_datatypes]

return TableRead(widgets=widgets) # type: ignore
else:
return super()._get_read_widget(fastcs_datatype)

def _get_write_widget(self, fastcs_datatype: DataType) -> WriteWidgetUnion | None:
if isinstance(fastcs_datatype, Table):
widgets = []
for _, datatype in fastcs_datatype.structured_dtype:
fastcs_datatype = numpy_to_fastcs_datatype(datatype)
if isinstance(fastcs_datatype, Bool):
# Replace with compact version for Table row
widget = CheckBox()
else:
widget = super()._get_write_widget(fastcs_datatype)
widgets.append(widget)
return TableWrite(widgets=widgets)
else:
return super()._get_write_widget(fastcs_datatype)
4 changes: 2 additions & 2 deletions src/fastcs/transport/epics/pva/adapter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastcs.controller_api import ControllerAPI
from fastcs.transport.adapter import TransportAdapter
from fastcs.transport.epics.docs import EpicsDocs
from fastcs.transport.epics.gui import EpicsGUI
from fastcs.transport.epics.gui import PvaEpicsGUI
from fastcs.transport.epics.pva.options import EpicsPVAOptions

from .ioc import P4PIOC
Expand Down Expand Up @@ -32,4 +32,4 @@ def create_docs(self) -> None:
EpicsDocs(self._controller_api).create_docs(self.options.docs)

def create_gui(self) -> None:
EpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui)
PvaEpicsGUI(self._controller_api, self._pv_prefix).create_gui(self.options.gui)
18 changes: 18 additions & 0 deletions src/fastcs/util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import re

import numpy as np

from fastcs.datatypes import Bool, DataType, Float, Int, String


def snake_to_pascal(name: str) -> str:
"""Converts string from snake case to Pascal case.
Expand All @@ -8,3 +12,17 @@ def snake_to_pascal(name: str) -> str:
if re.fullmatch(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*", name):
name = re.sub(r"(?:^|_)([a-z0-9])", lambda match: match.group(1).upper(), name)
return name


def numpy_to_fastcs_datatype(np_type) -> DataType:
"""Converts numpy types to fastcs types for widget creation.
Only types important for widget creation are explicitly converted
"""
if np.issubdtype(np_type, np.integer):
return Int()
elif np.issubdtype(np_type, np.floating):
return Float()
elif np.issubdtype(np_type, np.bool_):
return Bool()
else:
return String()
22 changes: 21 additions & 1 deletion tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import numpy as np
import pytest
from pvi.device import SignalR
from pydantic import ValidationError

from fastcs.util import snake_to_pascal
from fastcs.datatypes import Bool, Float, Int, String
from fastcs.util import numpy_to_fastcs_datatype, snake_to_pascal


def test_snake_to_pascal():
Expand Down Expand Up @@ -36,3 +38,21 @@ def test_pvi_validation_error():
name = snake_to_pascal("Name-With_%_Invalid-&-Symbols_£_")
with pytest.raises(ValidationError):
SignalR(name=name, read_pv="test")


@pytest.mark.parametrize(
"numpy_type, fastcs_datatype",
[
(np.float16, Float()),
(np.float32, Float()),
(np.int16, Int()),
(np.int32, Int()),
(np.bool, Bool()),
(np.dtype("S1000"), String()),
(np.dtype("U25"), String()),
(np.dtype(">i4"), Int()),
(np.dtype("d"), Float()),
],
)
def test_numpy_to_fastcs_datatype(numpy_type, fastcs_datatype):
assert fastcs_datatype == numpy_to_fastcs_datatype(numpy_type)
10 changes: 6 additions & 4 deletions tests/transport/epics/ca/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ def test_get_attribute_component_none(mocker, controller_api):
assert gui._get_attribute_component([], "Attr", AttrRW(Int())) is None


def test_get_read_widget_none():
assert EpicsGUI._get_read_widget(AttrR(Waveform(np.int32))) is None
def test_get_read_widget_none(controller_api):
gui = EpicsGUI(controller_api, "DEVICE")
assert gui._get_read_widget(fastcs_datatype=Waveform(np.int32)) is None


def test_get_write_widget_none():
assert EpicsGUI._get_write_widget(AttrW(Waveform(np.int32))) is None
def test_get_write_widget_none(controller_api):
gui = EpicsGUI(controller_api, "DEVICE")
assert gui._get_write_widget(fastcs_datatype=Waveform(np.int32)) is None


def test_get_components(controller_api):
Expand Down
76 changes: 76 additions & 0 deletions tests/transport/epics/pva/test_pva_gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import numpy as np
from pvi.device import (
LED,
CheckBox,
SignalR,
SignalW,
TableRead,
TableWrite,
TextFormat,
TextRead,
TextWrite,
)

from fastcs.attributes import AttrR, AttrW
from fastcs.datatypes import Table
from fastcs.transport.epics.gui import PvaEpicsGUI


def test_get_pv_in_pva(controller_api):
gui = PvaEpicsGUI(controller_api, "DEVICE")

assert gui._get_pv([], "A") == "pva://DEVICE:A"
assert gui._get_pv(["B"], "C") == "pva://DEVICE:B:C"
assert gui._get_pv(["D", "E"], "F") == "pva://DEVICE:D:E:F"


def test_get_attribute_component_table_write(controller_api):
gui = PvaEpicsGUI(controller_api, "DEVICE")

attribute_component = gui._get_attribute_component(
[],
"Table",
AttrW(
Table(
structured_dtype=[
("FIELD1", np.uint32),
("FIELD2", np.bool),
("FIELD3", np.dtype("S1000")),
]
)
),
)

assert isinstance(attribute_component, SignalW)
assert isinstance(attribute_component.write_widget, TableWrite)
assert attribute_component.write_widget.widgets == [
TextWrite(),
CheckBox(),
TextWrite(format=TextFormat.string),
]


def test_get_attribute_component_table_read(controller_api):
gui = PvaEpicsGUI(controller_api, "DEVICE")

attribute_component = gui._get_attribute_component(
[],
"Table",
AttrR(
Table(
structured_dtype=[
("FIELD1", np.uint32),
("FIELD2", np.bool),
("FIELD3", np.dtype("S1000")),
]
)
),
)

assert isinstance(attribute_component, SignalR)
assert isinstance(attribute_component.read_widget, TableRead)
assert attribute_component.read_widget.widgets == [
TextRead(),
LED(),
TextRead(format=TextFormat.string),
]
Loading