Skip to content

Commit 9a8d0a4

Browse files
committed
Add attenuator motor squad to i19 access controlled devices
* Add attenuator motor squad class to represent a set of mutually cooperating motors which are not co-mounted ( therefore do not form a physical stage ); but rather act in concert * Add initimately related position demand class used as driving argument to command motor squad to alter the posture of a beamline's transmission system * Add tests for both new classes: AttenuatorMotorPositionDemands AttenuatorMotorSquad * Adds access controlled subdirectory in the i19 device tests (to match the src devices file system layout) * Moves established test class for access controlled I19 shutters into the access controlled subdirectory dodal#1384
1 parent dff7317 commit 9a8d0a4

File tree

7 files changed

+353
-5
lines changed

7 files changed

+353
-5
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from typing import Annotated, Any, Self
2+
3+
from ophyd_async.core import AsyncStatus
4+
from pydantic import BaseModel, model_validator
5+
from pydantic.types import PositiveInt, StringConstraints
6+
7+
from dodal.devices.i19.access_controlled.blueapi_device import (
8+
OpticsBlueAPIDevice,
9+
)
10+
from dodal.devices.i19.access_controlled.hutch_access import ACCESS_DEVICE_NAME
11+
12+
PermittedKeyStr = Annotated[str, StringConstraints(pattern="^[A-Za-z0-9-_]*$")]
13+
14+
15+
class AttenuatorMotorPositionDemands(BaseModel):
16+
continuous_demands: dict[PermittedKeyStr, float] = {}
17+
indexed_demands: dict[PermittedKeyStr, PositiveInt] = {}
18+
19+
@model_validator(mode="after")
20+
def no_keys_clash(self) -> Self:
21+
common_key_filter = filter(
22+
lambda k: k in self.continuous_demands, self.indexed_demands
23+
)
24+
common_key_count = sum(1 for _ in common_key_filter)
25+
if common_key_count < 1:
26+
return self
27+
else:
28+
ks: str = "key" if common_key_count == 1 else "keys"
29+
error_msg = (
30+
f"{common_key_count} common {ks} found in distinct motor demands"
31+
)
32+
raise ValueError(error_msg)
33+
34+
def restful_format(self) -> dict[PermittedKeyStr, Any]:
35+
return self.continuous_demands | self.indexed_demands
36+
37+
38+
class AttenuatorMotorSquad(OpticsBlueAPIDevice):
39+
""" I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
40+
41+
Sends REST call to blueapi controlling optics on the I19 cluster.
42+
The hutch in use is compared against the hutch which sent the REST call.
43+
Only the hutch in use will be permitted to execute a plan (requesting motor moves).
44+
As the two hutches are located in series, checking the hutch in use is necessary to \
45+
avoid accidentally operating optics devices from one hutch while the other has beam time.
46+
47+
The name of the hutch that wants to operate the optics device is passed to the \
48+
access controlled device upon instantiation of the latter.
49+
50+
For details see the architecture described in \
51+
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
52+
"""
53+
54+
@AsyncStatus.wrap
55+
async def set(self, value: AttenuatorMotorPositionDemands):
56+
request_params = {
57+
"name": "operate_motor_squad_plan",
58+
"params": {
59+
"experiment_hutch": self._get_invoking_hutch(),
60+
"access_device": ACCESS_DEVICE_NAME,
61+
"attenuator_demands": value.restful_format(),
62+
},
63+
"instrument_session": self.instrument_session,
64+
}
65+
await super().set(request_params)

src/dodal/devices/i19/access_controlled/blueapi_device.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ class OpticsBlueAPIDevice(StandardReadable, Movable[D]):
2929
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
3030
"""
3131

32-
def __init__(self, name: str = "") -> None:
32+
def __init__(
33+
self, hutch: HutchState, instrument_session: str = "", name: str = ""
34+
) -> None:
35+
self.hutch_request = hutch
36+
self.instrument_session = instrument_session
3337
self.url = OPTICS_BLUEAPI_URL
3438
self.headers = HEADERS
3539
super().__init__(name)
3640

41+
def _get_invoking_hutch(self) -> str:
42+
return self.hutch_request.value
43+
3744
@AsyncStatus.wrap
3845
async def set(self, value: D):
3946
""" On set send a POST request to the optics blueapi with the name and \

src/dodal/devices/i19/access_controlled/shutter.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,14 @@ def __init__(
3636
# see https://github.com/DiamondLightSource/blueapi/issues/1187
3737
with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
3838
self.shutter_status = epics_signal_r(ShutterState, f"{prefix}STA")
39-
self.hutch_request = hutch
40-
self.instrument_session = instrument_session
41-
super().__init__(name)
39+
super().__init__(hutch=hutch, instrument_session=instrument_session, name=name)
4240

4341
@AsyncStatus.wrap
4442
async def set(self, value: ShutterDemand):
4543
request_params = {
4644
"name": "operate_shutter_plan",
4745
"params": {
48-
"experiment_hutch": self.hutch_request.value,
46+
"experiment_hutch": self._get_invoking_hutch(),
4947
"access_device": ACCESS_DEVICE_NAME,
5048
"shutter_demand": value.value,
5149
},

tests/devices/i19/access_controlled/__init__.py

Whitespace-only changes.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from unittest.mock import AsyncMock, MagicMock, patch
2+
3+
import pytest
4+
from aiohttp.client import ClientConnectionError
5+
from bluesky.run_engine import RunEngine
6+
7+
from dodal.devices.i19.access_controlled.attenuator_motor_squad import (
8+
AttenuatorMotorPositionDemands,
9+
AttenuatorMotorSquad,
10+
)
11+
from dodal.devices.i19.access_controlled.blueapi_device import HutchState
12+
13+
14+
def given_position_demands() -> AttenuatorMotorPositionDemands:
15+
position_demand = MagicMock()
16+
restful_payload = {"x": 54.3, "y": 72.1, "w": 4}
17+
position_demand.restful_format = MagicMock(return_value=restful_payload)
18+
return position_demand
19+
20+
21+
def given_an_unhappy_restful_response() -> AsyncMock:
22+
unhappy_response = AsyncMock()
23+
unhappy_response.ok = False
24+
unhappy_response.json.return_value = {"task_id": "alas_not"}
25+
return unhappy_response
26+
27+
28+
async def given_a_squad_of_attenuator_motors(
29+
hutch: HutchState,
30+
) -> AttenuatorMotorSquad:
31+
motor_squad = AttenuatorMotorSquad(
32+
hutch, instrument_session="cm54321-0", name="attenuator_motor_squad"
33+
)
34+
await motor_squad.connect(mock=True)
35+
36+
motor_squad.url = "http://test-blueapi.url"
37+
return motor_squad
38+
39+
40+
@pytest.fixture
41+
async def eh1_motor_squad(re: RunEngine) -> AttenuatorMotorSquad:
42+
return await given_a_squad_of_attenuator_motors(HutchState.EH1)
43+
44+
45+
@pytest.fixture
46+
async def eh2_motor_squad(re: RunEngine) -> AttenuatorMotorSquad:
47+
return await given_a_squad_of_attenuator_motors(HutchState.EH2)
48+
49+
50+
@pytest.mark.parametrize("invoking_hutch", [HutchState.EH1, HutchState.EH2])
51+
async def test_that_motor_squad_can_be_instantiated(invoking_hutch):
52+
motor_squad: AttenuatorMotorSquad = await given_a_squad_of_attenuator_motors(
53+
invoking_hutch
54+
)
55+
assert isinstance(motor_squad, AttenuatorMotorSquad)
56+
57+
58+
@pytest.mark.parametrize("invoking_hutch", [HutchState.EH1, HutchState.EH2])
59+
@patch(
60+
"dodal.devices.i19.access_controlled.attenuator_motor_squad.OpticsBlueAPIDevice.set",
61+
new_callable=AsyncMock,
62+
)
63+
async def test_when_motor_squad_is_set_that_expected_request_params_are_passed(
64+
internal_setter, invoking_hutch
65+
):
66+
motors: AttenuatorMotorSquad = await given_a_squad_of_attenuator_motors(
67+
invoking_hutch
68+
)
69+
position_demands: AttenuatorMotorPositionDemands = given_position_demands()
70+
await motors.set(position_demands) # when motor position demand is set
71+
72+
expected_hutch: str = invoking_hutch.value
73+
expected_device: str = "access_control"
74+
expected_request_name: str = "operate_motor_squad_plan"
75+
expected_parameters: dict = {
76+
"experiment_hutch": expected_hutch,
77+
"access_device": expected_device,
78+
"attenuator_demands": {"x": 54.3, "y": 72.1, "w": 4},
79+
}
80+
expected_instrument_session: str = "cm54321-0"
81+
expected_request: dict = {
82+
"name": expected_request_name,
83+
"params": expected_parameters,
84+
"instrument_session": expected_instrument_session,
85+
}
86+
internal_setter.assert_called_once_with(expected_request)
87+
88+
89+
@pytest.mark.parametrize("invoking_hutch", [HutchState.EH1, HutchState.EH2])
90+
@patch("dodal.devices.i19.access_controlled.blueapi_device.ClientSession.post")
91+
async def test_when_rest_post_unsuccessful_that_error_raised(
92+
unsuccessful_post, invoking_hutch
93+
):
94+
motors: AttenuatorMotorSquad = await given_a_squad_of_attenuator_motors(
95+
invoking_hutch
96+
)
97+
with pytest.raises(ClientConnectionError):
98+
restful_response: AsyncMock = given_an_unhappy_restful_response()
99+
unsuccessful_post.return_value.__aenter__.return_value = restful_response
100+
101+
postion_demands = given_position_demands()
102+
await motors.set(postion_demands)
103+
104+
105+
@pytest.mark.parametrize("invoking_hutch", [HutchState.EH1, HutchState.EH2])
106+
@patch("dodal.devices.i19.access_controlled.blueapi_device.LOGGER")
107+
@patch("dodal.devices.i19.access_controlled.blueapi_device.ClientSession.put")
108+
@patch("dodal.devices.i19.access_controlled.blueapi_device.ClientSession.post")
109+
async def test_that_error_is_logged_when_response_to_position_demand_set_indicates_failure(
110+
restful_post, restful_put, logger, invoking_hutch
111+
):
112+
response_to_post: AsyncMock = AsyncMock()
113+
response_to_post.ok = True
114+
response_to_post.json.return_value = {"task_id": "0123"}
115+
restful_post.return_value.__aenter__.return_value = response_to_post
116+
117+
response_to_put = AsyncMock()
118+
response_to_put.ok = False
119+
restful_put.return_value.__aenter__.return_value = response_to_put
120+
121+
motors: AttenuatorMotorSquad = await given_a_squad_of_attenuator_motors(
122+
invoking_hutch
123+
)
124+
position_demands: AttenuatorMotorPositionDemands = given_position_demands()
125+
126+
logger.error.assert_not_called()
127+
await motors.set(position_demands)
128+
logger.error.assert_called_once()
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import pytest
2+
3+
from dodal.devices.i19.access_controlled.attenuator_motor_squad import (
4+
AttenuatorMotorPositionDemands,
5+
)
6+
7+
8+
def test_that_attenuator_position_demand_can_be_created_with_only_one_wedge():
9+
wedge_position_demands = {"x": 0.5}
10+
wheel_position_demands = {}
11+
position_demand = AttenuatorMotorPositionDemands(
12+
continuous_demands=wedge_position_demands,
13+
indexed_demands=wheel_position_demands,
14+
)
15+
assert position_demand is not None
16+
17+
18+
def test_that_attenuator_position_demand_with_only_one_wedge_provides_expected_rest_format():
19+
wedge_position_demands = {"y": 14.9}
20+
wheel_position_demands = {}
21+
position_demand = AttenuatorMotorPositionDemands(
22+
continuous_demands=wedge_position_demands,
23+
indexed_demands=wheel_position_demands,
24+
)
25+
restful_payload = position_demand.restful_format()
26+
assert restful_payload["y"] == 14.9
27+
28+
29+
def test_that_attenuator_position_demand_can_be_created_with_only_one_wheel():
30+
wedge_position_demands = {}
31+
wheel_position_demands = {"w": 2}
32+
position_demand = AttenuatorMotorPositionDemands(
33+
continuous_demands=wedge_position_demands,
34+
indexed_demands=wheel_position_demands,
35+
)
36+
assert position_demand is not None
37+
38+
39+
def test_that_attenuator_position_demand_with_only_one_wheel_provides_expected_rest_format():
40+
wedge_position_demands = {}
41+
wheel_position_demands = {"w": 6}
42+
position_demand = AttenuatorMotorPositionDemands(
43+
continuous_demands=wedge_position_demands,
44+
indexed_demands=wheel_position_demands,
45+
)
46+
restful_payload = position_demand.restful_format()
47+
assert restful_payload["w"] == 6
48+
49+
50+
def test_that_empty_attenuator_position_demand_can_be_created():
51+
wedge_position_demands = {}
52+
wheel_position_demands = {}
53+
position_demand = AttenuatorMotorPositionDemands(
54+
continuous_demands=wedge_position_demands,
55+
indexed_demands=wheel_position_demands,
56+
)
57+
assert position_demand is not None
58+
59+
60+
def test_that_empty_attenuator_position_demand_provides_empty_rest_format():
61+
wedge_position_demands = {}
62+
wheel_position_demands = {}
63+
position_demand = AttenuatorMotorPositionDemands(
64+
continuous_demands=wedge_position_demands,
65+
indexed_demands=wheel_position_demands,
66+
)
67+
restful_payload = position_demand.restful_format()
68+
assert restful_payload == {}
69+
70+
71+
def test_that_attenuator_position_demand_triplet_can_be_created():
72+
standard_wedge_position_demand = {"x": 25.9, "y": 5.0}
73+
standard_wheel_position_demand = {"w": 4}
74+
position_demand = AttenuatorMotorPositionDemands(
75+
continuous_demands=standard_wedge_position_demand,
76+
indexed_demands=standard_wheel_position_demand,
77+
)
78+
assert position_demand is not None
79+
80+
81+
def test_that_attenuator_position_demand_triplet_provides_expected_rest_format():
82+
wedge_position_demands = {"x": 0.1, "y": 90.1}
83+
wheel_position_demands = {"w": 6}
84+
position_demand = AttenuatorMotorPositionDemands(
85+
continuous_demands=wedge_position_demands,
86+
indexed_demands=wheel_position_demands,
87+
)
88+
restful_payload = position_demand.restful_format()
89+
assert restful_payload == {"x": 0.1, "y": 90.1, "w": 6}
90+
91+
92+
# Happy path tests above
93+
94+
# Unhappy path tests below
95+
96+
97+
def test_that_attenuator_position_raises_error_when_discrete_and_continuous_demands_overload_axis_label():
98+
wedge_position_demands = {"x": 0.1, "v": 90.1}
99+
wheel_position_demands = {"w": 6, "v": 7}
100+
preamble: str = (
101+
"1 validation error for AttenuatorMotorPositionDemands\n Value error,"
102+
)
103+
anticipated_message: str = (
104+
f"{preamble} 1 common key found in distinct motor demands"
105+
)
106+
with pytest.raises(expected_exception=ValueError, match=anticipated_message):
107+
AttenuatorMotorPositionDemands(
108+
continuous_demands=wedge_position_demands,
109+
indexed_demands=wheel_position_demands,
110+
)
111+
112+
113+
def test_that_attenuator_position_creation_raises_error_when_continuous_position_demand_is_none():
114+
wedge_position_demands = {"x": None, "y": 90.1}
115+
wheel_position_demands = {}
116+
with pytest.raises(expected_exception=ValueError):
117+
AttenuatorMotorPositionDemands(
118+
continuous_demands=wedge_position_demands,
119+
indexed_demands=wheel_position_demands,
120+
)
121+
122+
123+
def test_that_attenuator_position_creation_raises_error_when_indexed_position_demand_is_none():
124+
wedge_position_demands = {"x": 14.88, "y": 90.1}
125+
wheel_position_demands = {"w": None, "v": 3}
126+
with pytest.raises(expected_exception=ValueError):
127+
AttenuatorMotorPositionDemands(
128+
continuous_demands=wedge_position_demands,
129+
indexed_demands=wheel_position_demands,
130+
)
131+
132+
133+
def test_that_attenuator_position_creation_raises_error_when_continuous_position_key_is_none():
134+
wedge_position_demands = {"x": 32.65, None: 80.1}
135+
wheel_position_demands = {"w": 8}
136+
with pytest.raises(expected_exception=ValueError):
137+
AttenuatorMotorPositionDemands(
138+
continuous_demands=wedge_position_demands,
139+
indexed_demands=wheel_position_demands,
140+
)
141+
142+
143+
def test_that_attenuator_position_creation_raises_error_when_indexed_position_key_is_none():
144+
wedge_position_demands = {"x": 24.08, "y": 71.4}
145+
wheel_position_demands = {"w": 1, None: 2}
146+
with pytest.raises(expected_exception=ValueError):
147+
AttenuatorMotorPositionDemands(
148+
continuous_demands=wedge_position_demands,
149+
indexed_demands=wheel_position_demands,
150+
)

0 commit comments

Comments
 (0)