Skip to content

Commit 5f88941

Browse files
JamesDoingStuffDominicOramolliesilvester
authored
Add generic calculation for translating from sample space to lab space and use for i23 OAV (#1215)
* Add I23-specific signals * Restore StrEnum to fix device defaults * Refactor component calculations * Make derived vertical postition more generic * Fix missing code from merge --------- Co-authored-by: Dominic Oram <[email protected]> Co-authored-by: Oliver Silvester <[email protected]>
1 parent b16e9de commit 5f88941

File tree

4 files changed

+116
-28
lines changed

4 files changed

+116
-28
lines changed

src/dodal/devices/aithre_lasershaping/__init__.py

Whitespace-only changes.
Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import asyncio
2-
import math
3-
4-
from ophyd_async.core import derived_signal_rw
51
from ophyd_async.epics.motor import Motor
62

7-
from dodal.devices.motors import XYZStage
3+
from dodal.devices.motors import XYZStage, create_axis_perp_to_rotation
84

95

106
class Goniometer(XYZStage):
@@ -23,26 +19,7 @@ def __init__(self, prefix: str, name: str = "") -> None:
2319
self.sampy = Motor(prefix + "SAMPY")
2420
self.sampz = Motor(prefix + "SAMPZ")
2521
self.omega = Motor(prefix + "OMEGA")
26-
self.vertical_position = derived_signal_rw(
27-
self._get,
28-
self._set,
29-
sampy=self.sampy,
30-
sampz=self.sampz,
31-
omega=self.omega,
22+
self.vertical_position = create_axis_perp_to_rotation(
23+
self.omega, self.sampz, self.sampy
3224
)
3325
super().__init__(name)
34-
35-
def _get(self, sampz: float, sampy: float, omega: float) -> float:
36-
z_component = sampz * math.cos(math.radians(omega))
37-
y_component = sampy * math.sin(math.radians(omega))
38-
return z_component + y_component
39-
40-
async def _set(self, value: float) -> None:
41-
omega = await self.omega.user_readback.get_value()
42-
z_component = value * math.cos(math.radians(omega))
43-
y_component = value * math.sin(math.radians(omega))
44-
await asyncio.gather(
45-
self.sampy.set(y_component),
46-
self.sampz.set(z_component),
47-
self.omega.set(omega),
48-
)

src/dodal/devices/motors.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import asyncio
2+
import math
13
from abc import ABC
24

3-
from ophyd_async.core import StandardReadable
5+
from ophyd_async.core import StandardReadable, derived_signal_rw
46
from ophyd_async.epics.motor import Motor
57

68
_X, _Y, _Z = "X", "Y", "Z"
@@ -101,12 +103,19 @@ def __init__(
101103
phi_infix: str = "PHI",
102104
omega_infix: str = "OMEGA",
103105
):
106+
"""Six-axis goniometer with a standard xyz stage and three axes of rotation:
107+
kappa, phi and omega.
108+
"""
104109
with self.add_children_as_readables():
105110
self.kappa = Motor(prefix + kappa_infix)
106111
self.phi = Motor(prefix + phi_infix)
107112
self.omega = Motor(prefix + omega_infix)
108113
super().__init__(prefix, name, x_infix, y_infix, z_infix)
109114

115+
self.vertical_in_lab_space = create_axis_perp_to_rotation(
116+
self.omega, self.y, self.z
117+
)
118+
110119

111120
class YZStage(Stage):
112121
def __init__(
@@ -116,3 +125,49 @@ def __init__(
116125
self.y = Motor(prefix + y_infix)
117126
self.z = Motor(prefix + z_infix)
118127
super().__init__(name)
128+
129+
130+
def create_axis_perp_to_rotation(motor_theta: Motor, motor_i: Motor, motor_j: Motor):
131+
"""Given a signal that controls a motor in a rotation axis and two other
132+
signals controlling motors on a pair of orthogonal axes, these axes being in the
133+
rotating frame of reference created by the first axis, create a derived signal
134+
that is a projection of the two axes in the non-rotating frame of reference.
135+
136+
The projection is onto the axis defined by i when the rotation angle is 0 and
137+
defined by j when the angle is at 90.
138+
139+
The usual use case for this is translating from sample space to lab space. For
140+
example, if you have a sample that is mounted on a goniometer to the right hand side
141+
of an OAV view this can provide an axis that will move the sample up/down in that
142+
view regardless of the omega orientation of the sample.
143+
144+
Args:
145+
motor_theta (Motor): this is the rotation axis of the sample.
146+
motor_i (Motor): this is the axis that, when the sample is at 0 deg rotation,
147+
a move here is entirely parallel with the derived axis.
148+
motor_j (Motor): this is the axis that, when the sample is at 90 deg rotation,
149+
a move here is entirely parallel with the derived axis.
150+
"""
151+
152+
def _get(j_val: float, i_val: float, rot_value: float) -> float:
153+
i_component = i_val * math.cos(math.radians(rot_value))
154+
j_component = j_val * math.sin(math.radians(rot_value))
155+
return i_component + j_component
156+
157+
async def _set(vertical_value: float) -> None:
158+
rot_value = await motor_theta.user_readback.get_value()
159+
i_component = vertical_value * math.cos(math.radians(rot_value))
160+
j_component = vertical_value * math.sin(math.radians(rot_value))
161+
await asyncio.gather(
162+
motor_i.set(i_component),
163+
motor_j.set(j_component),
164+
motor_theta.set(rot_value),
165+
)
166+
167+
return derived_signal_rw(
168+
_get,
169+
_set,
170+
i_val=motor_i,
171+
j_val=motor_j,
172+
rot_value=motor_theta,
173+
)

tests/devices/unit_tests/test_motors.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
import math
2+
13
import pytest
24
from bluesky.run_engine import RunEngine
35
from ophyd_async.core import init_devices
46
from ophyd_async.testing import assert_reading, partial_reading
57

68
from dodal.devices.motors import SixAxisGonio
9+
from dodal.devices.util.test_utils import patch_motor
710

811

912
@pytest.fixture
10-
def six_axis_gonio(RE: RunEngine):
13+
def six_axis_gonio(RE: RunEngine) -> SixAxisGonio:
1114
with init_devices(mock=True):
1215
gonio = SixAxisGonio("")
16+
patch_motor(gonio.omega)
17+
patch_motor(gonio.z)
18+
patch_motor(gonio.y)
19+
patch_motor(gonio.x)
1320

1421
return gonio
1522

@@ -26,3 +33,52 @@ async def test_reading_six_axis_gonio(six_axis_gonio: SixAxisGonio):
2633
"gonio-x": partial_reading(0.0),
2734
},
2835
)
36+
37+
38+
@pytest.mark.parametrize(
39+
"set_value, omega_set_value, expected_horz, expected_vert",
40+
[
41+
[2, 60, math.sqrt(3), 1],
42+
[-10, 390, -5, -5 * math.sqrt(3)],
43+
[0.5, -135, -math.sqrt(2) / 4, -math.sqrt(2) / 4],
44+
[1, 0, 0, 1],
45+
],
46+
)
47+
async def test_vertical_in_lab_space_for_default_axes(
48+
six_axis_gonio: SixAxisGonio,
49+
set_value: float,
50+
omega_set_value: float,
51+
expected_horz: float,
52+
expected_vert: float,
53+
):
54+
await six_axis_gonio.omega.set(omega_set_value)
55+
await six_axis_gonio.vertical_in_lab_space.set(set_value)
56+
57+
assert await six_axis_gonio.z.user_readback.get_value() == pytest.approx(
58+
expected_horz
59+
)
60+
assert await six_axis_gonio.y.user_readback.get_value() == pytest.approx(
61+
expected_vert
62+
)
63+
64+
await six_axis_gonio.vertical_in_lab_space.set(set_value * 2)
65+
assert await six_axis_gonio.z.user_readback.get_value() == pytest.approx(
66+
expected_horz * 2
67+
)
68+
assert await six_axis_gonio.y.user_readback.get_value() == pytest.approx(
69+
expected_vert * 2
70+
)
71+
72+
73+
@pytest.mark.parametrize(
74+
"set_point",
75+
[
76+
-5,
77+
0,
78+
100,
79+
0.7654,
80+
],
81+
)
82+
async def test_lab_vertical_round_trip(six_axis_gonio: SixAxisGonio, set_point: float):
83+
await six_axis_gonio.vertical_in_lab_space.set(set_point)
84+
assert await six_axis_gonio.vertical_in_lab_space.get_value() == set_point

0 commit comments

Comments
 (0)