Skip to content

Commit ac02784

Browse files
authored
Implement robot unload for udc default state (#1607)
* Implement robot unload for udc default state
1 parent e912733 commit ac02784

File tree

5 files changed

+142
-30
lines changed

5 files changed

+142
-30
lines changed

src/mx_bluesky/common/device_setup_plans/robot_load_unload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def robot_unload(
7373
smargon: Smargon,
7474
aperture_scatterguard: ApertureScatterguard,
7575
lower_gonio: XYZStage,
76-
visit: str,
76+
visit: str | None,
7777
):
7878
"""Unloads the currently mounted pin into the location that it was loaded from. The
7979
loaded location is stored on the robot and so need not be provided.

src/mx_bluesky/hyperion/experiment_plans/udc_default_state.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dodal.common.beamlines.beamline_parameters import (
55
get_beamline_parameters,
66
)
7-
from dodal.devices.aperturescatterguard import ApertureValue
7+
from dodal.devices.aperturescatterguard import ApertureScatterguard, ApertureValue
88
from dodal.devices.collimation_table import CollimationTable
99
from dodal.devices.cryostream import (
1010
CryoStreamGantry,
@@ -16,6 +16,7 @@
1616
from dodal.devices.fluorescence_detector_motion import FluorescenceDetector
1717
from dodal.devices.fluorescence_detector_motion import InOut as FlouInOut
1818
from dodal.devices.hutch_shutter import HutchShutter, ShutterDemand
19+
from dodal.devices.motors import XYZStage
1920
from dodal.devices.mx_phase1.beamstop import BeamstopPositions
2021
from dodal.devices.oav.oav_detector import OAV
2122
from dodal.devices.robot import BartRobot, PinMounted
@@ -24,6 +25,7 @@
2425
from dodal.devices.smargon import Smargon
2526
from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutterState
2627

28+
from mx_bluesky.common.device_setup_plans.robot_load_unload import robot_unload
2729
from mx_bluesky.common.experiment_plans.beamstop_check import (
2830
BeamstopCheckDevices,
2931
move_beamstop_in_and_verify_using_diode,
@@ -47,6 +49,7 @@ class UDCDefaultDevices(BeamstopCheckDevices):
4749
cryostream_gantry: CryoStreamGantry
4850
fluorescence_det_motion: FluorescenceDetector
4951
hutch_shutter: HutchShutter
52+
lower_gonio: XYZStage
5053
robot: BartRobot
5154
scintillator: Scintillator
5255
gonio: Smargon
@@ -65,8 +68,6 @@ def move_to_udc_default_state(devices: UDCDefaultDevices):
6568

6669
yield from _check_cryostream(devices)
6770

68-
yield from _verify_no_sample_present(devices.robot)
69-
7071
# Close fast shutter before opening hutch shutter
7172
yield from bps.abs_set(devices.sample_shutter, ZebraShutterState.CLOSE, wait=True)
7273

@@ -108,6 +109,13 @@ def move_to_udc_default_state(devices: UDCDefaultDevices):
108109
# Wait for all of the above to complete
109110
yield from bps.wait(group=_GROUP_PRE_BEAMSTOP_CHECK, timeout=10)
110111

112+
yield from _unload_sample_if_present(
113+
devices.robot,
114+
devices.gonio,
115+
devices.aperture_scatterguard,
116+
devices.lower_gonio,
117+
)
118+
111119
feature_flags: HyperionFeatureSettings = (
112120
get_hyperion_config_client().get_feature_flags()
113121
)
@@ -175,11 +183,16 @@ def _verify_correct_cryostream_selected(
175183
)
176184

177185

178-
def _verify_no_sample_present(robot: BartRobot):
186+
def _unload_sample_if_present(
187+
robot: BartRobot,
188+
smargon: Smargon,
189+
aperture_scatterguard: ApertureScatterguard,
190+
lower_gonio: XYZStage,
191+
):
179192
pin_mounted = yield from bps.rd(robot.gonio_pin_sensor)
180193

181194
if pin_mounted != PinMounted.NO_PIN_MOUNTED:
182-
# Cannot unload this sample because we do not know the correct visit for it
183-
raise UnexpectedSampleError(
184-
"An unexpected sample was found, please unload the sample manually."
195+
LOGGER.info("Pin detected, unloading sample...")
196+
yield from robot_unload(
197+
robot, smargon, aperture_scatterguard, lower_gonio, None
185198
)

src/mx_bluesky/hyperion/external_interaction/callbacks/robot_actions/ispyb_callback.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,24 @@ def activity_gated_start(self, doc: RunStart):
5151
ISPYB_ZOCALO_CALLBACK_LOGGER.debug(
5252
f"ISPyB robot load callback received: {doc}"
5353
)
54-
self.run_uid = doc.get("uid")
5554
metadata = doc.get("metadata")
5655
assert isinstance(metadata, dict)
57-
self._sample_id = metadata["sample_id"]
58-
assert isinstance(self._sample_id, int)
59-
proposal, session = get_proposal_and_session_from_visit_string(
60-
metadata["visit"]
61-
)
62-
self.action_id = self.expeye.start_robot_action(
63-
"LOAD" if subplan == CONST.PLAN.ROBOT_LOAD else "UNLOAD",
64-
proposal,
65-
session,
66-
self._sample_id,
67-
)
56+
self._sample_id = metadata.get("sample_id")
57+
if not isinstance(self._sample_id, int):
58+
ISPYB_ZOCALO_CALLBACK_LOGGER.info(
59+
"No sample ID provided, not recording robot action in ispyb."
60+
)
61+
else:
62+
self.run_uid = doc.get("uid")
63+
proposal, session = get_proposal_and_session_from_visit_string(
64+
metadata["visit"]
65+
)
66+
self.action_id = self.expeye.start_robot_action(
67+
"LOAD" if subplan == CONST.PLAN.ROBOT_LOAD else "UNLOAD",
68+
proposal,
69+
session,
70+
self._sample_id,
71+
)
6872
return super().activity_gated_start(doc)
6973

7074
def activity_gated_descriptor(self, doc: EventDescriptor) -> EventDescriptor | None:
@@ -73,7 +77,11 @@ def activity_gated_descriptor(self, doc: EventDescriptor) -> EventDescriptor | N
7377

7478
def activity_gated_event(self, doc: Event) -> Event | None:
7579
event_descriptor = self.descriptors.get(doc["descriptor"])
76-
if (
80+
if self._sample_id is None:
81+
ISPYB_ZOCALO_CALLBACK_LOGGER.info(
82+
"Ignoring robot update event as no sample ID provided."
83+
)
84+
elif (
7785
event_descriptor
7886
and event_descriptor.get("name") == CONST.DESCRIPTORS.ROBOT_UPDATE
7987
):

tests/unit_tests/hyperion/experiment_plans/test_udc_default_state.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dodal.devices.fluorescence_detector_motion import FluorescenceDetector
1818
from dodal.devices.fluorescence_detector_motion import InOut as FlouInOut
1919
from dodal.devices.hutch_shutter import HutchShutter, ShutterDemand
20+
from dodal.devices.motors import XYZStage
2021
from dodal.devices.mx_phase1.beamstop import BeamstopPositions
2122
from dodal.devices.robot import BartRobot, PinMounted
2223
from dodal.devices.scintillator import InOut, Scintillator
@@ -27,7 +28,6 @@
2728
from mx_bluesky.hyperion.experiment_plans.udc_default_state import (
2829
CryoStreamError,
2930
UDCDefaultDevices,
30-
UnexpectedSampleError,
3131
move_to_udc_default_state,
3232
)
3333
from mx_bluesky.hyperion.parameters.constants import CONST, HyperionFeatureSettings
@@ -64,6 +64,7 @@ async def default_devices(
6464
hutch_shutter = HutchShutter("")
6565
scintillator = Scintillator("", MagicMock(), MagicMock(), name="scin")
6666
collimation_table = CollimationTable("")
67+
lower_gonio = XYZStage("")
6768

6869
with patch("dodal.devices.hutch_shutter.TEST_MODE", True):
6970
devices = UDCDefaultDevices(
@@ -73,6 +74,7 @@ async def default_devices(
7374
cryostream_gantry=cryostream_gantry,
7475
fluorescence_det_motion=fluo,
7576
hutch_shutter=hutch_shutter,
77+
lower_gonio=lower_gonio,
7678
robot=robot,
7779
scintillator=scintillator,
7880
gonio=smargon,
@@ -156,6 +158,10 @@ def test_udc_default_state_runs_in_real_run_engine(
156158
run_engine(move_to_udc_default_state(default_devices))
157159

158160

161+
@patch(
162+
"mx_bluesky.hyperion.experiment_plans.udc_default_state._unload_sample_if_present",
163+
MagicMock(return_value=iter([Msg("robot_unload")])),
164+
)
159165
def test_beamstop_moved_to_data_collection_if_diode_check_not_enabled(
160166
sim_run_engine: RunEngineSimulator,
161167
default_devices: UDCDefaultDevices,
@@ -166,13 +172,14 @@ def test_beamstop_moved_to_data_collection_if_diode_check_not_enabled(
166172
msgs,
167173
lambda msg: msg.command == "wait" and msg.kwargs["group"] == pre_beamstop_group,
168174
)
175+
assert msgs[1].command == "robot_unload"
169176
assert (
170-
msgs[1].command == "set"
171-
and msgs[1].obj is default_devices.beamstop.selected_pos
172-
and msgs[1].args[0] == BeamstopPositions.DATA_COLLECTION
177+
msgs[2].command == "set"
178+
and msgs[2].obj is default_devices.beamstop.selected_pos
179+
and msgs[2].args[0] == BeamstopPositions.DATA_COLLECTION
173180
)
174181
assert (
175-
msgs[2].command == "wait" and msgs[2].kwargs["group"] == msgs[1].kwargs["group"]
182+
msgs[3].command == "wait" and msgs[3].kwargs["group"] == msgs[2].kwargs["group"]
176183
)
177184

178185

@@ -306,7 +313,19 @@ def test_udc_default_state_checks_cryostream_selection(
306313
run_engine(move_to_udc_default_state(default_devices))
307314

308315

309-
def test_udc_default_state_checks_that_pin_not_mounted(
316+
@patch(
317+
"mx_bluesky.hyperion.experiment_plans.udc_default_state._verify_correct_cryostream_selected",
318+
MagicMock(return_value=iter([Msg("cryostream_gantry_check")])),
319+
)
320+
@patch(
321+
"mx_bluesky.hyperion.experiment_plans.udc_default_state._check_cryostream",
322+
MagicMock(return_value=iter([Msg("cryostream_check")])),
323+
)
324+
@patch(
325+
"mx_bluesky.hyperion.experiment_plans.udc_default_state.robot_unload",
326+
MagicMock(return_value=iter([Msg("unload")])),
327+
)
328+
def test_udc_default_state_unloads_if_sample_present(
310329
default_devices, sim_run_engine, beamline_parameters
311330
):
312331
sim_run_engine.add_read_handler_for(
@@ -316,8 +335,38 @@ def test_udc_default_state_checks_that_pin_not_mounted(
316335
"mx_bluesky.hyperion.experiment_plans.udc_default_state.get_beamline_parameters",
317336
return_value=beamline_parameters,
318337
):
319-
with pytest.raises(UnexpectedSampleError):
320-
sim_run_engine.simulate_plan(move_to_udc_default_state(default_devices))
338+
msgs = sim_run_engine.simulate_plan(move_to_udc_default_state(default_devices))
339+
340+
msgs = assert_message_and_return_remaining(
341+
msgs, lambda msg: msg.command == "cryostream_gantry_check"
342+
)
343+
msgs = assert_message_and_return_remaining(
344+
msgs, lambda msg: msg.command == "cryostream_check"
345+
)
346+
msgs = assert_message_and_return_remaining(
347+
msgs,
348+
lambda msg: msg.command == "set"
349+
and msg.obj == default_devices.scintillator.selected_pos
350+
and msg.args[0] == InOut.OUT,
351+
)
352+
msgs = assert_message_and_return_remaining(
353+
msgs, lambda msg: msg.command == "unload"
354+
)
355+
356+
357+
def test_udc_default_state_no_unload_if_no_sample_present(
358+
default_devices, sim_run_engine, beamline_parameters
359+
):
360+
sim_run_engine.add_read_handler_for(
361+
default_devices.robot.gonio_pin_sensor, PinMounted.NO_PIN_MOUNTED
362+
)
363+
with patch(
364+
"mx_bluesky.hyperion.experiment_plans.udc_default_state.get_beamline_parameters",
365+
return_value=beamline_parameters,
366+
):
367+
msgs = sim_run_engine.simulate_plan(move_to_udc_default_state(default_devices))
368+
369+
assert not [m for m in msgs if m.command == "unload"]
321370

322371

323372
def test_default_state_closes_sample_shutter_before_open_hutch_shutter(

tests/unit_tests/hyperion/external_interaction/callbacks/robot_load/test_robot_load_ispyb_callback.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import MagicMock, patch
1+
from unittest.mock import MagicMock, Mock, patch
22

33
import bluesky.plan_stubs as bps
44
import bluesky.preprocessors as bpp
@@ -35,6 +35,17 @@
3535
],
3636
}
3737

38+
metadata_unload_no_visit = {
39+
"subplan_name": CONST.PLAN.ROBOT_UNLOAD,
40+
"metadata": {
41+
"sample_puck": SAMPLE_PUCK,
42+
"sample_pin": SAMPLE_PIN,
43+
},
44+
"activate_callbacks": [
45+
"RobotLoadISPyBCallback",
46+
],
47+
}
48+
3849
update_doc_data = {
3950
"sampleBarcode": "BARCODE",
4051
"xtalSnapshotBefore": "test_webcam_snapshot",
@@ -127,6 +138,15 @@ def unsuccessful_robot_load_plan():
127138
raise AssertionError("Test failure")
128139

129140

141+
@bpp.run_decorator(md=metadata_unload_no_visit)
142+
def robot_unload_plan(robot: BartRobot, oav: OAV, webcam: Webcam):
143+
yield from bps.create(name=CONST.DESCRIPTORS.ROBOT_UPDATE)
144+
yield from bps.read(robot)
145+
yield from bps.read(oav.snapshot)
146+
yield from bps.read(webcam)
147+
yield from bps.save()
148+
149+
130150
@patch(
131151
"mx_bluesky.hyperion.external_interaction.callbacks.robot_actions.ispyb_callback.ExpeyeInteraction",
132152
autospec=True,
@@ -195,3 +215,25 @@ def test_robot_load_fails_triggers_bl_sample_status_error(
195215
mock_sample_handling.return_value.update_sample_status.assert_called_with(
196216
SAMPLE_ID, BLSampleStatus.ERROR_BEAMLINE
197217
)
218+
219+
220+
@patch(
221+
"mx_bluesky.hyperion.external_interaction.callbacks.robot_actions.ispyb_callback.ExpeyeInteraction",
222+
autospec=True,
223+
)
224+
def test_robot_unload_event_without_sample_id_and_visit_is_ignored(
225+
mock_expeye: MagicMock,
226+
run_engine: RunEngine,
227+
robot: BartRobot,
228+
oav: OAV,
229+
webcam: Webcam,
230+
):
231+
unwrapped = RobotLoadISPyBCallback()
232+
callback = Mock(wraps=unwrapped)
233+
run_engine.subscribe(callback)
234+
235+
run_engine(robot_unload_plan(robot, oav, webcam))
236+
mock_expeye.return_value.start_robot_action.assert_not_called()
237+
mock_expeye.return_value.update_robot_action.assert_not_called()
238+
mock_expeye.return_value.end_robot_action.assert_not_called()
239+
mock_expeye.return_value.update_sample_status.assert_not_called()

0 commit comments

Comments
 (0)