Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies = [
"jupyterlab",
"matplotlib",
"nexgen >= 0.11.0",
"numpy == 2.2.6", # See https://github.com/DiamondLightSource/mx-bluesky/issues/1119
"numpy == 2.2.6", # See https://github.com/DiamondLightSource/mx-bluesky/issues/1119
"opencv-python", # Needed for I24 ssx moveonclick. To be changed to headless once this is moved to separate ui.
"opentelemetry-distro",
"opentelemetry-exporter-otlp",
Expand All @@ -46,7 +46,7 @@ dependencies = [
"ophyd >= 1.10.5",
"ophyd-async >= 0.10.0a2",
"bluesky >= 1.13.1",
"dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@main",
"dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@8c5ee7a2548b739a8d258ceb7c299205202ee42c",
]


Expand Down
123 changes: 123 additions & 0 deletions src/mx_bluesky/common/device_setup_plans/robot_load_unload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from __future__ import annotations

import bluesky.plan_stubs as bps
import bluesky.preprocessors as bpp
from bluesky.utils import MsgGenerator
from dodal.devices.aperturescatterguard import ApertureScatterguard, ApertureValue
from dodal.devices.motors import XYZStage
from dodal.devices.robot import BartRobot
from dodal.devices.smargon import CombinedMove, Smargon, StubPosition
from dodal.plan_stubs.motor_utils import MoveTooLarge, home_and_reset_wrapper

from mx_bluesky.common.utils.log import LOGGER
from mx_bluesky.hyperion.parameters.constants import CONST


def wait_for_smargon_not_disabled(smargon: Smargon, timeout=60):
"""Waits for the smargon disabled flag to go low. The robot hardware is responsible
for setting this to low when it is safe to move. It does this through a physical
connection between the robot and the smargon.
"""
LOGGER.info("Waiting for smargon enabled")
SLEEP_PER_CHECK = 0.1
times_to_check = int(timeout / SLEEP_PER_CHECK)
for _ in range(times_to_check):
smargon_disabled = yield from bps.rd(smargon.disabled)
if not smargon_disabled:
LOGGER.info("Smargon now enabled")
return
yield from bps.sleep(SLEEP_PER_CHECK)
raise TimeoutError(
"Timed out waiting for smargon to become enabled after robot load"
)


def _raise_exception_if_moved_out_of_cryojet(exception):
yield from bps.null()
if isinstance(exception, MoveTooLarge):
raise Exception(

Check warning on line 38 in src/mx_bluesky/common/device_setup_plans/robot_load_unload.py

View check run for this annotation

Codecov / codecov/patch

src/mx_bluesky/common/device_setup_plans/robot_load_unload.py#L38

Added line #L38 was not covered by tests
f"Moving {exception.axis} back to {exception.position} after \
robot load would move it out of the cryojet. The max safe \
distance is {exception.maximum_move}"
)


def do_plan_while_lower_gonio_at_home(plan: MsgGenerator, lower_gonio: XYZStage):
"""Moves the lower gonio to home then performs the provided plan and moves it back.

The lower gonio must be in the correct position for the robot load and we
want to put it back afterwards. Note we don't need to wait for the move as the robot
is interlocked to the lower gonio and the move is quicker than the robot takes to
get to the load position.

Args:
plan (MsgGenerator): The plan to run while the lower gonio is at home.
lower_gonio (XYZStage): The lower gonio to home.
"""
yield from bpp.contingency_wrapper(
home_and_reset_wrapper(
plan,
lower_gonio,
BartRobot.LOAD_TOLERANCE_MM,
CONST.HARDWARE.CRYOJET_MARGIN_MM,
"lower_gonio",
wait_for_all=False,
),
except_plan=_raise_exception_if_moved_out_of_cryojet,
)
return "reset-lower_gonio"


def prepare_for_robot_load(
aperture_scatterguard: ApertureScatterguard, smargon: Smargon
):
yield from bps.abs_set(
aperture_scatterguard.selected_aperture,
ApertureValue.OUT_OF_BEAM,
group="prepare_robot_load",
)

yield from bps.mv(smargon.stub_offsets, StubPosition.RESET_TO_ROBOT_LOAD)

yield from bps.mv(smargon, CombinedMove(x=0, y=0, z=0, chi=0, phi=0, omega=0))

yield from bps.wait("prepare_robot_load")


def robot_unload(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be an asymmetry between robot load and robot unload in terms of usage.

robot_unload manages the preparation and the moving of the gonio, whereas robot_load_and_snapshots is outside of the module and calls these things separately. One is a device setup plan and the other is an experiment plan.

Perhaps it would be better to consider moving robot_load_and_snapshots into robot_load_unload - thawing and taking snapshots seem like a natural part of loading which people would want on any beamline if there is the facility to do so. Then the implementation functions can be private to the module, and it only exposes loading and unloading.

Changing energy to me seems to be the only thing which is not naturally part of loading; you could supply an arbitrary plan as a parameter which you execute while waiting for the robot to load.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, pulled into #1137

robot: BartRobot,
smargon: Smargon,
aperture_scatterguard: ApertureScatterguard,
lower_gonio: XYZStage,
visit: str,
):
"""Unloads the currently mounted pin into the location that it was loaded from. The
loaded location is stored on the robot and so need not be provided.
"""
yield from prepare_for_robot_load(aperture_scatterguard, smargon)
Copy link
Contributor

@rtuck99 rtuck99 Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think it might be worth noting here in a docstring, that it's implicit that the the puck/pin location is stored on the robot.

sample_id = yield from bps.rd(robot.sample_id)

@bpp.run_decorator(
md={
"subplan_name": CONST.PLAN.ROBOT_UNLOAD,
"metadata": {"visit": visit, "sample_id": sample_id},
"activate_callbacks": [
"RobotLoadISPyBCallback",
],
},
)
def do_robot_unload_and_send_to_ispyb():
yield from bps.create(name=CONST.DESCRIPTORS.ROBOT_UPDATE)
yield from bps.read(robot)
yield from bps.save()

def _unload():
yield from bps.trigger(robot.unload, wait=True)
yield from wait_for_smargon_not_disabled(smargon)

gonio_finished = yield from do_plan_while_lower_gonio_at_home(
_unload(), lower_gonio
)
yield from bps.wait(gonio_finished)

yield from do_robot_unload_and_send_to_ispyb()
50 changes: 26 additions & 24 deletions src/mx_bluesky/common/external_interaction/ispyb/exp_eye_store.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import configparser
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Literal

from event_model.documents import Event
from requests import JSONDecodeError, patch, post
from requests.auth import AuthBase

Expand Down Expand Up @@ -63,6 +65,19 @@ class BLSampleStatus(StrEnum):
)


def create_update_data_from_event_doc(
Copy link
Contributor

@rtuck99 rtuck99 Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure about the wisdom of this - do we want to expose our plans to the vagaries of the expeye API, or do we want to insulate them from it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither of these modules owns the namespaces of the things being mapped. I don't really like this, but I can't come up with anything better that doesn't also increase verbosity.

mapping: dict[str, str], event: Event
) -> dict[str, Any]:
"""Given a mapping between bluesky event data and an event itself this function will
create a dict that can be used to update exp-eye."""
event_data = event["data"]
return {
target_key: event_data[source_key]
for source_key, target_key in mapping.items()
if source_key in event_data
}


class ExpeyeInteraction:
"""Exposes functionality from the Expeye core API"""

Expand All @@ -74,24 +89,22 @@ def __init__(self) -> None:
self._base_url = url
self._auth = BearerAuth(token)

def start_load(
def start_robot_action(
self,
action_type: Literal["LOAD", "UNLOAD"],
proposal_reference: str,
visit_number: int,
sample_id: int,
dewar_location: int,
container_location: int,
) -> RobotActionID:
"""Create a robot load entry in ispyb.
"""Create a robot action entry in ispyb.

Args:
action_type ("LOAD" | "UNLOAD"): The robot action being performed
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
Expand All @@ -102,39 +115,28 @@ def start_load(

data = {
"startTimestamp": get_current_time_string(),
"actionType": action_type,
"sampleId": sample_id,
"actionType": "LOAD",
"containerLocation": container_location,
"dewarLocation": dewar_location,
}
response = _send_and_get_response(self._auth, url, data, post)
return response["robotActionId"]

def update_barcode_and_snapshots(
def update_robot_action(
self,
action_id: RobotActionID,
barcode: str,
snapshot_before_path: str,
snapshot_after_path: str,
data: dict[str, Any],
):
"""Update the barcode and snapshots of an existing robot action.
"""Update an existing robot action to contain additional info.

Args:
action_id (RobotActionID): The id of the action to update
barcode (str): The barcode to give the action
snapshot_before_path (str): Path to the snapshot before robot load
snapshot_after_path (str): Path to the snapshot after robot load
data (dict): The data to update with, where the keys match those expected
by exp-eye.
"""
url = self._base_url + self.UPDATE_ROBOT_ACTION.format(action_id=action_id)

data = {
"sampleBarcode": barcode,
"xtalSnapshotBefore": snapshot_before_path,
"xtalSnapshotAfter": snapshot_after_path,
}
_send_and_get_response(self._auth, url, data, patch)

def end_load(self, action_id: RobotActionID, status: str, reason: str):
def end_robot_action(self, action_id: RobotActionID, status: str, reason: str):
"""Finish an existing robot action, providing final information about how it went

Args:
Expand Down
7 changes: 4 additions & 3 deletions src/mx_bluesky/common/parameters/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@

@dataclass(frozen=True)
class DocDescriptorNames:
# Robot load event descriptor
ROBOT_LOAD = "robot_load"
# Robot load/unload event descriptor
ROBOT_UPDATE = "robot_update"
# For callbacks to use
OAV_ROTATION_SNAPSHOT_TRIGGERED = "rotation_snapshot_triggered"
OAV_GRID_SNAPSHOT_TRIGGERED = "snapshot_to_ispyb"
Expand All @@ -41,8 +41,9 @@ class OavConstants:
@dataclass(frozen=True)
class PlanNameConstants:
LOAD_CENTRE_COLLECT = "load_centre_collect"
# Robot load subplan
# Robot subplans
ROBOT_LOAD = "robot_load"
ROBOT_UNLOAD = "robot_unload"
# Gridscan
GRID_DETECT_AND_DO_GRIDSCAN = "grid_detect_and_do_gridscan"
GRID_DETECT_INNER = "grid_detect"
Expand Down
Loading
Loading