Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
462ece5
Add I09 and I09-1 sample stages
oliwenmandiamond Jan 27, 2026
3756de7
Add tests for new stages
oliwenmandiamond Jan 27, 2026
0fc5aec
Update doc strings
oliwenmandiamond Jan 27, 2026
cbd692a
Correct doc string
oliwenmandiamond Jan 27, 2026
a107355
Add test
oliwenmandiamond Jan 27, 2026
0eda406
Remove skip
oliwenmandiamond Jan 27, 2026
df80ee8
Merge branch 'main' into add_sample_stage_for_i09
oliwenmandiamond Jan 27, 2026
031e4dd
Remove comments
oliwenmandiamond Jan 27, 2026
c5f3441
Merge branch 'add_sample_stage_for_i09' of ssh://github.com/DiamondLi…
oliwenmandiamond Jan 27, 2026
99dd78a
Moved tilt stage to core motors
oliwenmandiamond Jan 28, 2026
09fb2a5
Also moved tests
oliwenmandiamond Jan 28, 2026
e9afad0
Merge branch 'main' into add_sample_stage_for_i09
oliwenmandiamond Jan 28, 2026
7c3f2d4
Add i05 sample stage
oliwenmandiamond Jan 28, 2026
044178e
Merge branch 'add_sample_stage_for_i09' into add_sample_stage_for_i05
oliwenmandiamond Jan 28, 2026
3b09018
Added I05-1
oliwenmandiamond Jan 28, 2026
19d8baf
Added doc string
oliwenmandiamond Jan 28, 2026
fdfa9d5
Merge branch 'add_sample_stage_for_i05' of ssh://github.com/DiamondLi…
oliwenmandiamond Jan 28, 2026
28fd04c
Correct doc strings
oliwenmandiamond Jan 28, 2026
a9d07f1
Add test for XYZPolarAzimuthDefocusStage
oliwenmandiamond Jan 28, 2026
578e200
Fix typo
oliwenmandiamond Jan 28, 2026
cf02211
Merge branch 'main' into add_sample_stage_for_i05
oliwenmandiamond Jan 28, 2026
143ce97
Add configuration back to stage
oliwenmandiamond Jan 28, 2026
8c68c3f
Added perp and long signals
oliwenmandiamond Jan 28, 2026
3fdbcc9
Remove comment
oliwenmandiamond Jan 28, 2026
267d70b
Added configurable rotation_angle_deg
oliwenmandiamond Jan 28, 2026
0715af9
Restructuted using rotation matrix
oliwenmandiamond Jan 29, 2026
46b7eb3
Used a Vector2D dataclass
oliwenmandiamond Jan 29, 2026
31327ad
Made the rotation matrix configurable
oliwenmandiamond Jan 29, 2026
1a105e1
Fixed tests
oliwenmandiamond Jan 29, 2026
f914865
Moved Vector2D to i05_shared (for now)
oliwenmandiamond Jan 29, 2026
7f15174
Merge branch 'main' into add_sample_stage_for_i05
oliwenmandiamond Jan 29, 2026
2bb24a9
Rename files to make less confusing when editing
oliwenmandiamond Jan 29, 2026
cde0f4c
Fix import
oliwenmandiamond Jan 29, 2026
a33e813
Fix import
oliwenmandiamond Jan 29, 2026
41b0bad
Removed class and instead use functions
oliwenmandiamond Jan 29, 2026
a603432
Further simplifed, single function
oliwenmandiamond Jan 29, 2026
d848d24
Improved doc string
oliwenmandiamond Jan 29, 2026
a1a1599
Fix doc string import
oliwenmandiamond Jan 29, 2026
a59f535
First attempt at creaint helper function to build signals
oliwenmandiamond Jan 29, 2026
b31c793
Optmise test
oliwenmandiamond Jan 30, 2026
2897073
Create helper function to create i and j virtaul axis. Apply to i05 a…
oliwenmandiamond Jan 30, 2026
a28493b
perp and long motors now correctly done, added read test
oliwenmandiamond Feb 2, 2026
db997ba
Added helper util function for testing rotation signals which can be …
oliwenmandiamond Feb 2, 2026
fbc91d4
Move maths to common, update common motors to use it
oliwenmandiamond Feb 3, 2026
2060152
Fix lint
oliwenmandiamond Feb 3, 2026
b2c29d7
Remove use of type: ignore by utilising bluesky maybe_await and Movable
oliwenmandiamond Feb 3, 2026
26ff07a
Correct doc string and update variable names
oliwenmandiamond Feb 3, 2026
da01cb3
Merge branch 'main' into add_sample_stage_for_i05
oliwenmandiamond Feb 3, 2026
8398fb4
Add more detailed doc strings and fix lint
oliwenmandiamond Feb 3, 2026
c9ec869
Reduced duplication of code
oliwenmandiamond Feb 3, 2026
0689c23
Add tests for maths rotation functions
oliwenmandiamond Feb 3, 2026
ec7b7b5
Simplify assert rotated function, add doc string, improve type checking
oliwenmandiamond Feb 3, 2026
d7db203
Improve type checking
oliwenmandiamond Feb 3, 2026
ec853de
Correct defocus infix
oliwenmandiamond Feb 3, 2026
b715606
Merge branch 'main' into add_sample_stage_for_i05
oliwenmandiamond Feb 3, 2026
8f180b9
Update i05 device location for main branch
oliwenmandiamond Feb 3, 2026
8789887
Fix test imports
oliwenmandiamond Feb 3, 2026
e13e2a2
Fix imports
oliwenmandiamond Feb 4, 2026
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
18 changes: 15 additions & 3 deletions src/dodal/beamlines/i05.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
from dodal.beamlines.i05_shared import devices as i05_shared_devices
from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
from dodal.device_manager import DeviceManager
from dodal.devices.beamlines.i05 import I05Goniometer
from dodal.devices.temperture_controller import Lakeshore336
from dodal.log import set_beamline as set_log_beamline
from dodal.utils import BeamlinePrefix, get_beamline_name

devices = DeviceManager()
devices.include(i05_shared_devices)

BL = get_beamline_name("i05")
PREFIX = BeamlinePrefix(BL)
set_log_beamline(BL)
set_utils_beamline(BL)

devices = DeviceManager()
devices.include(i05_shared_devices)


@devices.factory()
def sample_temperature_controller() -> Lakeshore336:
return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-02:")


@devices.factory()
def sa() -> I05Goniometer:
"""Sample Manipulator."""
return I05Goniometer(
f"{PREFIX.beamline_prefix}-EA-SM-01:",
x_infix="SAX",
y_infix="SAY",
z_infix="SAZ",
)
7 changes: 7 additions & 0 deletions src/dodal/beamlines/i05_1.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from dodal.beamlines.i05_shared import devices as i05_shared_devices
from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
from dodal.device_manager import DeviceManager
from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage
from dodal.log import set_beamline as set_log_beamline
from dodal.utils import BeamlinePrefix, get_beamline_name

Expand All @@ -11,3 +12,9 @@

devices = DeviceManager()
devices.include(i05_shared_devices)


@devices.factory
def sm() -> XYZPolarAzimuthDefocusStage:
"""Sample Manipulator."""
return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:")
42 changes: 42 additions & 0 deletions src/dodal/common/maths.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,45 @@ def contains(self, x: float, y: float) -> bool:
self.get_min_x() <= x <= self.get_max_x()
and self.get_min_y() <= y <= self.get_max_y()
)


"""
| Rotation | Formula for X_rot | Formula for Y_rot |
| -------- | ----------------- | ----------------- |
| CW | x cosθ + y sinθ | -x sinθ + y cosθ |
| CCW | x cosθ - y sinθ | x sinθ + y cosθ |
"""


def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]:
positions = np.array([x, y])
rotation = rotation_matrix @ positions
return float(rotation[0]), float(rotation[1])


def rotate_clockwise(
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think you actually need 2 functions rotate_cw and rotate_ccw - you can rotate ccw using cw function by changing angle alpha either to (-1)alpha or to (2Pi-alpha)

theta: float,
x: float,
y: float,
) -> tuple[float, float]:
rotation_matrix = np.array(
[
[np.cos(theta), np.sin(theta)],
[-np.sin(theta), np.cos(theta)],
]
)
return do_rotation(x, y, rotation_matrix)


def rotate_counter_clockwise(
theta: float,
x: float,
y: float,
) -> tuple[float, float]:
rotation_matrix = np.array(
[
[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)],
]
)
return do_rotation(x, y, rotation_matrix)
5 changes: 3 additions & 2 deletions src/dodal/devices/beamlines/i05/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dodal.devices.beamlines.i05.enums import Grating
from .enums import Grating
from .i05_motors import I05Goniometer

__all__ = ["Grating"]
__all__ = ["Grating", "I05Goniometer"]
69 changes: 69 additions & 0 deletions src/dodal/devices/beamlines/i05/i05_motors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from dodal.devices.beamlines.i05_shared.rotation_signals import (
create_rotational_ij_component_signals,
)
from dodal.devices.motors import (
_AZIMUTH,
_POLAR,
_TILT,
_X,
_Y,
_Z,
XYZPolarAzimuthTiltStage,
)


class I05Goniometer(XYZPolarAzimuthTiltStage):
"""Six-axis stage with a standard xyz stage and three axis of rotation: polar,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""Six-axis stage with a standard xyz stage and three axis of rotation: polar,
"""Six-physical-axis stage with a standard xyz translational stage and three axis of rotation: polar,

azimuth, and tilt.

In addition, it defines two virtual translational axes, `perp` and `long`, which
form a rotated Cartesian frame within the x-y plane.

- `long`: Translation along the longitudinal direction of the rotated in-plane
coordinate frame defined by ``rotation_angle_deg``.
Comment on lines +22 to +23
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
- `long`: Translation along the longitudinal direction of the rotated in-plane
coordinate frame defined by ``rotation_angle_deg``.
- `long`: Translation along the rotated X-axis.


- `perp`: Translation perpendicular to `long` within the x-y plane.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
- `perp`: Translation perpendicular to `long` within the x-y plane.
- `perp`: Translation along the rotated Y-axis.


The `perp` and `long` axes are derived from the underlying x and y motors using a
fixed rotation angle (default 50 degrees). From the user's point of view, these
behave as ordinary orthogonal Cartesian translation axes aligned with physically
meaningful directions on the sample, while internally coordinating motion of the x
and y motors.
Comment on lines +27 to +31
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
The `perp` and `long` axes are derived from the underlying x and y motors using a
fixed rotation angle (default 50 degrees). From the user's point of view, these
behave as ordinary orthogonal Cartesian translation axes aligned with physically
meaningful directions on the sample, while internally coordinating motion of the x
and y motors.
The `perp` and `long` axes are virtual axes derived from the underlying x and y motors using a
fixed rotation angle (default 50 degrees). Rotation angle corresponds to an angle between analyser axis and X-ray beam axis. From the user's point of view, these virtual axes
behave as ordinary orthogonal Cartesian translation axes aligned with the incoming X-ray beam (long) and perpendicular to it (perp), while internally coordinating motion of the x (perpendicular to analyser axis)
and y (along analyser axis) motors.


Unlike sample-frame axes that rotate with a live rotation motor, these axes are
defined at a constant orientation set by `rotation_angle_deg`.
Comment on lines +33 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Unlike sample-frame axes that rotate with a live rotation motor, these axes are
defined at a constant orientation set by `rotation_angle_deg`.

"""

def __init__(
self,
prefix: str,
x_infix: str = _X,
y_infix: str = _Y,
z_infix: str = _Z,
polar_infix: str = _POLAR,
azimuth_infix: str = _AZIMUTH,
tilt_infix: str = _TILT,
rotation_angle_deg: float = 50.0,
name: str = "",
):
self.rotation_angle_deg = rotation_angle_deg

super().__init__(
prefix,
name,
x_infix=x_infix,
y_infix=y_infix,
z_infix=z_infix,
polar_infix=polar_infix,
azimuth_infix=azimuth_infix,
tilt_infix=tilt_infix,
)

with self.add_children_as_readables():
self.perp, self.long = create_rotational_ij_component_signals(
i_read=self.x.user_readback,
i_write=self.x,
j_read=self.y.user_readback,
j_write=self.y,
angle_deg=self.rotation_angle_deg,
)
3 changes: 3 additions & 0 deletions src/dodal/devices/beamlines/i05_1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .i05_1_motors import XYZPolarAzimuthDefocusStage

__all__ = ["XYZPolarAzimuthDefocusStage"]
75 changes: 75 additions & 0 deletions src/dodal/devices/beamlines/i05_1/i05_1_motors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from ophyd_async.epics.motor import Motor

from dodal.devices.beamlines.i05_shared.rotation_signals import (
create_rotational_ij_component_signals,
)
from dodal.devices.motors import XYZPolarAzimuthStage


class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage):
"""Six-axis stage with a standard xyz stage and three axis of rotation: polar,
azimuth, and defocus.
Comment on lines +10 to +11
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""Six-axis stage with a standard xyz stage and three axis of rotation: polar,
azimuth, and defocus.
"""Six-physical-axis stage with a standard xyz stage, 2 axis of rotation: polar,
azimuth and one extra tranlastional axis defocus.


This device exposes four virtual translational axes that are defined in frames
of reference attached to the sample:

- `hor` and `vert`:
Horizontal and vertical translation axes in the sample frame.
These axes are derived from the lab-frame x and y motors and rotate
with the azimuth angle, so that motion along `hor` and `vert`
remains aligned with the sample regardless of its azimuthal rotation.
Comment on lines +17 to +20
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Horizontal and vertical translation axes in the sample frame.
These axes are derived from the lab-frame x and y motors and rotate
with the azimuth angle, so that motion along `hor` and `vert`
remains aligned with the sample regardless of its azimuthal rotation.
Horizontal and vertical virtual translation axes of the rotated sample frame.
These axes are derived from X and Y axes rotated
with the azimuth angle, so that motion along `hor` and `vert`
remains aligned with the gravity direction regardless of its azimuthal rotation.


- `long` and `perp`:
Longitudinal and perpendicular translation axes in the tilted sample
frame. These axes are derived from the lab-frame z motor and the
sample-frame `hor` axis, and rotate with the polar angle.
Motion along `long` follows the sample's longitudinal direction,
while `perp` moves perpendicular to it within the polar rotation plane.
Comment on lines +23 to +27
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Longitudinal and perpendicular translation axes in the tilted sample
frame. These axes are derived from the lab-frame z motor and the
sample-frame `hor` axis, and rotate with the polar angle.
Motion along `long` follows the sample's longitudinal direction,
while `perp` moves perpendicular to it within the polar rotation plane.
Longitudinal and perpendicular virtual translation axes in the rotated sample
frame. These axes are derived from the Z-axis and the
virtual `hor` axis, and depend on the polar angle.
Motion along `long` aligned with the analyser axis,
while `perp` moves perpendicular to it within the polar rotation plane.


All four virtual axes behave as ordinary orthogonal Cartesian translations
from the user's point of view, while internally coordinating motion of the
underlying motors to account for the current rotation angles.

This allows users to position the sample in physically meaningful, sample-aligned
coordinates without needing to manually compensate for azimuth or polar rotations.
"""

def __init__(
self,
prefix: str,
x_infix="SMX",
y_infix="SMY",
z_infix="SMZ",
polar_infix="POL",
azimuth_infix="AZM",
defocus_infix="SMDF",
name="",
):
super().__init__(
prefix,
name,
x_infix=x_infix,
y_infix=y_infix,
z_infix=z_infix,
polar_infix=polar_infix,
azimuth_infix=azimuth_infix,
)

with self.add_children_as_readables():
self.defocus = Motor(prefix + defocus_infix)
self.hor, self.vert = create_rotational_ij_component_signals(
i_read=self.x.user_readback,
j_read=self.y.user_readback,
i_write=self.x,
j_write=self.y,
angle_deg=self.azimuth.user_readback,
clockwise_frame=True,
)
self.long, self.perp = create_rotational_ij_component_signals(
i_read=self.z.user_readback,
i_write=self.z,
j_read=self.hor,
j_write=self.hor,
angle_deg=self.polar.user_readback,
clockwise_frame=False,
)
Empty file.
89 changes: 89 additions & 0 deletions src/dodal/devices/beamlines/i05_shared/rotation_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import asyncio
from math import radians

from bluesky.protocols import Movable
from bluesky.utils import maybe_await
from ophyd_async.core import (
SignalR,
SignalRW,
derived_signal_rw,
)

from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise


async def _get_angle_deg(angle_deg: SignalR[float] | float) -> float:
if isinstance(angle_deg, SignalR):
return await angle_deg.get_value()
return angle_deg


def create_rotational_ij_component_signals(
i_read: SignalR[float],
j_read: SignalR[float],
i_write: Movable[float],
j_write: Movable[float],
Comment on lines +22 to +25
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is there a separation of read and write if there could be just one SignalRW ?

angle_deg: float | SignalR[float],
clockwise_frame: bool = True,
) -> tuple[SignalRW[float], SignalRW[float]]:
"""Create virtual i/j signals representing a Cartesian coordinate frame
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""Create virtual i/j signals representing a Cartesian coordinate frame
"""Create virtual i/j axes representing a Cartesian coordinate frame

that is rotated by a given angle relative to the underlying equipment axes.

The returned signals expose the position of the system in a *rotated frame
of reference* (e.g. the sample or stage frame), while transparently mapping
reads and writes onto the real i/j signals in the fixed equipment (lab) frame.

From the user's point of view, i and j behave like ordinary orthogonal
Cartesian axes attached to the rotating object. Internally, all reads apply
a rotation to the real motor positions, and all writes apply the inverse
rotation so that the requested motion is achieved in the rotated frame.

Args:
i_read (SignalR): SignalR representing the i motor readback.
j_read (SignalR): representing the j motor readback.
i_write (Movable): object for setting the i position.
j_write (Movable): object for setting the j position.
angle_deg (float | SignalR): Rotation angle in degrees.
clockwise_frame (boolean): If True, the rotated frame is defined using a
clockwise rotation; otherwise, a counter-clockwise rotation is used.

Returns:
tuple[SignalRW[float], SignalRW[float]] Two virtual read/write signals
corresponding to the rotated i and j components.
"""
rotate = rotate_clockwise if clockwise_frame else rotate_counter_clockwise
inverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise

async def _read_rotated() -> tuple[float, float, float]:
i, j, ang = await asyncio.gather(
i_read.get_value(),
j_read.get_value(),
_get_angle_deg(angle_deg),
)
return (*rotate(radians(ang), i, j), ang)

async def _write_rotated(i_rot: float, j_rot: float, ang: float) -> None:
i_new, j_new = inverse_rotate(radians(ang), i_rot, j_rot)
await asyncio.gather(
maybe_await(i_write.set(i_new)),
maybe_await(j_write.set(j_new)),
)

def _read_i(i: float, j: float, ang: float) -> float:
return rotate(radians(ang), i, j)[0]

async def _set_i(value: float) -> None:
i_rot, j_rot, ang = await _read_rotated()
await _write_rotated(value, j_rot, ang)

def _read_j(i: float, j: float, ang: float) -> float:
return rotate(radians(ang), i, j)[1]

async def _set_j(value: float) -> None:
i_rot, j_rot, ang = await _read_rotated()
await _write_rotated(i_rot, value, ang)

return (
derived_signal_rw(_read_i, _set_i, i=i_read, j=j_read, ang=angle_deg),
derived_signal_rw(_read_j, _set_j, i=i_read, j=j_read, ang=angle_deg),
)
Loading