Skip to content

Commit eaf23e9

Browse files
committed
Add units, min/max, and alarm limits to Int and Float
made a `_Numerical` `DataType` Both `Int` and `Float` inherit from it.
1 parent 53d3174 commit eaf23e9

File tree

4 files changed

+113
-22
lines changed

4 files changed

+113
-22
lines changed

src/fastcs/attributes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from enum import Enum
44
from typing import Any, Generic, Protocol, runtime_checkable
55

6-
from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T
6+
from .datatypes import ATTRIBUTE_TYPES, AttrCallback, DataType, T, validate_value
77

88

99
class AttrMode(Enum):
@@ -115,7 +115,7 @@ def get(self) -> T:
115115
return self._value
116116

117117
async def set(self, value: T) -> None:
118-
self._value = self._datatype.dtype(value)
118+
self._value = self._datatype.dtype(validate_value(self._datatype, value))
119119

120120
if self._update_callback is not None:
121121
await self._update_callback(self._value)
@@ -202,6 +202,6 @@ def __init__(
202202
)
203203

204204
async def process(self, value: T) -> None:
205-
await self.set(value)
205+
await self.set(validate_value(self._datatype, value))
206206

207207
await super().process(value) # type: ignore

src/fastcs/backends/epics/ioc.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,27 @@ def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper:
186186
match attribute.datatype:
187187
case Bool(znam, onam):
188188
return builder.boolIn(pv, ZNAM=znam, ONAM=onam, **attribute_fields)
189-
case Int():
190-
return builder.longIn(pv, **attribute_fields)
191-
case Float(prec):
192-
return builder.aIn(pv, PREC=prec, **attribute_fields)
189+
case Int(units, min, max, min_alarm, max_alarm):
190+
return builder.longIn(
191+
pv,
192+
EGU=units,
193+
DRVL=min,
194+
DRVH=max,
195+
LOPR=min_alarm,
196+
HOPR=max_alarm,
197+
**attribute_fields,
198+
)
199+
case Float(prec, units, min, max, min_alarm, max_alarm):
200+
return builder.aIn(
201+
pv,
202+
PREC=prec,
203+
EGU=units,
204+
DRVL=min,
205+
DRVH=max,
206+
LOPR=min_alarm,
207+
HOPR=max_alarm,
208+
**attribute_fields,
209+
)
193210
case String():
194211
return builder.longStringIn(pv, **attribute_fields)
195212
case _:
@@ -236,7 +253,13 @@ def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any:
236253
isinstance(v, str) for v in attribute.allowed_values
237254
)
238255
state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False))
239-
return builder.mbbOut(pv, always_update=True, on_update=on_update, **state_keys, **attribute_fields)
256+
return builder.mbbOut(
257+
pv,
258+
always_update=True,
259+
on_update=on_update,
260+
**state_keys,
261+
**attribute_fields,
262+
)
240263

241264
match attribute.datatype:
242265
case Bool(znam, onam):
@@ -247,12 +270,35 @@ def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any:
247270
always_update=True,
248271
on_update=on_update,
249272
)
250-
case Int():
251-
return builder.longOut(pv, always_update=True, on_update=on_update, **attribute_fields)
252-
case Float(prec):
253-
return builder.aOut(pv, always_update=True, on_update=on_update, PREC=prec, **attribute_fields)
273+
case Int(units, min, max, min_alarm, max_alarm):
274+
return builder.longOut(
275+
pv,
276+
always_update=True,
277+
on_update=on_update,
278+
EGU=units,
279+
DRVL=min,
280+
DRVH=max,
281+
LOPR=min_alarm,
282+
HOPR=max_alarm,
283+
**attribute_fields,
284+
)
285+
case Float(prec, units, min, max, min_alarm, max_alarm):
286+
return builder.aOut(
287+
pv,
288+
always_update=True,
289+
on_update=on_update,
290+
PREC=prec,
291+
EGU=units,
292+
DRVL=min,
293+
DRVH=max,
294+
LOPR=min_alarm,
295+
HOPR=max_alarm,
296+
**attribute_fields,
297+
)
254298
case String():
255-
return builder.longStringOut(pv, always_update=True, on_update=on_update, **attribute_fields)
299+
return builder.longStringOut(
300+
pv, always_update=True, on_update=on_update, **attribute_fields
301+
)
256302
case _:
257303
raise FastCSException(
258304
f"Unsupported type {type(attribute.datatype)}: {attribute.datatype}"

src/fastcs/datatypes.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,20 @@ def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars
2121
pass
2222

2323

24+
T_Numerical = TypeVar("T_Numerical", int, float)
25+
26+
27+
@dataclass(frozen=True)
28+
class _Numerical(DataType[T_Numerical]):
29+
units: str | None = None
30+
min: int | None = None
31+
max: int | None = None
32+
min_alarm: int | None = None
33+
max_alarm: int | None = None
34+
35+
2436
@dataclass(frozen=True)
25-
class Int(DataType[int]):
37+
class Int(_Numerical[int]):
2638
"""`DataType` mapping to builtin ``int``."""
2739

2840
@property
@@ -31,7 +43,7 @@ def dtype(self) -> type[int]:
3143

3244

3345
@dataclass(frozen=True)
34-
class Float(DataType[float]):
46+
class Float(_Numerical[float]):
3547
"""`DataType` mapping to builtin ``float``."""
3648

3749
prec: int = 2
@@ -60,3 +72,15 @@ class String(DataType[str]):
6072
@property
6173
def dtype(self) -> type[str]:
6274
return str
75+
76+
77+
def validate_value(datatype: DataType[T], value: T) -> T:
78+
"""Validate a value against a datatype."""
79+
80+
if isinstance(datatype, (Int | Float)):
81+
assert isinstance(value, (int | float)), f"Value {value} is not a number"
82+
if datatype.min is not None and value < datatype.min:
83+
raise ValueError(f"Value {value} is less than minimum {datatype.min}")
84+
if datatype.max is not None and value > datatype.max:
85+
raise ValueError(f"Value {value} is greater than maximum {datatype.max}")
86+
return value

tests/backends/epics/test_ioc.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ def test_get_output_record_raises(mocker: MockerFixture):
211211
_get_output_record("PV", mocker.MagicMock(), on_update=mocker.MagicMock())
212212

213213

214+
DEFAULT_SCALAR_FIELD_ARGS = {
215+
"EGU": None,
216+
"DRVL": None,
217+
"DRVH": None,
218+
"LOPR": None,
219+
"HOPR": None,
220+
}
221+
222+
214223
def test_ioc(mocker: MockerFixture, mapping: Mapping):
215224
builder = mocker.patch("fastcs.backends.epics.ioc.builder")
216225
add_pvi_info = mocker.patch("fastcs.backends.epics.ioc._add_pvi_info")
@@ -222,15 +231,26 @@ def test_ioc(mocker: MockerFixture, mapping: Mapping):
222231

223232
# Check records are created
224233
builder.boolIn.assert_called_once_with(f"{DEVICE}:ReadBool", ZNAM="OFF", ONAM="ON")
225-
builder.longIn.assert_any_call(f"{DEVICE}:ReadInt")
226-
builder.aIn.assert_called_once_with(f"{DEVICE}:ReadWriteFloat_RBV", PREC=2)
234+
builder.longIn.assert_any_call(f"{DEVICE}:ReadInt", **DEFAULT_SCALAR_FIELD_ARGS)
235+
builder.aIn.assert_called_once_with(
236+
f"{DEVICE}:ReadWriteFloat_RBV", PREC=2, **DEFAULT_SCALAR_FIELD_ARGS
237+
)
227238
builder.aOut.assert_any_call(
228-
f"{DEVICE}:ReadWriteFloat", always_update=True, on_update=mocker.ANY, PREC=2
239+
f"{DEVICE}:ReadWriteFloat",
240+
always_update=True,
241+
on_update=mocker.ANY,
242+
PREC=2,
243+
**DEFAULT_SCALAR_FIELD_ARGS,
244+
)
245+
builder.longIn.assert_any_call(f"{DEVICE}:BigEnum", **DEFAULT_SCALAR_FIELD_ARGS)
246+
builder.longIn.assert_any_call(
247+
f"{DEVICE}:ReadWriteInt_RBV", **DEFAULT_SCALAR_FIELD_ARGS
229248
)
230-
builder.longIn.assert_any_call(f"{DEVICE}:BigEnum")
231-
builder.longIn.assert_any_call(f"{DEVICE}:ReadWriteInt_RBV")
232249
builder.longOut.assert_called_with(
233-
f"{DEVICE}:ReadWriteInt", always_update=True, on_update=mocker.ANY
250+
f"{DEVICE}:ReadWriteInt",
251+
always_update=True,
252+
on_update=mocker.ANY,
253+
**DEFAULT_SCALAR_FIELD_ARGS,
234254
)
235255
builder.mbbIn.assert_called_once_with(
236256
f"{DEVICE}:StringEnum_RBV", ZRST="red", ONST="green", TWST="blue"
@@ -390,9 +410,10 @@ def test_long_pv_names_discarded(mocker: MockerFixture):
390410
f"{DEVICE}:{short_pv_name}",
391411
always_update=True,
392412
on_update=mocker.ANY,
413+
**DEFAULT_SCALAR_FIELD_ARGS,
393414
)
394415
builder.longIn.assert_called_once_with(
395-
f"{DEVICE}:{short_pv_name}_RBV",
416+
f"{DEVICE}:{short_pv_name}_RBV", **DEFAULT_SCALAR_FIELD_ARGS
396417
)
397418

398419
long_pv_name = long_attr_name.title().replace("_", "")

0 commit comments

Comments
 (0)