Skip to content

Commit 130bb7c

Browse files
authored
Merge branch 'main' into add_apple_knot_to_i05
2 parents 859b569 + f188f57 commit 130bb7c

File tree

14 files changed

+707
-15
lines changed

14 files changed

+707
-15
lines changed

src/dodal/beamlines/i05.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
11
from dodal.beamlines.i05_shared import devices as i05_shared_devices
22
from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
33
from dodal.device_manager import DeviceManager
4+
from dodal.devices.beamlines.i05 import I05Goniometer
45
from dodal.devices.temperture_controller import Lakeshore336
56
from dodal.log import set_beamline as set_log_beamline
67
from dodal.utils import BeamlinePrefix, get_beamline_name
78

8-
devices = DeviceManager()
9-
devices.include(i05_shared_devices)
10-
119
BL = get_beamline_name("i05")
1210
PREFIX = BeamlinePrefix(BL)
1311
set_log_beamline(BL)
1412
set_utils_beamline(BL)
1513

14+
devices = DeviceManager()
15+
devices.include(i05_shared_devices)
16+
1617

1718
@devices.factory()
1819
def sample_temperature_controller() -> Lakeshore336:
1920
return Lakeshore336(prefix=f"{PREFIX.beamline_prefix}-EA-TCTRL-02:")
21+
22+
23+
@devices.factory()
24+
def sa() -> I05Goniometer:
25+
"""Sample Manipulator."""
26+
return I05Goniometer(
27+
f"{PREFIX.beamline_prefix}-EA-SM-01:",
28+
x_infix="SAX",
29+
y_infix="SAY",
30+
z_infix="SAZ",
31+
)

src/dodal/beamlines/i05_1.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dodal.beamlines.i05_shared import devices as i05_shared_devices
22
from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
33
from dodal.device_manager import DeviceManager
4+
from dodal.devices.beamlines.i05_1 import XYZPolarAzimuthDefocusStage
45
from dodal.log import set_beamline as set_log_beamline
56
from dodal.utils import BeamlinePrefix, get_beamline_name
67

@@ -11,3 +12,9 @@
1112

1213
devices = DeviceManager()
1314
devices.include(i05_shared_devices)
15+
16+
17+
@devices.factory
18+
def sm() -> XYZPolarAzimuthDefocusStage:
19+
"""Sample Manipulator."""
20+
return XYZPolarAzimuthDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:")

src/dodal/common/maths.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,31 @@ def contains(self, x: float, y: float) -> bool:
116116
self.get_min_x() <= x <= self.get_max_x()
117117
and self.get_min_y() <= y <= self.get_max_y()
118118
)
119+
120+
121+
"""
122+
| Rotation | Formula for X_rot | Formula for Y_rot |
123+
| -------- | ----------------- | ----------------- |
124+
| CW | x cosθ + y sinθ | -x sinθ + y cosθ |
125+
| CCW | x cosθ - y sinθ | x sinθ + y cosθ |
126+
"""
127+
128+
129+
def do_rotation(x: float, y: float, rotation_matrix: np.ndarray) -> tuple[float, float]:
130+
positions = np.array([x, y])
131+
rotation = rotation_matrix @ positions
132+
return float(rotation[0]), float(rotation[1])
133+
134+
135+
def rotate_clockwise(theta: float, x: float, y: float) -> tuple[float, float]:
136+
rotation_matrix = np.array(
137+
[
138+
[np.cos(theta), np.sin(theta)],
139+
[-np.sin(theta), np.cos(theta)],
140+
]
141+
)
142+
return do_rotation(x, y, rotation_matrix)
143+
144+
145+
def rotate_counter_clockwise(theta: float, x: float, y: float) -> tuple[float, float]:
146+
return rotate_clockwise(-theta, x, y)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from dodal.devices.beamlines.i05_shared.rotation_signals import (
2+
create_rotational_ij_component_signals,
3+
)
4+
from dodal.devices.motors import (
5+
_AZIMUTH,
6+
_POLAR,
7+
_TILT,
8+
_X,
9+
_Y,
10+
_Z,
11+
XYZPolarAzimuthTiltStage,
12+
)
13+
14+
15+
class I05Goniometer(XYZPolarAzimuthTiltStage):
16+
"""Six-physical-axis stage with a standard xyz translational stage and three axis of
17+
rotation: polar, azimuth, and tilt.
18+
19+
In addition, it defines two virtual translational axes derived signals, `perp` and
20+
`long`, which form a rotated Cartesian frame within the x-y plane.
21+
- `long`: Translation along the rotated X-axis.
22+
- `perp`: Translation along the rotated Y-axis.
23+
24+
The `perp` and `long` axes are virtual axes derived from the underlying x and y
25+
motors using a fixed rotation angle (default 50 degrees). Rotation angle corresponds
26+
to an angle between analyser axis and X-ray beam axis. From the user's point of
27+
view, these virtual axes behave as ordinary orthogonal Cartesian translation axes
28+
aligned with the incoming X-ray beam (long) and perpendicular to it (perp),
29+
while internally coordinating motion of the x (perpendicular to analyser axis) and y
30+
(along analyser axis) motors.
31+
"""
32+
33+
def __init__(
34+
self,
35+
prefix: str,
36+
x_infix: str = _X,
37+
y_infix: str = _Y,
38+
z_infix: str = _Z,
39+
polar_infix: str = _POLAR,
40+
azimuth_infix: str = _AZIMUTH,
41+
tilt_infix: str = _TILT,
42+
rotation_angle_deg: float = 50.0,
43+
name: str = "",
44+
):
45+
self.rotation_angle_deg = rotation_angle_deg
46+
47+
super().__init__(
48+
prefix,
49+
name,
50+
x_infix=x_infix,
51+
y_infix=y_infix,
52+
z_infix=z_infix,
53+
polar_infix=polar_infix,
54+
azimuth_infix=azimuth_infix,
55+
tilt_infix=tilt_infix,
56+
)
57+
58+
with self.add_children_as_readables():
59+
self.perp, self.long = create_rotational_ij_component_signals(
60+
i_read=self.x.user_readback,
61+
i_write=self.x,
62+
j_read=self.y.user_readback,
63+
j_write=self.y,
64+
angle_deg=self.rotation_angle_deg,
65+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .i05_1_motors import XYZPolarAzimuthDefocusStage
2+
3+
__all__ = ["XYZPolarAzimuthDefocusStage"]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from ophyd_async.epics.motor import Motor
2+
3+
from dodal.devices.beamlines.i05_shared.rotation_signals import (
4+
create_rotational_ij_component_signals,
5+
)
6+
from dodal.devices.motors import XYZPolarAzimuthStage
7+
8+
9+
class XYZPolarAzimuthDefocusStage(XYZPolarAzimuthStage):
10+
"""Six-physical-axis stage with a standard xyz stage, 2 axis of rotation: polar,
11+
azimuth and one extra tranlastional axis defocus.
12+
13+
This device exposes four virtual translational axes that are defined in frames
14+
of reference attached to the sample:
15+
16+
- `hor` and `vert`:
17+
Horizontal and vertical virtual translation axes of the rotated sample frame.
18+
These axes are derived from X and Y axes rotated
19+
with the azimuth angle, so that motion along `hor` and `vert`
20+
remains aligned with the gravity direction regardless of its azimuthal rotation.
21+
22+
- `long` and `perp`:
23+
Longitudinal and perpendicular virtual translation axes in the rotated sample
24+
frame. These axes are derived from the Z-axis and the
25+
virtual `hor` axis, and depend on the polar angle.
26+
Motion along `long` aligned with the analyser axis,
27+
while `perp` moves perpendicular to it within the polar rotation plane.
28+
29+
All four virtual axes behave as ordinary orthogonal Cartesian translations
30+
from the user's point of view, while internally coordinating motion of the
31+
underlying motors to account for the current rotation angles.
32+
33+
This allows users to position the sample in physically meaningful, sample-aligned
34+
coordinates without needing to manually compensate for azimuth or polar rotations.
35+
"""
36+
37+
def __init__(
38+
self,
39+
prefix: str,
40+
x_infix: str = "SMX",
41+
y_infix: str = "SMY",
42+
z_infix: str = "SMZ",
43+
polar_infix: str = "POL",
44+
azimuth_infix: str = "AZM",
45+
defocus_infix: str = "SMDF",
46+
name: str = "",
47+
):
48+
super().__init__(
49+
prefix,
50+
name,
51+
x_infix=x_infix,
52+
y_infix=y_infix,
53+
z_infix=z_infix,
54+
polar_infix=polar_infix,
55+
azimuth_infix=azimuth_infix,
56+
)
57+
58+
with self.add_children_as_readables():
59+
self.defocus = Motor(prefix + defocus_infix)
60+
self.hor, self.vert = create_rotational_ij_component_signals(
61+
i_read=self.x.user_readback,
62+
j_read=self.y.user_readback,
63+
i_write=self.x,
64+
j_write=self.y,
65+
angle_deg=self.azimuth.user_readback,
66+
clockwise_frame=True,
67+
)
68+
self.long, self.perp = create_rotational_ij_component_signals(
69+
i_read=self.z.user_readback,
70+
i_write=self.z,
71+
j_read=self.hor,
72+
j_write=self.hor,
73+
angle_deg=self.polar.user_readback,
74+
clockwise_frame=False,
75+
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import asyncio
2+
from math import radians
3+
4+
from bluesky.protocols import Movable
5+
from bluesky.utils import maybe_await
6+
from ophyd_async.core import (
7+
SignalR,
8+
SignalRW,
9+
derived_signal_rw,
10+
)
11+
12+
from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise
13+
14+
15+
async def _get_angle_deg(angle_deg: SignalR[float] | float) -> float:
16+
if isinstance(angle_deg, SignalR):
17+
return await angle_deg.get_value()
18+
return angle_deg
19+
20+
21+
def create_rotational_ij_component_signals(
22+
i_read: SignalR[float],
23+
j_read: SignalR[float],
24+
i_write: Movable[float],
25+
j_write: Movable[float],
26+
angle_deg: float | SignalR[float],
27+
clockwise_frame: bool = True,
28+
) -> tuple[SignalRW[float], SignalRW[float]]:
29+
"""Create virtual i/j axes representing a Cartesian coordinate frame
30+
that is rotated by a given angle relative to the underlying equipment axes.
31+
32+
The returned signals expose the position of the system in a *rotated frame
33+
of reference* (e.g. the sample or stage frame), while transparently mapping
34+
reads and writes onto the real i/j signals in the fixed equipment (lab) frame.
35+
36+
From the user's point of view, i and j behave like ordinary orthogonal
37+
Cartesian axes attached to the rotating object. Internally, all reads apply
38+
a rotation to the real motor positions, and all writes apply the inverse
39+
rotation so that the requested motion is achieved in the rotated frame.
40+
41+
Args:
42+
i_read (SignalR): SignalR representing the i motor readback.
43+
j_read (SignalR): representing the j motor readback.
44+
i_write (Movable): object for setting the i position.
45+
j_write (Movable): object for setting the j position.
46+
angle_deg (float | SignalR): Rotation angle in degrees.
47+
clockwise_frame (boolean): If True, the rotated frame is defined using a
48+
clockwise rotation; otherwise, a counter-clockwise rotation is used.
49+
50+
Returns:
51+
tuple[SignalRW[float], SignalRW[float]] Two virtual read/write signals
52+
corresponding to the rotated i and j components.
53+
"""
54+
rotate = rotate_clockwise if clockwise_frame else rotate_counter_clockwise
55+
inverse_rotate = rotate_counter_clockwise if clockwise_frame else rotate_clockwise
56+
57+
async def _read_rotated() -> tuple[float, float, float]:
58+
i, j, ang = await asyncio.gather(
59+
i_read.get_value(),
60+
j_read.get_value(),
61+
_get_angle_deg(angle_deg),
62+
)
63+
return (*rotate(radians(ang), i, j), ang)
64+
65+
async def _write_rotated(i_rot: float, j_rot: float, ang: float) -> None:
66+
i_new, j_new = inverse_rotate(radians(ang), i_rot, j_rot)
67+
await asyncio.gather(
68+
maybe_await(i_write.set(i_new)),
69+
maybe_await(j_write.set(j_new)),
70+
)
71+
72+
def _read_i(i: float, j: float, ang: float) -> float:
73+
return rotate(radians(ang), i, j)[0]
74+
75+
async def _set_i(value: float) -> None:
76+
i_rot, j_rot, ang = await _read_rotated()
77+
await _write_rotated(value, j_rot, ang)
78+
79+
def _read_j(i: float, j: float, ang: float) -> float:
80+
return rotate(radians(ang), i, j)[1]
81+
82+
async def _set_j(value: float) -> None:
83+
i_rot, j_rot, ang = await _read_rotated()
84+
await _write_rotated(i_rot, value, ang)
85+
86+
return (
87+
derived_signal_rw(_read_i, _set_i, i=i_read, j=j_read, ang=angle_deg),
88+
derived_signal_rw(_read_j, _set_j, i=i_read, j=j_read, ang=angle_deg),
89+
)

src/dodal/devices/motors.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import math
33
from abc import ABC
44

5-
from ophyd_async.core import StandardReadable, derived_signal_rw
5+
from ophyd_async.core import SignalRW, StandardReadable, derived_signal_rw
66
from ophyd_async.epics.motor import Motor
77

8+
from dodal.common.maths import rotate_clockwise, rotate_counter_clockwise
9+
810
_X, _Y, _Z = "X", "Y", "Z"
911

1012
_OMEGA = "OMEGA"
@@ -315,7 +317,9 @@ def __init__(
315317
super().__init__(name)
316318

317319

318-
def create_axis_perp_to_rotation(motor_theta: Motor, motor_i: Motor, motor_j: Motor):
320+
def create_axis_perp_to_rotation(
321+
motor_theta: Motor, motor_i: Motor, motor_j: Motor
322+
) -> SignalRW[float]:
319323
"""Given a signal that controls a motor in a rotation axis and two other
320324
signals controlling motors on a pair of orthogonal axes, these axes being in the
321325
rotating frame of reference created by the first axis, create a derived signal
@@ -337,25 +341,24 @@ def create_axis_perp_to_rotation(motor_theta: Motor, motor_i: Motor, motor_j: Mo
337341
a move here is entirely parallel with the derived axis.
338342
"""
339343

340-
def _get(j_val: float, i_val: float, rot_value: float) -> float:
341-
i_component = i_val * math.cos(math.radians(rot_value))
342-
j_component = j_val * math.sin(math.radians(rot_value))
343-
return i_component + j_component
344+
def _get(j_val: float, i_val: float, rot_deg_value: float) -> float:
345+
x, y = rotate_clockwise(math.radians(rot_deg_value), i_val, j_val)
346+
return x
344347

345348
async def _set(vertical_value: float) -> None:
346-
rot_value = await motor_theta.user_readback.get_value()
347-
i_component = vertical_value * math.cos(math.radians(rot_value))
348-
j_component = vertical_value * math.sin(math.radians(rot_value))
349+
rot_deg_value = await motor_theta.user_readback.get_value()
350+
theta = math.radians(rot_deg_value)
351+
i_component, j_component = rotate_counter_clockwise(theta, vertical_value, 0.0)
349352
await asyncio.gather(
350353
motor_i.set(i_component),
351354
motor_j.set(j_component),
352-
motor_theta.set(rot_value),
355+
motor_theta.set(rot_deg_value),
353356
)
354357

355358
return derived_signal_rw(
356359
_get,
357360
_set,
358361
i_val=motor_i,
359362
j_val=motor_j,
360-
rot_value=motor_theta,
363+
rot_deg_value=motor_theta,
361364
)

0 commit comments

Comments
 (0)