Skip to content

Commit 3227a85

Browse files
committed
Add tutorial for introspection
1 parent 1083706 commit 3227a85

File tree

3 files changed

+241
-0
lines changed

3 files changed

+241
-0
lines changed

docs/snippets/dynamic.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import json
2+
from dataclasses import dataclass
3+
from typing import Any, Literal
4+
5+
from pydantic import BaseModel, ConfigDict, ValidationError
6+
7+
from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW, Handler
8+
from fastcs.connections import IPConnection, IPConnectionSettings
9+
from fastcs.controller import BaseController, Controller, SubController
10+
from fastcs.datatypes import Bool, DataType, Float, Int, String
11+
from fastcs.launch import FastCS
12+
from fastcs.transport.epics.ca.options import EpicsCAOptions
13+
from fastcs.transport.epics.options import EpicsIOCOptions
14+
15+
16+
class TemperatureControllerParameter(BaseModel):
17+
model_config = ConfigDict(extra="forbid")
18+
19+
command: str
20+
type: Literal["bool", "int", "float", "str"]
21+
access_mode: Literal["r", "rw"]
22+
23+
@property
24+
def fastcs_datatype(self) -> DataType:
25+
match self.type:
26+
case "bool":
27+
return Bool()
28+
case "int":
29+
return Int()
30+
case "float":
31+
return Float()
32+
case "str":
33+
return String()
34+
35+
36+
def create_attributes(parameters: dict[str, Any]) -> dict[str, Attribute]:
37+
attributes: dict[str, Attribute] = {}
38+
for name, parameter in parameters.items():
39+
name = name.replace(" ", "_").lower()
40+
41+
try:
42+
parameter = TemperatureControllerParameter.model_validate(parameter)
43+
except ValidationError as e:
44+
print(f"Failed to validate parameter '{parameter}'\n{e}")
45+
continue
46+
47+
handler = TemperatureControllerHandler(parameter.command)
48+
match parameter.access_mode:
49+
case "r":
50+
attributes[name] = AttrR(parameter.fastcs_datatype, handler=handler)
51+
case "rw":
52+
attributes[name] = AttrRW(parameter.fastcs_datatype, handler=handler)
53+
54+
return attributes
55+
56+
57+
@dataclass
58+
class TemperatureControllerHandler(Handler):
59+
command_name: str
60+
update_period: float | None = 0.2
61+
62+
async def update(self, controller: BaseController, attr: AttrR):
63+
assert isinstance(controller, TemperatureController | TemperatureRampController)
64+
65+
response = await controller.connection.send_query(f"{self.command_name}?\r\n")
66+
value = response.strip("\r\n")
67+
68+
await attr.set(attr.dtype(value))
69+
70+
async def put(self, controller: BaseController, attr: AttrW, value: Any):
71+
assert isinstance(controller, TemperatureController | TemperatureRampController)
72+
73+
await controller.connection.send_command(f"{self.command_name}={value}\r\n")
74+
75+
76+
class TemperatureRampController(SubController):
77+
def __init__(self, index: int, connection: IPConnection):
78+
super().__init__(f"Ramp {index}")
79+
80+
self.connection = connection
81+
82+
async def initialise(self, parameters: dict[str, Any]):
83+
self.attributes.update(create_attributes(parameters))
84+
85+
86+
class TemperatureController(Controller):
87+
def __init__(self, settings: IPConnectionSettings):
88+
super().__init__()
89+
90+
self._ip_settings = settings
91+
self.connection = IPConnection()
92+
93+
async def connect(self):
94+
await self.connection.connect(self._ip_settings)
95+
96+
async def initialise(self):
97+
await self.connect()
98+
99+
api = json.loads((await self.connection.send_query("API?\r\n")).strip("\r\n"))
100+
101+
ramps_api = api.pop("Ramps")
102+
self.attributes.update(create_attributes(api))
103+
104+
for idx, ramp_parameters in enumerate(ramps_api):
105+
ramp_controller = TemperatureRampController(idx + 1, self.connection)
106+
self.register_sub_controller(f"Ramp{idx + 1:02d}", ramp_controller)
107+
await ramp_controller.initialise(ramp_parameters)
108+
109+
await self.connection.close()
110+
111+
112+
epics_options = EpicsCAOptions(ioc=EpicsIOCOptions(pv_prefix="DEMO"))
113+
connection_settings = IPConnectionSettings("localhost", 25565)
114+
fastcs = FastCS(TemperatureController(connection_settings), [epics_options])
115+
116+
# fastcs.run() # Commented as this will block

docs/tutorials/dynamic-drivers.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Dynamic FastCS drivers
2+
3+
## Demo Simulation
4+
5+
The demo simulation used in the previous tutorial has a command `API?` to list all of its
6+
commands. This allows you to introspect the device and create the API dynamically,
7+
instead of defining all the attributes statically. The response will look like this
8+
9+
```
10+
{
11+
"Device ID": {"command": "ID", "type": "str", "access_mode": "r"},
12+
"Power": {"command": "P", "type": "float", "access_mode": "rw"},
13+
"Ramp Rate": {"command": "R", "type": "float", "access_mode": "rw"},
14+
"Ramps": [
15+
{
16+
"Start": {"command": "S01", "type": "int", "access_mode": "rw"},
17+
"End": {"command": "E01", "type": "int", "access_mode": "rw"},
18+
"Enabled": {"command": "N01", "type": "int", "access_mode": "rw"},
19+
"Target": {"command": "T01", "type": "float", "access_mode": "rw"},
20+
"Actual": {"command": "A01", "type": "float", "access_mode": "rw"},
21+
},
22+
...,
23+
],
24+
}
25+
```
26+
27+
This contains all the metadata about the parameters in the API needed to create the
28+
`Attributes` from the previous tutorial. For a real device, this might also include
29+
fields such as the units of numerical parameters, limits that a parameter can be set to,
30+
or a description for the parameter.
31+
32+
## FastCS Initialisation
33+
34+
Specific `Controller` classes can optionally implement an async `initialise` method to
35+
perform any start up logic. The intention here is that the `__init__` method should be
36+
minimal and the `initialise` method performs any long running calls, such as querying an
37+
API, allowing FastCS to run these concurrently to reduce start times.
38+
39+
Take the driver implementation from the previous tutorial and remove the
40+
statically defined `Attributes` and creation of sub controllers in `__init__`. Then
41+
implement an `initialise` method to create these dynamically instead.
42+
43+
Create a pydantic model to validate the response from the device
44+
45+
:::{literalinclude} /snippets/dynamic.py
46+
:lines: 3-5,14-33
47+
:::
48+
49+
Create a function to parse the dictionary and validate the entries against the model
50+
51+
:::{literalinclude} /snippets/dynamic.py
52+
:lines: 36-54
53+
:::
54+
55+
Update the controllers to not define attributes statically and implement initialise
56+
methods to create these attributes dynamically.
57+
58+
:::{literalinclude} /snippets/dynamic.py
59+
:lines: 76,81-83
60+
:::
61+
62+
:::{literalinclude} /snippets/dynamic.py
63+
:lines: 86,95-109
64+
:::
65+
66+
The `suffix` field should also be removed from `TemperatureController` and
67+
`TemperatureRampController` and then not used in `TemperatureControllerHandler` because
68+
the `command` field on `TemperatureControllerParameter` includes this.
69+
70+
TODO: Add `enabled` back in to `TemperatureRampController` and recreate `disable_all` to
71+
demonstrate validation of introspected Attributes.
72+
73+
The full code is as follows
74+
75+
::::{admonition} Code
76+
:class: dropdown, hint
77+
78+
:::{literalinclude} /snippets/dynamic.py
79+
:::
80+
81+
::::

src/fastcs/demo/simulation/device.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import traceback
34
from os import _exit
@@ -144,6 +145,45 @@ def update(self, time: SimTime, inputs: Inputs) -> DeviceUpdate[Outputs]:
144145
call_at = SimTime(int(time) + int(1e8)) if np.any(self._enabled) else None
145146
return DeviceUpdate(TempControllerDevice.Outputs(flux=inputs["flux"]), call_at)
146147

148+
def get_commands(self):
149+
return json.dumps(
150+
{
151+
"Device ID": {"command": "ID", "type": "str", "access_mode": "r"},
152+
"Power": {"command": "P", "type": "float", "access_mode": "rw"},
153+
"Ramp Rate": {"command": "R", "type": "float", "access_mode": "rw"},
154+
"Ramps": [
155+
{
156+
"Start": {
157+
"command": f"S{idx:02d}",
158+
"type": "int",
159+
"access_mode": "rw",
160+
},
161+
"End": {
162+
"command": f"E{idx:02d}",
163+
"type": "int",
164+
"access_mode": "rw",
165+
},
166+
"Enabled": {
167+
"command": f"N{idx:02d}",
168+
"type": "int",
169+
"access_mode": "rw",
170+
},
171+
"Target": {
172+
"command": f"T{idx:02d}",
173+
"type": "float",
174+
"access_mode": "rw",
175+
},
176+
"Actual": {
177+
"command": f"A{idx:02d}",
178+
"type": "float",
179+
"access_mode": "rw",
180+
},
181+
}
182+
for idx in range(1, self._num + 1)
183+
],
184+
}
185+
)
186+
147187

148188
class TempControllerAdapter(CommandAdapter):
149189
device: TempControllerDevice
@@ -218,6 +258,10 @@ async def get_voltages(self) -> bytes:
218258
async def get_power(self) -> bytes:
219259
return str(self.device.get_power()).encode("utf-8")
220260

261+
@RegexCommand(r"API\?", False, "utf-8")
262+
async def get_commands(self) -> bytes:
263+
return str(self.device.get_commands()).encode("utf-8")
264+
221265
@RegexCommand(r"\w*", False, "utf-8")
222266
async def ignore_whitespace(self) -> None:
223267
pass

0 commit comments

Comments
 (0)