Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix rotation omega to match GDA behavior #720

Merged
merged 5 commits into from
Jan 9, 2025
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
23 changes: 19 additions & 4 deletions src/mx_bluesky/common/external_interaction/nexus/nexus_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import time
from datetime import UTC, datetime, timedelta
from enum import Enum

import numpy as np
from dodal.devices.detector import DetectorParams
from dodal.devices.zebra import RotationDirection
from nexgen.nxs_utils import Attenuator, Axis, Beam, Detector, EigerDetector, Goniometer
from nexgen.nxs_utils.axes import TransformationType
from numpy.typing import DTypeLike
Expand All @@ -14,6 +14,17 @@
from mx_bluesky.common.utils.utils import convert_eV_to_angstrom


class AxisDirection(Enum):
"""
Identifies whether the omega axis of rotation is on the positive x-axis or
negative x-axis as per the Nexus standard:
https://journals.iucr.org/m/issues/2020/05/00/ti5018/ti5018.pdf
"""

POSITIVE = 1
NEGATIVE = -1


def vds_type_based_on_bit_depth(detector_bit_depth: int) -> DTypeLike:
"""Works out the datatype for the VDS, based on the bit depth from the detector."""
if detector_bit_depth == 8:
Expand All @@ -35,8 +46,8 @@ def create_goniometer_axes(
x_y_z_increments: tuple[float, float, float] = (0.0, 0.0, 0.0),
chi: float = 0.0,
phi: float = 0.0,
rotation_direction: RotationDirection = RotationDirection.NEGATIVE,
):
omega_axis_direction: AxisDirection = AxisDirection.NEGATIVE,
) -> Goniometer:
"""Returns a Nexgen 'Goniometer' object with the dependency chain of I03's Smargon
goniometer. If scan points is provided these values will be used in preference to
those from the params object.
Expand All @@ -50,13 +61,17 @@ def create_goniometer_axes(
x_y_z_increments: optionally, specify the increments between each image for
the x, y, and z axes. Will be ignored if scan_points
is provided.
omega_axis_direction: The direction of the omega axis, this determines the
coordinate space parity
Returns:
The created Goniometer object
"""
gonio_axes = [
Axis(
"omega",
".",
TransformationType.ROTATION,
(1.0 * rotation_direction.multiplier, 0.0, 0.0),
(1.0 * omega_axis_direction.value, 0.0, 0.0),
omega_start,
),
Axis(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
import math
from pathlib import Path

from dodal.devices.zebra import RotationDirection
from dodal.utils import get_beamline_name
from nexgen.nxs_utils import Attenuator, Beam, Detector, Goniometer, Source
from nexgen.nxs_write.nxmx_writer import NXmxFileWriter
from numpy.typing import DTypeLike
from scanspec.core import AxesPoints

from mx_bluesky.common.external_interaction.nexus.nexus_utils import (
AxisDirection,
create_detector_parameters,
create_goniometer_axes,
get_start_and_predicted_end_time,
Expand All @@ -39,7 +39,7 @@ def __init__(
# detector arming event:
full_num_of_images: int | None = None,
meta_data_run_number: int | None = None,
rotation_direction: RotationDirection = RotationDirection.NEGATIVE,
axis_direction: AxisDirection = AxisDirection.NEGATIVE,
) -> None:
self.beam: Beam | None = None
self.attenuator: Attenuator | None = None
Expand Down Expand Up @@ -69,7 +69,7 @@ def __init__(
self.scan_points,
chi=chi_start_deg,
phi=phi_start_deg,
rotation_direction=rotation_direction,
omega_axis_direction=axis_direction,
)

def create_nexus_file(self, bit_depth: DTypeLike):
Expand Down
28 changes: 22 additions & 6 deletions src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,26 @@ def calculate_motion_profile(
See https://github.com/DiamondLightSource/hyperion/wiki/rotation-scan-geometry
for a simple pictorial explanation."""

direction = params.rotation_direction.multiplier
assert params.rotation_increment_deg > 0

direction = params.rotation_direction
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a docstring to RotationDirection to explain that this now means the direction of rotation relative to the omega sweep in the Hyperion request

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not the correct interpretation of RotationDirection, RotationDirection is used both in Zebra PositionCompare and in the RotationScan parameter class. A positive rotation in both these represent opposite rotation directions in the real world, if omega_flip is true.

RotationDirection represents the direction of the rotation in the context of the coordinate space of the interface that requires it as a parameter. It has only the meaning, that the sweep / rotation increment should be added or subtracted from the initial position, in whatever class is using it.

start_scan_deg = params.omega_start_deg

if params.features.omega_flip:
# If omega_flip is True then the motor omega axis is inverted with respect to the
# hyperion coordinate system.
start_scan_deg = -start_scan_deg
direction = (
direction.POSITIVE
if direction == direction.NEGATIVE
else direction.NEGATIVE
)

num_images = params.num_images
shutter_time_s = params.shutter_opening_time_s
image_width_deg = params.rotation_increment_deg
exposure_time_s = params.exposure_time_s
motor_time_to_speed_s *= ACCELERATION_MARGIN
start_scan_deg = params.omega_start_deg

LOGGER.info("Calculating rotation scan motion profile:")
LOGGER.info(
Expand All @@ -155,9 +168,9 @@ def calculate_motion_profile(
f"{acceleration_offset_deg=} = {motor_time_to_speed_s=} * {speed_for_rotation_deg_s=}"
)

start_motion_deg = start_scan_deg - (acceleration_offset_deg * direction)
start_motion_deg = start_scan_deg - (acceleration_offset_deg * direction.multiplier)
LOGGER.info(
f"{start_motion_deg=} = {start_scan_deg=} - ({acceleration_offset_deg=} * {direction=})"
f"{start_motion_deg=} = {start_scan_deg=} - ({acceleration_offset_deg=} * {direction.multiplier=})"
)

shutter_opening_deg = speed_for_rotation_deg_s * shutter_time_s
Expand All @@ -175,7 +188,7 @@ def calculate_motion_profile(

distance_to_move_deg = (
scan_width_deg + shutter_opening_deg + acceleration_offset_deg * 2
) * direction
) * direction.multiplier
LOGGER.info(
f"{distance_to_move_deg=} = ({scan_width_deg=} + {shutter_opening_deg=} + {acceleration_offset_deg=} * 2) * {direction=})"
)
Expand All @@ -185,7 +198,7 @@ def calculate_motion_profile(
start_motion_deg=start_motion_deg,
scan_width_deg=scan_width_deg,
shutter_time_s=shutter_time_s,
direction=params.rotation_direction,
direction=direction,
speed_for_rotation_deg_s=speed_for_rotation_deg_s,
acceleration_offset_deg=acceleration_offset_deg,
shutter_opening_deg=shutter_opening_deg,
Expand Down Expand Up @@ -346,6 +359,8 @@ def rotation_scan(
parameters: RotationScan,
oav_params: OAVParameters | None = None,
) -> MsgGenerator:
parameters.features.update_self_from_server()

if not oav_params:
oav_params = OAVParameters(context="xrayCentring")

Expand Down Expand Up @@ -396,6 +411,7 @@ def multi_rotation_scan(
parameters: MultiRotationScan,
oav_params: OAVParameters | None = None,
) -> MsgGenerator:
parameters.features.update_self_from_server()
if not oav_params:
oav_params = OAVParameters(context="xrayCentring")
eiger: EigerDetector = composite.eiger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PlanReactiveCallback,
)
from mx_bluesky.common.external_interaction.nexus.nexus_utils import (
AxisDirection,
create_beam_and_attenuator_parameters,
vds_type_based_on_bit_depth,
)
Expand Down Expand Up @@ -101,5 +102,7 @@ def activity_gated_start(self, doc: RunStart):
vds_start_index=parameters.nexus_vds_start_img,
full_num_of_images=self.full_num_of_images,
meta_data_run_number=self.meta_data_run_number,
rotation_direction=parameters.rotation_direction,
axis_direction=AxisDirection.NEGATIVE
if parameters.features.omega_flip
else AxisDirection.POSITIVE,
)
14 changes: 14 additions & 0 deletions src/mx_bluesky/hyperion/external_interaction/config_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@


class HyperionFeatureFlags(FeatureFlags):
"""
Feature flags specific to Hyperion.

Attributes:
use_panda_for_gridscan: If True then the PandA is used for gridscans, otherwise the zebra is used
compare_cpu_and_gpu_zocalo: If True then GPU result processing is enabled alongside CPU, if False then
CPU only is used.
set_stub_offsets: If True then set the stub offsets after moving to the crystal (ignored for
multi-centre)
omega_flip: If True then invert the smargon omega motor rotation commands with respect to
the hyperion request.
"""

@staticmethod
@cache
def get_config_server() -> ConfigServer:
Expand All @@ -16,3 +29,4 @@ def get_config_server() -> ConfigServer:
use_panda_for_gridscan: bool = CONST.I03.USE_PANDA_FOR_GRIDSCAN
compare_cpu_and_gpu_zocalo: bool = CONST.I03.COMPARE_CPU_AND_GPU_ZOCALO
set_stub_offsets: bool = CONST.I03.SET_STUB_OFFSETS
omega_flip: bool = CONST.I03.OMEGA_FLIP
1 change: 1 addition & 0 deletions src/mx_bluesky/hyperion/parameters/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class I03Constants:
SHUTTER_TIME_S = 0.06
USE_PANDA_FOR_GRIDSCAN = False
SET_STUB_OFFSETS = False
OMEGA_FLIP = True

# Turns on GPU processing for zocalo and logs a comparison between GPU and CPU-
# processed results. GPU results never used in analysis for now
Expand Down
16 changes: 15 additions & 1 deletion src/mx_bluesky/hyperion/parameters/rotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,34 @@
SplitScan,
WithScan,
)
from mx_bluesky.hyperion.parameters.components import WithHyperionFeatures
from mx_bluesky.hyperion.parameters.constants import (
CONST,
I03Constants,
)


class RotationScanPerSweep(OptionalGonioAngleStarts, OptionalXyzStarts):
"""
Describes a rotation scan about the specified axis.

Attributes:
rotation_axis: The rotation axis, by default this is the omega axis
omega_start_deg: The initial angle of the rotation in degrees (default 0)
scan_width_deg: The sweep of the rotation in degrees, this must be positive (default 360)
rotation_direction: Indicates the direction of rotation, if RotationDirection.POSITIVE
the final angle is obtained by adding scan_width_deg, otherwise by subtraction (default NEGATIVE)
nexus_vds_start_img: The frame number of the first frame captured during the rotation
"""

omega_start_deg: float = Field(default=0) # type: ignore
rotation_axis: RotationAxis = Field(default=RotationAxis.OMEGA)
scan_width_deg: float = Field(default=360, gt=0)
rotation_direction: RotationDirection = Field(default=RotationDirection.NEGATIVE)
nexus_vds_start_img: int = Field(default=0, ge=0)


class RotationExperiment(DiffractionExperimentWithSample):
class RotationExperiment(DiffractionExperimentWithSample, WithHyperionFeatures):
shutter_opening_time_s: float = Field(default=CONST.I03.SHUTTER_TIME_S)
rotation_increment_deg: float = Field(default=0.1, gt=0)
ispyb_experiment_type: IspybExperimentType = Field(
Expand Down Expand Up @@ -98,6 +111,7 @@ def detector_params(self):

@property
def scan_points(self) -> AxesPoints:
"""The scan points are defined in application space"""
scan_spec = Line(
axis="omega",
start=self.omega_start_deg,
Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
from mx_bluesky.common.external_interaction.callbacks.common.logging_callback import (
VerbosePlanExecutionLoggingCallback,
)
from mx_bluesky.common.external_interaction.config_server import FeatureFlags
from mx_bluesky.common.parameters.constants import (
DocDescriptorNames,
EnvironmentConstants,
Expand Down Expand Up @@ -226,6 +227,17 @@ def patch_async_motor(
return callback_on_mock_put(motor.user_setpoint, pass_on_mock(motor, call_log))


@pytest.fixture(params=[False, True])
def feature_flags_update_with_omega_flip(request):
def update_with_overrides(self):
self.overriden_features["omega_flip"] = request.param
self.omega_flip = request.param

with patch.object(FeatureFlags, "update_self_from_server", autospec=True) as update:
update.side_effect = update_with_overrides
yield update


@pytest.fixture
def beamline_parameters():
return GDABeamlineParameters.from_file(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ def test_ispyb_deposition_in_rotation_plan(
fetch_comment: Callable[..., Any],
fetch_datacollection_attribute: Callable[..., Any],
fetch_datacollection_position_attribute: Callable[..., Any],
feature_flags_update_with_omega_flip,
):
os.environ["ISPYB_CONFIG_PATH"] = CONST.SIM.DEV_ISPYB_DATABASE_CFG
ispyb_cb = RotationISPyBCallback()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import numpy as np
import pytest
from numpy.typing import DTypeLike
from scanspec.core import AxesPoints

from mx_bluesky.common.external_interaction.nexus.nexus_utils import (
AxisDirection,
create_goniometer_axes,
vds_type_based_on_bit_depth,
)

Expand All @@ -15,3 +18,28 @@ def test_vds_type_is_expected_based_on_bit_depth(
bit_depth: int, expected_type: DTypeLike
):
assert vds_type_based_on_bit_depth(bit_depth) == expected_type


@pytest.fixture
def scan_points(test_rotation_params) -> AxesPoints:
return test_rotation_params.scan_points


@pytest.mark.parametrize(
"omega_axis_direction, expected_axis_direction",
[[AxisDirection.NEGATIVE, -1], [AxisDirection.POSITIVE, 1]],
)
def test_omega_axis_direction_determined_from_features(
omega_axis_direction: AxisDirection,
expected_axis_direction: float,
scan_points: AxesPoints,
):
omega_start = 0
gonio = create_goniometer_axes(
omega_start, scan_points, (0, 0, 0), 0, 0, omega_axis_direction
)
assert gonio.axes_list[0].name == "omega" and gonio.axes_list[0].vector == (
expected_axis_direction,
0,
0,
)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ophyd_async.testing import set_mock_value

from mx_bluesky.common.external_interaction.ispyb.ispyb_store import StoreInIspyb
from mx_bluesky.common.external_interaction.nexus.nexus_utils import AxisDirection
from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import (
RotationScanComposite,
calculate_motion_profile,
Expand Down Expand Up @@ -257,15 +258,18 @@ def test_full_multi_rotation_plan_nexus_writer_called_correctly(
test_multi_rotation_params.single_rotation_scans,
strict=False,
):
assert call.args[0] == rotation_params
callback_params = call.args[0]
assert callback_params == rotation_params
assert call.kwargs == {
"omega_start_deg": rotation_params.omega_start_deg,
"chi_start_deg": rotation_params.chi_start_deg,
"phi_start_deg": rotation_params.phi_start_deg,
"vds_start_index": rotation_params.nexus_vds_start_img,
"full_num_of_images": test_multi_rotation_params.num_images,
"meta_data_run_number": first_run_number,
"rotation_direction": rotation_params.rotation_direction,
"axis_direction": AxisDirection.NEGATIVE
if rotation_params.features.omega_flip
else AxisDirection.POSITIVE,
}


Expand All @@ -276,6 +280,7 @@ def test_full_multi_rotation_plan_nexus_writer_called_correctly(
def test_full_multi_rotation_plan_nexus_files_written_correctly(
_,
RE: RunEngine,
feature_flags_update_with_omega_flip: MagicMock,
test_multi_rotation_params: MultiRotationScan,
fake_create_rotation_devices: RotationScanComposite,
oav_parameters_for_rotation: OAVParameters,
Expand Down Expand Up @@ -385,7 +390,10 @@ def _expected_dset_number(image_number: int):
h5py.Dataset,
)
assert isinstance(omega_vec := omega_transform.attrs["vector"], np.ndarray)
assert tuple(omega_vec) == (1.0 * scan.rotation_direction.multiplier, 0, 0)
omega_flip = (
feature_flags_update_with_omega_flip.mock_calls[0].args[0].omega_flip
)
assert tuple(omega_vec) == (-1.0 if omega_flip else 1.0, 0, 0)


@patch(
Expand Down
Loading
Loading