mx-bluesky 1.1.0__py3-none-any.whl → 1.4.0__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.
- mx_bluesky/__init__.py +8 -3
- mx_bluesky/__main__.py +12 -7
- mx_bluesky/_version.py +2 -2
- mx_bluesky/beamlines/i04/callbacks/murko_callback.py +14 -4
- mx_bluesky/beamlines/i04/thawing_plan.py +48 -10
- mx_bluesky/beamlines/i24/serial/__init__.py +3 -0
- mx_bluesky/beamlines/i24/serial/extruder/i24ssx_Extruder_Collect_py3v2.py +68 -90
- mx_bluesky/beamlines/i24/serial/fixed_target/FT-gui-edm/DiamondChipI24-py3v1.edl +1 -1
- mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_Collect_py3v1.py +104 -126
- mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_Manager_py3v1.py +139 -162
- mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_Mapping_py3v1.py +25 -36
- mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_Chip_StartUp_py3v1.py +24 -34
- mx_bluesky/beamlines/i24/serial/fixed_target/i24ssx_moveonclick.py +14 -11
- mx_bluesky/beamlines/i24/serial/log.py +58 -49
- mx_bluesky/beamlines/i24/serial/parameters/fixed_target/cs/cs_maker.json +3 -3
- mx_bluesky/beamlines/i24/serial/run_extruder.sh +30 -5
- mx_bluesky/beamlines/i24/serial/run_fixed_target.sh +31 -7
- mx_bluesky/beamlines/i24/serial/run_serial.py +24 -8
- mx_bluesky/beamlines/i24/serial/setup_beamline/ca.py +0 -2
- mx_bluesky/beamlines/i24/serial/setup_beamline/setup_beamline.py +1 -1
- mx_bluesky/beamlines/i24/serial/setup_beamline/setup_detector.py +8 -18
- mx_bluesky/beamlines/i24/serial/setup_beamline/setup_zebra_plans.py +2 -2
- mx_bluesky/common/__init__.py +0 -0
- mx_bluesky/common/device_setup_plans/read_hardware_for_setup.py +14 -0
- mx_bluesky/common/parameters/components.py +221 -0
- mx_bluesky/common/parameters/constants.py +133 -0
- mx_bluesky/common/plans/__init__.py +1 -0
- mx_bluesky/common/plans/do_fgs.py +121 -0
- mx_bluesky/common/utils/log.py +116 -0
- mx_bluesky/{hyperion → common/utils}/tracing.py +2 -2
- mx_bluesky/hyperion/__main__.py +11 -9
- mx_bluesky/hyperion/device_setup_plans/dcm_pitch_roll_mirror_adjuster.py +31 -26
- mx_bluesky/hyperion/device_setup_plans/read_hardware_for_setup.py +6 -12
- mx_bluesky/hyperion/device_setup_plans/setup_oav.py +6 -12
- mx_bluesky/hyperion/device_setup_plans/setup_panda.py +1 -2
- mx_bluesky/hyperion/device_setup_plans/setup_zebra.py +48 -17
- mx_bluesky/hyperion/device_setup_plans/smargon.py +6 -6
- mx_bluesky/hyperion/device_setup_plans/utils.py +13 -2
- mx_bluesky/hyperion/device_setup_plans/xbpm_feedback.py +4 -4
- mx_bluesky/hyperion/experiment_plans/experiment_registry.py +9 -0
- mx_bluesky/hyperion/experiment_plans/flyscan_xray_centre_plan.py +59 -108
- mx_bluesky/hyperion/experiment_plans/grid_detect_then_xray_centre_plan.py +7 -5
- mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py +46 -0
- mx_bluesky/hyperion/experiment_plans/oav_grid_detection_plan.py +19 -18
- mx_bluesky/hyperion/experiment_plans/oav_snapshot_plan.py +8 -5
- mx_bluesky/hyperion/experiment_plans/pin_centre_then_xray_centre_plan.py +4 -4
- mx_bluesky/hyperion/experiment_plans/pin_tip_centring_plan.py +17 -17
- mx_bluesky/hyperion/experiment_plans/robot_load_and_change_energy.py +241 -0
- mx_bluesky/hyperion/experiment_plans/robot_load_then_centre_plan.py +24 -181
- mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py +6 -4
- mx_bluesky/hyperion/experiment_plans/set_energy_plan.py +3 -11
- mx_bluesky/hyperion/external_interaction/callbacks/__main__.py +1 -2
- mx_bluesky/hyperion/external_interaction/callbacks/common/callback_util.py +18 -0
- mx_bluesky/hyperion/external_interaction/callbacks/common/ispyb_mapping.py +1 -9
- mx_bluesky/hyperion/external_interaction/callbacks/grid_detection_callback.py +18 -13
- mx_bluesky/hyperion/external_interaction/callbacks/ispyb_callback_base.py +32 -15
- mx_bluesky/hyperion/external_interaction/callbacks/log_uid_tag_callback.py +1 -1
- mx_bluesky/hyperion/external_interaction/callbacks/robot_load/ispyb_callback.py +3 -5
- mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py +4 -3
- mx_bluesky/hyperion/external_interaction/callbacks/xray_centre/ispyb_callback.py +23 -18
- mx_bluesky/hyperion/external_interaction/config_server.py +22 -10
- mx_bluesky/hyperion/external_interaction/ispyb/ispyb_store.py +1 -1
- mx_bluesky/hyperion/external_interaction/ispyb/ispyb_utils.py +0 -2
- mx_bluesky/hyperion/external_interaction/nexus/nexus_utils.py +2 -2
- mx_bluesky/hyperion/external_interaction/nexus/write_nexus.py +1 -1
- mx_bluesky/hyperion/log.py +0 -84
- mx_bluesky/hyperion/parameters/components.py +1 -242
- mx_bluesky/hyperion/parameters/constants.py +22 -118
- mx_bluesky/hyperion/parameters/gridscan.py +20 -11
- mx_bluesky/hyperion/parameters/load_centre_collect.py +50 -0
- mx_bluesky/hyperion/parameters/robot_load.py +16 -0
- mx_bluesky/hyperion/parameters/rotation.py +9 -5
- mx_bluesky/hyperion/utils/utils.py +17 -0
- mx_bluesky/hyperion/utils/validation.py +5 -6
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/METADATA +4 -2
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/RECORD +80 -70
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/WHEEL +1 -1
- mx_bluesky/example.py +0 -19
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/LICENSE +0 -0
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/entry_points.txt +0 -0
- {mx_bluesky-1.1.0.dist-info → mx_bluesky-1.4.0.dist-info}/top_level.txt +0 -0
|
@@ -7,12 +7,14 @@ import mx_bluesky.hyperion.experiment_plans.flyscan_xray_centre_plan as flyscan_
|
|
|
7
7
|
import mx_bluesky.hyperion.experiment_plans.rotation_scan_plan as rotation_scan_plan
|
|
8
8
|
from mx_bluesky.hyperion.experiment_plans import (
|
|
9
9
|
grid_detect_then_xray_centre_plan,
|
|
10
|
+
load_centre_collect_full_plan,
|
|
10
11
|
pin_centre_then_xray_centre_plan,
|
|
11
12
|
robot_load_then_centre_plan,
|
|
12
13
|
)
|
|
13
14
|
from mx_bluesky.hyperion.external_interaction.callbacks.common.callback_util import (
|
|
14
15
|
CallbacksFactory,
|
|
15
16
|
create_gridscan_callbacks,
|
|
17
|
+
create_load_centre_collect_callbacks,
|
|
16
18
|
create_robot_load_and_centre_callbacks,
|
|
17
19
|
create_rotation_callbacks,
|
|
18
20
|
)
|
|
@@ -22,6 +24,7 @@ from mx_bluesky.hyperion.parameters.gridscan import (
|
|
|
22
24
|
RobotLoadThenCentre,
|
|
23
25
|
ThreeDGridScan,
|
|
24
26
|
)
|
|
27
|
+
from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect
|
|
25
28
|
from mx_bluesky.hyperion.parameters.rotation import MultiRotationScan, RotationScan
|
|
26
29
|
|
|
27
30
|
|
|
@@ -42,6 +45,7 @@ class ExperimentRegistryEntry(TypedDict):
|
|
|
42
45
|
| MultiRotationScan
|
|
43
46
|
| PinTipCentreThenXrayCentre
|
|
44
47
|
| RobotLoadThenCentre
|
|
48
|
+
| LoadCentreCollect
|
|
45
49
|
]
|
|
46
50
|
callbacks_factory: CallbacksFactory
|
|
47
51
|
|
|
@@ -77,6 +81,11 @@ PLAN_REGISTRY: dict[str, ExperimentRegistryEntry] = {
|
|
|
77
81
|
"param_type": MultiRotationScan,
|
|
78
82
|
"callbacks_factory": create_rotation_callbacks,
|
|
79
83
|
},
|
|
84
|
+
"load_centre_collect_full_plan": {
|
|
85
|
+
"setup": load_centre_collect_full_plan.create_devices,
|
|
86
|
+
"param_type": LoadCentreCollect,
|
|
87
|
+
"callbacks_factory": create_load_centre_collect_callbacks,
|
|
88
|
+
},
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
|
|
@@ -4,7 +4,6 @@ import dataclasses
|
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
from functools import partial
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from time import time
|
|
8
7
|
from typing import Protocol
|
|
9
8
|
|
|
10
9
|
import bluesky.plan_stubs as bps
|
|
@@ -40,16 +39,15 @@ from dodal.devices.zocalo.zocalo_results import (
|
|
|
40
39
|
ZOCALO_READING_PLAN_NAME,
|
|
41
40
|
ZOCALO_STAGE_GROUP,
|
|
42
41
|
ZocaloResults,
|
|
43
|
-
|
|
42
|
+
get_full_processing_results,
|
|
44
43
|
)
|
|
45
|
-
from dodal.plans.check_topup import check_topup_and_wait_if_necessary
|
|
46
44
|
from ophyd_async.fastcs.panda import HDFPanda
|
|
47
|
-
from scanspec.core import AxesPoints, Axis
|
|
48
45
|
|
|
46
|
+
from mx_bluesky.common.plans.do_fgs import kickoff_and_complete_gridscan
|
|
47
|
+
from mx_bluesky.common.utils.tracing import TRACER
|
|
49
48
|
from mx_bluesky.hyperion.device_setup_plans.manipulate_sample import move_x_y_z
|
|
50
49
|
from mx_bluesky.hyperion.device_setup_plans.read_hardware_for_setup import (
|
|
51
50
|
read_hardware_during_collection,
|
|
52
|
-
read_hardware_for_zocalo,
|
|
53
51
|
read_hardware_pre_collection,
|
|
54
52
|
)
|
|
55
53
|
from mx_bluesky.hyperion.device_setup_plans.setup_panda import (
|
|
@@ -69,7 +67,6 @@ from mx_bluesky.hyperion.exceptions import WarningException
|
|
|
69
67
|
from mx_bluesky.hyperion.log import LOGGER
|
|
70
68
|
from mx_bluesky.hyperion.parameters.constants import CONST
|
|
71
69
|
from mx_bluesky.hyperion.parameters.gridscan import ThreeDGridScan
|
|
72
|
-
from mx_bluesky.hyperion.tracing import TRACER
|
|
73
70
|
from mx_bluesky.hyperion.utils.context import device_composite_from_context
|
|
74
71
|
|
|
75
72
|
|
|
@@ -77,6 +74,12 @@ class SmargonSpeedException(Exception):
|
|
|
77
74
|
pass
|
|
78
75
|
|
|
79
76
|
|
|
77
|
+
class CrystalNotFoundException(WarningException):
|
|
78
|
+
"""Raised if grid detection completed normally but no crystal was found."""
|
|
79
|
+
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
80
83
|
@dataclasses.dataclass
|
|
81
84
|
class FlyScanXRayCentreComposite:
|
|
82
85
|
"""All devices which are directly or indirectly required by this plan"""
|
|
@@ -128,8 +131,8 @@ def flyscan_xray_centre(
|
|
|
128
131
|
"""
|
|
129
132
|
parameters.features.update_self_from_server()
|
|
130
133
|
composite.eiger.set_detector_parameters(parameters.detector_params)
|
|
131
|
-
composite.zocalo.zocalo_environment =
|
|
132
|
-
composite.zocalo.use_cpu_and_gpu = parameters.
|
|
134
|
+
composite.zocalo.zocalo_environment = CONST.ZOCALO_ENV
|
|
135
|
+
composite.zocalo.use_cpu_and_gpu = parameters.features.compare_cpu_and_gpu_zocalo
|
|
133
136
|
|
|
134
137
|
feature_controlled = _get_feature_controlled(composite, parameters)
|
|
135
138
|
|
|
@@ -137,8 +140,6 @@ def flyscan_xray_centre(
|
|
|
137
140
|
@bpp.run_decorator( # attach experiment metadata to the start document
|
|
138
141
|
md={
|
|
139
142
|
"subplan_name": CONST.PLAN.GRIDSCAN_OUTER,
|
|
140
|
-
CONST.TRIGGER.ZOCALO: CONST.PLAN.DO_FGS,
|
|
141
|
-
"zocalo_environment": parameters.zocalo_environment,
|
|
142
143
|
"hyperion_parameters": parameters.model_dump_json(),
|
|
143
144
|
"activate_callbacks": [
|
|
144
145
|
"GridscanNexusFileCallback",
|
|
@@ -190,47 +191,52 @@ def run_gridscan_and_move(
|
|
|
190
191
|
|
|
191
192
|
LOGGER.info("Grid scan finished, getting results.")
|
|
192
193
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
LOGGER.info("Zocalo triggered and read, interpreting results.")
|
|
198
|
-
xray_centre, bbox_size = yield from get_processing_result(fgs_composite.zocalo)
|
|
199
|
-
LOGGER.info(f"Got xray centre: {xray_centre}, bbox size: {bbox_size}")
|
|
200
|
-
if xray_centre is not None:
|
|
201
|
-
xray_centre = parameters.FGS_params.grid_position_to_motor_position(
|
|
202
|
-
xray_centre
|
|
194
|
+
try:
|
|
195
|
+
with TRACER.start_span("wait_for_zocalo"):
|
|
196
|
+
yield from bps.trigger_and_read(
|
|
197
|
+
[fgs_composite.zocalo], name=ZOCALO_READING_PLAN_NAME
|
|
203
198
|
)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
LOGGER.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
199
|
+
LOGGER.info("Zocalo triggered and read, interpreting results.")
|
|
200
|
+
xrc_results = yield from get_full_processing_results(fgs_composite.zocalo)
|
|
201
|
+
LOGGER.info(f"Got xray centring results: {xrc_results}")
|
|
202
|
+
if xrc_results:
|
|
203
|
+
best_result = xrc_results[0]
|
|
204
|
+
xrc_centre_grid_coords = best_result["centre_of_mass"]
|
|
205
|
+
xray_centre = parameters.FGS_params.grid_position_to_motor_position(
|
|
206
|
+
np.array(xrc_centre_grid_coords)
|
|
211
207
|
)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
208
|
+
with TRACER.start_span("change_aperture"):
|
|
209
|
+
bbox_size = np.abs(
|
|
210
|
+
np.array(best_result["bounding_box"][1])
|
|
211
|
+
- np.array(best_result["bounding_box"][0])
|
|
212
|
+
)
|
|
213
|
+
yield from set_aperture_for_bbox_size(
|
|
214
|
+
fgs_composite.aperture_scatterguard, bbox_size
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
LOGGER.warning("No X-ray centre received")
|
|
218
|
+
raise CrystalNotFoundException()
|
|
219
|
+
|
|
220
|
+
# once we have the results, go to the appropriate position
|
|
221
|
+
LOGGER.info("Moving to centre of mass.")
|
|
222
|
+
with TRACER.start_span("move_to_result"):
|
|
223
|
+
x, y, z = xray_centre
|
|
224
|
+
yield from move_x_y_z(fgs_composite.sample_motors, x, y, z, wait=True)
|
|
225
|
+
|
|
226
|
+
if parameters.FGS_params.set_stub_offsets:
|
|
227
|
+
LOGGER.info("Recentring smargon co-ordinate system to this point.")
|
|
228
|
+
yield from bps.mv(
|
|
229
|
+
fgs_composite.sample_motors.stub_offsets, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
230
|
+
StubPosition.CURRENT_AS_CENTER, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
231
|
+
)
|
|
232
|
+
finally:
|
|
233
|
+
# Turn off dev/shm streaming to avoid filling disk, see https://github.com/DiamondLightSource/hyperion/issues/1395
|
|
234
|
+
LOGGER.info("Turning off Eiger dev/shm streaming")
|
|
235
|
+
yield from bps.abs_set(fgs_composite.eiger.odin.fan.dev_shm_enable, 0) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
230
236
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
237
|
+
# Wait on everything before returning to GDA (particularly apertures), can be removed
|
|
238
|
+
# when we do not return to GDA here
|
|
239
|
+
yield from bps.wait()
|
|
234
240
|
|
|
235
241
|
|
|
236
242
|
@bpp.set_run_key_decorator(CONST.PLAN.GRIDSCAN_MAIN)
|
|
@@ -257,7 +263,7 @@ def run_gridscan(
|
|
|
257
263
|
fgs_composite.undulator,
|
|
258
264
|
fgs_composite.synchrotron,
|
|
259
265
|
fgs_composite.s4_slit_gaps,
|
|
260
|
-
fgs_composite.
|
|
266
|
+
fgs_composite.dcm,
|
|
261
267
|
fgs_composite.smargon,
|
|
262
268
|
)
|
|
263
269
|
|
|
@@ -278,7 +284,7 @@ def run_gridscan(
|
|
|
278
284
|
|
|
279
285
|
LOGGER.info("Waiting for arming to finish")
|
|
280
286
|
yield from bps.wait(CONST.WAIT.GRID_READY_FOR_DC)
|
|
281
|
-
yield from bps.stage(fgs_composite.eiger)
|
|
287
|
+
yield from bps.stage(fgs_composite.eiger) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
282
288
|
|
|
283
289
|
yield from kickoff_and_complete_gridscan(
|
|
284
290
|
feature_controlled.fgs_motors,
|
|
@@ -286,66 +292,11 @@ def run_gridscan(
|
|
|
286
292
|
fgs_composite.synchrotron,
|
|
287
293
|
[parameters.scan_points_first_grid, parameters.scan_points_second_grid],
|
|
288
294
|
parameters.scan_indices,
|
|
289
|
-
|
|
295
|
+
plan_during_collection=read_during_collection,
|
|
290
296
|
)
|
|
291
297
|
yield from bps.abs_set(feature_controlled.fgs_motors.z_steps, 0, wait=False)
|
|
292
298
|
|
|
293
299
|
|
|
294
|
-
def kickoff_and_complete_gridscan(
|
|
295
|
-
gridscan: FastGridScanCommon,
|
|
296
|
-
eiger: EigerDetector,
|
|
297
|
-
synchrotron: Synchrotron,
|
|
298
|
-
scan_points: list[AxesPoints[Axis]],
|
|
299
|
-
scan_start_indices: list[int],
|
|
300
|
-
do_during_run: Callable[[], MsgGenerator] | None = None,
|
|
301
|
-
):
|
|
302
|
-
@TRACER.start_as_current_span(CONST.PLAN.DO_FGS)
|
|
303
|
-
@bpp.set_run_key_decorator(CONST.PLAN.DO_FGS)
|
|
304
|
-
@bpp.run_decorator(
|
|
305
|
-
md={
|
|
306
|
-
"subplan_name": CONST.PLAN.DO_FGS,
|
|
307
|
-
"scan_points": scan_points,
|
|
308
|
-
"scan_start_indices": scan_start_indices,
|
|
309
|
-
}
|
|
310
|
-
)
|
|
311
|
-
@bpp.contingency_decorator(
|
|
312
|
-
except_plan=lambda e: (yield from bps.stop(eiger)),
|
|
313
|
-
else_plan=lambda: (yield from bps.unstage(eiger)),
|
|
314
|
-
)
|
|
315
|
-
def do_fgs():
|
|
316
|
-
# Check topup gate
|
|
317
|
-
expected_images = yield from bps.rd(gridscan.expected_images)
|
|
318
|
-
exposure_sec_per_image = yield from bps.rd(eiger.cam.acquire_time)
|
|
319
|
-
LOGGER.info("waiting for topup if necessary...")
|
|
320
|
-
yield from check_topup_and_wait_if_necessary(
|
|
321
|
-
synchrotron,
|
|
322
|
-
expected_images * exposure_sec_per_image,
|
|
323
|
-
30.0,
|
|
324
|
-
)
|
|
325
|
-
yield from read_hardware_for_zocalo(eiger)
|
|
326
|
-
LOGGER.info("Wait for all moves with no assigned group")
|
|
327
|
-
yield from bps.wait()
|
|
328
|
-
LOGGER.info("kicking off FGS")
|
|
329
|
-
yield from bps.kickoff(gridscan, wait=True)
|
|
330
|
-
gridscan_start_time = time()
|
|
331
|
-
LOGGER.info("Waiting for Zocalo device queue to have been cleared...")
|
|
332
|
-
yield from bps.wait(
|
|
333
|
-
ZOCALO_STAGE_GROUP
|
|
334
|
-
) # Make sure ZocaloResults queue is clear and ready to accept our new data
|
|
335
|
-
if do_during_run:
|
|
336
|
-
LOGGER.info(f"Running {do_during_run} during FGS")
|
|
337
|
-
yield from do_during_run()
|
|
338
|
-
LOGGER.info("completing FGS")
|
|
339
|
-
yield from bps.complete(gridscan, wait=True)
|
|
340
|
-
|
|
341
|
-
# Remove this logging statement once metrics have been added
|
|
342
|
-
LOGGER.info(
|
|
343
|
-
f"Gridscan motion program took {round(time()-gridscan_start_time,2)} to complete"
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
yield from do_fgs()
|
|
347
|
-
|
|
348
|
-
|
|
349
300
|
def wait_for_gridscan_valid(fgs_motors: FastGridScanCommon, timeout=0.5):
|
|
350
301
|
LOGGER.info("Waiting for valid fgs_params")
|
|
351
302
|
SLEEP_PER_CHECK = 0.1
|
|
@@ -506,8 +457,8 @@ def _panda_triggering_setup(
|
|
|
506
457
|
)
|
|
507
458
|
|
|
508
459
|
yield from bps.mv(
|
|
509
|
-
fgs_composite.panda_fast_grid_scan.time_between_x_steps_ms,
|
|
510
|
-
time_between_x_steps_ms,
|
|
460
|
+
fgs_composite.panda_fast_grid_scan.time_between_x_steps_ms, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
461
|
+
time_between_x_steps_ms, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
511
462
|
)
|
|
512
463
|
|
|
513
464
|
directory_provider_root = Path(parameters.storage_directory)
|
|
@@ -15,7 +15,7 @@ from dodal.devices.eiger import EigerDetector
|
|
|
15
15
|
from dodal.devices.fast_grid_scan import PandAFastGridScan, ZebraFastGridScan
|
|
16
16
|
from dodal.devices.flux import Flux
|
|
17
17
|
from dodal.devices.oav.oav_detector import OAV
|
|
18
|
-
from dodal.devices.oav.oav_parameters import
|
|
18
|
+
from dodal.devices.oav.oav_parameters import OAVParameters
|
|
19
19
|
from dodal.devices.oav.pin_image_recognition import PinTipDetection
|
|
20
20
|
from dodal.devices.robot import BartRobot
|
|
21
21
|
from dodal.devices.s4_slit_gaps import S4SlitGaps
|
|
@@ -28,6 +28,7 @@ from dodal.devices.zebra_controlled_shutter import ZebraShutter
|
|
|
28
28
|
from dodal.devices.zocalo import ZocaloResults
|
|
29
29
|
from ophyd_async.fastcs.panda import HDFPanda
|
|
30
30
|
|
|
31
|
+
from mx_bluesky.common.parameters.constants import OavConstants
|
|
31
32
|
from mx_bluesky.hyperion.device_setup_plans.manipulate_sample import (
|
|
32
33
|
move_aperture_if_required,
|
|
33
34
|
)
|
|
@@ -109,7 +110,7 @@ def detect_grid_and_do_gridscan(
|
|
|
109
110
|
):
|
|
110
111
|
snapshot_template = f"{parameters.detector_params.prefix}_{parameters.detector_params.run_number}_{{angle}}"
|
|
111
112
|
|
|
112
|
-
grid_params_callback = GridDetectionCallback(
|
|
113
|
+
grid_params_callback = GridDetectionCallback()
|
|
113
114
|
|
|
114
115
|
@bpp.subs_decorator([grid_params_callback])
|
|
115
116
|
def run_grid_detection_plan(
|
|
@@ -129,7 +130,8 @@ def detect_grid_and_do_gridscan(
|
|
|
129
130
|
oav_params,
|
|
130
131
|
snapshot_template,
|
|
131
132
|
str(snapshot_dir),
|
|
132
|
-
|
|
133
|
+
parameters.grid_width_um,
|
|
134
|
+
parameters.box_size_um,
|
|
133
135
|
)
|
|
134
136
|
|
|
135
137
|
yield from run_grid_detection_plan(
|
|
@@ -178,7 +180,7 @@ def detect_grid_and_do_gridscan(
|
|
|
178
180
|
def grid_detect_then_xray_centre(
|
|
179
181
|
composite: GridDetectThenXRayCentreComposite,
|
|
180
182
|
parameters: GridScanWithEdgeDetect,
|
|
181
|
-
oav_config: str = OAV_CONFIG_JSON,
|
|
183
|
+
oav_config: str = OavConstants.OAV_CONFIG_JSON,
|
|
182
184
|
) -> MsgGenerator:
|
|
183
185
|
"""
|
|
184
186
|
A plan which combines the collection of snapshots from the OAV and the determination
|
|
@@ -204,6 +206,6 @@ def grid_detect_then_xray_centre(
|
|
|
204
206
|
eiger,
|
|
205
207
|
composite.detector_motion,
|
|
206
208
|
parameters.detector_params.detector_distance,
|
|
207
|
-
plan_to_perform,
|
|
209
|
+
plan_to_perform, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809 and MsgGenerator should allow for return values
|
|
208
210
|
group=CONST.WAIT.GRID_READY_FOR_DC,
|
|
209
211
|
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
3
|
+
from blueapi.core import BlueskyContext
|
|
4
|
+
from dodal.devices.oav.oav_parameters import OAVParameters
|
|
5
|
+
|
|
6
|
+
from mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan import (
|
|
7
|
+
RobotLoadThenCentreComposite,
|
|
8
|
+
robot_load_then_centre,
|
|
9
|
+
)
|
|
10
|
+
from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import (
|
|
11
|
+
RotationScanComposite,
|
|
12
|
+
multi_rotation_scan,
|
|
13
|
+
)
|
|
14
|
+
from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect
|
|
15
|
+
from mx_bluesky.hyperion.utils.context import device_composite_from_context
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclasses.dataclass
|
|
19
|
+
class LoadCentreCollectComposite(RobotLoadThenCentreComposite, RotationScanComposite):
|
|
20
|
+
"""Composite that provides access to the required devices."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_devices(context: BlueskyContext) -> LoadCentreCollectComposite:
|
|
26
|
+
"""Create the necessary devices for the plan."""
|
|
27
|
+
return device_composite_from_context(context, LoadCentreCollectComposite)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_centre_collect_full_plan(
|
|
31
|
+
composite: LoadCentreCollectComposite,
|
|
32
|
+
params: LoadCentreCollect,
|
|
33
|
+
oav_params: OAVParameters | None = None,
|
|
34
|
+
):
|
|
35
|
+
"""Attempt a complete data collection experiment, consisting of the following:
|
|
36
|
+
* Load the sample if necessary
|
|
37
|
+
* Move to the specified goniometer start angles
|
|
38
|
+
* Perform optical centring, then X-ray centring
|
|
39
|
+
* If X-ray centring finds a diffracting centre then move to that centre and
|
|
40
|
+
* do a collection with the specified parameters.
|
|
41
|
+
"""
|
|
42
|
+
if not oav_params:
|
|
43
|
+
oav_params = OAVParameters(context="xrayCentring")
|
|
44
|
+
yield from robot_load_then_centre(composite, params.robot_load_then_centre)
|
|
45
|
+
|
|
46
|
+
yield from multi_rotation_scan(composite, params.multi_rotation_scan, oav_params)
|
|
@@ -63,7 +63,7 @@ def grid_detection_plan(
|
|
|
63
63
|
snapshot_template: str,
|
|
64
64
|
snapshot_dir: str,
|
|
65
65
|
grid_width_microns: float,
|
|
66
|
-
box_size_um: float
|
|
66
|
+
box_size_um: float,
|
|
67
67
|
):
|
|
68
68
|
"""
|
|
69
69
|
Creates the parameters for two grids that are 90 degrees from each other and
|
|
@@ -74,7 +74,7 @@ def grid_detection_plan(
|
|
|
74
74
|
parameters (OAVParameters): Object containing parameters for setting up the OAV
|
|
75
75
|
snapshot_template (str): A template for the name of the snapshots, expected to be filled in with an angle
|
|
76
76
|
snapshot_dir (str): The location to save snapshots
|
|
77
|
-
grid_width_microns (
|
|
77
|
+
grid_width_microns (float): The width of the grid to scan in microns
|
|
78
78
|
box_size_um (float): The size of each box of the grid in microns
|
|
79
79
|
"""
|
|
80
80
|
oav: OAV = composite.oav
|
|
@@ -90,16 +90,17 @@ def grid_detection_plan(
|
|
|
90
90
|
|
|
91
91
|
LOGGER.info("OAV Centring: Camera set up")
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
assert isinstance(oav.parameters.micronsPerYPixel, float)
|
|
96
|
-
box_size_y_pixels = box_size_um / oav.parameters.micronsPerYPixel
|
|
93
|
+
microns_per_pixel_x = yield from bps.rd(oav.microns_per_pixel_x)
|
|
94
|
+
microns_per_pixel_y = yield from bps.rd(oav.microns_per_pixel_y)
|
|
97
95
|
|
|
98
|
-
|
|
96
|
+
box_size_x_pixels = box_size_um / microns_per_pixel_x
|
|
97
|
+
box_size_y_pixels = box_size_um / microns_per_pixel_y
|
|
98
|
+
|
|
99
|
+
grid_width_pixels = int(grid_width_microns / microns_per_pixel_x)
|
|
99
100
|
|
|
100
101
|
# The FGS uses -90 so we need to match it
|
|
101
102
|
for angle in [0, -90]:
|
|
102
|
-
yield from bps.mv(smargon.omega, angle)
|
|
103
|
+
yield from bps.mv(smargon.omega, angle) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
103
104
|
# need to wait for the OAV image to update
|
|
104
105
|
# See #673 for improvements
|
|
105
106
|
yield from bps.sleep(CONST.HARDWARE.OAV_REFRESH_DELAY)
|
|
@@ -115,7 +116,7 @@ def grid_detection_plan(
|
|
|
115
116
|
(yield from bps.rd(pin_tip_detection.triggered_bottom_edge))
|
|
116
117
|
)
|
|
117
118
|
|
|
118
|
-
full_image_height_px = yield from bps.rd(oav.cam.
|
|
119
|
+
full_image_height_px = yield from bps.rd(oav.cam.array_size_y)
|
|
119
120
|
|
|
120
121
|
# only use the area from the start of the pin onwards
|
|
121
122
|
top_edge = top_edge[tip_x_px : tip_x_px + grid_width_pixels]
|
|
@@ -151,20 +152,20 @@ def grid_detection_plan(
|
|
|
151
152
|
|
|
152
153
|
upper_left = (tip_x_px, min_y)
|
|
153
154
|
|
|
154
|
-
yield from bps.abs_set(oav.grid_snapshot.top_left_x, upper_left[0])
|
|
155
|
-
yield from bps.abs_set(oav.grid_snapshot.top_left_y, upper_left[1])
|
|
156
|
-
yield from bps.abs_set(oav.grid_snapshot.box_width, box_size_x_pixels)
|
|
157
|
-
yield from bps.abs_set(oav.grid_snapshot.num_boxes_x, x_steps)
|
|
158
|
-
yield from bps.abs_set(oav.grid_snapshot.num_boxes_y, y_steps)
|
|
155
|
+
yield from bps.abs_set(oav.grid_snapshot.top_left_x, upper_left[0]) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
156
|
+
yield from bps.abs_set(oav.grid_snapshot.top_left_y, upper_left[1]) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
157
|
+
yield from bps.abs_set(oav.grid_snapshot.box_width, box_size_x_pixels) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
158
|
+
yield from bps.abs_set(oav.grid_snapshot.num_boxes_x, x_steps) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
159
|
+
yield from bps.abs_set(oav.grid_snapshot.num_boxes_y, y_steps) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
159
160
|
|
|
160
161
|
snapshot_filename = snapshot_template.format(angle=abs(angle))
|
|
161
162
|
|
|
162
|
-
yield from bps.abs_set(oav.grid_snapshot.filename, snapshot_filename)
|
|
163
|
-
yield from bps.abs_set(oav.grid_snapshot.directory, snapshot_dir)
|
|
164
|
-
yield from bps.trigger(oav.grid_snapshot, wait=True)
|
|
163
|
+
yield from bps.abs_set(oav.grid_snapshot.filename, snapshot_filename) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
164
|
+
yield from bps.abs_set(oav.grid_snapshot.directory, snapshot_dir) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
165
|
+
yield from bps.trigger(oav.grid_snapshot, wait=True) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
165
166
|
yield from bps.create(CONST.DESCRIPTORS.OAV_GRID_SNAPSHOT_TRIGGERED)
|
|
166
167
|
|
|
167
|
-
yield from bps.read(oav.
|
|
168
|
+
yield from bps.read(oav) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
168
169
|
yield from bps.read(smargon)
|
|
169
170
|
yield from bps.save()
|
|
170
171
|
|
|
@@ -9,8 +9,8 @@ from dodal.devices.oav.oav_detector import OAV
|
|
|
9
9
|
from dodal.devices.oav.oav_parameters import OAVParameters
|
|
10
10
|
from dodal.devices.smargon import Smargon
|
|
11
11
|
|
|
12
|
+
from mx_bluesky.common.parameters.components import WithSnapshot
|
|
12
13
|
from mx_bluesky.hyperion.device_setup_plans.setup_oav import setup_general_oav_params
|
|
13
|
-
from mx_bluesky.hyperion.parameters.components import WithSnapshot
|
|
14
14
|
from mx_bluesky.hyperion.parameters.constants import CONST, DocDescriptorNames
|
|
15
15
|
|
|
16
16
|
OAV_SNAPSHOT_SETUP_SHOT = "oav_snapshot_setup_shot"
|
|
@@ -61,7 +61,8 @@ def _setup_oav(
|
|
|
61
61
|
):
|
|
62
62
|
yield from setup_general_oav_params(composite.oav, oav_parameters)
|
|
63
63
|
yield from bps.abs_set(
|
|
64
|
-
composite.oav.snapshot.directory,
|
|
64
|
+
composite.oav.snapshot.directory, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
65
|
+
str(parameters.snapshot_directory),
|
|
65
66
|
)
|
|
66
67
|
|
|
67
68
|
|
|
@@ -72,10 +73,12 @@ def _take_oav_snapshot(composite: OavSnapshotComposite, omega: float):
|
|
|
72
73
|
time_now = datetime.now()
|
|
73
74
|
filename = f"{time_now.strftime('%H%M%S')}_oav_snapshot_{omega:.0f}"
|
|
74
75
|
yield from bps.abs_set(
|
|
75
|
-
composite.oav.snapshot.filename,
|
|
76
|
+
composite.oav.snapshot.filename, # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
77
|
+
filename,
|
|
78
|
+
group=OAV_SNAPSHOT_SETUP_SHOT,
|
|
76
79
|
)
|
|
77
80
|
yield from bps.wait(group=OAV_SNAPSHOT_SETUP_SHOT)
|
|
78
|
-
yield from bps.trigger(composite.oav.snapshot, wait=True)
|
|
81
|
+
yield from bps.trigger(composite.oav.snapshot, wait=True) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
79
82
|
yield from bps.create(DocDescriptorNames.OAV_ROTATION_SNAPSHOT_TRIGGERED)
|
|
80
|
-
yield from bps.read(composite.oav.snapshot)
|
|
83
|
+
yield from bps.read(composite.oav.snapshot) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
81
84
|
yield from bps.save()
|
|
@@ -4,8 +4,9 @@ import json
|
|
|
4
4
|
|
|
5
5
|
from blueapi.core import BlueskyContext, MsgGenerator
|
|
6
6
|
from dodal.devices.eiger import EigerDetector
|
|
7
|
-
from dodal.devices.oav.oav_parameters import
|
|
7
|
+
from dodal.devices.oav.oav_parameters import OAVParameters
|
|
8
8
|
|
|
9
|
+
from mx_bluesky.common.parameters.constants import OavConstants
|
|
9
10
|
from mx_bluesky.hyperion.device_setup_plans.manipulate_sample import move_phi_chi_omega
|
|
10
11
|
from mx_bluesky.hyperion.device_setup_plans.utils import (
|
|
11
12
|
start_preparing_data_collection_then_do_plan,
|
|
@@ -55,11 +56,10 @@ def create_parameters_for_grid_detection(
|
|
|
55
56
|
def pin_centre_then_xray_centre_plan(
|
|
56
57
|
composite: GridDetectThenXRayCentreComposite,
|
|
57
58
|
parameters: PinTipCentreThenXrayCentre,
|
|
58
|
-
oav_config_file: str = OAV_CONFIG_JSON,
|
|
59
|
+
oav_config_file: str = OavConstants.OAV_CONFIG_JSON,
|
|
59
60
|
):
|
|
60
61
|
"""Plan that perfoms a pin tip centre followed by an xray centre to completely
|
|
61
62
|
centre the sample"""
|
|
62
|
-
oav_config_file = parameters.oav_centring_file
|
|
63
63
|
|
|
64
64
|
pin_tip_centring_composite = PinTipCentringComposite(
|
|
65
65
|
oav=composite.oav,
|
|
@@ -102,7 +102,7 @@ def pin_centre_then_xray_centre_plan(
|
|
|
102
102
|
def pin_tip_centre_then_xray_centre(
|
|
103
103
|
composite: GridDetectThenXRayCentreComposite,
|
|
104
104
|
parameters: PinTipCentreThenXrayCentre,
|
|
105
|
-
oav_config_file: str = OAV_CONFIG_JSON,
|
|
105
|
+
oav_config_file: str = OavConstants.OAV_CONFIG_JSON,
|
|
106
106
|
) -> MsgGenerator:
|
|
107
107
|
"""Starts preparing for collection then performs the pin tip centre and xray centre"""
|
|
108
108
|
|
|
@@ -7,7 +7,7 @@ from bluesky.utils import Msg
|
|
|
7
7
|
from dodal.devices.backlight import Backlight
|
|
8
8
|
from dodal.devices.oav.oav_detector import OAV
|
|
9
9
|
from dodal.devices.oav.oav_parameters import OAV_CONFIG_JSON, OAVParameters
|
|
10
|
-
from dodal.devices.oav.pin_image_recognition import PinTipDetection
|
|
10
|
+
from dodal.devices.oav.pin_image_recognition import PinTipDetection, Tip
|
|
11
11
|
from dodal.devices.oav.utils import (
|
|
12
12
|
Pixel,
|
|
13
13
|
get_move_required_so_that_beam_is_at_pixel,
|
|
@@ -43,11 +43,11 @@ def create_devices(context: BlueskyContext) -> PinTipCentringComposite:
|
|
|
43
43
|
|
|
44
44
|
def trigger_and_return_pin_tip(
|
|
45
45
|
pin_tip: PinTipDetection,
|
|
46
|
-
) -> Generator[Msg, None,
|
|
46
|
+
) -> Generator[Msg, None, Tip]:
|
|
47
47
|
yield from bps.trigger(pin_tip, wait=True)
|
|
48
48
|
tip_x_y_px = yield from bps.rd(pin_tip.triggered_tip)
|
|
49
49
|
LOGGER.info(f"Pin tip found at {tip_x_y_px}")
|
|
50
|
-
return tip_x_y_px
|
|
50
|
+
return tip_x_y_px
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def move_pin_into_view(
|
|
@@ -74,16 +74,16 @@ def move_pin_into_view(
|
|
|
74
74
|
Tuple[int, int]: The location of the pin tip in pixels
|
|
75
75
|
"""
|
|
76
76
|
|
|
77
|
-
def pin_tip_valid(
|
|
78
|
-
return
|
|
77
|
+
def pin_tip_valid(pin_xy: Tip):
|
|
78
|
+
return not all(pin_xy == pin_tip_device.INVALID_POSITION) and pin_xy[0] != 0
|
|
79
79
|
|
|
80
80
|
for _ in range(max_steps):
|
|
81
|
-
|
|
81
|
+
tip_xy_px = yield from trigger_and_return_pin_tip(pin_tip_device)
|
|
82
82
|
|
|
83
|
-
if pin_tip_valid(
|
|
84
|
-
return (
|
|
83
|
+
if pin_tip_valid(tip_xy_px):
|
|
84
|
+
return (int(tip_xy_px[0]), int(tip_xy_px[1]))
|
|
85
85
|
|
|
86
|
-
if
|
|
86
|
+
if tip_xy_px[0] == 0:
|
|
87
87
|
# Pin is off in the -ve direction
|
|
88
88
|
step_size_mm = -step_size_mm
|
|
89
89
|
|
|
@@ -97,19 +97,19 @@ def move_pin_into_view(
|
|
|
97
97
|
f"Pin tip is off screen, and moving {step_size_mm} mm would cross limits, "
|
|
98
98
|
f"moving to {move_within_limits} instead"
|
|
99
99
|
)
|
|
100
|
-
yield from bps.mv(smargon.x, move_within_limits)
|
|
100
|
+
yield from bps.mv(smargon.x, move_within_limits) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
101
101
|
|
|
102
102
|
# Some time for the view to settle after the move
|
|
103
103
|
yield from bps.sleep(CONST.HARDWARE.OAV_REFRESH_DELAY)
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
tip_xy_px = yield from trigger_and_return_pin_tip(pin_tip_device)
|
|
106
106
|
|
|
107
|
-
if not pin_tip_valid(
|
|
107
|
+
if not pin_tip_valid(tip_xy_px):
|
|
108
108
|
raise WarningException(
|
|
109
109
|
"Pin tip centring failed - pin too long/short/bent and out of range"
|
|
110
110
|
)
|
|
111
111
|
else:
|
|
112
|
-
return (
|
|
112
|
+
return (int(tip_xy_px[0]), int(tip_xy_px[1]))
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def pin_tip_centre_plan(
|
|
@@ -132,13 +132,13 @@ def pin_tip_centre_plan(
|
|
|
132
132
|
pin_tip_setup = composite.pin_tip_detection
|
|
133
133
|
pin_tip_detect = composite.pin_tip_detection
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
tip_offset_px = int(tip_offset_microns /
|
|
135
|
+
microns_per_pixel_x = yield from bps.rd(oav.microns_per_pixel_x)
|
|
136
|
+
tip_offset_px = int(tip_offset_microns / microns_per_pixel_x)
|
|
137
137
|
|
|
138
138
|
def offset_and_move(tip: Pixel):
|
|
139
139
|
pixel_to_move_to = (tip[0] + tip_offset_px, tip[1])
|
|
140
140
|
position_mm = yield from get_move_required_so_that_beam_is_at_pixel(
|
|
141
|
-
smargon, pixel_to_move_to, oav
|
|
141
|
+
smargon, pixel_to_move_to, oav
|
|
142
142
|
)
|
|
143
143
|
LOGGER.info(f"Tip centring moving to : {position_mm}")
|
|
144
144
|
yield from move_smargon_warn_on_out_of_range(smargon, position_mm)
|
|
@@ -154,7 +154,7 @@ def pin_tip_centre_plan(
|
|
|
154
154
|
tip = yield from move_pin_into_view(pin_tip_detect, smargon)
|
|
155
155
|
yield from offset_and_move(tip)
|
|
156
156
|
|
|
157
|
-
yield from bps.mvr(smargon.omega, 90)
|
|
157
|
+
yield from bps.mvr(smargon.omega, 90) # type: ignore # See: https://github.com/bluesky/bluesky/issues/1809
|
|
158
158
|
|
|
159
159
|
# need to wait for the OAV image to update
|
|
160
160
|
# See #673 for improvements
|