From 3300dd1443fde1f3bb091a8290942937ec514a74 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 3 Oct 2025 13:59:42 +0000 Subject: [PATCH 1/6] Remove stray mentions of backend --- src/fastcs/attributes.py | 2 +- src/fastcs/controller.py | 6 +++--- src/fastcs/transport/epics/ca/util.py | 2 +- tests/conftest.py | 4 ++-- tests/transport/epics/pva/test_p4p.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index 6c5fd25a8..bef0d0b6f 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -41,7 +41,7 @@ def __init__( self.description = description # A callback to use when setting the datatype to a different value, for example - # changing the units on an int. This should be implemented in the backend. + # changing the units on an int. self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] # Name to be filled in by Controller when the Attribute is bound diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index b3895d2f1..7b79b59fe 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -179,9 +179,9 @@ class Controller(BaseController): """Top-level controller for a device. This is the primary class for implementing device support in FastCS. Instances of - this class can be loaded into a backend to access its ``Attribute``s. The backend - can then perform a specific function with the set of ``Attributes``, such as - generating a UI or creating parameters for a control system. + this class can be loaded into a FastCS to expose its ``Attribute``s to the transport + layer, which can then perform a specific function with the set of ``Attributes``, + such as generating a UI or creating parameters for a control system. """ root_attribute: Attribute | None = None diff --git a/src/fastcs/transport/epics/ca/util.py b/src/fastcs/transport/epics/ca/util.py index b08f5beea..d526eb99a 100644 --- a/src/fastcs/transport/epics/ca/util.py +++ b/src/fastcs/transport/epics/ca/util.py @@ -71,7 +71,7 @@ def record_metadata_from_datatype( case Waveform(): if len(datatype.shape) != 1: raise TypeError( - f"Unsupported shape {datatype.shape}, the EPICS backend only " + f"Unsupported shape {datatype.shape}, the EPICS transport only " "supports to 1D arrays" ) arguments["length"] = datatype.shape[0] diff --git a/tests/conftest.py b/tests/conftest.py index a4aaed68c..f6770d5e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -150,7 +150,7 @@ def run_ioc_as_subprocess( if not error_queue.empty(): raise error_queue.get() - # close backend caches before the event loop + # close ca caches before the event loop purge_channel_caches() error_queue.close() @@ -224,7 +224,7 @@ def test_controller(tango_system, register_device): if time.monotonic() - start_time > timeout: raise TimeoutError("Controller did not start in time") - # close backend caches before the event loop + # close ca caches before the event loop purge_channel_caches() # Stop buffer from getting full and blocking the subprocess diff --git a/tests/transport/epics/pva/test_p4p.py b/tests/transport/epics/pva/test_p4p.py index 7f1dd3f2e..1cb5f9f98 100644 --- a/tests/transport/epics/pva/test_p4p.py +++ b/tests/transport/epics/pva/test_p4p.py @@ -71,7 +71,7 @@ async def test_scan_method(p4p_subprocess: tuple[str, Queue]): e_values = asyncio.Queue() # While the scan method will update every 0.1 seconds, it takes around that - # time for the p4p backends to update, broadcast, get. + # time for the p4p transport to update, broadcast, get. latency = 1e8 e_monitor = ctxt.monitor(f"{pv_prefix}:Child1:E", e_values.put) From f3a92b88e1465d991091b1da1dd67bdfc45da412 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 28 Oct 2025 10:14:41 +0000 Subject: [PATCH 2/6] Replace Controller.get_sub_controllers with Controller.sub_controllers property --- src/fastcs/control_system.py | 2 +- src/fastcs/controller.py | 13 ++++++++----- tests/test_controller.py | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/fastcs/control_system.py b/src/fastcs/control_system.py index 64abf8f8e..3f9fb4cde 100644 --- a/src/fastcs/control_system.py +++ b/src/fastcs/control_system.py @@ -200,7 +200,7 @@ def _build_controller_api(controller: BaseController, path: list[str]) -> Contro command_methods=command_methods, sub_apis={ name: _build_controller_api(sub_controller, path + [name]) - for name, sub_controller in controller.get_sub_controllers().items() + for name, sub_controller in controller.sub_controllers.items() }, description=controller.description, ) diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 7b79b59fe..1eae86040 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -67,7 +67,7 @@ def connect_attribute_ios(self) -> None: if isinstance(attr, AttrR): attr.set_update_callback(io.update) - for controller in self.get_sub_controllers().values(): + for controller in self.sub_controllers.values(): controller.connect_attribute_ios() @property @@ -158,13 +158,16 @@ def add_sub_controller(self, name: str, sub_controller: Controller): if isinstance(sub_controller.root_attribute, Attribute): self.attributes[name] = sub_controller.root_attribute - def get_sub_controllers(self) -> dict[str, Controller]: + @property + def sub_controllers(self) -> dict[str, Controller]: return self.__sub_controller_tree def __repr__(self): - return f"""\ -{type(self).__name__}({self.path}, {list(self.__sub_controller_tree.keys())})\ -""" + name = self.__class__.__name__ + path = ".".join(self.path) or None + sub_controllers = list(self.sub_controllers.keys()) or None + + return f"{name}(path={path}, sub_controllers={sub_controllers})" def __setattr__(self, name, value): if isinstance(value, Attribute): diff --git a/tests/test_controller.py b/tests/test_controller.py index 6dceddd6c..0bb0df26d 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -15,8 +15,8 @@ def test_controller_nesting(): assert sub_controller.path == ["a"] assert sub_sub_controller.path == ["a", "b"] - assert controller.get_sub_controllers() == {"a": sub_controller} - assert sub_controller.get_sub_controllers() == {"b": sub_sub_controller} + assert controller.sub_controllers == {"a": sub_controller} + assert sub_controller.sub_controllers == {"b": sub_sub_controller} with pytest.raises(ValueError, match=r"existing sub controller"): controller.a = Controller() From 7fdda11f36145a275d1df9a30548105aafb5ca6d Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 3 Oct 2025 16:18:54 +0000 Subject: [PATCH 3/6] Add path to attribute so __repr__ is fully unique --- src/fastcs/attributes.py | 31 ++++++++++++++++++++++++++----- src/fastcs/controller.py | 3 +++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/fastcs/attributes.py b/src/fastcs/attributes.py index bef0d0b6f..14eba97d0 100644 --- a/src/fastcs/attributes.py +++ b/src/fastcs/attributes.py @@ -44,8 +44,9 @@ def __init__( # changing the units on an int. self._update_datatype_callbacks: list[Callable[[DataType[T]], None]] = [] - # Name to be filled in by Controller when the Attribute is bound - self._name = None + # Path and name to be filled in by Controller it is bound to + self._name = "" + self._path = [] @property def io_ref(self) -> AttributeIORefT: @@ -68,6 +69,14 @@ def dtype(self) -> type[T]: def group(self) -> str | None: return self._group + @property + def name(self) -> str: + return self._name + + @property + def path(self) -> list[str]: + return self._path + def add_update_datatype_callback( self, callback: Callable[[DataType[T]], None] ) -> None: @@ -82,16 +91,28 @@ def update_datatype(self, datatype: DataType[T]) -> None: for callback in self._update_datatype_callbacks: callback(datatype) - def set_name(self, name: list[str]): + def set_name(self, name: str): if self._name: - raise ValueError( + raise RuntimeError( f"Attribute is already registered with a controller as {self._name}" ) self._name = name + def set_path(self, path: list[str]): + if self._path: + raise RuntimeError( + f"Attribute is already registered with a controller at {self._path}" + ) + + self._path = path + def __repr__(self): - return f"{self.__class__.__name__}({self._name}, {self._datatype})" + name = self.__class__.__name__ + path = ".".join(self._path + [self._name]) or None + datatype = self._datatype.__class__.__name__ + + return f"{name}(path={path}, datatype={datatype}, io_ref={self._io_ref})" AttrIOUpdateCallback = Callable[["AttrR[T, Any]"], Awaitable[None]] diff --git a/src/fastcs/controller.py b/src/fastcs/controller.py index 1eae86040..56f224412 100755 --- a/src/fastcs/controller.py +++ b/src/fastcs/controller.py @@ -80,6 +80,8 @@ def set_path(self, path: list[str]): raise ValueError(f"sub controller is already registered under {self.path}") self._path = path + for attribute in self.attributes.values(): + attribute.set_path(path) def _bind_attrs(self) -> None: """Search for `Attributes` and `Methods` to bind them to this instance. @@ -136,6 +138,7 @@ def add_attribute(self, name, attribute: Attribute): ) attribute.set_name(name) + attribute.set_path(self.path) self.attributes[name] = attribute super().__setattr__(name, attribute) From aedd2622b1449cb5f929542251d89329dcdf8173 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 28 Oct 2025 11:03:56 +0000 Subject: [PATCH 4/6] Pass Attribute.description to pvi signals to display in UI --- src/fastcs/transport/epics/gui.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/fastcs/transport/epics/gui.py b/src/fastcs/transport/epics/gui.py index 7c355c8eb..f2a0be9ff 100644 --- a/src/fastcs/transport/epics/gui.py +++ b/src/fastcs/transport/epics/gui.py @@ -100,6 +100,7 @@ def _get_attribute_component( return None return SignalRW( name=name, + description=attribute.description, write_pv=pv, write_widget=write_widget, read_pv=pv + "_RBV", @@ -109,12 +110,22 @@ def _get_attribute_component( 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) + return SignalR( + name=name, + description=attribute.description, + read_pv=pv, + read_widget=read_widget, + ) case AttrW(): 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) + return SignalW( + name=name, + description=attribute.description, + write_pv=pv, + write_widget=write_widget, + ) case _: raise FastCSError(f"Unsupported attribute type: {type(attribute)}") From 5aa6463b1b669e0086fea6a9cf36897d485771d4 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 28 Oct 2025 11:25:08 +0000 Subject: [PATCH 5/6] Update to handle @scan(ONCE) explicitly --- src/fastcs/controller_api.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/fastcs/controller_api.py b/src/fastcs/controller_api.py index a2daffbf7..88b4c38ff 100644 --- a/src/fastcs/controller_api.py +++ b/src/fastcs/controller_api.py @@ -49,7 +49,7 @@ def get_scan_and_initial_coros( initial_coros: list[Callable] = [] for controller_api in self.walk_api(): - _add_scan_method_tasks(scan_dict, controller_api) + _add_scan_method_tasks(scan_dict, initial_coros, controller_api) _add_attribute_update_tasks(scan_dict, initial_coros, controller_api) scan_coros = _get_periodic_scan_coros(scan_dict) @@ -57,10 +57,15 @@ def get_scan_and_initial_coros( def _add_scan_method_tasks( - scan_dict: dict[float, list[Callable]], controller_api: ControllerAPI + scan_dict: dict[float, list[Callable]], + initial_coros: list[Callable], + controller_api: ControllerAPI, ): for method in controller_api.scan_methods.values(): - scan_dict[method.period].append(method.fn) + if method.period is ONCE: + initial_coros.append(method.fn) + else: + scan_dict[method.period].append(method.fn) def _add_attribute_update_tasks( From fbc354d13b40568321cb3ad875e9b64335abd7cb Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 28 Oct 2025 11:48:54 +0000 Subject: [PATCH 6/6] Update all DataType.validate to take Any and convert to required type --- src/fastcs/datatypes.py | 55 ++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 6839d3af9..d152adba5 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -67,13 +67,16 @@ class _Numerical(DataType[T_Numerical]): min_alarm: T_Numerical | None = None max_alarm: T_Numerical | None = None - def validate(self, value: T_Numerical) -> T_Numerical: - super().validate(value) - if self.min is not None and value < self.min: - raise ValueError(f"Value {value} is less than minimum {self.min}") - if self.max is not None and value > self.max: - raise ValueError(f"Value {value} is greater than maximum {self.max}") - return value + def validate(self, value: Any) -> T_Numerical: + _value = super().validate(value) + + if self.min is not None and _value < self.min: + raise ValueError(f"Value {_value} is less than minimum {self.min}") + + if self.max is not None and _value > self.max: + raise ValueError(f"Value {_value} is greater than maximum {self.max}") + + return _value @property def initial_value(self) -> T_Numerical: @@ -99,11 +102,13 @@ class Float(_Numerical[float]): def dtype(self) -> type[float]: return float - def validate(self, value: float) -> float: - super().validate(value) + def validate(self, value: Any) -> float: + _value = super().validate(value) + if self.prec is not None: - value = round(value, self.prec) - return value + _value = round(_value, self.prec) + + return _value @dataclass(frozen=True) @@ -177,21 +182,24 @@ def initial_value(self) -> np.ndarray: return np.zeros(self.shape, dtype=self.array_dtype) def validate(self, value: np.ndarray) -> np.ndarray: - super().validate(value) - if self.array_dtype != value.dtype: + _value = super().validate(value) + + if self.array_dtype != _value.dtype: raise ValueError( - f"Value dtype {value.dtype} is not the same as the array dtype " + f"Value dtype {_value.dtype} is not the same as the array dtype " f"{self.array_dtype}" ) - if len(self.shape) != len(value.shape) or any( + + if len(self.shape) != len(_value.shape) or any( shape1 > shape2 - for shape1, shape2 in zip(value.shape, self.shape, strict=True) + for shape1, shape2 in zip(_value.shape, self.shape, strict=True) ): raise ValueError( - f"Value shape {value.shape} exceeeds the shape maximum shape " + f"Value shape {_value.shape} exceeeds the shape maximum shape " f"{self.shape}" ) - return value + + return _value @dataclass(frozen=True) @@ -207,12 +215,13 @@ def dtype(self) -> type[np.ndarray]: def initial_value(self) -> np.ndarray: return np.array([], dtype=self.structured_dtype) - def validate(self, value: np.ndarray) -> np.ndarray: - super().validate(value) + def validate(self, value: Any) -> np.ndarray: + _value = super().validate(value) - if self.structured_dtype != value.dtype: + if self.structured_dtype != _value.dtype: raise ValueError( - f"Value dtype {value.dtype.descr} is not the same as the structured " + f"Value dtype {_value.dtype.descr} is not the same as the structured " f"dtype {self.structured_dtype}" ) - return value + + return _value