mx-bluesky 1.4.5__py3-none-any.whl → 1.4.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. mx_bluesky/_version.py +9 -4
  2. mx_bluesky/beamlines/aithre_lasershaping/__init__.py +13 -0
  3. mx_bluesky/beamlines/aithre_lasershaping/check_goniometer_performance.py +29 -0
  4. mx_bluesky/beamlines/aithre_lasershaping/goniometer_controls.py +18 -0
  5. mx_bluesky/beamlines/i04/redis_to_murko_forwarder.py +45 -28
  6. mx_bluesky/beamlines/i04/thawing_plan.py +19 -14
  7. mx_bluesky/beamlines/i24/serial/__init__.py +14 -0
  8. mx_bluesky/beamlines/i24/serial/dcid.py +3 -1
  9. mx_bluesky/beamlines/i24/serial/extruder/EX-gui-edm/DiamondExtruder-I24-py3v1.edl +12 -12
  10. mx_bluesky/beamlines/i24/serial/extruder/i24ssx_Extruder_Collect_py3v2.py +31 -30
  11. mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_Collect_py3v1.py +16 -14
  12. mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_Manager_py3v1.py +19 -21
  13. mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_moveonclick.py +11 -4
  14. mx_bluesky/beamlines/i24/serial/parameters/constants.py +1 -1
  15. mx_bluesky/beamlines/i24/serial/set_visit_directory.sh +1 -1
  16. mx_bluesky/beamlines/i24/serial/setup_beamline/pv.py +16 -16
  17. mx_bluesky/beamlines/i24/serial/setup_beamline/setup_beamline.py +48 -49
  18. mx_bluesky/beamlines/i24/serial/setup_beamline/setup_detector.py +2 -2
  19. mx_bluesky/beamlines/i24/serial/setup_beamline/setup_zebra_plans.py +11 -9
  20. mx_bluesky/beamlines/i24/serial/web_gui_plans/general_plans.py +109 -0
  21. mx_bluesky/beamlines/i24/serial/write_nexus.py +5 -4
  22. mx_bluesky/common/device_setup_plans/xbpm_feedback.py +45 -0
  23. mx_bluesky/common/external_interaction/callbacks/common/ispyb_callback_base.py +2 -4
  24. mx_bluesky/common/external_interaction/callbacks/common/ispyb_mapping.py +1 -1
  25. mx_bluesky/common/external_interaction/callbacks/common/plan_reactive_callback.py +2 -2
  26. mx_bluesky/common/external_interaction/callbacks/common/zocalo_callback.py +18 -15
  27. mx_bluesky/common/external_interaction/callbacks/sample_handling/__init__.py +0 -0
  28. mx_bluesky/{hyperion → common}/external_interaction/callbacks/sample_handling/sample_handling_callback.py +29 -12
  29. mx_bluesky/common/external_interaction/callbacks/xray_centre/ispyb_callback.py +43 -7
  30. mx_bluesky/common/external_interaction/callbacks/xray_centre/ispyb_mapping.py +1 -1
  31. mx_bluesky/common/external_interaction/callbacks/xray_centre/nexus_callback.py +2 -1
  32. mx_bluesky/common/external_interaction/ispyb/data_model.py +1 -0
  33. mx_bluesky/common/external_interaction/ispyb/exp_eye_store.py +6 -2
  34. mx_bluesky/common/external_interaction/ispyb/ispyb_store.py +21 -1
  35. mx_bluesky/common/external_interaction/nexus/nexus_utils.py +1 -1
  36. mx_bluesky/common/parameters/constants.py +3 -1
  37. mx_bluesky/common/parameters/gridscan.py +36 -1
  38. mx_bluesky/common/plans/do_fgs.py +4 -6
  39. mx_bluesky/common/plans/read_hardware.py +78 -0
  40. mx_bluesky/common/plans/write_sample_status.py +46 -0
  41. mx_bluesky/common/preprocessors/__init__.py +0 -0
  42. mx_bluesky/common/preprocessors/preprocessors.py +105 -0
  43. mx_bluesky/common/protocols/__init__.py +0 -0
  44. mx_bluesky/common/protocols/protocols.py +10 -0
  45. mx_bluesky/common/utils/context.py +68 -0
  46. mx_bluesky/{hyperion/experiment_plans/common → common}/xrc_result.py +16 -0
  47. mx_bluesky/hyperion/__main__.py +7 -9
  48. mx_bluesky/hyperion/baton_handler.py +84 -0
  49. mx_bluesky/hyperion/device_setup_plans/setup_oav.py +5 -5
  50. mx_bluesky/hyperion/device_setup_plans/setup_panda.py +5 -1
  51. mx_bluesky/hyperion/device_setup_plans/setup_zebra.py +2 -2
  52. mx_bluesky/hyperion/device_setup_plans/smargon.py +6 -6
  53. mx_bluesky/hyperion/device_setup_plans/utils.py +2 -2
  54. mx_bluesky/hyperion/experiment_plans/__init__.py +0 -4
  55. mx_bluesky/hyperion/experiment_plans/change_aperture_then_move_plan.py +12 -31
  56. mx_bluesky/hyperion/experiment_plans/experiment_registry.py +0 -7
  57. mx_bluesky/hyperion/experiment_plans/flyscan_xray_centre_plan.py +44 -97
  58. mx_bluesky/hyperion/experiment_plans/grid_detect_then_xray_centre_plan.py +6 -6
  59. mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py +8 -6
  60. mx_bluesky/hyperion/experiment_plans/oav_grid_detection_plan.py +11 -11
  61. mx_bluesky/hyperion/experiment_plans/oav_snapshot_plan.py +5 -5
  62. mx_bluesky/hyperion/experiment_plans/optimise_attenuation_plan.py +1 -1
  63. mx_bluesky/hyperion/experiment_plans/pin_centre_then_xray_centre_plan.py +2 -4
  64. mx_bluesky/hyperion/experiment_plans/pin_tip_centring_plan.py +15 -13
  65. mx_bluesky/hyperion/experiment_plans/robot_load_and_change_energy.py +10 -10
  66. mx_bluesky/hyperion/experiment_plans/robot_load_then_centre_plan.py +1 -29
  67. mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py +30 -27
  68. mx_bluesky/hyperion/experiment_plans/set_energy_plan.py +25 -6
  69. mx_bluesky/hyperion/external_interaction/agamemnon.py +242 -0
  70. mx_bluesky/hyperion/external_interaction/callbacks/__main__.py +12 -6
  71. mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py +1 -1
  72. mx_bluesky/hyperion/external_interaction/callbacks/snapshot_callback.py +107 -0
  73. mx_bluesky/hyperion/external_interaction/config_server.py +6 -6
  74. mx_bluesky/hyperion/parameters/device_composites.py +49 -0
  75. mx_bluesky/hyperion/parameters/gridscan.py +3 -3
  76. mx_bluesky/hyperion/parameters/rotation.py +1 -1
  77. mx_bluesky/hyperion/utils/__init__.py +1 -0
  78. mx_bluesky/hyperion/utils/context.py +0 -65
  79. mx_bluesky/hyperion/utils/validation.py +3 -3
  80. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info}/METADATA +6 -5
  81. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info}/RECORD +86 -72
  82. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info}/WHEEL +1 -1
  83. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info}/entry_points.txt +1 -0
  84. mx_bluesky/common/device_setup_plans/read_hardware_for_setup.py +0 -14
  85. mx_bluesky/common/external_interaction/callbacks/common/aperture_change_callback.py +0 -22
  86. mx_bluesky/hyperion/device_setup_plans/read_hardware_for_setup.py +0 -54
  87. mx_bluesky/hyperion/device_setup_plans/xbpm_feedback.py +0 -103
  88. /mx_bluesky/{hyperion/external_interaction/callbacks/sample_handling → beamlines/i24/serial/web_gui_plans}/__init__.py +0 -0
  89. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info/licenses}/LICENSE +0 -0
  90. {mx_bluesky-1.4.5.dist-info → mx_bluesky-1.4.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,242 @@
1
+ import dataclasses
2
+ import json
3
+ import re
4
+ from os import path
5
+ from typing import Any, TypeVar
6
+
7
+ import requests
8
+ from deepdiff.diff import DeepDiff
9
+ from dodal.utils import get_beamline_name
10
+ from jsonschema import ValidationError
11
+ from pydantic_extra_types.semantic_version import SemanticVersion
12
+
13
+ from mx_bluesky.common.parameters.components import (
14
+ PARAMETER_VERSION,
15
+ MxBlueskyParameters,
16
+ TopNByMaxCountSelection,
17
+ WithCentreSelection,
18
+ WithOptionalEnergyChange,
19
+ WithSample,
20
+ WithVisit,
21
+ )
22
+ from mx_bluesky.common.parameters.constants import (
23
+ GridscanParamConstants,
24
+ )
25
+ from mx_bluesky.common.utils.log import LOGGER
26
+ from mx_bluesky.common.utils.utils import convert_angstrom_to_eV
27
+ from mx_bluesky.hyperion.parameters.components import WithHyperionUDCFeatures
28
+ from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect
29
+ from mx_bluesky.hyperion.parameters.robot_load import RobotLoadThenCentre
30
+
31
+ T = TypeVar("T", bound=WithVisit)
32
+ AGAMEMNON_URL = "http://agamemnon.diamond.ac.uk/"
33
+ MULTIPIN_PREFIX = "multipin"
34
+ MULTIPIN_FORMAT_DESC = "Expected multipin format is multipin_{number_of_wells}x{well_size}+{distance_between_tip_and_first_well}"
35
+ MULTIPIN_REGEX = rf"^{MULTIPIN_PREFIX}_(\d+)x(\d+(?:\.\d+)?)\+(\d+(?:\.\d+)?)$"
36
+ MX_GENERAL_ROOT_REGEX = r"^/dls/(?P<beamline>[^/]+)/data/[^/]*/(?P<visit>[^/]+)(?:/|$)"
37
+
38
+
39
+ class AgamemnonLoadCentreCollect(
40
+ MxBlueskyParameters,
41
+ WithVisit,
42
+ WithSample,
43
+ WithCentreSelection,
44
+ WithHyperionUDCFeatures,
45
+ WithOptionalEnergyChange,
46
+ ):
47
+ """Experiment parameters to compare against GDA populated LoadCentreCollect."""
48
+
49
+ robot_load_then_centre: RobotLoadThenCentre
50
+
51
+
52
+ @dataclasses.dataclass
53
+ class PinType:
54
+ expected_number_of_crystals: int
55
+ single_well_width_um: float
56
+ tip_to_first_well_um: float = 0
57
+
58
+ @property
59
+ def full_width(self) -> float:
60
+ """This is the "width" of the area where there may be samples.
61
+
62
+ From a pin perspective this is along the length of the pin but we use width here as
63
+ we mount the sample at 90 deg to the optical camera.
64
+
65
+ We calculate the full width by adding all the gaps between wells then assuming
66
+ there is a buffer of {tip_to_first_well_um} either side too. In reality the
67
+ calculation does not need to be very exact as long as we get a width that's good
68
+ enough to use for optical centring and XRC grid size.
69
+ """
70
+ return (self.expected_number_of_crystals - 1) * self.single_well_width_um + (
71
+ 2 * self.tip_to_first_well_um
72
+ )
73
+
74
+
75
+ class SinglePin(PinType):
76
+ def __init__(self):
77
+ super().__init__(1, GridscanParamConstants.WIDTH_UM)
78
+
79
+ @property
80
+ def full_width(self) -> float:
81
+ return self.single_well_width_um
82
+
83
+
84
+ def _get_parameters_from_url(url: str) -> dict:
85
+ response = requests.get(url, headers={"Accept": "application/json"})
86
+ response.raise_for_status()
87
+ response_json = json.loads(response.content)
88
+ try:
89
+ return response_json["collect"]
90
+ except KeyError as e:
91
+ raise KeyError(f"Unexpected json from agamemnon: {response_json}") from e
92
+
93
+
94
+ def get_pin_type_from_agamemnon_parameters(parameters: dict) -> PinType:
95
+ loop_type_name: str | None = parameters["sample"]["loopType"]
96
+ if loop_type_name:
97
+ regex_search = re.search(MULTIPIN_REGEX, loop_type_name)
98
+ if regex_search:
99
+ wells, well_size, tip_to_first_well = regex_search.groups()
100
+ return PinType(int(wells), float(well_size), float(tip_to_first_well))
101
+ else:
102
+ loop_type_message = (
103
+ f"Agamemnon loop type of {loop_type_name} not recognised"
104
+ )
105
+ if loop_type_name.startswith(MULTIPIN_PREFIX):
106
+ raise ValueError(f"{loop_type_message}. {MULTIPIN_FORMAT_DESC}")
107
+ LOGGER.warning(f"{loop_type_message}, assuming single pin")
108
+ return SinglePin()
109
+
110
+
111
+ def get_next_instruction(beamline: str) -> dict:
112
+ return _get_parameters_from_url(AGAMEMNON_URL + f"getnextcollect/{beamline}")
113
+
114
+
115
+ def get_withvisit_parameters_from_agamemnon(parameters: dict) -> tuple:
116
+ try:
117
+ prefix = parameters["prefix"]
118
+ collection = parameters["collection"]
119
+ # Assuming distance is identical for multiple collections. Remove after https://github.com/DiamondLightSource/mx-bluesky/issues/773
120
+ detector_distance = collection[0]["distance"]
121
+ except KeyError as e:
122
+ raise KeyError("Unexpected json from agamemnon") from e
123
+
124
+ match = re.match(MX_GENERAL_ROOT_REGEX, prefix) if prefix else None
125
+
126
+ if match:
127
+ return (match.group("visit"), detector_distance)
128
+
129
+ raise ValueError(
130
+ f"Agamemnon prefix '{prefix}' does not match MX-General root structure"
131
+ )
132
+
133
+
134
+ def get_withsample_parameters_from_agamemnon(parameters: dict) -> dict[str, Any]:
135
+ assert parameters.get("sample"), "instruction does not have a sample"
136
+ return {
137
+ "sample_id": parameters["sample"]["id"],
138
+ "sample_puck": parameters["sample"]["container"],
139
+ "sample_pin": parameters["sample"]["position"],
140
+ }
141
+
142
+
143
+ def get_withenergy_parameters_from_agamemnon(parameters: dict) -> dict[str, Any]:
144
+ try:
145
+ first_collection: dict = parameters["collection"][0]
146
+ wavelength = first_collection.get("wavelength")
147
+ assert isinstance(wavelength, float)
148
+ demand_energy_ev = convert_angstrom_to_eV(wavelength)
149
+ return {"demand_energy_ev": demand_energy_ev}
150
+ except (KeyError, IndexError, AttributeError, TypeError):
151
+ return {"demand_energy_ev": None}
152
+
153
+
154
+ def get_param_version() -> SemanticVersion:
155
+ return SemanticVersion.validate_from_str(str(PARAMETER_VERSION))
156
+
157
+
158
+ def create_robot_load_then_centre_params_from_agamemnon(
159
+ parameters: dict,
160
+ ) -> RobotLoadThenCentre:
161
+ visit, detector_distance = get_withvisit_parameters_from_agamemnon(parameters)
162
+ with_sample_params = get_withsample_parameters_from_agamemnon(parameters)
163
+ with_energy_params = get_withenergy_parameters_from_agamemnon(parameters)
164
+ visit_directory, file_name = path.split(parameters["prefix"])
165
+ return RobotLoadThenCentre(
166
+ parameter_model_version=get_param_version(),
167
+ storage_directory=visit_directory + "/xraycentring",
168
+ visit=visit,
169
+ detector_distance_mm=detector_distance,
170
+ snapshot_directory=visit_directory + "/snapshots",
171
+ file_name=file_name,
172
+ **with_energy_params,
173
+ **with_sample_params,
174
+ )
175
+
176
+
177
+ def populate_parameters_from_agamemnon(agamemnon_params):
178
+ visit, detector_distance = get_withvisit_parameters_from_agamemnon(agamemnon_params)
179
+ with_sample_params = get_withsample_parameters_from_agamemnon(agamemnon_params)
180
+ with_energy_params = get_withenergy_parameters_from_agamemnon(agamemnon_params)
181
+ pin_type = get_pin_type_from_agamemnon_parameters(agamemnon_params)
182
+ robot_load_params = create_robot_load_then_centre_params_from_agamemnon(
183
+ agamemnon_params
184
+ )
185
+ return AgamemnonLoadCentreCollect(
186
+ parameter_model_version=SemanticVersion.validate_from_str(
187
+ str(PARAMETER_VERSION)
188
+ ),
189
+ visit=visit,
190
+ detector_distance_mm=detector_distance,
191
+ select_centres=TopNByMaxCountSelection(n=pin_type.expected_number_of_crystals),
192
+ robot_load_then_centre=robot_load_params,
193
+ **with_sample_params,
194
+ **with_energy_params,
195
+ )
196
+
197
+
198
+ def create_parameters_from_agamemnon() -> AgamemnonLoadCentreCollect:
199
+ beamline_name = get_beamline_name("i03")
200
+ agamemnon_params = get_next_instruction(beamline_name)
201
+
202
+ return populate_parameters_from_agamemnon(agamemnon_params)
203
+
204
+
205
+ def compare_params(load_centre_collect_params):
206
+ try:
207
+ parameters = create_parameters_from_agamemnon()
208
+
209
+ # Log differences against GDA populated parameters
210
+ differences = DeepDiff(
211
+ parameters, load_centre_collect_params, math_epsilon=1e-5
212
+ )
213
+ if differences:
214
+ LOGGER.info(
215
+ f"Different parameters found when directly reading from Hyperion: {differences}"
216
+ )
217
+ except (ValueError, KeyError) as e:
218
+ LOGGER.warning(f"Failed to compare parameters: {e}")
219
+ except Exception as e:
220
+ LOGGER.warning(f"Unexpected error occurred. Failed to compare parameters: {e}")
221
+
222
+
223
+ def update_params_from_agamemnon(parameters: T) -> T:
224
+ try:
225
+ beamline_name = get_beamline_name("i03")
226
+ agamemnon_params = get_next_instruction(beamline_name)
227
+ pin_type = get_pin_type_from_agamemnon_parameters(agamemnon_params)
228
+ if isinstance(parameters, LoadCentreCollect):
229
+ parameters.robot_load_then_centre.tip_offset_um = pin_type.full_width / 2
230
+ parameters.robot_load_then_centre.grid_width_um = pin_type.full_width
231
+ parameters.select_centres.n = pin_type.expected_number_of_crystals
232
+ if pin_type != SinglePin():
233
+ # Snapshots between each collection take a lot of time.
234
+ # Before we do https://github.com/DiamondLightSource/mx-bluesky/issues/226
235
+ # this will give no snapshots but that's preferable
236
+ parameters.multi_rotation_scan.snapshot_omegas_deg = []
237
+ except (ValueError, ValidationError) as e:
238
+ LOGGER.warning(f"Failed to update parameters: {e}")
239
+ except Exception as e:
240
+ LOGGER.warning(f"Unexpected error occurred. Failed to update parameters: {e}")
241
+
242
+ return parameters
@@ -1,8 +1,8 @@
1
1
  import logging
2
2
  from collections.abc import Callable, Sequence
3
3
  from threading import Thread
4
- from time import sleep
5
4
 
5
+ import bluesky.plan_stubs as bps
6
6
  from bluesky.callbacks import CallbackBase
7
7
  from bluesky.callbacks.zmq import Proxy, RemoteDispatcher
8
8
  from dodal.log import LOGGER as dodal_logger
@@ -14,6 +14,9 @@ from mx_bluesky.common.external_interaction.callbacks.common.log_uid_tag_callbac
14
14
  from mx_bluesky.common.external_interaction.callbacks.common.zocalo_callback import (
15
15
  ZocaloCallback,
16
16
  )
17
+ from mx_bluesky.common.external_interaction.callbacks.sample_handling.sample_handling_callback import (
18
+ SampleHandlingCallback,
19
+ )
17
20
  from mx_bluesky.common.external_interaction.callbacks.xray_centre.ispyb_callback import (
18
21
  GridscanISPyBCallback,
19
22
  )
@@ -35,8 +38,8 @@ from mx_bluesky.hyperion.external_interaction.callbacks.rotation.ispyb_callback
35
38
  from mx_bluesky.hyperion.external_interaction.callbacks.rotation.nexus_callback import (
36
39
  RotationNexusFileCallback,
37
40
  )
38
- from mx_bluesky.hyperion.external_interaction.callbacks.sample_handling.sample_handling_callback import (
39
- SampleHandlingCallback,
41
+ from mx_bluesky.hyperion.external_interaction.callbacks.snapshot_callback import (
42
+ BeamDrawingCallback,
40
43
  )
41
44
  from mx_bluesky.hyperion.parameters.cli import parse_callback_dev_mode_arg
42
45
  from mx_bluesky.hyperion.parameters.constants import CONST
@@ -67,15 +70,18 @@ def create_rotation_callbacks() -> tuple[
67
70
  return (
68
71
  RotationNexusFileCallback(),
69
72
  RotationISPyBCallback(
70
- emit=ZocaloCallback(CONST.PLAN.ROTATION_MAIN, CONST.ZOCALO_ENV)
73
+ emit=ZocaloCallback(CONST.PLAN.ROTATION_MULTI, CONST.ZOCALO_ENV)
71
74
  ),
72
75
  )
73
76
 
74
77
 
75
78
  def setup_callbacks() -> list[CallbackBase]:
79
+ rot_nexus_cb, rot_ispyb_cb = create_rotation_callbacks()
80
+ snapshot_cb = BeamDrawingCallback(emit=rot_ispyb_cb)
76
81
  return [
77
82
  *create_gridscan_callbacks(),
78
- *create_rotation_callbacks(),
83
+ rot_nexus_cb,
84
+ snapshot_cb,
79
85
  LogUidTaggingCallback(),
80
86
  RobotLoadISPyBCallback(),
81
87
  SampleHandlingCallback(),
@@ -134,7 +140,7 @@ def wait_for_threads_forever(threads: Sequence[Thread]):
134
140
  try:
135
141
  log_debug("Trying to wait forever on callback and dispatcher threads")
136
142
  while all(alive):
137
- sleep(LIVENESS_POLL_SECONDS)
143
+ yield from bps.sleep(LIVENESS_POLL_SECONDS)
138
144
  alive = [t.is_alive() for t in threads]
139
145
  except KeyboardInterrupt:
140
146
  log_info("Main thread received interrupt - exiting.")
@@ -136,7 +136,7 @@ class RotationISPyBCallback(BaseISPyBCallback):
136
136
  "handle_ispyb_hardware_read triggered before activity_gated_start"
137
137
  )
138
138
  motor_positions_um = [position * 1000 for position in motor_positions_mm]
139
- comment = f"Sample position (µm): ({motor_positions_um[0]:.0f}, {motor_positions_um[1]:.0f}, {motor_positions_um[2]:.0f}) {self.params.comment} "
139
+ comment = f"Sample position (µm): ({motor_positions_um[0]:.0f}, {motor_positions_um[1]:.0f}, {motor_positions_um[2]:.0f})"
140
140
  scan_data_infos[0].data_collection_info.comments = comment
141
141
  return scan_data_infos
142
142
 
@@ -0,0 +1,107 @@
1
+ import re
2
+
3
+ from dodal.devices.oav.snapshots.snapshot_image_processing import (
4
+ compute_beam_centre_pixel_xy_for_mm_position,
5
+ draw_crosshair,
6
+ )
7
+ from event_model import Event, EventDescriptor, RunStart
8
+ from PIL import Image
9
+
10
+ from mx_bluesky.common.external_interaction.callbacks.common.plan_reactive_callback import (
11
+ PlanReactiveCallback,
12
+ )
13
+ from mx_bluesky.common.parameters.constants import DocDescriptorNames
14
+ from mx_bluesky.common.utils.log import ISPYB_ZOCALO_CALLBACK_LOGGER as CALLBACK_LOGGER
15
+
16
+
17
+ class BeamDrawingCallback(PlanReactiveCallback):
18
+ """
19
+ Callback that monitors for OAV_ROTATION_SNAPSHOT_TRIGGERED events and
20
+ draws a crosshair at the beam centre, saving the snapshot to a file.
21
+ The callback assumes an OAV device "oav"
22
+ Examples:
23
+ Take a snapshot at the current location
24
+ >>> from bluesky.run_engine import RunEngine
25
+ >>> import bluesky.preprocessors as bpp
26
+ >>> import bluesky.plan_stubs as bps
27
+ >>> from dodal.devices.oav.oav_detector import OAV
28
+ >>> from mx_bluesky.common.parameters.components import WithSnapshot
29
+ >>> def take_snapshot(params: WithSnapshot, oav: OAV, run_engine: RunEngine):
30
+ ... run_engine.subscribe(BeamDrawingCallback())
31
+ ... @bpp.run_decorator(md={
32
+ ... "activate_callbacks": ["BeamDrawingCallback"],
33
+ ... "with_snapshot": params.model_dump_json(),
34
+ ... })
35
+ ... def inner_plan():
36
+ ... yield from bps.abs_set(oav.snapshot.directory, "/path/to/snapshot_folder", wait=True)
37
+ ... yield from bps.abs_set(oav.snapshot.filename, "my_snapshot_prefix", wait=True)
38
+ ... yield from bps.trigger(oav.snapshot, wait=True)
39
+ ... yield from bps.create(DocDescriptorNames.OAV_ROTATION_SNAPSHOT_TRIGGERED)
40
+ ... yield from bps.read(oav)
41
+ ... yield from bps.save()
42
+ """
43
+
44
+ def __init__(self, *args, **kwargs):
45
+ super().__init__(*args, log=CALLBACK_LOGGER, **kwargs)
46
+ self._snapshot_files: list[str] = []
47
+ self._microns_per_pixel: tuple[float, float]
48
+ self._beam_centre: tuple[int, int]
49
+ self._rotation_snapshot_descriptor: str = ""
50
+
51
+ def activity_gated_start(self, doc: RunStart):
52
+ if self.activity_uid == doc.get("uid"):
53
+ with_snapshot_json = doc.get("with_snapshot") # type: ignore
54
+ assert with_snapshot_json, (
55
+ "run start event did not have expected snapshot json"
56
+ )
57
+ return doc
58
+
59
+ def activity_gated_descriptor(self, doc: EventDescriptor) -> EventDescriptor | None:
60
+ if doc.get("name") == DocDescriptorNames.OAV_ROTATION_SNAPSHOT_TRIGGERED:
61
+ self._rotation_snapshot_descriptor = doc["uid"]
62
+ return doc
63
+
64
+ def activity_gated_event(self, doc: Event) -> Event:
65
+ if doc["descriptor"] == self._rotation_snapshot_descriptor:
66
+ self._handle_rotation_snapshot(doc)
67
+ return doc
68
+
69
+ def _extract_base_snapshot_params(self, doc: Event):
70
+ data = doc["data"]
71
+ self._snapshot_files.append(data["oav-snapshot-last_saved_path"])
72
+ self._microns_per_pixel = (
73
+ data["oav-microns_per_pixel_x"],
74
+ data["oav-microns_per_pixel_y"],
75
+ )
76
+ self._beam_centre = (data["oav-beam_centre_i"], data["oav-beam_centre_j"])
77
+
78
+ def _handle_rotation_snapshot(self, doc: Event):
79
+ self._extract_base_snapshot_params(doc)
80
+ data = doc["data"]
81
+ snapshot_path = data["oav-snapshot-last_saved_path"]
82
+ match = re.match("(.*)\\.png", snapshot_path)
83
+ assert match, f"Snapshot {snapshot_path} was not a .png file"
84
+ snapshot_base = match.groups()[0]
85
+ output_snapshot_path = f"{snapshot_base}_with_beam_centre.png"
86
+ self._generate_snapshot_at(snapshot_path, output_snapshot_path, 0, 0)
87
+ data["oav-snapshot-last_saved_path"] = output_snapshot_path
88
+ return doc
89
+
90
+ def _generate_snapshot_at(
91
+ self, input_snapshot_path: str, output_snapshot_path: str, x_mm: int, y_mm: int
92
+ ):
93
+ """
94
+ Save a snapshot to the specified path, with an annotated crosshair at the specified
95
+ position
96
+ Args:
97
+ input_snapshot_path: The non-annotated image path.
98
+ output_snapshot_path: The path to the image that will be annotated.
99
+ x_mm: Relative x location of the sample to the original image (mm)
100
+ y_mm: Relative y location of the sample to the original image (mm)
101
+ """
102
+ image = Image.open(input_snapshot_path)
103
+ x_px, y_px = compute_beam_centre_pixel_xy_for_mm_position(
104
+ (x_mm, y_mm), self._beam_centre, self._microns_per_pixel
105
+ )
106
+ draw_crosshair(image, x_px, y_px)
107
+ image.save(output_snapshot_path, format="png")
@@ -29,15 +29,15 @@ class HyperionFeatureFlags(FeatureFlags):
29
29
  def get_config_server() -> ConfigServer:
30
30
  return ConfigServer(CONST.CONFIG_SERVER_URL, LOGGER)
31
31
 
32
- use_panda_for_gridscan: bool = CONST.I03.USE_PANDA_FOR_GRIDSCAN
33
- compare_cpu_and_gpu_zocalo: bool = CONST.I03.COMPARE_CPU_AND_GPU_ZOCALO
34
- use_gpu_results: bool = CONST.I03.USE_GPU_RESULTS
35
- set_stub_offsets: bool = CONST.I03.SET_STUB_OFFSETS
36
- omega_flip: bool = CONST.I03.OMEGA_FLIP
37
-
38
32
  @model_validator(mode="after")
39
33
  def use_gpu_and_compare_cannot_both_be_true(self):
40
34
  assert not (self.use_gpu_results and self.compare_cpu_and_gpu_zocalo), (
41
35
  "Cannot both use GPU results and compare them to CPU"
42
36
  )
43
37
  return self
38
+
39
+ use_panda_for_gridscan: bool = CONST.I03.USE_PANDA_FOR_GRIDSCAN
40
+ compare_cpu_and_gpu_zocalo: bool = CONST.I03.COMPARE_CPU_AND_GPU_ZOCALO
41
+ use_gpu_results: bool = CONST.I03.USE_GPU_RESULTS
42
+ set_stub_offsets: bool = CONST.I03.SET_STUB_OFFSETS
43
+ omega_flip: bool = CONST.I03.OMEGA_FLIP
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import pydantic
4
+ from dodal.devices.aperturescatterguard import (
5
+ ApertureScatterguard,
6
+ )
7
+ from dodal.devices.attenuator.attenuator import BinaryFilterAttenuator
8
+ from dodal.devices.backlight import Backlight
9
+ from dodal.devices.dcm import DCM
10
+ from dodal.devices.eiger import EigerDetector
11
+ from dodal.devices.fast_grid_scan import (
12
+ PandAFastGridScan,
13
+ ZebraFastGridScan,
14
+ )
15
+ from dodal.devices.flux import Flux
16
+ from dodal.devices.robot import BartRobot
17
+ from dodal.devices.s4_slit_gaps import S4SlitGaps
18
+ from dodal.devices.smargon import Smargon
19
+ from dodal.devices.synchrotron import Synchrotron
20
+ from dodal.devices.undulator import Undulator
21
+ from dodal.devices.xbpm_feedback import XBPMFeedback
22
+ from dodal.devices.zebra.zebra import Zebra
23
+ from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter
24
+ from dodal.devices.zocalo import ZocaloResults
25
+ from ophyd_async.fastcs.panda import HDFPanda
26
+
27
+
28
+ @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True})
29
+ class HyperionFlyScanXRayCentreComposite:
30
+ """All devices which are directly or indirectly required by this plan"""
31
+
32
+ aperture_scatterguard: ApertureScatterguard
33
+ attenuator: BinaryFilterAttenuator
34
+ backlight: Backlight
35
+ dcm: DCM
36
+ eiger: EigerDetector
37
+ zebra_fast_grid_scan: ZebraFastGridScan
38
+ flux: Flux
39
+ s4_slit_gaps: S4SlitGaps
40
+ smargon: Smargon
41
+ undulator: Undulator
42
+ synchrotron: Synchrotron
43
+ xbpm_feedback: XBPMFeedback
44
+ zebra: Zebra
45
+ zocalo: ZocaloResults
46
+ panda: HDFPanda
47
+ panda_fast_grid_scan: PandAFastGridScan
48
+ robot: BartRobot
49
+ sample_shutter: ZebraShutter
@@ -36,7 +36,7 @@ class GridCommonWithHyperionDetectorParams(GridCommon, WithHyperionUDCFeatures):
36
36
  return DetectorParams(
37
37
  detector_size_constants=I03Constants.DETECTOR,
38
38
  expected_energy_ev=self.demand_energy_ev,
39
- exposure_time=self.exposure_time_s,
39
+ exposure_time_s=self.exposure_time_s,
40
40
  directory=self.storage_directory,
41
41
  prefix=self.file_name,
42
42
  detector_distance=self.detector_distance_mm,
@@ -53,7 +53,7 @@ class GridCommonWithHyperionDetectorParams(GridCommon, WithHyperionUDCFeatures):
53
53
  )
54
54
 
55
55
 
56
- class HyperionSpecifiedThreeDGridScan(SpecifiedThreeDGridScan, WithHyperionUDCFeatures):
56
+ class HyperionSpecifiedThreeDGridScan(WithHyperionUDCFeatures, SpecifiedThreeDGridScan):
57
57
  """Hyperion's 3D grid scan deviates from the common class due to: optionally using a PandA, optionally using dev_shm for GPU analysis, and using a config server for features"""
58
58
 
59
59
  # These detector params only exist so that we can properly select enable_dev_shm. Remove in
@@ -73,7 +73,7 @@ class HyperionSpecifiedThreeDGridScan(SpecifiedThreeDGridScan, WithHyperionUDCFe
73
73
  return DetectorParams(
74
74
  detector_size_constants=I03Constants.DETECTOR,
75
75
  expected_energy_ev=self.demand_energy_ev,
76
- exposure_time=self.exposure_time_s,
76
+ exposure_time_s=self.exposure_time_s,
77
77
  directory=self.storage_directory,
78
78
  prefix=self.file_name,
79
79
  detector_distance=self.detector_distance_mm,
@@ -75,7 +75,7 @@ class RotationExperiment(DiffractionExperimentWithSample, WithHyperionUDCFeature
75
75
  return DetectorParams(
76
76
  detector_size_constants=I03Constants.DETECTOR,
77
77
  expected_energy_ev=self.demand_energy_ev,
78
- exposure_time=self.exposure_time_s,
78
+ exposure_time_s=self.exposure_time_s,
79
79
  directory=self.storage_directory,
80
80
  prefix=self.file_name,
81
81
  detector_distance=self.detector_distance_mm,
@@ -0,0 +1 @@
1
+ # placeholder file to start layout
@@ -1,74 +1,9 @@
1
- import dataclasses
2
- from typing import Any, ClassVar, Protocol, TypeVar, get_type_hints
3
-
4
1
  from blueapi.core import BlueskyContext
5
- from blueapi.core.bluesky_types import Device
6
2
  from dodal.utils import get_beamline_based_on_environment_variable
7
3
 
8
4
  import mx_bluesky.hyperion.experiment_plans as hyperion_plans
9
5
  from mx_bluesky.common.utils.log import LOGGER
10
6
 
11
- T = TypeVar("T", bound=Device)
12
-
13
-
14
- class _IsDataclass(Protocol):
15
- """Protocol followed by any dataclass"""
16
-
17
- __dataclass_fields__: ClassVar[dict]
18
-
19
-
20
- DT = TypeVar("DT", bound=_IsDataclass)
21
-
22
-
23
- def find_device_in_context(
24
- context: BlueskyContext,
25
- name: str,
26
- # Typing in here is wrong (see https://github.com/microsoft/pyright/issues/7228#issuecomment-1934500232)
27
- # but this whole thing will go away when we do https://github.com/DiamondLightSource/hyperion/issues/868
28
- expected_type: type[T] = Device, # type: ignore
29
- ) -> T:
30
- LOGGER.debug(f"Looking for device {name} of type {expected_type} in context")
31
-
32
- device = context.find_device(name)
33
- if device is None:
34
- raise ValueError(
35
- f"Cannot find device named '{name}' in bluesky context {context.devices}."
36
- )
37
-
38
- if not isinstance(device, expected_type):
39
- raise ValueError(
40
- f"Found device named '{name}' and expected it to be a '{expected_type}' but it was a '{device.__class__.__name__}'"
41
- )
42
-
43
- LOGGER.debug(f"Found matching device {device}")
44
- return device
45
-
46
-
47
- def device_composite_from_context(context: BlueskyContext, dc: type[DT]) -> DT:
48
- """
49
- Initializes all of the devices referenced in a given dataclass from a provided
50
- context, checking that the types of devices returned by the context are compatible
51
- with the type annotations of the dataclass.
52
-
53
- Note that if the context was not created with `wait_for_connection=True` devices may
54
- still be unconnected.
55
- """
56
- LOGGER.debug(
57
- f"Attempting to initialize devices referenced in dataclass {dc} from blueapi context"
58
- )
59
-
60
- devices: dict[str, Any] = {}
61
- dc_type_hints: dict[str, Any] = get_type_hints(dc)
62
-
63
- for field in dataclasses.fields(dc):
64
- device = find_device_in_context(
65
- context, field.name, expected_type=dc_type_hints.get(field.name, Device)
66
- )
67
-
68
- devices[field.name] = device
69
-
70
- return dc(**devices)
71
-
72
7
 
73
8
  def setup_context(wait_for_connection: bool = True) -> BlueskyContext:
74
9
  context = BlueskyContext()
@@ -11,8 +11,8 @@ from dodal.beamlines import i03
11
11
  from dodal.devices.oav.oav_parameters import OAVConfig
12
12
  from ophyd_async.testing import set_mock_value
13
13
 
14
- from mx_bluesky.hyperion.device_setup_plans.read_hardware_for_setup import (
15
- read_hardware_during_collection,
14
+ from mx_bluesky.common.plans.read_hardware import (
15
+ standard_read_hardware_during_collection,
16
16
  )
17
17
  from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import (
18
18
  RotationScanComposite,
@@ -67,7 +67,7 @@ def fake_rotation_scan(
67
67
  }
68
68
  )
69
69
  def plan():
70
- yield from read_hardware_during_collection(
70
+ yield from standard_read_hardware_during_collection(
71
71
  rotation_devices.aperture_scatterguard,
72
72
  rotation_devices.attenuator,
73
73
  rotation_devices.flux,
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: mx-bluesky
3
- Version: 1.4.5
3
+ Version: 1.4.7
4
4
  Summary: Bluesky tools for MX Beamlines at DLS
5
5
  Author-email: Dominic Oram <dominic.oram@diamond.ac.uk>
6
6
  License: Apache License
@@ -238,8 +238,8 @@ Requires-Dist: blueapi>=0.5.0
238
238
  Requires-Dist: daq-config-server>=0.1.1
239
239
  Requires-Dist: ophyd==1.9.0
240
240
  Requires-Dist: ophyd-async>=0.9.0a2
241
- Requires-Dist: bluesky>=1.13
242
- Requires-Dist: dls-dodal==1.40.0
241
+ Requires-Dist: bluesky>=1.13.1
242
+ Requires-Dist: dls-dodal==1.44.0
243
243
  Provides-Extra: dev
244
244
  Requires-Dist: black; extra == "dev"
245
245
  Requires-Dist: build; extra == "dev"
@@ -266,6 +266,7 @@ Requires-Dist: tox-direct; extra == "dev"
266
266
  Requires-Dist: tox; extra == "dev"
267
267
  Requires-Dist: types-mock; extra == "dev"
268
268
  Requires-Dist: types-requests; extra == "dev"
269
+ Dynamic: license-file
269
270
 
270
271
  mx-bluesky
271
272
  ===========================
@@ -284,7 +285,7 @@ Releases https://github.com/DiamondLightSource/mx-bluesky/releases
284
285
  Getting Started
285
286
  ===============
286
287
 
287
- To get started with developing this repo at DLS run ``dls_dev_setup.sh``.
288
+ To get started with developing this repo at DLS run ``./utility_scripts/dls_dev_env.sh``.
288
289
 
289
290
  If you want to develop interactively at the beamline we recommend using jupyter notebooks. You can get started with this by running::
290
291