Skip to content

Commit 00a4573

Browse files
committed
feat(scan): implement DeviceRpc class and associated tests for RPC functionality
1 parent 3aed9d5 commit 00a4573

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""
2+
Device RPC implementation.
3+
4+
Scan procedure:
5+
- prepare_scan
6+
- open_scan
7+
- stage
8+
- pre_scan
9+
- scan_core
10+
- at_each_point (optionally called by scan_core)
11+
- post_scan
12+
- unstage
13+
- close_scan
14+
- on_exception (called if any exception is raised during the scan)
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Annotated
20+
21+
from bec_lib.device import DeviceBase
22+
from bec_lib.logger import bec_logger
23+
from bec_lib.scan_args import ScanArgument
24+
from bec_server.scan_server.scans.scan_base import ScanBase
25+
from bec_server.scan_server.scans.scan_modifier import scan_hook
26+
27+
logger = bec_logger.logger
28+
29+
30+
class DeviceRpc(ScanBase):
31+
# Scan Type: Hardware triggered or software triggered?
32+
# If the main trigger and readout logic is done within the at_each_point method in scan_core, choose SOFTWARE_TRIGGERED.
33+
# If the main trigger and readout logic is implemented on a device that is simply kicked off in this scan, choose HARDWARE_TRIGGERED.
34+
# This primarily serves as information for devices: The device may need to react differently if a software trigger is expected
35+
# for every point.
36+
scan_type = None
37+
is_scan = False
38+
39+
# Scan name: This is the name of the scan, e.g. "line_scan". This is used for display purposes and to identify the scan type in user interfaces.
40+
# Choose a descriptive name that does not conflict with existing scan names.
41+
# It must be a valid Python identifier, that is, it can only contain letters, numbers, and underscores, and must not start with a number.
42+
scan_name = "_v4_device_rpc"
43+
44+
def __init__(
45+
self,
46+
device: Annotated[DeviceBase, ScanArgument(display_name="Device", description="Device.")],
47+
func: Annotated[
48+
str, ScanArgument(display_name="RPC function", description="RPC function.")
49+
],
50+
func_args: Annotated[
51+
list,
52+
ScanArgument(
53+
display_name="RPC function arguments", description="RPC function arguments."
54+
),
55+
],
56+
func_kwargs: Annotated[
57+
dict,
58+
ScanArgument(
59+
display_name="RPC function keyword arguments",
60+
description="RPC function keyword arguments.",
61+
),
62+
],
63+
rpc_id: Annotated[str, ScanArgument(display_name="RPC ID", description="RPC ID.")],
64+
**kwargs,
65+
):
66+
"""
67+
Scan implementation.
68+
69+
Args:
70+
device (DeviceBase): Device.
71+
func (str): RPC function.
72+
func_args (list): RPC function arguments.
73+
func_kwargs (dict): RPC function keyword arguments.
74+
75+
Returns:
76+
ScanReport
77+
"""
78+
super().__init__(**kwargs)
79+
self.device = device
80+
self.rpc_id = rpc_id
81+
self.func = func
82+
self.func_args = func_args
83+
self.func_kwargs = func_kwargs
84+
85+
@scan_hook
86+
def prepare_scan(self):
87+
"""
88+
Prepare the scan. This can include any steps that need to be executed
89+
before the scan is opened, such as preparing the positions (if not done already)
90+
or setting up the devices.
91+
"""
92+
93+
@scan_hook
94+
def open_scan(self):
95+
"""
96+
Open the scan.
97+
This step must call self.actions.open_scan() to ensure that a new scan is
98+
opened. Make sure to prepare the scan metadata before, either in
99+
prepare_scan() or in open_scan() itself and call self.update_scan_info(...)
100+
to update the scan metadata if needed.
101+
"""
102+
103+
@scan_hook
104+
def stage(self):
105+
"""
106+
Stage the devices for the upcoming scan. The stage logic is typically
107+
implemented on the device itself (i.e. by the device's stage method).
108+
However, if there are any additional steps that need to be executed before
109+
staging the devices, they can be implemented here.
110+
"""
111+
112+
@scan_hook
113+
def pre_scan(self):
114+
"""
115+
Pre-scan steps to be executed before the main scan logic.
116+
This is typically the last chance to prepare the devices before the core scan
117+
logic is executed. For example, this is a good place to initialize time-criticial
118+
devices, e.g. devices that have a short timeout.
119+
The pre-scan logic is typically implemented on the device itself.
120+
"""
121+
122+
@scan_hook
123+
def scan_core(self):
124+
"""
125+
Core scan logic to be executed during the scan.
126+
This is where the main scan logic should be implemented.
127+
"""
128+
status = self.actions.rpc_call_no_wait(
129+
self.device, self.func, self.rpc_id, *self.func_args, **self.func_kwargs
130+
)
131+
# We don't wait for the RPC to resolve
132+
self.actions._status_registry.pop(status._device_instr_id)
133+
134+
@scan_hook
135+
def at_each_point(self):
136+
"""
137+
Logic to be executed at each acquisition point during the scan.
138+
"""
139+
140+
@scan_hook
141+
def post_scan(self):
142+
"""
143+
Post-scan steps to be executed after the main scan logic.
144+
"""
145+
146+
@scan_hook
147+
def unstage(self):
148+
"""Unstage the scan by executing post-scan steps."""
149+
150+
@scan_hook
151+
def close_scan(self):
152+
"""Close the scan."""
153+
154+
@scan_hook
155+
def on_exception(self, exception: Exception):
156+
"""
157+
Handle exceptions that occur during the scan.
158+
This is a good place to implement any cleanup logic that needs to be executed in case of an exception,
159+
such as returning the devices to a safe state or moving the motors back to their starting position.
160+
"""
161+
162+
#######################################################
163+
######### Helper methods for the scan logic ###########
164+
#######################################################
165+
166+
# Implement scan-specific helper methods below.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from unittest import mock
2+
3+
import pytest
4+
5+
6+
@pytest.mark.parametrize(
7+
("hook_name",),
8+
[
9+
("prepare_scan",),
10+
("open_scan",),
11+
("stage",),
12+
("pre_scan",),
13+
("post_scan",),
14+
("unstage",),
15+
("close_scan",),
16+
],
17+
)
18+
def test_device_rpc_scan_default_noop_hooks_do_not_raise(v4_scan_assembler, hook_name):
19+
scan = v4_scan_assembler("_v4_device_rpc", "samx", "read", [], {}, rpc_id="rpc-id-123")
20+
21+
getattr(scan, hook_name)()
22+
23+
24+
def test_device_rpc_scan_core_sends_fire_and_forget_rpc(v4_scan_assembler):
25+
scan = v4_scan_assembler(
26+
"_v4_device_rpc",
27+
"samx",
28+
"controller.set_mode",
29+
[1, "fast"],
30+
{"armed": True},
31+
rpc_id="rpc-id-123",
32+
)
33+
status = mock.MagicMock(_device_instr_id="device-instr-id")
34+
scan.actions.rpc_call_no_wait = mock.MagicMock(return_value=status)
35+
scan.actions._status_registry = {"device-instr-id": status}
36+
37+
scan.scan_core()
38+
39+
scan.actions.rpc_call_no_wait.assert_called_once_with(
40+
scan.device, "controller.set_mode", "rpc-id-123", 1, "fast", armed=True
41+
)
42+
assert scan.actions._status_registry == {}
43+
44+
45+
def test_device_rpc_scan_is_registered_as_non_scan(v4_scan_assembler):
46+
scan = v4_scan_assembler("_v4_device_rpc", "samx", "read", [], {}, rpc_id="rpc-id-123")
47+
48+
assert scan.scan_name == "_v4_device_rpc"
49+
assert scan.is_scan is False
50+
assert scan.scan_info.scan_type is None

0 commit comments

Comments
 (0)