dls-dodal 1.46.0__py3-none-any.whl → 1.48.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.
Files changed (81) hide show
  1. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/METADATA +2 -2
  2. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/RECORD +74 -63
  3. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/WHEEL +1 -1
  4. dodal/_version.py +2 -2
  5. dodal/beamlines/__init__.py +0 -1
  6. dodal/beamlines/aithre.py +6 -0
  7. dodal/beamlines/b01_1.py +1 -1
  8. dodal/beamlines/b07.py +2 -6
  9. dodal/beamlines/b07_1.py +1 -3
  10. dodal/beamlines/i03.py +33 -21
  11. dodal/beamlines/i04.py +65 -26
  12. dodal/beamlines/i09.py +1 -3
  13. dodal/beamlines/i09_1.py +1 -3
  14. dodal/beamlines/i18.py +1 -1
  15. dodal/beamlines/i19_1.py +9 -6
  16. dodal/beamlines/i23.py +17 -1
  17. dodal/beamlines/i24.py +5 -5
  18. dodal/beamlines/p38.py +1 -1
  19. dodal/beamlines/p60.py +2 -6
  20. dodal/beamlines/p99.py +48 -4
  21. dodal/common/beamlines/beamline_parameters.py +3 -30
  22. dodal/common/data_util.py +4 -0
  23. dodal/devices/aithre_lasershaping/goniometer.py +36 -2
  24. dodal/devices/aithre_lasershaping/laser_robot.py +27 -0
  25. dodal/devices/aperturescatterguard.py +47 -47
  26. dodal/devices/current_amplifiers/struck_scaler_counter.py +1 -1
  27. dodal/devices/diamond_filter.py +5 -17
  28. dodal/devices/eiger.py +1 -1
  29. dodal/devices/electron_analyser/__init__.py +18 -0
  30. dodal/devices/electron_analyser/abstract/__init__.py +22 -0
  31. dodal/devices/electron_analyser/abstract/base_detector.py +223 -0
  32. dodal/devices/electron_analyser/abstract/base_driver_io.py +230 -0
  33. dodal/devices/electron_analyser/{abstract_region.py → abstract/base_region.py} +3 -9
  34. dodal/devices/electron_analyser/specs/__init__.py +10 -0
  35. dodal/devices/electron_analyser/specs/detector.py +13 -0
  36. dodal/devices/electron_analyser/specs/driver_io.py +89 -0
  37. dodal/devices/electron_analyser/{specs_region.py → specs/region.py} +1 -1
  38. dodal/devices/electron_analyser/types.py +6 -0
  39. dodal/devices/electron_analyser/util.py +13 -0
  40. dodal/devices/electron_analyser/vgscienta/__init__.py +11 -0
  41. dodal/devices/electron_analyser/vgscienta/detector.py +22 -0
  42. dodal/devices/electron_analyser/vgscienta/driver_io.py +67 -0
  43. dodal/devices/electron_analyser/{vgscienta_region.py → vgscienta/region.py} +1 -2
  44. dodal/devices/fast_grid_scan.py +7 -9
  45. dodal/devices/i03/__init__.py +3 -0
  46. dodal/devices/i04/__init__.py +3 -0
  47. dodal/devices/i04/constants.py +9 -0
  48. dodal/devices/i04/murko_results.py +192 -0
  49. dodal/devices/i10/diagnostics.py +9 -61
  50. dodal/devices/i18/diode.py +37 -4
  51. dodal/devices/i24/focus_mirrors.py +9 -13
  52. dodal/devices/i24/pilatus_metadata.py +9 -9
  53. dodal/devices/i24/pmac.py +19 -14
  54. dodal/devices/{i03 → mx_phase1}/beamstop.py +26 -15
  55. dodal/devices/oav/oav_calculations.py +2 -2
  56. dodal/devices/oav/oav_detector.py +80 -32
  57. dodal/devices/oav/oav_parameters.py +46 -16
  58. dodal/devices/oav/oav_to_redis_forwarder.py +2 -2
  59. dodal/devices/oav/utils.py +2 -2
  60. dodal/devices/p99/andor2_point.py +41 -0
  61. dodal/devices/positioner.py +49 -0
  62. dodal/devices/robot.py +20 -1
  63. dodal/devices/smargon.py +43 -4
  64. dodal/devices/tetramm.py +5 -2
  65. dodal/devices/util/adjuster_plans.py +1 -1
  66. dodal/devices/zebra/zebra.py +8 -0
  67. dodal/devices/zebra/zebra_constants_mapping.py +1 -1
  68. dodal/devices/zocalo/__init__.py +0 -3
  69. dodal/devices/zocalo/zocalo_results.py +6 -32
  70. dodal/log.py +14 -14
  71. dodal/plans/configure_arm_trigger_and_disarm_detector.py +167 -0
  72. dodal/common/signal_utils.py +0 -88
  73. dodal/devices/electron_analyser/abstract_analyser_io.py +0 -47
  74. dodal/devices/electron_analyser/specs_analyser_io.py +0 -19
  75. dodal/devices/electron_analyser/vgscienta_analyser_io.py +0 -26
  76. dodal/devices/logging_ophyd_device.py +0 -17
  77. dodal/plan_stubs/electron_analyser/__init__.py +0 -0
  78. dodal/plan_stubs/electron_analyser/configure_controller.py +0 -80
  79. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/entry_points.txt +0 -0
  80. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/licenses/LICENSE +0 -0
  81. {dls_dodal-1.46.0.dist-info → dls_dodal-1.48.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,192 @@
1
+ import json
2
+ import pickle
3
+ from enum import Enum
4
+ from typing import TypedDict
5
+
6
+ import numpy as np
7
+ from bluesky.protocols import Stageable, Triggerable
8
+ from ophyd_async.core import (
9
+ AsyncStatus,
10
+ StandardReadable,
11
+ soft_signal_r_and_setter,
12
+ soft_signal_rw,
13
+ )
14
+ from redis.asyncio import StrictRedis
15
+
16
+ from dodal.devices.i04.constants import RedisConstants
17
+ from dodal.devices.oav.oav_calculations import (
18
+ calculate_beam_distance,
19
+ )
20
+ from dodal.log import LOGGER
21
+
22
+ NO_MURKO_RESULT = (-1, -1)
23
+
24
+ MurkoResult = dict
25
+ FullMurkoResults = dict[str, list[MurkoResult]]
26
+
27
+
28
+ class MurkoMetadata(TypedDict):
29
+ zoom_percentage: float
30
+ microns_per_x_pixel: float
31
+ microns_per_y_pixel: float
32
+ beam_centre_i: int
33
+ beam_centre_j: int
34
+ sample_id: str
35
+ omega_angle: float
36
+ uuid: str
37
+
38
+
39
+ class Coord(Enum):
40
+ x = 0
41
+ y = 1
42
+ z = 2
43
+
44
+
45
+ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
46
+ """Device that takes crystal centre values from Murko and uses them to set the
47
+ x, y, z coordinate of the sample to be in line with the beam centre.
48
+ The most_likely_click[1] value from Murko corresponds with the x coordinate of the
49
+ sample. The most_likely_click[0] value from Murko corresponds with a component of
50
+ the y and z coordinates of the sample, depending on the omega angle, as the sample
51
+ is rotated around the x axis.
52
+
53
+ Given a most_likely_click value at a certain omega angle θ:
54
+ most_likely_click[1] = x
55
+ most_likely_click[0] = cos(θ)y - sin(θ)z
56
+
57
+ A value for x can be found by averaging all most_likely_click[1] values, and
58
+ solutions for y and z can be calculated using numpy's linear algebra library.
59
+ """
60
+
61
+ TIMEOUT_S = 2
62
+
63
+ def __init__(
64
+ self,
65
+ redis_host=RedisConstants.REDIS_HOST,
66
+ redis_password=RedisConstants.REDIS_PASSWORD,
67
+ redis_db=RedisConstants.MURKO_REDIS_DB,
68
+ name="",
69
+ stop_angle=350,
70
+ ):
71
+ self.redis_client = StrictRedis(
72
+ host=redis_host,
73
+ password=redis_password,
74
+ db=redis_db,
75
+ )
76
+ self.pubsub = self.redis_client.pubsub()
77
+ self._last_omega = 0
78
+ self.sample_id = soft_signal_rw(str) # Should get from redis
79
+ self.stop_angle = stop_angle
80
+ self.x_dists_mm = []
81
+ self.y_dists_mm = []
82
+ self.omegas = []
83
+
84
+ with self.add_children_as_readables():
85
+ # Diffs from current x/y/z
86
+ self.x_mm, self._x_mm_setter = soft_signal_r_and_setter(float)
87
+ self.y_mm, self._y_mm_setter = soft_signal_r_and_setter(float)
88
+ self.z_mm, self._z_mm_setter = soft_signal_r_and_setter(float)
89
+ super().__init__(name=name)
90
+
91
+ @AsyncStatus.wrap
92
+ async def stage(self):
93
+ await self.pubsub.subscribe("murko-results")
94
+ self._x_mm_setter(0)
95
+ self._y_mm_setter(0)
96
+ self._z_mm_setter(0)
97
+
98
+ @AsyncStatus.wrap
99
+ async def unstage(self):
100
+ await self.pubsub.unsubscribe()
101
+
102
+ @AsyncStatus.wrap
103
+ async def trigger(self):
104
+ # Wait for results
105
+ sample_id = await self.sample_id.get_value()
106
+ while self._last_omega < self.stop_angle:
107
+ # waits here for next batch to be received
108
+ message = await self.pubsub.get_message(timeout=self.TIMEOUT_S)
109
+ if message is None: # No more messages to process
110
+ break
111
+ await self.process_batch(message, sample_id)
112
+
113
+ for i in range(len(self.omegas)):
114
+ LOGGER.debug(
115
+ f"omega: {round(self.omegas[i], 2)}, x: {round(self.x_dists_mm[i], 2)}, y: {round(self.y_dists_mm[i], 2)}"
116
+ )
117
+
118
+ LOGGER.info(f"Using average of x beam distances: {self.x_dists_mm}")
119
+ avg_x = float(np.mean(self.x_dists_mm))
120
+ LOGGER.info(f"Finding least square y and z from y distances: {self.y_dists_mm}")
121
+ best_y, best_z = get_yz_least_squares(self.y_dists_mm, self.omegas)
122
+ # x, y, z are relative to beam centre. Need to move negative these values to get centred.
123
+ self._x_mm_setter(-avg_x)
124
+ self._y_mm_setter(-best_y)
125
+ self._z_mm_setter(-best_z)
126
+
127
+ async def process_batch(self, message: dict | None, sample_id: str):
128
+ if message and message["type"] == "message":
129
+ batch_results: list[dict] = pickle.loads(message["data"])
130
+ for results in batch_results:
131
+ for uuid, result in results.items():
132
+ if metadata_str := await self.redis_client.hget( # type: ignore
133
+ f"murko:{sample_id}:metadata", uuid
134
+ ):
135
+ LOGGER.info(
136
+ f"Found metadata for uuid {uuid}, processing result"
137
+ )
138
+ self.process_result(
139
+ result, MurkoMetadata(json.loads(metadata_str))
140
+ )
141
+ else:
142
+ LOGGER.info(f"Found no metadata for uuid {uuid}")
143
+
144
+ def process_result(self, result: dict, metadata: MurkoMetadata):
145
+ """Uses the 'most_likely_click' coordinates from Murko to calculate the
146
+ horizontal and vertical distances from the beam centre, and store these values
147
+ as well as the omega angle the image was taken at.
148
+ """
149
+ omega = metadata["omega_angle"]
150
+ coords = result["most_likely_click"] # As proportion from top, left of image
151
+ LOGGER.info(f"Got most_likely_click: {coords} at angle {omega}")
152
+ if (
153
+ tuple(coords) == NO_MURKO_RESULT
154
+ ): # See https://github.com/MartinSavko/murko/issues/9
155
+ LOGGER.info("Murko didn't produce a result, moving on")
156
+ else:
157
+ shape = result["original_shape"] # Dimensions of image in pixels
158
+ # Murko returns coords as y, x
159
+ centre_px = (coords[1] * shape[1], coords[0] * shape[0])
160
+
161
+ beam_dist_px = calculate_beam_distance(
162
+ (metadata["beam_centre_i"], metadata["beam_centre_j"]),
163
+ centre_px[0],
164
+ centre_px[1],
165
+ )
166
+ self.x_dists_mm.append(
167
+ beam_dist_px[0] * metadata["microns_per_x_pixel"] / 1000
168
+ )
169
+ self.y_dists_mm.append(
170
+ beam_dist_px[1] * metadata["microns_per_y_pixel"] / 1000
171
+ )
172
+ self.omegas.append(omega)
173
+ self._last_omega = omega
174
+
175
+
176
+ def get_yz_least_squares(vertical_dists: list, omegas: list) -> tuple[float, float]:
177
+ """Get the least squares solution for y and z from the vertical distances and omega angles.
178
+
179
+ Args:
180
+ v_dists (list): List of vertical distances from beam centre. Any units
181
+ omegas (list): List of omega angles in degrees.
182
+
183
+ Returns:
184
+ tuple[float, float]: y, z distances from centre, in whichever units
185
+ v_dists came as.
186
+ """
187
+ thetas = np.radians(omegas)
188
+ matrix = np.column_stack([np.cos(thetas), -np.sin(thetas)])
189
+
190
+ yz, residuals, rank, s = np.linalg.lstsq(matrix, vertical_dists, rcond=None)
191
+ y, z = yz
192
+ return y, z
@@ -1,6 +1,4 @@
1
- from bluesky.protocols import Movable
2
1
  from ophyd_async.core import (
3
- AsyncStatus,
4
2
  Device,
5
3
  StandardReadable,
6
4
  StrictEnum,
@@ -13,7 +11,6 @@ from ophyd_async.epics.core import (
13
11
  epics_signal_r,
14
12
  epics_signal_rw,
15
13
  )
16
- from ophyd_async.epics.motor import Motor
17
14
 
18
15
  from dodal.devices.current_amplifiers import (
19
16
  CurrentAmpDet,
@@ -23,6 +20,7 @@ from dodal.devices.current_amplifiers import (
23
20
  FemtoDDPCA,
24
21
  StruckScaler,
25
22
  )
23
+ from dodal.devices.positioner import create_positioner
26
24
 
27
25
 
28
26
  class D3Position(StrictEnum):
@@ -70,36 +68,6 @@ class InOutReadBackTable(StrictEnum):
70
68
  OUT_OF_BEAM = "Out of Beam"
71
69
 
72
70
 
73
- class Positioner(StandardReadable, Movable):
74
- """1D stage with a enum table to select positions."""
75
-
76
- def __init__(
77
- self,
78
- prefix: str,
79
- positioner_enum: type[StrictEnum],
80
- positioner_suffix: str = "",
81
- Positioner_pv_suffix: str = ":MP:SELECT",
82
- name: str = "",
83
- ) -> None:
84
- self._stage_motion = Motor(prefix=prefix + positioner_suffix)
85
- with self.add_children_as_readables(Format.CONFIG_SIGNAL):
86
- self.stage_position = epics_signal_rw(
87
- positioner_enum,
88
- read_pv=prefix + positioner_suffix + Positioner_pv_suffix,
89
- )
90
- super().__init__(name=name)
91
- self.positioner_enum = positioner_enum
92
-
93
- @AsyncStatus.wrap
94
- async def set(self, value: StrictEnum) -> None:
95
- if value in self.positioner_enum:
96
- await self.stage_position.set(value=value)
97
- else:
98
- raise ValueError(
99
- f"{value} is not an allow position. Position must be: {self.positioner_enum}"
100
- )
101
-
102
-
103
71
  class I10PneumaticStage(StandardReadable):
104
72
  """Pneumatic stage only has two real positions in or out.
105
73
  Use for fluorescent screen which can be insert into the x-ray beam.
@@ -154,16 +122,13 @@ class FullDiagnostic(Device):
154
122
  self,
155
123
  prefix: str,
156
124
  positioner_enum: type[StrictEnum],
157
- positioner_suffix: str = "",
158
- Positioner_pv_suffix: str = ":MP:SELECT",
125
+ positioner_suffix: str,
159
126
  cam_infix: str = "DCAM:",
160
127
  name: str = "",
161
128
  ) -> None:
162
- self.positioner = Positioner(
163
- prefix=prefix,
164
- positioner_enum=positioner_enum,
165
- positioner_suffix=positioner_suffix,
166
- Positioner_pv_suffix=Positioner_pv_suffix,
129
+ self.positioner = create_positioner(
130
+ positioner_enum,
131
+ prefix + positioner_suffix,
167
132
  )
168
133
  self.screen = ScreenCam(
169
134
  prefix,
@@ -185,28 +150,11 @@ class I10Diagnostic(Device):
185
150
  positioner_suffix="DET:X",
186
151
  )
187
152
  self.d4 = ScreenCam(prefix=prefix + "PHDGN-04:")
188
- self.d5 = Positioner(
189
- prefix=prefix + "IONC-01:",
190
- positioner_enum=D5Position,
191
- positioner_suffix="Y",
192
- )
193
-
194
- self.d5A = Positioner(
195
- prefix=prefix + "PHDGN-06:",
196
- positioner_enum=D5APosition,
197
- positioner_suffix="DET:X",
198
- )
153
+ self.d5 = create_positioner(D5Position, f"{prefix}IONC-01:Y")
154
+ self.d5A = create_positioner(D5APosition, f"{prefix}PHDGN-06:DET:X")
155
+ self.d6 = FullDiagnostic(f"{prefix}PHDGN-05:", D6Position, "DET:X")
156
+ self.d7 = create_positioner(D7Position, f"{prefix}PHDGN-07:Y")
199
157
 
200
- self.d6 = FullDiagnostic(
201
- prefix=prefix + "PHDGN-05:",
202
- positioner_enum=D6Position,
203
- positioner_suffix="DET:X",
204
- )
205
- self.d7 = Positioner(
206
- prefix=prefix + "PHDGN-07:",
207
- positioner_enum=D7Position,
208
- positioner_suffix="Y",
209
- )
210
158
  super().__init__(name)
211
159
 
212
160
 
@@ -1,8 +1,36 @@
1
- from ophyd_async.core import (
2
- StandardReadable,
3
- )
1
+ from ophyd_async.core import StandardReadable, StrictEnum
4
2
  from ophyd_async.epics.core import epics_signal_r
5
3
 
4
+ from dodal.devices.positioner import create_positioner
5
+
6
+
7
+ class FilterAValues(StrictEnum):
8
+ """Maps from a short usable name to the string name in EPICS"""
9
+
10
+ AL_2MM = "2 mm Al"
11
+ AL_1_5MM = "1.5 mm Al"
12
+ AL_1_25MM = "1.25 mm Al"
13
+ AL_0_8MM = "0.8 mm Al"
14
+ AL_0_55MM = "0.55 mm Al"
15
+ AL_0_5MM = "0.5 mm Al"
16
+ AL_0_3MM = "0.3 mm Al"
17
+ AL_0_25MM = "0.25 mm Al"
18
+ AL_0_15MM = "0.15 mm Al"
19
+ AL_0_1MM = "0.1 mm Al"
20
+ AL_0_05MM = "0.05 mm Al"
21
+ AL_0_025MM = "0.025 mm Al"
22
+ AL_GAP = "Gap"
23
+
24
+
25
+ class FilterBValues(StrictEnum):
26
+ DIAMOND_THIN = "Diamond thin"
27
+ DIAMOND_THICK = "Diamond thick"
28
+ NI_DRAIN = "ni drain"
29
+ AU_DRAIN = "au drain"
30
+ AL_DRAIN = "al drain"
31
+ GAP = "Gap"
32
+ IN_LINE_DIODE = "in line diode"
33
+
6
34
 
7
35
  class Diode(StandardReadable):
8
36
  def __init__(
@@ -10,8 +38,13 @@ class Diode(StandardReadable):
10
38
  prefix: str,
11
39
  name: str = "",
12
40
  ):
13
- self._prefix = prefix
14
41
  with self.add_children_as_readables():
15
42
  self.signal = epics_signal_r(float, prefix + "B:DIODE:I")
43
+ self.positioner_a = create_positioner(
44
+ FilterAValues, prefix + "A:MP", positioner_pv_suffix=":SELECT"
45
+ ) # more complex, will be fixed on Tuesday 20.05.2025
46
+ self.positioner_b = create_positioner(
47
+ FilterBValues, prefix + "B:MP", positioner_pv_suffix=":SELECT"
48
+ )
16
49
 
17
50
  super().__init__(name=name)
@@ -1,8 +1,6 @@
1
- from ophyd_async.core import StandardReadable, StrictEnum
1
+ from ophyd_async.core import StandardReadable, StrictEnum, derived_signal_r
2
2
  from ophyd_async.epics.core import epics_signal_rw
3
3
 
4
- from dodal.common.signal_utils import create_r_hardware_backed_soft_signal
5
-
6
4
 
7
5
  class HFocusMode(StrictEnum):
8
6
  FOCUS_10 = "HMFMfocus10"
@@ -40,21 +38,19 @@ class FocusMirrorsMode(StandardReadable):
40
38
  self.vertical = epics_signal_rw(VFocusMode, prefix + "G0:TARGETAPPLY")
41
39
 
42
40
  with self.add_children_as_readables():
43
- self.beam_size_x = create_r_hardware_backed_soft_signal(
44
- int, self._get_beam_size_x, units="um"
41
+ self.beam_size_x = derived_signal_r(
42
+ self._get_beam_size_x, horizontal=self.horizontal, derived_units="um"
45
43
  )
46
- self.beam_size_y = create_r_hardware_backed_soft_signal(
47
- int, self._get_beam_size_y, units="um"
44
+ self.beam_size_y = derived_signal_r(
45
+ self._get_beam_size_y, vertical=self.vertical, derived_units="um"
48
46
  )
49
47
 
50
48
  super().__init__(name)
51
49
 
52
- async def _get_beam_size_x(self) -> int:
53
- h_mode = await self.horizontal.get_value()
54
- beam_x = BEAM_SIZES[h_mode.removeprefix("HMFM")][0]
50
+ def _get_beam_size_x(self, horizontal: HFocusMode) -> int:
51
+ beam_x = BEAM_SIZES[horizontal.removeprefix("HMFM")][0]
55
52
  return beam_x
56
53
 
57
- async def _get_beam_size_y(self) -> int:
58
- v_mode = await self.vertical.get_value()
59
- beam_y = BEAM_SIZES[v_mode.removeprefix("VMFM")][1]
54
+ def _get_beam_size_y(self, vertical: VFocusMode) -> int:
55
+ beam_y = BEAM_SIZES[vertical.removeprefix("VMFM")][1]
60
56
  return beam_y
@@ -2,11 +2,9 @@
2
2
 
3
3
  import re
4
4
 
5
- from ophyd_async.core import StandardReadable
5
+ from ophyd_async.core import StandardReadable, derived_signal_r
6
6
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
7
7
 
8
- from dodal.common.signal_utils import create_r_hardware_backed_soft_signal
9
-
10
8
 
11
9
  class PilatusMetadata(StandardReadable):
12
10
  def __init__(self, prefix: str, name: str = "") -> None:
@@ -14,21 +12,23 @@ class PilatusMetadata(StandardReadable):
14
12
  self.template = epics_signal_r(str, prefix + "cam1:FileTemplate_RBV")
15
13
  self.filenumber = epics_signal_r(int, prefix + "cam1:FileNumber_RBV")
16
14
  with self.add_children_as_readables():
17
- self.filename_template = create_r_hardware_backed_soft_signal(
18
- str, self._get_full_filename_template
15
+ self.filename_template = derived_signal_r(
16
+ self._get_full_filename_template,
17
+ filename=self.filename,
18
+ filename_template=self.template,
19
+ file_number=self.filenumber,
19
20
  )
20
21
  super().__init__(name)
21
22
 
22
- async def _get_full_filename_template(self) -> str:
23
+ def _get_full_filename_template(
24
+ self, filename: str, filename_template: str, file_number: int
25
+ ) -> str:
23
26
  """
24
27
  Get the template file path by querying the detector PVs.
25
28
  Mirror the construction that the PPU does.
26
29
 
27
30
  Returns: A template string, with the image numbers replaced with '#'
28
31
  """
29
- filename = await self.filename.get_value()
30
- filename_template = await self.template.get_value()
31
- file_number = await self.filenumber.get_value()
32
32
  # Exploit fact that passing negative numbers will put the - before the 0's
33
33
  expected_filename = str(
34
34
  filename_template % (filename, f"{file_number:05d}_", -9)
dodal/devices/i24/pmac.py CHANGED
@@ -10,6 +10,7 @@ from ophyd_async.core import (
10
10
  SignalR,
11
11
  SignalRW,
12
12
  StandardReadable,
13
+ observe_signals_value,
13
14
  soft_signal_rw,
14
15
  wait_for_value,
15
16
  )
@@ -120,15 +121,16 @@ class ProgramRunner(Device, Flyable):
120
121
  self,
121
122
  pmac_str_sig: SignalRW,
122
123
  status_sig: SignalR,
124
+ counter_sig: SignalR,
123
125
  prog_num_sig: SignalRW,
124
- collection_time_sig: SignalRW,
126
+ counter_time_sig: SignalRW,
125
127
  name: str = "",
126
128
  ) -> None:
127
129
  self._signal_ref = Reference(pmac_str_sig)
128
130
  self._status_ref = Reference(status_sig)
131
+ self._counter_ref = Reference(counter_sig)
129
132
  self._prog_num_ref = Reference(prog_num_sig)
130
-
131
- self._collection_time_ref = Reference(collection_time_sig)
133
+ self._counter_time_ref = Reference(counter_time_sig)
132
134
 
133
135
  super().__init__(name)
134
136
 
@@ -151,16 +153,18 @@ class ProgramRunner(Device, Flyable):
151
153
 
152
154
  @AsyncStatus.wrap
153
155
  async def complete(self):
154
- """Stop collecting when the scan status PV goes to 0.
155
-
156
- Args:
157
- complete_time (float): total time required by the collection to \
158
- finish correctly.
156
+ """Stop collecting when the scan status PV goes to 0 or when counter PV hasn't \
157
+ updated for 30 seconds.
159
158
  """
160
- scan_complete_time = await self._collection_time_ref().get_value()
161
- await wait_for_value(
162
- self._status_ref(), ScanState.DONE, timeout=scan_complete_time
163
- )
159
+ counter_time = await self._counter_time_ref().get_value()
160
+ async for signal, value in observe_signals_value(
161
+ self._status_ref(),
162
+ self._counter_ref(),
163
+ timeout=counter_time,
164
+ ):
165
+ if signal is self._status_ref():
166
+ if value == ScanState.DONE:
167
+ break
164
168
 
165
169
 
166
170
  class ProgramAbort(Triggerable):
@@ -217,13 +221,14 @@ class PMAC(StandardReadable):
217
221
  # A couple of soft signals for running a collection: program number to send to
218
222
  # the PMAC_STRING and expected collection time.
219
223
  self.program_number = soft_signal_rw(int)
220
- self.collection_time = soft_signal_rw(float, initial_value=600.0, units="s")
224
+ self.counter_time = soft_signal_rw(float, initial_value=30.0, units="s")
221
225
 
222
226
  self.run_program = ProgramRunner(
223
227
  self.pmac_string,
224
228
  self.scanstatus,
229
+ self.counter,
225
230
  self.program_number,
226
- self.collection_time,
231
+ self.counter_time,
227
232
  )
228
233
  self.abort_program = ProgramAbort(self.pmac_string, self.scanstatus)
229
234
 
@@ -1,11 +1,14 @@
1
- from asyncio import gather
1
+ import asyncio
2
2
  from math import isclose
3
3
 
4
- from ophyd_async.core import StandardReadable, StrictEnum
4
+ from ophyd_async.core import (
5
+ StandardReadable,
6
+ StrictEnum,
7
+ derived_signal_rw,
8
+ )
5
9
  from ophyd_async.epics.motor import Motor
6
10
 
7
11
  from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters
8
- from dodal.common.signal_utils import create_r_hardware_backed_soft_signal
9
12
 
10
13
 
11
14
  class BeamstopPositions(StrictEnum):
@@ -33,14 +36,13 @@ class BeamstopPositions(StrictEnum):
33
36
 
34
37
  class Beamstop(StandardReadable):
35
38
  """
36
- Beamstop for I03.
39
+ Beamstop for I03 and I04.
37
40
 
38
41
  Attributes:
39
42
  x: beamstop x position in mm
40
43
  y: beamstop y position in mm
41
44
  z: beamstop z position in mm
42
- selected_pos: Get the current position of the beamstop as an enum. Currently this
43
- is read-only.
45
+ selected_pos: Get or set the current position of the beamstop as an enum.
44
46
  """
45
47
 
46
48
  def __init__(
@@ -53,10 +55,13 @@ class Beamstop(StandardReadable):
53
55
  self.x_mm = Motor(prefix + "X")
54
56
  self.y_mm = Motor(prefix + "Y")
55
57
  self.z_mm = Motor(prefix + "Z")
56
- self.selected_pos = create_r_hardware_backed_soft_signal(
57
- BeamstopPositions, self._get_selected_position
58
+ self.selected_pos = derived_signal_rw(
59
+ self._get_selected_position,
60
+ self._set_selected_position,
61
+ x=self.x_mm,
62
+ y=self.y_mm,
63
+ z=self.z_mm,
58
64
  )
59
-
60
65
  self._in_beam_xyz_mm = [
61
66
  float(beamline_parameters[f"in_beam_{axis}_STANDARD"])
62
67
  for axis in ("x", "y", "z")
@@ -68,12 +73,8 @@ class Beamstop(StandardReadable):
68
73
 
69
74
  super().__init__(name)
70
75
 
71
- async def _get_selected_position(self) -> BeamstopPositions:
72
- current_pos = await gather(
73
- self.x_mm.user_readback.get_value(),
74
- self.y_mm.user_readback.get_value(),
75
- self.z_mm.user_readback.get_value(),
76
- )
76
+ def _get_selected_position(self, x: float, y: float, z: float) -> BeamstopPositions:
77
+ current_pos = [x, y, z]
77
78
  if all(
78
79
  isclose(axis_pos, axis_in_beam, abs_tol=axis_tolerance)
79
80
  for axis_pos, axis_in_beam, axis_tolerance in zip(
@@ -83,3 +84,13 @@ class Beamstop(StandardReadable):
83
84
  return BeamstopPositions.DATA_COLLECTION
84
85
  else:
85
86
  return BeamstopPositions.UNKNOWN
87
+
88
+ async def _set_selected_position(self, position: BeamstopPositions) -> None:
89
+ if position == BeamstopPositions.DATA_COLLECTION:
90
+ await asyncio.gather(
91
+ self.x_mm.set(self._in_beam_xyz_mm[0]),
92
+ self.y_mm.set(self._in_beam_xyz_mm[1]),
93
+ self.z_mm.set(self._in_beam_xyz_mm[2]),
94
+ )
95
+ elif position == BeamstopPositions.UNKNOWN:
96
+ raise ValueError(f"Cannot set beamstop to position {position}")
@@ -1,7 +1,7 @@
1
1
  import numpy as np
2
2
 
3
3
 
4
- def camera_coordinates_to_xyz(
4
+ def camera_coordinates_to_xyz_mm(
5
5
  horizontal: float,
6
6
  vertical: float,
7
7
  omega: float,
@@ -9,7 +9,7 @@ def camera_coordinates_to_xyz(
9
9
  microns_per_j_pixel: float,
10
10
  ) -> np.ndarray:
11
11
  """
12
- Converts from (horizontal,vertical) pixel measurements from the OAV camera into to (x, y, z) motor coordinates in millmeters.
12
+ Converts from (horizontal,vertical) pixel measurements from the OAV camera into to (x, y, z) motor coordinates in millimetres.
13
13
  For an overview of the coordinate system for I03 see https://github.com/DiamondLightSource/hyperion/wiki/Gridscan-Coordinate-System.
14
14
 
15
15
  Args: