diff --git a/src/hyperion/device_setup_plans/setup_panda.py b/src/hyperion/device_setup_plans/setup_panda.py index 94434cf0b..7c84d801f 100644 --- a/src/hyperion/device_setup_plans/setup_panda.py +++ b/src/hyperion/device_setup_plans/setup_panda.py @@ -140,12 +140,18 @@ def setup_panda_for_flyscan( # Home the PandA X encoder using current motor position yield from bps.abs_set( - panda.inenc[1].setp, initial_x * MM_TO_ENCODER_COUNTS, wait=True # type: ignore + panda.inenc[1].setp, # type: ignore + initial_x * MM_TO_ENCODER_COUNTS, + wait=True, ) LOGGER.info(f"Setting PandA clock to period {time_between_x_steps_ms}") - yield from bps.abs_set(panda.clock[1].period, time_between_x_steps_ms, group="panda-config") # type: ignore + yield from bps.abs_set( + panda.clock[1].period, # type: ignore + time_between_x_steps_ms, + group="panda-config", + ) yield from bps.abs_set( panda.pulse[1].width, DETECTOR_TRIGGER_WIDTH, group="panda-config" @@ -159,19 +165,24 @@ def setup_panda_for_flyscan( yield from bps.abs_set(panda.seq[1].table, table, group="panda-config") - # Wait here since we need PCAP to be enabled before armed - yield from bps.abs_set(panda.pcap.enable, Enabled.ENABLED.value, wait=True) # type: ignore - - yield from arm_panda_for_gridscan(panda, group="panda-config") + yield from bps.abs_set( + panda.pcap.enable, # type: ignore + Enabled.ENABLED.value, + group="panda-config", + ) + # Values need to be set before blocks are enabled, so wait here yield from bps.wait(group="panda-config", timeout=GENERAL_TIMEOUT) + yield from arm_panda_for_gridscan(panda) + def arm_panda_for_gridscan(panda: PandA, group="arm_panda_gridscan"): yield from bps.abs_set(panda.seq[1].enable, Enabled.ENABLED.value, group=group) # type: ignore yield from bps.abs_set(panda.pulse[1].enable, Enabled.ENABLED.value, group=group) # type: ignore yield from bps.abs_set(panda.counter[1].enable, Enabled.ENABLED.value, group=group) # type: ignore yield from bps.abs_set(panda.pcap.arm, PcapArm.ARMED.value, group=group) # type: ignore + yield from bps.wait(group=group, timeout=GENERAL_TIMEOUT) def disarm_panda_for_gridscan(panda, group="disarm_panda_gridscan") -> MsgGenerator: diff --git a/src/hyperion/experiment_plans/experiment_registry.py b/src/hyperion/experiment_plans/experiment_registry.py index 335bd1108..16b0f9d9e 100644 --- a/src/hyperion/experiment_plans/experiment_registry.py +++ b/src/hyperion/experiment_plans/experiment_registry.py @@ -17,6 +17,7 @@ from hyperion.external_interaction.callbacks.common.callback_util import ( CallbacksFactory, create_gridscan_callbacks, + create_robot_load_and_centre_callbacks, create_rotation_callbacks, ) from hyperion.parameters.plan_specific.grid_scan_with_edge_detect_params import ( @@ -101,7 +102,7 @@ class ExperimentRegistryEntry(TypedDict): "setup": robot_load_then_centre_plan.create_devices, "internal_param_type": RobotLoadThenCentreInternalParameters, "experiment_param_type": RobotLoadThenCentreParams, - "callbacks_factory": create_gridscan_callbacks, + "callbacks_factory": create_robot_load_and_centre_callbacks, }, } EXPERIMENT_NAMES = list(PLAN_REGISTRY.keys()) diff --git a/src/hyperion/experiment_plans/robot_load_then_centre_plan.py b/src/hyperion/experiment_plans/robot_load_then_centre_plan.py index da06b9c75..b83151fae 100644 --- a/src/hyperion/experiment_plans/robot_load_then_centre_plan.py +++ b/src/hyperion/experiment_plans/robot_load_then_centre_plan.py @@ -5,6 +5,7 @@ from typing import cast import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp from blueapi.core import BlueskyContext, MsgGenerator from dodal.devices.aperturescatterguard import AperturePositions, ApertureScatterguard from dodal.devices.attenuator import Attenuator @@ -45,6 +46,7 @@ set_energy_plan, ) from hyperion.log import LOGGER +from hyperion.parameters.constants import CONST from hyperion.parameters.plan_specific.pin_centre_then_xray_centre_params import ( PinCentreThenXrayCentreInternalParameters, ) @@ -128,7 +130,6 @@ def prepare_for_robot_load(composite: RobotLoadThenCentreComposite): composite.smargon.z, 0, composite.smargon.omega, 0, composite.smargon.chi, 0, - composite.smargon.phi, 0) # fmt: on @@ -141,24 +142,45 @@ def robot_load_then_centre_plan( ): yield from prepare_for_robot_load(composite) - yield from bps.abs_set( - composite.robot, - SampleLocation( - parameters.experiment_params.sample_puck, - parameters.experiment_params.sample_pin, - ), - group="robot_load", + @bpp.run_decorator( + md={ + "subplan_name": CONST.PLAN.ROBOT_LOAD, + "metadata": { + "visit_path": parameters.hyperion_params.ispyb_params.visit_path, + "sample_id": parameters.hyperion_params.ispyb_params.sample_id, + "sample_puck": parameters.experiment_params.sample_puck, + "sample_pin": parameters.experiment_params.sample_pin, + }, + "activate_callbacks": [ + "RobotLoadISPyBCallback", + ], + } ) - - if parameters.experiment_params.requested_energy_kev: - yield from set_energy_plan( - parameters.experiment_params.requested_energy_kev, - cast(SetEnergyComposite, composite), + def robot_load(): + yield from bps.abs_set( + composite.robot, + SampleLocation( + parameters.experiment_params.sample_puck, + parameters.experiment_params.sample_pin, + ), + group="robot_load", ) - yield from bps.wait("robot_load") + if parameters.experiment_params.requested_energy_kev: + yield from set_energy_plan( + parameters.experiment_params.requested_energy_kev, + cast(SetEnergyComposite, composite), + ) + + yield from bps.wait("robot_load") + + yield from bps.create(name=CONST.PLAN.ROBOT_LOAD) + yield from bps.read(composite.robot.barcode) + yield from bps.save() + + yield from wait_for_smargon_not_disabled(composite.smargon) - yield from wait_for_smargon_not_disabled(composite.smargon) + yield from robot_load() params_json = json.loads(parameters.json()) pin_centre_params = PinCentreThenXrayCentreInternalParameters(**params_json) diff --git a/src/hyperion/external_interaction/callbacks/common/callback_util.py b/src/hyperion/external_interaction/callbacks/common/callback_util.py index fabb803ec..80585bf47 100644 --- a/src/hyperion/external_interaction/callbacks/common/callback_util.py +++ b/src/hyperion/external_interaction/callbacks/common/callback_util.py @@ -2,6 +2,9 @@ from bluesky.callbacks import CallbackBase +from hyperion.external_interaction.callbacks.robot_load.ispyb_callback import ( + RobotLoadISPyBCallback, +) from hyperion.external_interaction.callbacks.rotation.ispyb_callback import ( RotationISPyBCallback, ) @@ -16,7 +19,17 @@ ) from hyperion.external_interaction.callbacks.zocalo_callback import ZocaloCallback -CallbacksFactory = Callable[[], Tuple[CallbackBase, CallbackBase]] +CallbacksFactory = Callable[[], Tuple[CallbackBase, ...]] + + +def create_robot_load_and_centre_callbacks() -> ( + Tuple[GridscanNexusFileCallback, GridscanISPyBCallback, RobotLoadISPyBCallback] +): + return ( + GridscanNexusFileCallback(), + GridscanISPyBCallback(emit=ZocaloCallback()), + RobotLoadISPyBCallback(), + ) def create_gridscan_callbacks() -> ( diff --git a/src/hyperion/external_interaction/callbacks/common/ispyb_mapping.py b/src/hyperion/external_interaction/callbacks/common/ispyb_mapping.py index 76dd76bfe..43b3835d0 100644 --- a/src/hyperion/external_interaction/callbacks/common/ispyb_mapping.py +++ b/src/hyperion/external_interaction/callbacks/common/ispyb_mapping.py @@ -89,6 +89,12 @@ def populate_remaining_data_collection_info( return data_collection_info +def get_proposal_and_session_from_visit_string(visit_string: str) -> tuple[str, int]: + visit_parts = visit_string.split("-") + assert len(visit_parts) == 2, f"Unexpected visit string {visit_string}" + return visit_parts[0], int(visit_parts[1]) + + def get_visit_string_from_path(path: Optional[str]) -> Optional[str]: match = re.search(VISIT_PATH_REGEX, path) if path else None return str(match.group(1)) if match else None diff --git a/src/hyperion/external_interaction/callbacks/robot_load/ispyb_callback.py b/src/hyperion/external_interaction/callbacks/robot_load/ispyb_callback.py new file mode 100644 index 000000000..17c36dd0d --- /dev/null +++ b/src/hyperion/external_interaction/callbacks/robot_load/ispyb_callback.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, Optional + +from event_model.documents import EventDescriptor + +from hyperion.external_interaction.callbacks.common.ispyb_mapping import ( + get_proposal_and_session_from_visit_string, + get_visit_string_from_path, +) +from hyperion.external_interaction.callbacks.plan_reactive_callback import ( + PlanReactiveCallback, +) +from hyperion.external_interaction.ispyb.exp_eye_store import ( + ExpeyeInteraction, + RobotActionID, +) +from hyperion.log import ISPYB_LOGGER +from hyperion.parameters.constants import CONST + +if TYPE_CHECKING: + from event_model.documents import Event, EventDescriptor, RunStart, RunStop + + +class RobotLoadISPyBCallback(PlanReactiveCallback): + def __init__(self) -> None: + ISPYB_LOGGER.debug("Initialising ISPyB Robot Load Callback") + super().__init__(log=ISPYB_LOGGER) + self.run_uid: Optional[str] = None + self.descriptors: Dict[str, EventDescriptor] = {} + self.action_id: RobotActionID | None = None + self.expeye = ExpeyeInteraction() + + def activity_gated_start(self, doc: RunStart): + ISPYB_LOGGER.debug("ISPyB robot load callback received start document.") + if doc.get("subplan_name") == CONST.PLAN.ROBOT_LOAD: + self.run_uid = doc.get("uid") + assert isinstance(metadata := doc.get("metadata"), Dict) + assert isinstance( + visit := get_visit_string_from_path(metadata["visit_path"]), str + ) + proposal, session = get_proposal_and_session_from_visit_string(visit) + self.action_id = self.expeye.start_load( + proposal, + session, + metadata["sample_id"], + metadata["sample_puck"], + metadata["sample_pin"], + ) + return super().activity_gated_start(doc) + + def activity_gated_descriptor(self, doc: EventDescriptor) -> EventDescriptor | None: + self.descriptors[doc["uid"]] = doc + + def activity_gated_event(self, doc: Event) -> Event | None: + event_descriptor = self.descriptors.get(doc["descriptor"]) + if event_descriptor and event_descriptor.get("name") == CONST.PLAN.ROBOT_LOAD: + assert ( + self.action_id is not None + ), "ISPyB Robot load callback event called unexpectedly" + barcode = doc["data"]["robot-barcode"] + self.expeye.update_barcode(self.action_id, barcode) + + return super().activity_gated_event(doc) + + def activity_gated_stop(self, doc: RunStop) -> RunStop | None: + ISPYB_LOGGER.debug("ISPyB robot load callback received stop document.") + if doc.get("run_start") == self.run_uid: + assert ( + self.action_id is not None + ), "ISPyB Robot load callback stop called unexpectedly" + exit_status = ( + doc.get("exit_status") or "Exit status not available in stop document!" + ) + reason = doc.get("reason") or "" + self.expeye.end_load(self.action_id, exit_status, reason) + self.action_id = None + return super().activity_gated_stop(doc) diff --git a/src/hyperion/external_interaction/ispyb/exp_eye_store.py b/src/hyperion/external_interaction/ispyb/exp_eye_store.py new file mode 100644 index 000000000..187bbaf50 --- /dev/null +++ b/src/hyperion/external_interaction/ispyb/exp_eye_store.py @@ -0,0 +1,114 @@ +import configparser +from typing import Dict, Tuple + +from requests import patch, post +from requests.auth import AuthBase + +from hyperion.external_interaction.exceptions import ISPyBDepositionNotMade +from hyperion.external_interaction.ispyb.ispyb_utils import ( + get_current_time_string, + get_ispyb_config, +) + +RobotActionID = int + + +class BearerAuth(AuthBase): + def __init__(self, token): + self.token = token + + def __call__(self, r): + r.headers["authorization"] = "Bearer " + self.token + return r + + +def _get_base_url_and_token() -> Tuple[str, str]: + config = configparser.ConfigParser() + conf = get_ispyb_config() + config.read(conf) + expeye_config = config["expeye"] + return expeye_config["url"], expeye_config["token"] + + +class ExpeyeInteraction: + CREATE_ROBOT_ACTION = "/proposals/{proposal}/sessions/{visit_number}/robot-actions" + UPDATE_ROBOT_ACTION = "/robot-actions/{action_id}" + + def __init__(self) -> None: + url, token = _get_base_url_and_token() + self.base_url = url + "/core" + self.auth = BearerAuth(token) + + def _send_and_get_response(self, url, data, send_func) -> Dict: + response = send_func(url, auth=self.auth, json=data) + if not response.ok: + raise ISPyBDepositionNotMade(f"Could not write {data} to {url}: {response}") + return response.json() + + def start_load( + self, + proposal_reference: str, + visit_number: int, + sample_id: int, + dewar_location: int, + container_location: int, + ) -> RobotActionID: + """Create a robot load entry in ispyb. + + Args: + proposal_reference (str): The proposal of the experiment e.g. cm37235 + visit_number (int): The visit number for the proposal, usually this can be + found added to the end of the proposal e.g. the data for + visit number 2 of proposal cm37235 is in cm37235-2 + sample_id (int): The id of the sample in the database + dewar_location (int): Which puck in the dewar the sample is in + container_location (int): Which pin in that puck has the sample + + Returns: + RobotActionID: The id of the robot load action that is created + """ + url = self.base_url + self.CREATE_ROBOT_ACTION.format( + proposal=proposal_reference, visit_number=visit_number + ) + + data = { + "startTimestamp": get_current_time_string(), + "sampleId": sample_id, + "actionType": "LOAD", + "containerLocation": container_location, + "dewarLocation": dewar_location, + } + response = self._send_and_get_response(url, data, post) + return response["robotActionId"] + + def update_barcode(self, action_id: RobotActionID, barcode: str): + """Update the barcode of an existing robot action. + + Args: + action_id (RobotActionID): The id of the action to update + barcode (str): The barcode to give the action + """ + url = self.base_url + self.UPDATE_ROBOT_ACTION.format(action_id=action_id) + + data = {"sampleBarcode": barcode} + self._send_and_get_response(url, data, patch) + + def end_load(self, action_id: RobotActionID, status: str, reason: str): + """Finish an existing robot action, providing final information about how it went + + Args: + action_id (RobotActionID): The action to finish. + status (str): The status of the action at the end, "success" for success, + otherwise error + reason (str): If the status is in error than the reason for that error + """ + url = self.base_url + self.UPDATE_ROBOT_ACTION.format(action_id=action_id) + + run_status = "SUCCESS" if status == "success" else "ERROR" + + data = { + "endTimestamp": get_current_time_string(), + "status": run_status, + "message": reason, + } + self._send_and_get_response(url, data, patch) diff --git a/src/hyperion/parameters/constants.py b/src/hyperion/parameters/constants.py index 8a44a620c..a0385483c 100644 --- a/src/hyperion/parameters/constants.py +++ b/src/hyperion/parameters/constants.py @@ -11,7 +11,7 @@ class SimConstants: # this one is for unit tests ISPYB_CONFIG = "tests/test_data/test_config.cfg" # this one is for system tests - DEV_ISPYB_DATABASE_CFG = "/dls_sw/dasc/mariadb/credentials/ispyb-dev.cfg" + DEV_ISPYB_DATABASE_CFG = "/dls_sw/dasc/mariadb/credentials/ispyb-hyperion-dev.cfg" @dataclass(frozen=True) @@ -21,6 +21,8 @@ class PlanNameConstants: ISPYB_HARDWARE_READ = "ispyb_reading_hardware" ISPYB_TRANSMISSION_FLUX_READ = "ispyb_update_transmission_flux" ZOCALO_HW_READ = "zocalo_read_hardware_plan" + # Robot load + ROBOT_LOAD = "robot_load" # Gridscan GRIDSCAN_OUTER = "run_gridscan_move_and_tidy" GRIDSCAN_AND_MOVE = "run_gridscan_and_move" diff --git a/tests/system_tests/external_interaction/test_exp_eye_dev.py b/tests/system_tests/external_interaction/test_exp_eye_dev.py new file mode 100644 index 000000000..53337f298 --- /dev/null +++ b/tests/system_tests/external_interaction/test_exp_eye_dev.py @@ -0,0 +1,41 @@ +import os +from time import sleep + +import pytest +from requests import get + +from hyperion.external_interaction.ispyb.exp_eye_store import ExpeyeInteraction +from hyperion.parameters.constants import CONST + + +@pytest.mark.s03 +def test_start_and_end_robot_load(): + os.environ["ISPYB_CONFIG_PATH"] = CONST.SIM.DEV_ISPYB_DATABASE_CFG + + SAMPLE_ID = 5289780 + BARCODE = "test_barcode" + + expeye = ExpeyeInteraction() + + robot_action_id = expeye.start_load("cm37235", 2, SAMPLE_ID, 40, 3) + + sleep(0.5) + + print(f"Created {robot_action_id}") + + expeye.update_barcode(robot_action_id, BARCODE) + + sleep(0.5) + + expeye.end_load(robot_action_id, "fail", "Oh no!") + + get_robot_data_url = f"{expeye.base_url}/robot-actions/{robot_action_id}" + response = get(get_robot_data_url, auth=expeye.auth) + + assert response.ok + + response = response.json() + assert response["robotActionId"] == robot_action_id + assert response["status"] == "ERROR" + assert response["sampleId"] == SAMPLE_ID + assert response["sampleBarcode"] == BARCODE diff --git a/tests/test_data/parameter_json_files/good_test_robot_load_params.json b/tests/test_data/parameter_json_files/good_test_robot_load_params.json index 63c0a79c3..d72f66990 100644 --- a/tests/test_data/parameter_json_files/good_test_robot_load_params.json +++ b/tests/test_data/parameter_json_files/good_test_robot_load_params.json @@ -26,7 +26,8 @@ "focal_spot_size_x": 0.0, "focal_spot_size_y": 0.0, "comment": "Descriptive comment.", - "sample_id": null + "sample_id": 12345, + "sample_barcode": null } }, "experiment_params": { diff --git a/tests/test_data/test_config.cfg b/tests/test_data/test_config.cfg index 2d4a4a280..cf55c64ca 100644 --- a/tests/test_data/test_config.cfg +++ b/tests/test_data/test_config.cfg @@ -21,3 +21,7 @@ password = notapassword host = computer-somewhere port = 3306 database = ispyb + +[expeye] +url = http://blah +token = notatoken \ No newline at end of file diff --git a/tests/unit_tests/device_setup_plans/test_setup_panda.py b/tests/unit_tests/device_setup_plans/test_setup_panda.py index 20cd84dfe..04b6e3ea7 100644 --- a/tests/unit_tests/device_setup_plans/test_setup_panda.py +++ b/tests/unit_tests/device_setup_plans/test_setup_panda.py @@ -2,6 +2,8 @@ import numpy as np import pytest +from bluesky.plan_stubs import null +from bluesky.run_engine import RunEngine from dodal.devices.panda_fast_grid_scan import PandAGridScanParams from ophyd_async.panda import SeqTrigger @@ -159,6 +161,42 @@ def test_setup_panda_correctly_configures_table( np.testing.assert_array_equal(table["outa2"], np.array([0, 1, 0, 0, 1, 0])) +def test_wait_between_setting_table_and_arming_panda(RE: RunEngine): + bps_wait_done = False + + def handle_wait(*args, **kwargs): + nonlocal bps_wait_done + bps_wait_done = True + yield from null() + + def assert_set_table_has_been_waited_on(*args, **kwargs): + assert bps_wait_done + yield from null() + + with patch( + "hyperion.device_setup_plans.setup_panda.arm_panda_for_gridscan", + MagicMock(side_effect=assert_set_table_has_been_waited_on), + ), patch( + "hyperion.device_setup_plans.setup_panda.bps.wait", + MagicMock(side_effect=handle_wait), + ), patch( + "hyperion.device_setup_plans.setup_panda.load_device" + ), patch( + "hyperion.device_setup_plans.setup_panda.bps.abs_set" + ): + RE( + setup_panda_for_flyscan( + MagicMock(), + "path", + PandAGridScanParams(), + 1, + 1, + 1, + get_smargon_speed(0.1, 1), + ) + ) + + # It also would be useful to have some system tests which check that (at least) # all the blocks which were enabled on setup are also disabled on tidyup def test_disarm_panda_disables_correct_blocks(): diff --git a/tests/unit_tests/experiment_plans/test_wait_for_robot_load_then_centre.py b/tests/unit_tests/experiment_plans/test_wait_for_robot_load_then_centre.py index 1acff3785..df08fefd5 100644 --- a/tests/unit_tests/experiment_plans/test_wait_for_robot_load_then_centre.py +++ b/tests/unit_tests/experiment_plans/test_wait_for_robot_load_then_centre.py @@ -14,6 +14,9 @@ prepare_for_robot_load, robot_load_then_centre, ) +from hyperion.external_interaction.callbacks.robot_load.ispyb_callback import ( + RobotLoadISPyBCallback, +) from hyperion.parameters.external_parameters import from_file as raw_params_from_file from hyperion.parameters.plan_specific.pin_centre_then_xray_centre_params import ( PinCentreThenXrayCentreInternalParameters, @@ -298,3 +301,40 @@ def test_when_prepare_for_robot_load_called_then_moves_as_expected( smargon.stub_offsets.set.assert_called_once_with(StubPosition.RESET_TO_ROBOT_LOAD) # type: ignore aperture_scatterguard.set.assert_called_once_with(AperturePositions.ROBOT_LOAD) # type: ignore + + +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.end_load" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.update_barcode" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.start_load" +) +@patch( + "hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_xray_centre_plan" +) +@patch( + "hyperion.experiment_plans.robot_load_then_centre_plan.set_energy_plan", + MagicMock(return_value=iter([])), +) +def test_given_ispyb_callback_attached_when_robot_load_then_centre_plan_called_then_ispyb_deposited( + mock_centring_plan: MagicMock, + start_load: MagicMock, + update_barcode: MagicMock, + end_load: MagicMock, + robot_load_composite: RobotLoadThenCentreComposite, + robot_load_then_centre_params: RobotLoadThenCentreInternalParameters, +): + RE = RunEngine() + RE.subscribe(RobotLoadISPyBCallback()) + + action_id = 1098 + start_load.return_value = action_id + + RE(robot_load_then_centre(robot_load_composite, robot_load_then_centre_params)) + + start_load.assert_called_once_with("cm31105", 4, "12345", 40, 3) + update_barcode.assert_called_once_with(action_id, "BARCODE") + end_load.assert_called_once_with(action_id, "success", "") diff --git a/tests/unit_tests/external_interaction/callbacks/robot_load/test_robot_load_ispyb_callback.py b/tests/unit_tests/external_interaction/callbacks/robot_load/test_robot_load_ispyb_callback.py new file mode 100644 index 000000000..335ef58bc --- /dev/null +++ b/tests/unit_tests/external_interaction/callbacks/robot_load/test_robot_load_ispyb_callback.py @@ -0,0 +1,125 @@ +from unittest.mock import MagicMock, patch + +import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp +import pytest +from bluesky.run_engine import RunEngine +from dodal.devices.robot import BartRobot + +from hyperion.external_interaction.callbacks.robot_load.ispyb_callback import ( + RobotLoadISPyBCallback, +) +from hyperion.parameters.constants import CONST + +VISIT_PATH = "/tmp/cm31105-4" + +SAMPLE_ID = 231412 +SAMPLE_PUCK = 50 +SAMPLE_PIN = 4 +ACTION_ID = 1098 + +metadata = { + "subplan_name": CONST.PLAN.ROBOT_LOAD, + "metadata": { + "visit_path": VISIT_PATH, + "sample_id": SAMPLE_ID, + "sample_puck": SAMPLE_PUCK, + "sample_pin": SAMPLE_PIN, + }, + "activate_callbacks": [ + "RobotLoadISPyBCallback", + ], +} + + +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.end_load" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.start_load" +) +def test_given_start_doc_with_expected_data_then_data_put_in_ispyb( + start_load: MagicMock, + end_load: MagicMock, +): + RE = RunEngine() + RE.subscribe(RobotLoadISPyBCallback()) + start_load.return_value = ACTION_ID + + @bpp.run_decorator(md=metadata) + def my_plan(): + yield from bps.null() + + RE(my_plan()) + + start_load.assert_called_once_with("cm31105", 4, SAMPLE_ID, SAMPLE_PUCK, SAMPLE_PIN) + end_load.assert_called_once_with(ACTION_ID, "success", "") + + +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.end_load" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.start_load" +) +def test_given_failing_plan_then_exception_detail( + start_load: MagicMock, + end_load: MagicMock, +): + RE = RunEngine() + RE.subscribe(RobotLoadISPyBCallback()) + start_load.return_value = ACTION_ID + + @bpp.run_decorator(md=metadata) + def my_plan(): + raise Exception("BAD") + yield from bps.null() + + with pytest.raises(Exception): + RE(my_plan()) + + start_load.assert_called_once_with("cm31105", 4, SAMPLE_ID, SAMPLE_PUCK, SAMPLE_PIN) + end_load.assert_called_once_with(ACTION_ID, "fail", "BAD") + + +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.end_load" +) +def test_given_end_called_but_no_start_then_exception_raised(end_load): + callback = RobotLoadISPyBCallback() + callback.active = True + with pytest.raises(AssertionError): + callback.activity_gated_stop({"run_uid": None}) # type: ignore + end_load.assert_not_called() + + +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.end_load" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.start_load" +) +@patch( + "hyperion.external_interaction.callbacks.robot_load.ispyb_callback.ExpeyeInteraction.update_barcode" +) +def test_given_plan_reads_barcode_then_data_put_in_ispyb( + update_barcode: MagicMock, + start_load: MagicMock, + end_load: MagicMock, + robot: BartRobot, +): + RE = RunEngine() + RE.subscribe(RobotLoadISPyBCallback()) + start_load.return_value = ACTION_ID + + @bpp.run_decorator(md=metadata) + def my_plan(): + yield from bps.create(name=CONST.PLAN.ROBOT_LOAD) + yield from bps.read(robot.barcode) + yield from bps.save() + + RE(my_plan()) + + start_load.assert_called_once_with("cm31105", 4, SAMPLE_ID, SAMPLE_PUCK, SAMPLE_PIN) + update_barcode.assert_called_once_with(ACTION_ID, "BARCODE") + end_load.assert_called_once_with(ACTION_ID, "success", "") diff --git a/tests/unit_tests/external_interaction/ispyb/test_expeye_interaction.py b/tests/unit_tests/external_interaction/ispyb/test_expeye_interaction.py new file mode 100644 index 000000000..2f2fe9e2a --- /dev/null +++ b/tests/unit_tests/external_interaction/ispyb/test_expeye_interaction.py @@ -0,0 +1,135 @@ +from unittest.mock import ANY, patch + +import pytest + +from hyperion.external_interaction.exceptions import ISPyBDepositionNotMade +from hyperion.external_interaction.ispyb.exp_eye_store import ( + BearerAuth, + ExpeyeInteraction, + _get_base_url_and_token, +) + + +def test_get_url_and_token_returns_expected_data(): + url, token = _get_base_url_and_token() + assert url == "http://blah" + assert token == "notatoken" + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.post") +def test_when_start_load_called_then_correct_expected_url_posted_to_with_expected_data( + mock_post, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.start_load("test", 3, 700, 10, 5) + + mock_post.assert_called_once() + assert ( + mock_post.call_args.args[0] + == "http://blah/core/proposals/test/sessions/3/robot-actions" + ) + expected_data = { + "startTimestamp": ANY, + "sampleId": 700, + "actionType": "LOAD", + "containerLocation": 5, + "dewarLocation": 10, + } + assert mock_post.call_args.kwargs["json"] == expected_data + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.post") +def test_when_start_called_then_returns_id(mock_post): + mock_post.return_value.json.return_value = {"robotActionId": 190} + expeye_interactor = ExpeyeInteraction() + robot_id = expeye_interactor.start_load("test", 3, 700, 10, 5) + assert robot_id == 190 + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.post") +def test_when_start_load_called_then_use_correct_token( + mock_post, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.start_load("test", 3, 700, 10, 5) + + assert isinstance(auth := mock_post.call_args.kwargs["auth"], BearerAuth) + assert auth.token == "notatoken" + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.post") +def test_given_server_does_not_respond_when_start_load_called_then_error(mock_post): + mock_post.return_value.ok = False + + expeye_interactor = ExpeyeInteraction() + with pytest.raises(ISPyBDepositionNotMade): + expeye_interactor.start_load("test", 3, 700, 10, 5) + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.patch") +def test_when_end_load_called_with_success_then_correct_expected_url_posted_to_with_expected_data( + mock_patch, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.end_load(3, "success", "") + + mock_patch.assert_called_once() + assert mock_patch.call_args.args[0] == "http://blah/core/robot-actions/3" + expected_data = { + "endTimestamp": ANY, + "status": "SUCCESS", + "message": "", + } + assert mock_patch.call_args.kwargs["json"] == expected_data + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.patch") +def test_when_end_load_called_with_failure_then_correct_expected_url_posted_to_with_expected_data( + mock_patch, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.end_load(3, "fail", "bad") + + mock_patch.assert_called_once() + assert mock_patch.call_args.args[0] == "http://blah/core/robot-actions/3" + expected_data = { + "endTimestamp": ANY, + "status": "ERROR", + "message": "bad", + } + assert mock_patch.call_args.kwargs["json"] == expected_data + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.patch") +def test_when_end_load_called_then_use_correct_token( + mock_patch, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.end_load(3, "success", "") + + assert isinstance(auth := mock_patch.call_args.kwargs["auth"], BearerAuth) + assert auth.token == "notatoken" + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.patch") +def test_given_server_does_not_respond_when_end_load_called_then_error(mock_patch): + mock_patch.return_value.ok = False + + expeye_interactor = ExpeyeInteraction() + with pytest.raises(ISPyBDepositionNotMade): + expeye_interactor.end_load(1, "", "") + + +@patch("hyperion.external_interaction.ispyb.exp_eye_store.patch") +def test_when_update_barcode_called_with_success_then_correct_expected_url_posted_to_with_expected_data( + mock_patch, +): + expeye_interactor = ExpeyeInteraction() + expeye_interactor.update_barcode(3, "test") + + mock_patch.assert_called_once() + assert mock_patch.call_args.args[0] == "http://blah/core/robot-actions/3" + expected_data = { + "sampleBarcode": "test", + } + assert mock_patch.call_args.kwargs["json"] == expected_data diff --git a/tests/unit_tests/external_interaction/test_ispyb_utils.py b/tests/unit_tests/external_interaction/test_ispyb_utils.py index 5a56cb53c..38174f30e 100644 --- a/tests/unit_tests/external_interaction/test_ispyb_utils.py +++ b/tests/unit_tests/external_interaction/test_ispyb_utils.py @@ -3,6 +3,7 @@ import pytest from hyperion.external_interaction.callbacks.common.ispyb_mapping import ( + get_proposal_and_session_from_visit_string, get_visit_string_from_path, ) from hyperion.external_interaction.ispyb.ispyb_utils import get_current_time_string @@ -33,3 +34,34 @@ def test_get_current_time_string(): def test_find_visit_in_visit_path(visit_path: str, expected_match: str): test_visit_path = get_visit_string_from_path(visit_path) assert test_visit_path == expected_match + + +@pytest.mark.parametrize( + "visit_string, expected_proposal, expected_session", + [ + ("cm6477-45", "cm6477", 45), + ("mx54663-1", "mx54663", 1), + ("ea54663985-13651", "ea54663985", 13651), + ], +) +def test_proposal_and_session_from_visit_string_happy_path( + visit_string: str, expected_proposal: str, expected_session: int +): + proposal, session = get_proposal_and_session_from_visit_string(visit_string) + assert proposal == expected_proposal + assert session == expected_session + + +@pytest.mark.parametrize( + "visit_string, exception_type", + [ + ("cm647-7-45", AssertionError), + ("mx54663.1", AssertionError), + ("mx54663-pop", ValueError), + ], +) +def test_given_invalid_visit_string_get_proposal_and_session_throws( + visit_string: str, exception_type +): + with pytest.raises(exception_type): + get_proposal_and_session_from_visit_string(visit_string)