From a297a1efaf224023fd79f4950d06d4aa8b516390 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 25 Mar 2025 14:28:55 +0000 Subject: [PATCH 01/11] Add demo --- .vscode/launch.json | 23 ++ pyproject.toml | 6 +- src/fastcs/demo/__init__.py | 0 src/fastcs/demo/__main__.py | 6 + src/fastcs/demo/controller.yaml | 16 ++ src/fastcs/demo/controllers.py | 108 ++++++++ src/fastcs/demo/schema.json | 257 ++++++++++++++++++ src/fastcs/demo/simulation/__init__.py | 0 src/fastcs/demo/simulation/device.py | 244 +++++++++++++++++ .../demo/simulation/temp_controller.yaml | 21 ++ 10 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 src/fastcs/demo/__init__.py create mode 100644 src/fastcs/demo/__main__.py create mode 100644 src/fastcs/demo/controller.yaml create mode 100755 src/fastcs/demo/controllers.py create mode 100644 src/fastcs/demo/schema.json create mode 100644 src/fastcs/demo/simulation/__init__.py create mode 100755 src/fastcs/demo/simulation/device.py create mode 100644 src/fastcs/demo/simulation/temp_controller.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json index 36d8f503a..99fa42870 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,29 @@ // Enable break on exception when debugging tests (see: tests/conftest.py) "PYTEST_RAISE": "1", }, + }, + { + "name": "Temp Controller Sim", + "type": "debugpy", + "request": "launch", + "module": "tickit", + "justMyCode": false, + "console": "integratedTerminal", + "args": [ + "--log-level", + "INFO", + "all", + "${workspaceFolder:FastCS}/src/fastcs/demo/simulation/temp_controller.yaml" + ] + }, + { + "name": "Temp Controller FastCS", + "type": "debugpy", + "request": "launch", + "justMyCode": false, + "module": "fastcs.demo", + "args": ["run", "${workspaceFolder:FastCS}/src/fastcs/demo/controller.yaml"], + "console": "integratedTerminal", } ] } diff --git a/pyproject.toml b/pyproject.toml index d36507ce6..d173ab4c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,10 @@ dev = [ "aioca", "p4p", "httpx", + "tickit~=0.4.3", +] +demo = [ + "tickit~=0.4.3", ] [project.scripts] @@ -82,7 +86,7 @@ testpaths = "docs src tests" [tool.coverage.run] data_file = "/tmp/fastcs.coverage" concurrency = ["thread", "multiprocessing"] -omit = ["tests/*"] +omit = ["tests/*", "src/fastcs/demo/*"] [tool.coverage.paths] # Tests are run from installed location, map back to the src directory diff --git a/src/fastcs/demo/__init__.py b/src/fastcs/demo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/demo/__main__.py b/src/fastcs/demo/__main__.py new file mode 100644 index 000000000..ff4548063 --- /dev/null +++ b/src/fastcs/demo/__main__.py @@ -0,0 +1,6 @@ +from fastcs import __version__ +from fastcs.launch import launch + +from .controllers import TemperatureController + +launch(TemperatureController, version=__version__) diff --git a/src/fastcs/demo/controller.yaml b/src/fastcs/demo/controller.yaml new file mode 100644 index 000000000..67f792cd6 --- /dev/null +++ b/src/fastcs/demo/controller.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=schema.json +controller: + ip_settings: + ip: "localhost" + port: 25565 + num_ramp_controllers: 4 +transport: + - gql: + host: localhost + port: 8083 + log_level: info + - ioc: + pv_prefix: GARY + gui: + title: Temperature Controller Demo + output_path: ./demo.bob diff --git a/src/fastcs/demo/controllers.py b/src/fastcs/demo/controllers.py new file mode 100755 index 000000000..8b2c761a9 --- /dev/null +++ b/src/fastcs/demo/controllers.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import enum +import json +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller, SubController +from fastcs.datatypes import Enum, Float, Int +from fastcs.wrappers import command, scan + + +class OnOffEnum(enum.StrEnum): + Off = "0" + On = "1" + + +@dataclass +class TemperatureControllerSettings: + num_ramp_controllers: int + ip_settings: IPConnectionSettings + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + name: str + update_period: float | None = 0.2 + _controller: TemperatureController | TemperatureRampController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController | TemperatureRampController) + self._controller = controller + + @property + def controller(self) -> TemperatureController | TemperatureRampController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def put(self, attr: AttrW, value: Any) -> None: + await self.controller.connection.send_command( + f"{self.name}{self.controller.suffix}={attr.dtype(value)}\r\n" + ) + + async def update(self, attr: AttrR) -> None: + response = await self.controller.connection.send_query( + f"{self.name}{self.controller.suffix}?\r\n" + ) + response = response.strip("\r\n") + + await attr.set(attr.dtype(response)) + + +class TemperatureController(Controller): + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + + def __init__(self, settings: TemperatureControllerSettings) -> None: + super().__init__() + + self.suffix = "" + self._settings = settings + self.connection = IPConnection() + + self._ramp_controllers: list[TemperatureRampController] = [] + for index in range(1, settings.num_ramp_controllers + 1): + controller = TemperatureRampController(index, self.connection) + self._ramp_controllers.append(controller) + self.register_sub_controller(f"R{index}", controller) + + @command() + async def cancel_all(self) -> None: + for rc in self._ramp_controllers: + await rc.enabled.process(OnOffEnum.Off) + # TODO: The requests all get concatenated and the sim doesn't handle it + await asyncio.sleep(0.1) + + async def connect(self) -> None: + await self.connection.connect(self._settings.ip_settings) + + async def close(self) -> None: + await self.connection.close() + + @scan(0.1) + async def update_voltages(self): + voltages = json.loads( + (await self.connection.send_query("V?\r\n")).strip("\r\n") + ) + for index, controller in enumerate(self._ramp_controllers): + await controller.voltage.set(float(voltages[index])) + + +class TemperatureRampController(SubController): + start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) + end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) + enabled = AttrRW(Enum(OnOffEnum), handler=TemperatureControllerHandler("N")) + target = AttrR(Float(prec=3), handler=TemperatureControllerHandler("T")) + actual = AttrR(Float(prec=3), handler=TemperatureControllerHandler("A")) + voltage = AttrR(Float(prec=3)) + + def __init__(self, index: int, conn: IPConnection) -> None: + self.suffix = f"{index:02d}" + super().__init__(f"Ramp{self.suffix}") + self.connection = conn diff --git a/src/fastcs/demo/schema.json b/src/fastcs/demo/schema.json new file mode 100644 index 000000000..5307b78fb --- /dev/null +++ b/src/fastcs/demo/schema.json @@ -0,0 +1,257 @@ +{ + "$defs": { + "EpicsCAOptions": { + "properties": { + "docs": { + "$ref": "#/$defs/EpicsDocsOptions" + }, + "gui": { + "$ref": "#/$defs/EpicsGUIOptions" + }, + "ioc": { + "$ref": "#/$defs/EpicsIOCOptions" + } + }, + "title": "EpicsCAOptions", + "type": "object" + }, + "EpicsDocsOptions": { + "properties": { + "path": { + "default": ".", + "format": "path", + "title": "Path", + "type": "string" + }, + "depth": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Depth" + } + }, + "title": "EpicsDocsOptions", + "type": "object" + }, + "EpicsGUIFormat": { + "description": "The format of an EPICS GUI.", + "enum": [ + ".bob", + ".edl" + ], + "title": "EpicsGUIFormat", + "type": "string" + }, + "EpicsGUIOptions": { + "properties": { + "output_path": { + "default": "output.bob", + "format": "path", + "title": "Output Path", + "type": "string" + }, + "file_format": { + "$ref": "#/$defs/EpicsGUIFormat", + "default": ".bob" + }, + "title": { + "default": "Simple Device", + "title": "Title", + "type": "string" + } + }, + "title": "EpicsGUIOptions", + "type": "object" + }, + "EpicsIOCOptions": { + "properties": { + "pv_prefix": { + "default": "MY-DEVICE-PREFIX", + "title": "Pv Prefix", + "type": "string" + } + }, + "title": "EpicsIOCOptions", + "type": "object" + }, + "EpicsPVAOptions": { + "properties": { + "docs": { + "$ref": "#/$defs/EpicsDocsOptions" + }, + "gui": { + "$ref": "#/$defs/EpicsGUIOptions" + }, + "ioc": { + "$ref": "#/$defs/EpicsIOCOptions" + } + }, + "title": "EpicsPVAOptions", + "type": "object" + }, + "GraphQLOptions": { + "properties": { + "gql": { + "$ref": "#/$defs/GraphQLServerOptions" + } + }, + "title": "GraphQLOptions", + "type": "object" + }, + "GraphQLServerOptions": { + "properties": { + "host": { + "default": "localhost", + "title": "Host", + "type": "string" + }, + "port": { + "default": 8080, + "title": "Port", + "type": "integer" + }, + "log_level": { + "default": "info", + "title": "Log Level", + "type": "string" + } + }, + "title": "GraphQLServerOptions", + "type": "object" + }, + "IPConnectionSettings": { + "properties": { + "ip": { + "default": "127.0.0.1", + "title": "Ip", + "type": "string" + }, + "port": { + "default": 25565, + "title": "Port", + "type": "integer" + } + }, + "title": "IPConnectionSettings", + "type": "object" + }, + "RestOptions": { + "properties": { + "rest": { + "$ref": "#/$defs/RestServerOptions" + } + }, + "title": "RestOptions", + "type": "object" + }, + "RestServerOptions": { + "properties": { + "host": { + "default": "localhost", + "title": "Host", + "type": "string" + }, + "port": { + "default": 8080, + "title": "Port", + "type": "integer" + }, + "log_level": { + "default": "info", + "title": "Log Level", + "type": "string" + } + }, + "title": "RestServerOptions", + "type": "object" + }, + "TangoDSROptions": { + "properties": { + "dev_name": { + "default": "MY/DEVICE/NAME", + "title": "Dev Name", + "type": "string" + }, + "dsr_instance": { + "default": "MY_SERVER_INSTANCE", + "title": "Dsr Instance", + "type": "string" + }, + "debug": { + "default": false, + "title": "Debug", + "type": "boolean" + } + }, + "title": "TangoDSROptions", + "type": "object" + }, + "TangoOptions": { + "properties": { + "dsr": { + "$ref": "#/$defs/TangoDSROptions" + } + }, + "title": "TangoOptions", + "type": "object" + }, + "TempControllerSettings": { + "properties": { + "num_ramp_controllers": { + "title": "Num Ramp Controllers", + "type": "integer" + }, + "ip_settings": { + "$ref": "#/$defs/IPConnectionSettings" + } + }, + "required": [ + "num_ramp_controllers", + "ip_settings" + ], + "title": "TempControllerSettings", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "controller": { + "$ref": "#/$defs/TempControllerSettings" + }, + "transport": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/EpicsPVAOptions" + }, + { + "$ref": "#/$defs/EpicsCAOptions" + }, + { + "$ref": "#/$defs/TangoOptions" + }, + { + "$ref": "#/$defs/RestOptions" + }, + { + "$ref": "#/$defs/GraphQLOptions" + } + ] + }, + "title": "Transport", + "type": "array" + } + }, + "required": [ + "controller", + "transport" + ], + "title": "TempController", + "type": "object" +} diff --git a/src/fastcs/demo/simulation/__init__.py b/src/fastcs/demo/simulation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/demo/simulation/device.py b/src/fastcs/demo/simulation/device.py new file mode 100755 index 000000000..33ed29e1d --- /dev/null +++ b/src/fastcs/demo/simulation/device.py @@ -0,0 +1,244 @@ +import logging +import traceback +from os import _exit +from random import uniform + +import numpy as np +import numpy.typing as npt +import pydantic.v1.dataclasses +from tickit.adapters.io import TcpIo +from tickit.adapters.specifications.regex_command import RegexCommand +from tickit.adapters.tcp import CommandAdapter +from tickit.core.adapter import AdapterContainer +from tickit.core.components.component import Component, ComponentConfig +from tickit.core.components.device_component import DeviceComponent +from tickit.core.device import Device, DeviceUpdate +from tickit.core.typedefs import SimTime +from tickit.utils.byte_format import ByteFormat +from typing_extensions import TypedDict + +LOGGER = logging.getLogger(__name__) + + +def handle_exceptions(func): + """Log and exit if the wrapped function raises an Exception. + + Tickit does not properly handle exceptions in e.g. update() methods. This wrapper is + used to ensure we see the errors as soon as they occur. + """ + + def wrapped_func(*args, **kwargs): + try: + return func(*args, **kwargs) + except: # noqa + logging.error(traceback.format_exc()) + _exit(-1) + + return wrapped_func + + +class TempControllerDevice(Device): + class Inputs(TypedDict): + flux: float + + class Outputs(TypedDict): + flux: float + + def __init__( + self, + num_ramp_controllers: int, + default_start: float, + default_end: float, + ) -> None: + self._num = num_ramp_controllers + self._start = np.full(num_ramp_controllers, default_start, dtype=int) + self._end = np.full(num_ramp_controllers, default_end, dtype=int) + self._target = np.zeros(num_ramp_controllers, dtype=float) + self._actual = np.zeros(num_ramp_controllers, dtype=float) + self._enabled = np.full(num_ramp_controllers, 0, dtype=int) + self._start_times = np.full(num_ramp_controllers, -1, dtype=int) + + self._ramp_rate: float = 1 # 1 unit/s + + def get_target(self, index: int): + return self._target[index] + + def get_actual(self, index: int): + return self._actual[index] + + def get_start(self, index: int): + return self._start[index] + + def set_start(self, index: int, value: int): + self._start[index] = value + LOGGER.info(f"Set ramp {index} start to {value}") + + def get_end(self, index: int): + return self._end[index] + + def set_end(self, index: int, value: int): + self._end[index] = value + LOGGER.info(f"Set ramp {index} end to {value}") + + def set_enabled(self, index: int, value: int): + if value: + self._target[index] = float(self._start[index]) + self._start_times[index] = -1 + self._enabled[index] = 1 + LOGGER.info(f"Started ramp {index}") + else: + self._enabled[index] = 0 + LOGGER.info(f"Stopped ramp {index}") + + def get_enabled(self, index: int): + return self._enabled[index] + + def get_ramp_rate(self): + return self._ramp_rate + + def set_ramp_rate(self, rate: float): + self._ramp_rate = rate + LOGGER.info(f"Set ramp rate to {rate}") + + def get_voltages(self): + return [self.get_actual(i) / 17 for i in range(0, self._num)] + + def get_power(self): + return sum(self.get_voltages() * 50) + + def ramp( + self, + periods: npt.NDArray, + ): + can_ramp = np.logical_and(self._enabled, self._target < self._end) + max_temps = self._target.copy() + max_temps[can_ramp] += self._ramp_rate * (periods[can_ramp] / 1e9) + self._target = np.minimum(max_temps, self._end) + self._enabled[self._target >= self._end] = 0 + + self._actual = [ + float(self.get_target(i) + uniform(-0.5, 0.5)) if self.get_enabled(i) else 0 + for i in range(0, self._num) + ] + + @handle_exceptions + def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: + self._start_times[self._start_times == -1] = int(time) + periods = np.full(self._num, int(time)) - self._start_times + self.ramp(periods) + self._start_times = np.full(self._num, int(time)) + + LOGGER.info( + f"Target Temperatures: {', '.join(f'{float(t):.3f}' for t in self._target)}" + ) + LOGGER.info( + f"Actual Temperatures: {', '.join(f'{float(t):.3f}' for t in self._actual)}" + ) + + call_at = SimTime(int(time) + int(1e8)) if np.any(self._enabled) else None + return DeviceUpdate(TempControllerDevice.Outputs(flux=inputs["flux"]), call_at) + + +class TempControllerAdapter(CommandAdapter): + device: TempControllerDevice + _byte_format: ByteFormat = ByteFormat(b"%b\r\n") + + def __init__(self, device: TempControllerDevice) -> None: + super().__init__() + self.device = device + + def _validate_index(self, index: str) -> int: + int_index = int(index) - 1 + assert int_index >= 0, "TempController Indices start at 01" + return int_index + + @RegexCommand(r"ID\?", False, "utf-8") + async def get_id(self) -> bytes: + return b"SIMTCONT123" + + @RegexCommand(r"T([0-9][0-9])\?", False, "utf-8") + async def get_target(self, index: str) -> bytes: + int_index = self._validate_index(index) + return str(self.device.get_target(int_index)).encode("utf-8") + + @RegexCommand(r"A([0-9][0-9])\?", False, "utf-8") + async def get_actual(self, index: str) -> bytes: + int_index = self._validate_index(index) + return str(self.device.get_actual(int_index)).encode("utf-8") + + @RegexCommand(r"N([0-9][0-9])\?", False, "utf-8") + async def get_enabled(self, index: str) -> bytes: + int_index = self._validate_index(index) + return str(self.device.get_enabled(int_index)).encode("utf-8") + + @RegexCommand(r"N([0-9][0-9])=([01])", True, "utf-8") + async def set_enabled(self, index: str, value: int) -> None: + int_index = self._validate_index(index) + self.device.set_enabled(int_index, value) + + @RegexCommand(r"S([0-9][0-9])\?", False, "utf-8") + async def get_start(self, index: str) -> bytes: + int_index = self._validate_index(index) + return str(self.device.get_start(int_index)).encode("utf-8") + + @RegexCommand(r"S([0-9][0-9])=(\d+\.?\d*)", True, "utf-8") + async def set_start(self, index: str, value: str) -> None: + int_index = self._validate_index(index) + self.device.set_start(int_index, int(value)) + + @RegexCommand(r"E([0-9][0-9])\?", False, "utf-8") + async def get_end(self, index: str) -> bytes: + int_index = self._validate_index(index) + return str(self.device.get_end(int_index)).encode("utf-8") + + @RegexCommand(r"E([0-9][0-9])=(\d+\.?\d*)", True, "utf-8") + async def set_end(self, index: str, value: str) -> None: + int_index = self._validate_index(index) + self.device.set_end(int_index, int(value)) + + @RegexCommand(r"R\?", False, "utf-8") + async def get_ramp_rate(self) -> bytes: + return str(self.device.get_ramp_rate()).encode("utf-8") + + @RegexCommand(r"R=(\d+\.?\d*)", True, "utf-8") + async def set_ramp_rate(self, value: str) -> None: + self.device.set_ramp_rate(float(value)) + + @RegexCommand(r"V\?", False, "utf-8") + async def get_voltages(self) -> bytes: + return str(self.device.get_voltages()).encode("utf-8") + + @RegexCommand(r"P\?", False, "utf-8") + async def get_power(self) -> bytes: + return str(self.device.get_power()).encode("utf-8") + + @RegexCommand(r"\w*", False, "utf-8") + async def ignore_whitespace(self) -> None: + pass + + +# https://github.com/DiamondLightSource/tickit/issues/212 +@pydantic.v1.dataclasses.dataclass +class TempController(ComponentConfig): # type: ignore + num_ramp_controllers: int + default_start: float = 0 + default_end: float = 50 + host: str = "localhost" + port: int = 25565 + + def __call__(self) -> Component: + device = TempControllerDevice( + num_ramp_controllers=self.num_ramp_controllers, + default_start=self.default_start, + default_end=self.default_end, + ) + return DeviceComponent( + name=self.name, + device=device, + adapters=[ + AdapterContainer( + TempControllerAdapter(device), + TcpIo(self.host, self.port), + ) + ], + ) diff --git a/src/fastcs/demo/simulation/temp_controller.yaml b/src/fastcs/demo/simulation/temp_controller.yaml new file mode 100644 index 000000000..682ce636b --- /dev/null +++ b/src/fastcs/demo/simulation/temp_controller.yaml @@ -0,0 +1,21 @@ +- type: tickit.devices.source.Source + name: source + inputs: {} + value: 42.0 + +- type: fastcs.demo.simulation.device.TempController + name: tempcont + inputs: + flux: + component: source + port: value + num_ramp_controllers: 4 + default_start: 10 + default_end: 50 + +- type: tickit.devices.sink.Sink + name: sink + inputs: + flux: + component: tempcont + port: flux From 70367da10c9f49c8415344957cbf3e909c0c05b1 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 11 Apr 2025 16:21:15 +0000 Subject: [PATCH 02/11] Ignore generated .bob files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 6d4d09c6b..8f3fdc264 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/*bob + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 2826ffd241b5a25f4bdf40a59283b4cf2af7b0eb Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 11 Apr 2025 16:20:29 +0000 Subject: [PATCH 03/11] Clarify error message --- src/fastcs/datatypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/datatypes.py b/src/fastcs/datatypes.py index 31d5eb6b4..9580c3574 100644 --- a/src/fastcs/datatypes.py +++ b/src/fastcs/datatypes.py @@ -39,7 +39,7 @@ def dtype(self) -> type[T]: # Using property due to lack of Generic ClassVars def validate(self, value: T) -> T: """Validate a value against fields in the datatype.""" if not isinstance(value, self.dtype): - raise ValueError(f"Value {value} is not of type {self.dtype}") + raise ValueError(f"Value '{value}' is not of type {self.dtype}") return value From ecab71b05886acdfba8a10c62addb6f38b838e7d Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 11 Apr 2025 16:19:54 +0000 Subject: [PATCH 04/11] Remove app entrypoint --- .github/workflows/_dist.yml | 4 ++-- pyproject.toml | 2 +- src/fastcs/__main__.py | 24 ------------------------ src/fastcs/launch.py | 2 +- tests/test_cli.py | 9 --------- tests/test_launch.py | 2 +- 6 files changed, 5 insertions(+), 38 deletions(-) delete mode 100644 src/fastcs/__main__.py delete mode 100644 tests/test_cli.py diff --git a/.github/workflows/_dist.yml b/.github/workflows/_dist.yml index b1c4c93c3..cb86f9d0b 100644 --- a/.github/workflows/_dist.yml +++ b/.github/workflows/_dist.yml @@ -31,6 +31,6 @@ jobs: with: pip-install: dist/*.whl - - name: Test module --version works using the installed wheel + - name: Test module __version__ works using the installed wheel # If more than one module in src/ replace with module name to test - run: python -m $(ls --hide='*.egg-info' src | head -1) --version + run: python -c 'from fastcs import __version__; print(__version__)' diff --git a/pyproject.toml b/pyproject.toml index d173ab4c3..8ed2b3211 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ demo = [ ] [project.scripts] -FastCS = "fastcs.__main__:main" +fastcs-demo = "fastcs.demo.__main__:main" [project.urls] GitHub = "https://github.com/DiamondLightSource/FastCS" diff --git a/src/fastcs/__main__.py b/src/fastcs/__main__.py deleted file mode 100644 index 8274cf041..000000000 --- a/src/fastcs/__main__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Interface for ``python -m fastcs``.""" - -from argparse import ArgumentParser -from collections.abc import Sequence - -from . import __version__ - -__all__ = ["main"] - - -def main(args: Sequence[str] | None = None) -> None: - """Argument parser for the CLI.""" - parser = ArgumentParser() - parser.add_argument( - "-v", - "--version", - action="version", - version=__version__, - ) - parser.parse_args(args) - - -if __name__ == "__main__": - main() diff --git a/src/fastcs/launch.py b/src/fastcs/launch.py index 0234bbe4d..de2c8dad9 100644 --- a/src/fastcs/launch.py +++ b/src/fastcs/launch.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, create_model from ruamel.yaml import YAML -from fastcs.__main__ import __version__ +from fastcs import __version__ from .backend import Backend from .controller import Controller diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 7a16a0bfd..000000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,9 +0,0 @@ -import subprocess -import sys - -from fastcs import __version__ - - -def test_cli_version(): - cmd = [sys.executable, "-m", "fastcs", "--version"] - assert subprocess.check_output(cmd).decode().strip() == __version__ diff --git a/tests/test_launch.py b/tests/test_launch.py index 7be01307a..f84541e22 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -7,7 +7,7 @@ from ruamel.yaml import YAML from typer.testing import CliRunner -from fastcs.__main__ import __version__ +from fastcs import __version__ from fastcs.attributes import AttrR from fastcs.controller import Controller from fastcs.datatypes import Int From 284e83c6381c5d22cdff04810a2f183582d66248 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 11 Apr 2025 16:18:02 +0000 Subject: [PATCH 05/11] Add tutorial for creating a static driver --- docs/conf.py | 12 +- docs/snippets/static01.py | 5 + docs/snippets/static02.py | 10 + docs/snippets/static03.py | 12 + docs/snippets/static04.py | 16 ++ docs/snippets/static05.py | 27 ++ docs/snippets/static06.py | 54 ++++ docs/snippets/static07.py | 58 ++++ docs/snippets/static08.py | 65 +++++ docs/snippets/static09.py | 85 ++++++ docs/snippets/static10.py | 92 +++++++ docs/snippets/static11.py | 105 ++++++++ docs/snippets/static12.py | 113 ++++++++ docs/tutorials/static-drivers.md | 388 +++++++++++++++++++++++++++ pyproject.toml | 4 +- src/fastcs/demo/simulation/device.py | 6 + tests/conftest.py | 9 +- tests/test_docs_snippets.py | 34 +++ 18 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 docs/snippets/static01.py create mode 100644 docs/snippets/static02.py create mode 100644 docs/snippets/static03.py create mode 100644 docs/snippets/static04.py create mode 100644 docs/snippets/static05.py create mode 100644 docs/snippets/static06.py create mode 100644 docs/snippets/static07.py create mode 100644 docs/snippets/static08.py create mode 100644 docs/snippets/static09.py create mode 100644 docs/snippets/static10.py create mode 100644 docs/snippets/static11.py create mode 100644 docs/snippets/static12.py create mode 100644 docs/tutorials/static-drivers.md create mode 100644 tests/test_docs_snippets.py diff --git a/docs/conf.py b/docs/conf.py index ed45991e6..f672df977 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,8 @@ "sphinx_design", # So we can write markdown files "myst_parser", + # Collapsible sections + "sphinx_togglebutton", ] # So we can use the ::: syntax @@ -91,7 +93,11 @@ ("py:class", "fastcs.datatypes.T_Numerical"), ("py:class", "strawberry.schema.schema.Schema"), ] -nitpick_ignore_regex = [("py:class", "fastcs.*.T")] +nitpick_ignore_regex = [ + ("py:class", "fastcs.*.T"), + (r"py:.*", r"fastcs\.demo.*"), + (r"py:.*", r"tickit.*"), +] # Both the class’ and the __init__ method’s docstring are concatenated and # inserted into the main body of the autoclass directive @@ -211,3 +217,7 @@ # Logo html_logo = "images/dls-logo.svg" html_favicon = html_logo + +# Custom CSS +html_static_path = ["_static"] +html_css_files = ["custom.css"] diff --git a/docs/snippets/static01.py b/docs/snippets/static01.py new file mode 100644 index 000000000..6f9c09227 --- /dev/null +++ b/docs/snippets/static01.py @@ -0,0 +1,5 @@ +from fastcs.controller import Controller + + +class TemperatureController(Controller): + pass diff --git a/docs/snippets/static02.py b/docs/snippets/static02.py new file mode 100644 index 000000000..034ddbd62 --- /dev/null +++ b/docs/snippets/static02.py @@ -0,0 +1,10 @@ +from fastcs.controller import Controller +from fastcs.launch import FastCS + + +class TemperatureController(Controller): + pass + + +fastcs = FastCS(TemperatureController(), []) +fastcs.run() diff --git a/docs/snippets/static03.py b/docs/snippets/static03.py new file mode 100644 index 000000000..5775d01ca --- /dev/null +++ b/docs/snippets/static03.py @@ -0,0 +1,12 @@ +from fastcs.attributes import AttrR +from fastcs.controller import Controller +from fastcs.datatypes import String +from fastcs.launch import FastCS + + +class TemperatureController(Controller): + device_id = AttrR(String()) + + +fastcs = FastCS(TemperatureController(), []) +fastcs.run() diff --git a/docs/snippets/static04.py b/docs/snippets/static04.py new file mode 100644 index 000000000..396ecd16f --- /dev/null +++ b/docs/snippets/static04.py @@ -0,0 +1,16 @@ +from fastcs.attributes import AttrR +from fastcs.controller import Controller +from fastcs.datatypes import String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +class TemperatureController(Controller): + device_id = AttrR(String()) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +fastcs = FastCS(TemperatureController(), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static05.py b/docs/snippets/static05.py new file mode 100644 index 000000000..e44ce2dca --- /dev/null +++ b/docs/snippets/static05.py @@ -0,0 +1,27 @@ +from fastcs.attributes import AttrR +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import Controller +from fastcs.datatypes import String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +class TemperatureController(Controller): + device_id = AttrR(String()) + + def __init__(self, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static06.py b/docs/snippets/static06.py new file mode 100644 index 000000000..48d8831e8 --- /dev/null +++ b/docs/snippets/static06.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastcs.attributes import AttrHandlerR, AttrR +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller +from fastcs.datatypes import String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +@dataclass +class IDUpdater(AttrHandlerR): + update_period: float | None = 0.2 + _controller: TemperatureController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController) + self._controller = controller + + @property + def controller(self) -> TemperatureController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query("ID?\r\n") + value = response.strip("\r\n") + + await attr.set(value) + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=IDUpdater()) + + def __init__(self, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static07.py b/docs/snippets/static07.py new file mode 100644 index 000000000..8887b5d1b --- /dev/null +++ b/docs/snippets/static07.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastcs.attributes import AttrHandlerR, AttrR +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller +from fastcs.datatypes import Float, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +@dataclass +class TemperatureControllerUpdater(AttrHandlerR): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController) + self._controller = controller + + @property + def controller(self) -> TemperatureController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerUpdater("ID")) + power = AttrR(Float(), handler=TemperatureControllerUpdater("P")) + + def __init__(self, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static08.py b/docs/snippets/static08.py new file mode 100644 index 000000000..bd0155ec0 --- /dev/null +++ b/docs/snippets/static08.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller +from fastcs.datatypes import Float, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController) + self._controller = controller + + @property + def controller(self) -> TemperatureController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + async def put(self, attr: AttrW, value: Any): + await self.controller.connection.send_command( + f"{self.command_name}={value}\r\n" + ) + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerHandler("ID")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + + def __init__(self, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static09.py b/docs/snippets/static09.py new file mode 100644 index 000000000..620af22cd --- /dev/null +++ b/docs/snippets/static09.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller, SubController +from fastcs.datatypes import Float, Int, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | TemperatureRampController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController | TemperatureRampController) + self._controller = controller + + @property + def controller(self) -> TemperatureController | TemperatureRampController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}{self.controller.suffix}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + async def put(self, attr: AttrW, value: Any): + await self.controller.connection.send_command( + f"{self.command_name}{self.controller.suffix}={value}\r\n" + ) + + +class TemperatureRampController(SubController): + start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) + end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) + + def __init__(self, index: int, connection: IPConnection): + self.suffix = f"{index:02d}" + + super().__init__(f"Ramp{self.suffix}") + + self.connection = connection + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerHandler("ID")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + + suffix = "" + + def __init__(self, ramp_count: int, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + self._ramp_controllers: list[TemperatureRampController] = [] + for idx in range(1, ramp_count + 1): + ramp_controller = TemperatureRampController(idx, self.connection) + self._ramp_controllers.append(ramp_controller) + self.register_sub_controller(f"R{idx}", ramp_controller) + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(4, connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static10.py b/docs/snippets/static10.py new file mode 100644 index 000000000..0270ec71a --- /dev/null +++ b/docs/snippets/static10.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller, SubController +from fastcs.datatypes import Enum, Float, Int, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | TemperatureRampController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController | TemperatureRampController) + self._controller = controller + + @property + def controller(self) -> TemperatureController | TemperatureRampController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}{self.controller.suffix}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + async def put(self, attr: AttrW, value: Any): + await self.controller.connection.send_command( + f"{self.command_name}{self.controller.suffix}={value}\r\n" + ) + + +class OnOffEnum(enum.StrEnum): + Off = "0" + On = "1" + + +class TemperatureRampController(SubController): + start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) + end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) + enabled = AttrRW(Enum(OnOffEnum), handler=TemperatureControllerHandler("N")) + + def __init__(self, index: int, connection: IPConnection): + self.suffix = f"{index:02d}" + + super().__init__(f"Ramp{self.suffix}") + + self.connection = connection + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerHandler("ID")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + + suffix = "" + + def __init__(self, ramp_count: int, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + self._ramp_controllers: list[TemperatureRampController] = [] + for idx in range(1, ramp_count + 1): + ramp_controller = TemperatureRampController(idx, self.connection) + self._ramp_controllers.append(ramp_controller) + self.register_sub_controller(f"R{idx}", ramp_controller) + + async def connect(self): + await self.connection.connect(self._ip_settings) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(4, connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static11.py b/docs/snippets/static11.py new file mode 100644 index 000000000..3b979f83b --- /dev/null +++ b/docs/snippets/static11.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import enum +import json +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller, SubController +from fastcs.datatypes import Enum, Float, Int, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.wrappers import scan + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | TemperatureRampController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController | TemperatureRampController) + self._controller = controller + + @property + def controller(self) -> TemperatureController | TemperatureRampController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}{self.controller.suffix}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + async def put(self, attr: AttrW, value: Any): + await self.controller.connection.send_command( + f"{self.command_name}{self.controller.suffix}={value}\r\n" + ) + + +class OnOffEnum(enum.StrEnum): + Off = "0" + On = "1" + + +class TemperatureRampController(SubController): + start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) + end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) + enabled = AttrRW(Enum(OnOffEnum), handler=TemperatureControllerHandler("N")) + target = AttrR(Float(), handler=TemperatureControllerHandler("T")) + actual = AttrR(Float(), handler=TemperatureControllerHandler("A")) + voltage = AttrR(Float()) + + def __init__(self, index: int, connection: IPConnection): + self.suffix = f"{index:02d}" + + super().__init__(f"Ramp{self.suffix}") + + self.connection = connection + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerHandler("ID")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + + suffix = "" + + def __init__(self, ramp_count: int, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + self._ramp_controllers: list[TemperatureRampController] = [] + for idx in range(1, ramp_count + 1): + ramp_controller = TemperatureRampController(idx, self.connection) + self._ramp_controllers.append(ramp_controller) + self.register_sub_controller(f"R{idx}", ramp_controller) + + async def connect(self): + await self.connection.connect(self._ip_settings) + + @scan(0.1) + async def update_voltages(self): + voltages = json.loads( + (await self.connection.send_query("V?\r\n")).strip("\r\n") + ) + for index, controller in enumerate(self._ramp_controllers): + await controller.voltage.set(float(voltages[index])) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(4, connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/snippets/static12.py b/docs/snippets/static12.py new file mode 100644 index 000000000..f5a4fd289 --- /dev/null +++ b/docs/snippets/static12.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import enum +import json +from dataclasses import dataclass +from typing import Any + +from fastcs.attributes import AttrHandlerRW, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import BaseController, Controller, SubController +from fastcs.datatypes import Enum, Float, Int, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions +from fastcs.wrappers import command, scan + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + name: str + update_period: float | None = 0.2 + _controller: TemperatureController | TemperatureRampController | None = None + + async def initialise(self, controller: BaseController): + assert isinstance(controller, TemperatureController | TemperatureRampController) + self._controller = controller + + @property + def controller(self) -> TemperatureController | TemperatureRampController: + if self._controller is None: + raise RuntimeError("Handler not initialised") + + return self._controller + + async def put(self, attr: AttrW, value: Any) -> None: + await self.controller.connection.send_command( + f"{self.name}{self.controller.suffix}={attr.dtype(value)}\r\n" + ) + + async def update(self, attr: AttrR) -> None: + response = await self.controller.connection.send_query( + f"{self.name}{self.controller.suffix}?\r\n" + ) + response = response.strip("\r\n") + + await attr.set(attr.dtype(response)) + + +class OnOffEnum(enum.StrEnum): + Off = "0" + On = "1" + + +class TemperatureRampController(SubController): + start = AttrRW(Int(), handler=TemperatureControllerHandler("S")) + end = AttrRW(Int(), handler=TemperatureControllerHandler("E")) + enabled = AttrRW(Enum(OnOffEnum), handler=TemperatureControllerHandler("N")) + target = AttrR(Float(), handler=TemperatureControllerHandler("T")) + actual = AttrR(Float(), handler=TemperatureControllerHandler("A")) + voltage = AttrR(Float()) + + def __init__(self, index: int, connection: IPConnection): + self.suffix = f"{index:02d}" + + super().__init__(f"Ramp{self.suffix}") + + self.connection = connection + + +class TemperatureController(Controller): + device_id = AttrR(String(), handler=TemperatureControllerHandler("ID")) + power = AttrR(Float(), handler=TemperatureControllerHandler("P")) + ramp_rate = AttrRW(Float(), handler=TemperatureControllerHandler("R")) + + suffix = "" + + def __init__(self, ramp_count: int, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + self._ramp_controllers: list[TemperatureRampController] = [] + for idx in range(1, ramp_count + 1): + ramp_controller = TemperatureRampController(idx, self.connection) + self._ramp_controllers.append(ramp_controller) + self.register_sub_controller(f"R{idx}", ramp_controller) + + async def connect(self): + await self.connection.connect(self._ip_settings) + + @scan(0.1) + async def update_voltages(self): + voltages = json.loads( + (await self.connection.send_query("V?\r\n")).strip("\r\n") + ) + for index, controller in enumerate(self._ramp_controllers): + await controller.voltage.set(float(voltages[index])) + + @command() + async def disable_all(self) -> None: + for rc in self._ramp_controllers: + await rc.enabled.process(OnOffEnum.Off) + # TODO: The requests all get concatenated and the sim doesn't handle it + await asyncio.sleep(0.1) + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(4, connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/tutorials/static-drivers.md b/docs/tutorials/static-drivers.md new file mode 100644 index 000000000..03337a200 --- /dev/null +++ b/docs/tutorials/static-drivers.md @@ -0,0 +1,388 @@ +# Creating a FastCS Driver + +## Demo Simulation + +Within FastCS there is a tickit simulation of a temperature controller. This can be run +with the `Temp Controller Sim` launch config by typing `Ctrl+P debug ` (note the +trailing whitespace), selecting the launch config and pressing enter. The simulation +will then sit and wait for commands to be sent. When it receives commands, it will log +them to the console to show what it is doing. + +:::{note} +FastCS must be installed with the `demo` extra for the demo simulator to run. This can +be done by running `pip install 'fastcs[demo]'`. +::: + +This tutorial will walk through the steps of writing a device driver to control this +simulation. + +## FastCS Controllers + +The core of a FastCS device driver is the `Controller`. This class is used to implement +control of a device and instances can be loaded into a FastCS application to expose its +functionality. + +Create a `TemperatureController` class that inherits from `Controller`. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static01.py +::: + +:::: + +## FastCS Launcher + +The entrypoint to a FastCS application is the `FastCS` class. This takes a `Controller` +and a list of transports to expose the API through and provides a `run` method to launch +the application. Create a `FastCS` instance, pass the `TemperatureController` to it +along with an empty list of transports (for now). + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static02.py +:emphasize-lines: 2,9,10 +::: + +:::: + +Now the application runs, but it still doesn't expose any API because the `Controller` +is empty. It also completes immediately. + +## FastCS Attributes + +The simulator has an API to get its ID. To expose this in the driver, an `Attribute` can +be added to the `Controller`. There are 3 types of `Attribute`: `AttrR`, `AttrW` and +`AttrRW`, representing the access mode of the API. The ID can be read, but it cannot be +written, so add an `AttrR`. An `Attribute` also needs a type. The ID from the simulator +is a string, so `String` should be used. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static03.py +:emphasize-lines: 1,3,8 +::: + +:::: + +Now the controller has a property that will appear in the API, but the application still +completes immediately because there are no transports being run on the event loop to +expose an API. + +Now that the controller has an attribute, it would be useful to open the generated UI. +A `demo.bob` will have been created in the directory the application was run from. This +can be launched with Phoebus. + +## FastCS Transports + +FastCS supports multiple transports to expose the API of the loaded `Controller`. The +following transports are currently supported + +- EPICS CA (using `pythonSoftIOC`) +- EPICS PVA (using `p4p`) +- Tango (using `pytango`) +- GraphQL (using `strawberry`) +- HTTP (using `fastapi`) + +One or more of these can be loaded into the application and run in parallel. Add the +EPICS CA transport to the application by creating an `EPICSCAOptions` instance and +passing it in. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static04.py +:emphasize-lines: 5,6,13,14 +::: + +:::: + +The application will now run until it is stopped. There will also be a `DEMO:DeviceId` +PV being served by the application. However, the record is unset because the +`Controller` is not yet querying the simulator for the value. + +```bash +❯ caget -S DEMO:DeviceId +DEMO:DeviceId +``` + +## FastCS Device Connection + +The `Attributes` of a FastCS `Controller` need some IO with the device in order to get +and set values. This is implemented with handlers and connections. There are three types +of handler - `Sender`, `Updater` and `Handler` (which implements both). Generally each +driver implements its own connection logic, but there are some built in options. + +Update the controller to create an `IPConnection` to communicate with the simulator over +TCP and implement a `connect` method that establishes the connection. The `connect` +method is called by the FastCS application at the appropriate time during start up to +ensure the connection is established before it is used. + +:::{note} +The simulator control connection is on port 25565. +::: + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static05.py +:emphasize-lines: 2,13-20,24,25 +::: + +:::: + +:::{warning} +The application will now fail to connect if the demo simulation is not running. +::: + +The `Controller` has now established a connection with the simulator. This connection +can be passed to an `Updater` to enable it to query the device API and update the value +in the `device_id` attribute. Create an `Updater` child class and implement the method +to send a query to the device and set the value of the attribute, and then pass an +instance of the `Updater` to the `device_id` attribute. + +:::{note} +The `update_period` property tells the base class how often to call `update` +::: + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static06.py +:emphasize-lines: 1,12-22,26 +::: + +:::: + +:::{note} +In the `update` method, errors won't crash the application, but it prints them to the +terminal. - `Update loop ... stopped:` +::: + +Now the PV will be set by reading from the simulator and the IOC has one fully +functional PV. + +```bash +❯ caget -S DEMO:DeviceId +DEMO:DeviceId SIMTCONT123 +``` + +## Building Up The API + +The simulator supports many other commands, for example it reports the total power +currently being drawn with the `P` command. This can be exposed by adding another +`AttrR` with a `Float` datatype, but the `IDUpdater` only supports the `ID` command to +get the device ID. This new attribute could have its own updater, but it would be better +to create common updater that can be configured with the command to send so that they +can share the same code. + +Modify the handler to take a `command_name` string and use it to create a new attribute +to read the power usage. + +:::{note} +All responses from the `IPConnection` are strings. This is fine for the `ID` command +because the value is actually a string, but for `P` the value is a float, so the +`update` methods needs to explicitly cast to the correct type. It can use +`Attribute.dtype` to call the builtin for its datatype - e.g. `int`, `float`, `str`, +etc. +:::: + +:::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static07.py +:emphasize-lines: 6,13-14,20,27-28 +::: + +:::: + +Now the IOC has two PVs being polled periodically. The new PV will be visible in the +Phoebus UI on refresh (right-click). `DEMO:Power` will read as `0` because the simulator +is not currently running a ramp. To do that the controller needs to be able to set +values on the device, as well as read them back. The ramp rate of the temperature can be +read with the `R` command and set with the `R=...` command. This means the controller +also needs a `Sender` handler that implements `put` to send values to the device. + +Update the existing handler to support both `update` and `put`, making it a `Handler` +(which implements both `Updater` and `Sender`). Then add a new `AttrRW` with type +`Float` to get and set the ramp rate. + +:::{note} +The set commands do not return a response, so use the `send_command` method instead of +`send_query`. +::: + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static08.py +:emphasize-lines: 2,4,14,26-29,35 +::: + +:::: + +Two new PVs will be created: one to set the ramp rate and one to read it back. + +```bash +❯ caget DEMO:RampRate_RBV +DEMO:RampRate_RBV 2 +❯ caput DEMO:RampRate 5 +Old : DEMO:RampRate 2 +New : DEMO:RampRate 5 +❯ caget DEMO:RampRate_RBV +DEMO:RampRate_RBV 5 +``` + +The changes will also be visible in the simulator terminal. + +``` +INFO:fastcs.demo.simulation.device:Set ramp rate to 5.0 +``` + +This adds the first method to modify the device, but more are needed to be able to run a +temperature ramp. The simulator has multiple temperature control loops that can be +ramped independently. They each have a common set of commands that control them +individually, for example to `S01=...` to set the start point for ramp 1, `E02=...` to +set the end point for ramp 2. + +Given that the device has `n` instances of a common interface, it makes sense to create +a class to encapsulate this control and then instantiate it for each ramp the simulator +has. This can be done with the use of sub controllers. Controllers can be arbitrarily +nested to match the structure of a device and this structure is then mirrored to the +transport layer for the visibility of the user. + +Create a `TemperatureRampController` with `AttrRW`s to get and set the ramp start and +end, update the `TemperatureControllerHandler` to include an optional suffix for the +commands so that it can be shared between the parent `TemperatureController` and add an +argument to define how many ramps there are, which is used to register the correct +number of ramp controllers with the parent. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static09.py +:emphasize-lines: 6,7,19,21-23,36-45,55,61-65,73 +::: + +:::: + +New PVs will be added (e.g. `DEMO:R1:Start`): +- `DEMO:R{1,2,3,4}:Start` +- `DEMO:R{1,2,3,4}:Start_RBV` +- `DEMO:R{1,2,3,4}:End` +- `DEMO:R{1,2,3,4}:End_RBV` + +Four buttons will also be added to the Phoebus UI to open sub screens for each ramp. + +This allows the controller to set the range of every temperature ramp. Again, the +simulator terminal will confirm that the changes are taking effect. The final commands +needed to run a temperature ramp are the `N01` and `N01=` commands, which are used to +enable (and disable) the ramping. + +Add an `AttrRW` to the `TemperatureRampController`s with an `Enum` type, using a +`StrEnum` with states `Off` and `On`. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static10.py +:emphasize-lines: 1,37-39,45 +::: + +:::: + +Now the temperature ramp can be run. + +```bash +❯ caput DEMO:R1:Enabled On +Old : DEMO:R1:Enabled Off +New : DEMO:R1:Enabled On +❯ caget DEMO:Power +DEMO:Power 56.84 +❯ caput DEMO:R1:Enabled Off +Old : DEMO:R1:Enabled On +New : DEMO:R1:Enabled Off +❯ caget DEMO:Power +DEMO:Power 0 +``` + +In the simulator terminal the progress of the ramp can be seen as it happens. + +``` +INFO:fastcs.demo.simulation.device:Started ramp 0 +INFO:fastcs.demo.simulation.device:Target Temperatures: 10.000, 0.000, 0.000, 0.000 +INFO:fastcs.demo.simulation.device:Actual Temperatures: 9.572, 0.000, 0.000, 0.000 +INFO:fastcs.demo.simulation.device:Target Temperatures: 10.200, 0.000, 0.000, 0.000 +INFO:fastcs.demo.simulation.device:Actual Temperatures: 9.952, 0.000, 0.000, 0.000 +INFO:fastcs.demo.simulation.device:Target Temperatures: 10.400, 0.000, 0.000, 0.000 +... +INFO:fastcs.demo.simulation.device:Stopped ramp 0 +``` + +The target and actual temperatures visible in the simulator terminal are also exposed in +the API with the `T01?` and `A01?` commands. + +## FastCS Methods + +The applied voltage for each ramp is also available with the `V?` command, but the value +is an array with each element corresponding to a ramp. Here it will be simplest to +manually fetch the array in the parent controller and pass each value into ramp +controller. This can be done with a `scan` method - these are called at a defined rate, +similar to the `update` method of handlers. + +Add an `AttrR` for the voltage to the `TemperatureRampController`, but do not pass it a +handler. Then add a method to the `TemperatureController` with a `@scan` decorator that +gets the array of voltages and sets each ramp controller with its value. Also add +`AttrR`s for the target and actual temperature for each ramp as described above. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static11.py +:emphasize-lines: 2,51,82-88 +::: + +:::: + +Creating attributes is intended to be a simple API covering most use cases, but where +more flexibility is needed wrapped controller methods can be useful to avoid adding +complexity to the handlers to handle a small subset of attributes. It is also useful for +implementing higher level logic on top of the attributes that expose the API of a device +directly. For example, it would be useful to have a single button to stop all of the +ramps at the same time. This can be done with a `command` method. These are similar to +`scan` methods except that they create an API in transport layer in the same way an +attribute does. + +Add a method with a `@command` decorator to set enabled to false in every ramp +controller. + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/static12.py +:emphasize-lines: 1,48-50,91-96 +::: + +:::: + +The new `DEMO:CancelAll` PV can be set (the value doesn't matter) to stop all of the +ramps. + +``` +❯ caget DEMO:R1:Enabled_RBV +DEMO:R1:Enabled_RBV On +❯ caput DEMO:DisableAll 1 +Old : DEMO:DisableAll +New : DEMO:DisableAll +❯ caget DEMO:R1:Enabled_RBV +DEMO:R1:Enabled_RBV Off +``` + +## Summary + +This demonstrates some of the simple use cases for a statically defined FastCS driver. +It is also possible to instantiate a driver dynamically by instantiating a device during +startup. See the next tutorial for how to do this (TODO). diff --git a/pyproject.toml b/pyproject.toml index 8ed2b3211..6cc7cca65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,9 +40,11 @@ dev = [ "pytest-cov", "pytest-mock", "pytest-asyncio", + "pytest-markdown-docs", "ruff", "sphinx-autobuild", "sphinx-copybutton", + "sphinx-togglebutton", "sphinx-design", "tox-direct", "types-mock", @@ -76,7 +78,7 @@ reportMissingImports = false # Ignore missing stubs in imported modules [tool.pytest.ini_options] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error addopts = """ - --tb=native -vv --doctest-modules --doctest-glob="*.rst" --benchmark-sort=mean --benchmark-columns="mean, min, max, outliers, ops, rounds" + --tb=native -vv --doctest-modules --doctest-glob="*.md" --ignore-glob docs/snippets/*py --benchmark-sort=mean --benchmark-columns="mean, min, max, outliers, ops, rounds" """ # https://iscinumpy.gitlab.io/post/bound-version-constraints/#watch-for-warnings filterwarnings = "error" diff --git a/src/fastcs/demo/simulation/device.py b/src/fastcs/demo/simulation/device.py index 33ed29e1d..1db128000 100755 --- a/src/fastcs/demo/simulation/device.py +++ b/src/fastcs/demo/simulation/device.py @@ -60,6 +60,8 @@ def __init__( self._ramp_rate: float = 1 # 1 unit/s + self.initialised = False + def get_target(self, index: int): return self._target[index] @@ -123,6 +125,10 @@ def ramp( @handle_exceptions def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: + if not self.initialised: + LOGGER.info("Temperature controller running") + self.initialised = True + self._start_times[self._start_times == -1] = int(time) periods = np.full(self._num, int(time)) - self._start_times self.ramp(periods) diff --git a/tests/conftest.py b/tests/conftest.py index 732fa44b8..80a76711d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,7 @@ import pytest from aioca import purge_channel_caches +from softioc import builder from fastcs.attributes import AttrR, AttrRW, AttrW from fastcs.backend import build_controller_api @@ -29,7 +30,10 @@ from tests.example_p4p_ioc import run as _run_p4p_ioc from tests.example_softioc import run as _run_softioc -DATA_PATH = Path(__file__).parent / "data" + +@pytest.fixture(scope="function", autouse=True) +def clear_softioc_records(): + builder.ClearRecords() class BackendTestController(MyTestController): @@ -51,6 +55,9 @@ def controller_api(controller): return build_controller_api(controller) +DATA_PATH = Path(__file__).parent / "data" + + @pytest.fixture def data() -> Path: return DATA_PATH diff --git a/tests/test_docs_snippets.py b/tests/test_docs_snippets.py new file mode 100644 index 000000000..5308f812c --- /dev/null +++ b/tests/test_docs_snippets.py @@ -0,0 +1,34 @@ +import glob +import runpy +import signal +import subprocess +from pathlib import Path +from time import sleep + +import pytest + +HERE = Path(__file__).parent + + +@pytest.fixture(scope="module", autouse=True) +def sim_temperature_controller(): + """Subprocess that runs ``tickit all ``.""" + config_path: str = f"{HERE}/../src/fastcs/demo/simulation/temp_controller.yaml" + proc = subprocess.Popen( + ["tickit", "all", config_path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + sleep(1) + + yield + + proc.send_signal(signal.SIGINT) + print(proc.communicate()[0]) + + +@pytest.mark.parametrize("filename", glob.glob("docs/snippets/*.py", recursive=True)) +def test_snippet(filename): + runpy.run_path(filename) From b10d1de17e4961843d9646548b9a22f32a745e66 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 28 Mar 2025 16:11:00 +0000 Subject: [PATCH 06/11] Use fastcs logo for docs --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index f672df977..03d63e927 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -215,7 +215,7 @@ html_show_copyright = False # Logo -html_logo = "images/dls-logo.svg" +html_logo = "images/fastcs.svg" html_favicon = html_logo # Custom CSS From 939fe68a8e0fb30d1749fd978eeebed17ba74098 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Thu, 10 Apr 2025 16:28:25 +0000 Subject: [PATCH 07/11] Add custom css to make content wider --- docs/_static/custom.css | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/_static/custom.css diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 000000000..feabac7ca --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,5 @@ +/* allow a wider screen so we can fit 88 chars of source code on it */ +.bd-page-width { + max-width: 100rem; + /* default is 88rem */ +} From 81695bfa68f13a61e05b74ee4e4dc4881d643e4e Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 11 Apr 2025 16:12:35 +0000 Subject: [PATCH 08/11] Add tutorial for introspection --- docs/snippets/dynamic.py | 119 +++++++++++++++++++++++++++ docs/tutorials/dynamic-drivers.md | 81 ++++++++++++++++++ src/fastcs/demo/simulation/device.py | 44 ++++++++++ 3 files changed, 244 insertions(+) create mode 100644 docs/snippets/dynamic.py create mode 100644 docs/tutorials/dynamic-drivers.md diff --git a/docs/snippets/dynamic.py b/docs/snippets/dynamic.py new file mode 100644 index 000000000..649cb3726 --- /dev/null +++ b/docs/snippets/dynamic.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, ValidationError + +from fastcs.attributes import AttrHandlerRW, Attribute, AttrR, AttrRW, AttrW +from fastcs.connections import IPConnection, IPConnectionSettings +from fastcs.controller import Controller, SubController +from fastcs.datatypes import Bool, DataType, Float, Int, String +from fastcs.launch import FastCS +from fastcs.transport.epics.ca.options import EpicsCAOptions +from fastcs.transport.epics.options import EpicsIOCOptions + + +class TemperatureControllerParameter(BaseModel): + model_config = ConfigDict(extra="forbid") + + command: str + type: Literal["bool", "int", "float", "str"] + access_mode: Literal["r", "rw"] + + @property + def fastcs_datatype(self) -> DataType: + match self.type: + case "bool": + return Bool() + case "int": + return Int() + case "float": + return Float() + case "str": + return String() + + +def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]: + attributes: dict[str, Attribute] = {} + for name, parameter in parameters.items(): + name = name.replace(" ", "_").lower() + + try: + parameter = TemperatureControllerParameter.model_validate(parameter) + except ValidationError as e: + print(f"Failed to validate parameter '{parameter}'\n{e}") + continue + + handler = TemperatureControllerHandler(parameter.command) + match parameter.access_mode: + case "r": + attributes[name] = AttrR(parameter.fastcs_datatype, handler=handler) + case "rw": + attributes[name] = AttrRW(parameter.fastcs_datatype, handler=handler) + + return attributes + + +@dataclass +class TemperatureControllerHandler(AttrHandlerRW): + command_name: str + update_period: float | None = 0.2 + _controller: TemperatureController | None = None + + async def update(self, attr: AttrR): + response = await self.controller.connection.send_query( + f"{self.command_name}?\r\n" + ) + value = response.strip("\r\n") + + await attr.set(attr.dtype(value)) + + async def put(self, attr: AttrW, value: Any): + await self.controller.connection.send_command( + f"{self.command_name}={value}\r\n" + ) + + +class TemperatureRampController(SubController): + def __init__(self, index: int, connection: IPConnection): + super().__init__(f"Ramp {index}") + + self.connection = connection + + async def initialise(self, parameters: dict[str, Any]): + self.attributes.update(create_attributes(parameters)) + + +class TemperatureController(Controller): + def __init__(self, settings: IPConnectionSettings): + super().__init__() + + self._ip_settings = settings + self.connection = IPConnection() + + async def connect(self): + await self.connection.connect(self._ip_settings) + + async def initialise(self): + await self.connect() + + api = json.loads((await self.connection.send_query("API?\r\n")).strip("\r\n")) + + ramps_api = api.pop("Ramps") + self.attributes.update(create_attributes(api)) + + for idx, ramp_parameters in enumerate(ramps_api): + ramp_controller = TemperatureRampController(idx + 1, self.connection) + self.register_sub_controller(f"Ramp{idx + 1:02d}", ramp_controller) + await ramp_controller.initialise(ramp_parameters) + + await self.connection.close() + + +epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO")) +connection_settings = IPConnectionSettings("localhost", 25565) +fastcs = FastCS(TemperatureController(connection_settings), [epics_options]) + +# fastcs.run() # Commented as this will block diff --git a/docs/tutorials/dynamic-drivers.md b/docs/tutorials/dynamic-drivers.md new file mode 100644 index 000000000..3e4393467 --- /dev/null +++ b/docs/tutorials/dynamic-drivers.md @@ -0,0 +1,81 @@ +# Dynamic FastCS drivers + +## Demo Simulation + +The demo simulation used in the previous tutorial has a command `API?` to list all of its +commands. This allows you to introspect the device and create the API dynamically, +instead of defining all the attributes statically. The response will look like this + +``` +{ + "Device ID": {"command": "ID", "type": "str", "access_mode": "r"}, + "Power": {"command": "P", "type": "float", "access_mode": "rw"}, + "Ramp Rate": {"command": "R", "type": "float", "access_mode": "rw"}, + "Ramps": [ + { + "Start": {"command": "S01", "type": "int", "access_mode": "rw"}, + "End": {"command": "E01", "type": "int", "access_mode": "rw"}, + "Enabled": {"command": "N01", "type": "int", "access_mode": "rw"}, + "Target": {"command": "T01", "type": "float", "access_mode": "rw"}, + "Actual": {"command": "A01", "type": "float", "access_mode": "rw"}, + }, + ..., + ], +} +``` + +This contains all the metadata about the parameters in the API needed to create the +`Attributes` from the previous tutorial. For a real device, this might also include +fields such as the units of numerical parameters, limits that a parameter can be set to, +or a description for the parameter. + +## FastCS Initialisation + +Specific `Controller` classes can optionally implement an async `initialise` method to +perform any start up logic. The intention here is that the `__init__` method should be +minimal and the `initialise` method performs any long running calls, such as querying an +API, allowing FastCS to run these concurrently to reduce start times. + +Take the driver implementation from the previous tutorial and remove the +statically defined `Attributes` and creation of sub controllers in `__init__`. Then +implement an `initialise` method to create these dynamically instead. + +Create a pydantic model to validate the response from the device + +:::{literalinclude} /snippets/dynamic.py +:lines: 3-5,14-33 +::: + +Create a function to parse the dictionary and validate the entries against the model + +:::{literalinclude} /snippets/dynamic.py +:lines: 36-54 +::: + +Update the controllers to not define attributes statically and implement initialise +methods to create these attributes dynamically. + +:::{literalinclude} /snippets/dynamic.py +:lines: 76,81-83 +::: + +:::{literalinclude} /snippets/dynamic.py +:lines: 86,95-109 +::: + +The `suffix` field should also be removed from `TemperatureController` and +`TemperatureRampController` and then not used in `TemperatureControllerHandler` because +the `command` field on `TemperatureControllerParameter` includes this. + +TODO: Add `enabled` back in to `TemperatureRampController` and recreate `disable_all` to +demonstrate validation of introspected Attributes. + +The full code is as follows + +::::{admonition} Code +:class: dropdown, hint + +:::{literalinclude} /snippets/dynamic.py +::: + +:::: diff --git a/src/fastcs/demo/simulation/device.py b/src/fastcs/demo/simulation/device.py index 1db128000..688f97a97 100755 --- a/src/fastcs/demo/simulation/device.py +++ b/src/fastcs/demo/simulation/device.py @@ -1,3 +1,4 @@ +import json import logging import traceback from os import _exit @@ -144,6 +145,45 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]: call_at = SimTime(int(time) + int(1e8)) if np.any(self._enabled) else None return DeviceUpdate(TempControllerDevice.Outputs(flux=inputs["flux"]), call_at) + def get_commands(self): + return json.dumps( + { + "Device ID": {"command": "ID", "type": "str", "access_mode": "r"}, + "Power": {"command": "P", "type": "float", "access_mode": "rw"}, + "Ramp Rate": {"command": "R", "type": "float", "access_mode": "rw"}, + "Ramps": [ + { + "Start": { + "command": f"S{idx:02d}", + "type": "int", + "access_mode": "rw", + }, + "End": { + "command": f"E{idx:02d}", + "type": "int", + "access_mode": "rw", + }, + "Enabled": { + "command": f"N{idx:02d}", + "type": "int", + "access_mode": "rw", + }, + "Target": { + "command": f"T{idx:02d}", + "type": "float", + "access_mode": "rw", + }, + "Actual": { + "command": f"A{idx:02d}", + "type": "float", + "access_mode": "rw", + }, + } + for idx in range(1, self._num + 1) + ], + } + ) + class TempControllerAdapter(CommandAdapter): device: TempControllerDevice @@ -218,6 +258,10 @@ async def get_voltages(self) -> bytes: async def get_power(self) -> bytes: return str(self.device.get_power()).encode("utf-8") + @RegexCommand(r"API\?", False, "utf-8") + async def get_commands(self) -> bytes: + return str(self.device.get_commands()).encode("utf-8") + @RegexCommand(r"\w*", False, "utf-8") async def ignore_whitespace(self) -> None: pass From d05374aee6d79ba35cb4702ba151f67728865e82 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 25 Apr 2025 15:32:56 +0000 Subject: [PATCH 09/11] Update setuptools for setuptools_dso before tests --- .github/workflows/_test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index f652d4145..3e5879cb9 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -50,6 +50,9 @@ jobs: python-version: ${{ inputs.python-version }} pip-install: ".[dev]" + - name: Update setuptools + run: pip install --upgrade setuptools + - name: Run tests run: tox -e tests From 632570e5a1a6def5c4e536c7a29876dab6e8eedd Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Fri, 25 Apr 2025 16:02:10 +0000 Subject: [PATCH 10/11] Fix tickit sim test fixture Detect start better tickit does not stop cleanly on python 3.12 so kill process --- tests/test_docs_snippets.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/test_docs_snippets.py b/tests/test_docs_snippets.py index 5308f812c..63d63ab7f 100644 --- a/tests/test_docs_snippets.py +++ b/tests/test_docs_snippets.py @@ -1,9 +1,8 @@ import glob import runpy -import signal import subprocess +import time from pathlib import Path -from time import sleep import pytest @@ -12,21 +11,30 @@ @pytest.fixture(scope="module", autouse=True) def sim_temperature_controller(): - """Subprocess that runs ``tickit all ``.""" config_path: str = f"{HERE}/../src/fastcs/demo/simulation/temp_controller.yaml" - proc = subprocess.Popen( + process = subprocess.Popen( ["tickit", "all", config_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) - sleep(1) + TIMEOUT = 10 + start_time = time.monotonic() + while process.stdout is not None: + line = process.stdout.readline() + if "Temperature controller running" in line: + break + + if time.monotonic() - start_time > TIMEOUT: + raise TimeoutError("Simulator did not start in time") + + time.sleep(0.1) yield - proc.send_signal(signal.SIGINT) - print(proc.communicate()[0]) + process.kill() + print(process.communicate()[0]) @pytest.mark.parametrize("filename", glob.glob("docs/snippets/*.py", recursive=True)) From 4d62414fc1e60784273c13e01497aea35968c7e6 Mon Sep 17 00:00:00 2001 From: Gary Yendell Date: Tue, 13 May 2025 14:21:01 +0000 Subject: [PATCH 11/11] Add codecov configuration --- codecov.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 codecov.yaml diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 000000000..f15ad3423 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,2 @@ +ignore: + - "src/fastcs/demo/"