dls-dodal 1.65.0__py3-none-any.whl → 1.67.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 (85) hide show
  1. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/METADATA +3 -4
  2. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/RECORD +82 -66
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/aithre.py +21 -2
  5. dodal/beamlines/i03.py +102 -198
  6. dodal/beamlines/i04.py +40 -4
  7. dodal/beamlines/i05.py +28 -1
  8. dodal/beamlines/i06.py +62 -0
  9. dodal/beamlines/i07.py +20 -0
  10. dodal/beamlines/i09_1.py +32 -3
  11. dodal/beamlines/i09_2.py +57 -2
  12. dodal/beamlines/i10_optics.py +46 -17
  13. dodal/beamlines/i17.py +7 -3
  14. dodal/beamlines/i18.py +3 -3
  15. dodal/beamlines/i19_1.py +26 -14
  16. dodal/beamlines/i19_2.py +49 -38
  17. dodal/beamlines/i21.py +2 -2
  18. dodal/beamlines/i22.py +19 -4
  19. dodal/beamlines/p38.py +3 -3
  20. dodal/beamlines/training_rig.py +0 -16
  21. dodal/cli.py +26 -12
  22. dodal/common/coordination.py +3 -2
  23. dodal/device_manager.py +604 -0
  24. dodal/devices/aithre_lasershaping/goniometer.py +26 -9
  25. dodal/devices/aperturescatterguard.py +3 -2
  26. dodal/devices/areadetector/plugins/mjpg.py +10 -3
  27. dodal/devices/beamsize/__init__.py +0 -0
  28. dodal/devices/beamsize/beamsize.py +6 -0
  29. dodal/devices/cryostream.py +28 -57
  30. dodal/devices/detector/det_resolution.py +4 -2
  31. dodal/devices/eiger.py +26 -18
  32. dodal/devices/fast_grid_scan.py +14 -2
  33. dodal/devices/i03/beamsize.py +35 -0
  34. dodal/devices/i03/constants.py +7 -0
  35. dodal/devices/i03/undulator_dcm.py +2 -2
  36. dodal/devices/i04/beamsize.py +45 -0
  37. dodal/devices/i04/max_pixel.py +38 -0
  38. dodal/devices/i04/murko_results.py +36 -26
  39. dodal/devices/i04/transfocator.py +23 -29
  40. dodal/devices/i07/id.py +38 -0
  41. dodal/devices/i09_1_shared/__init__.py +13 -2
  42. dodal/devices/i09_1_shared/hard_energy.py +112 -0
  43. dodal/devices/i09_1_shared/hard_undulator_functions.py +85 -21
  44. dodal/devices/i09_2_shared/__init__.py +0 -0
  45. dodal/devices/i09_2_shared/i09_apple2.py +86 -0
  46. dodal/devices/i10/i10_apple2.py +39 -331
  47. dodal/devices/i17/i17_apple2.py +37 -22
  48. dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
  49. dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
  50. dodal/devices/i19/access_controlled/shutter.py +2 -4
  51. dodal/devices/insertion_device/__init__.py +0 -0
  52. dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +122 -69
  53. dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
  54. dodal/devices/insertion_device/lookup_table_models.py +287 -0
  55. dodal/devices/ipin.py +20 -2
  56. dodal/devices/motors.py +33 -3
  57. dodal/devices/mx_phase1/beamstop.py +31 -12
  58. dodal/devices/oav/oav_calculations.py +9 -4
  59. dodal/devices/oav/oav_detector.py +65 -7
  60. dodal/devices/oav/oav_parameters.py +3 -1
  61. dodal/devices/oav/oav_to_redis_forwarder.py +18 -15
  62. dodal/devices/oav/pin_image_recognition/__init__.py +5 -1
  63. dodal/devices/oav/pin_image_recognition/utils.py +23 -1
  64. dodal/devices/oav/snapshots/snapshot_with_grid.py +8 -2
  65. dodal/devices/oav/utils.py +16 -6
  66. dodal/devices/robot.py +33 -18
  67. dodal/devices/scintillator.py +36 -14
  68. dodal/devices/smargon.py +2 -3
  69. dodal/devices/thawer.py +7 -45
  70. dodal/devices/undulator.py +152 -68
  71. dodal/plans/__init__.py +1 -1
  72. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
  73. dodal/plans/load_panda_yaml.py +9 -0
  74. dodal/plans/verify_undulator_gap.py +2 -2
  75. dodal/testing/fixtures/devices/__init__.py +0 -0
  76. dodal/testing/fixtures/devices/apple2.py +78 -0
  77. dodal/utils.py +6 -3
  78. dodal/beamline_specific_utils/i03.py +0 -17
  79. dodal/testing/__init__.py +0 -3
  80. dodal/testing/setup.py +0 -67
  81. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/WHEEL +0 -0
  82. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/entry_points.txt +0 -0
  83. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/licenses/LICENSE +0 -0
  84. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/top_level.txt +0 -0
  85. /dodal/plans/{scanspec.py → spec_path.py} +0 -0
@@ -0,0 +1,287 @@
1
+ """Apple2 lookup table utilities and CSV converter.
2
+
3
+ This module provides helpers to read, validate and convert Apple2 insertion-device
4
+ lookup tables (energy -> gap/phase polynomials) from CSV sources into an
5
+ in-memory dictionary format used by the Apple2 controllers.
6
+
7
+ Data format produced
8
+ The lookup-table dictionary created by convert_csv_to_lookup() follows this
9
+ structure:
10
+
11
+ {
12
+ "POL_MODE": {
13
+ "energy_entries": [
14
+ {
15
+ "low": <float>,
16
+ "high": <float>,
17
+ "poly": <numpy.poly1d>
18
+ },
19
+ ...
20
+ ]
21
+ },
22
+ ...
23
+ }
24
+ """
25
+
26
+ import csv
27
+ import io
28
+ from collections.abc import Generator
29
+ from typing import Annotated as A
30
+ from typing import Any, NamedTuple, Self
31
+
32
+ import numpy as np
33
+ from pydantic import (
34
+ BaseModel,
35
+ ConfigDict,
36
+ Field,
37
+ RootModel,
38
+ field_serializer,
39
+ field_validator,
40
+ )
41
+
42
+ from dodal.devices.insertion_device.apple2_undulator import Pol
43
+
44
+ DEFAULT_POLY_DEG = [
45
+ "7th-order",
46
+ "6th-order",
47
+ "5th-order",
48
+ "4th-order",
49
+ "3rd-order",
50
+ "2nd-order",
51
+ "1st-order",
52
+ "b",
53
+ ]
54
+
55
+ MODE_NAME_CONVERT = {"cr": "pc", "cl": "nc"}
56
+ DEFAULT_GAP_FILE = "IDEnergy2GapCalibrations.csv"
57
+ DEFAULT_PHASE_FILE = "IDEnergy2PhaseCalibrations.csv"
58
+
59
+ ROW_PHASE_MOTOR_TOLERANCE = 0.004
60
+ ROW_PHASE_CIRCULAR = 15
61
+ MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
62
+ MAXIMUM_GAP_MOTOR_POSITION = 100
63
+
64
+ DEFAULT_POLY1D_PARAMETERS = {
65
+ Pol.LH: [0],
66
+ Pol.LV: [MAXIMUM_ROW_PHASE_MOTOR_POSITION],
67
+ Pol.PC: [ROW_PHASE_CIRCULAR],
68
+ Pol.NC: [-ROW_PHASE_CIRCULAR],
69
+ Pol.LH3: [0],
70
+ }
71
+
72
+
73
+ class Source(NamedTuple):
74
+ column: str
75
+ value: str
76
+
77
+
78
+ class LookupTableColumnConfig(BaseModel):
79
+ """Configuration on how to process a csv file columns into a LookupTable data model."""
80
+
81
+ source: A[
82
+ Source | None,
83
+ Field(
84
+ description="If not None, only process the row if the source column name match the value."
85
+ ),
86
+ ] = None
87
+ mode: A[str, Field(description="Polarisation mode column name.")] = "Mode"
88
+ min_energy: A[str, Field(description="Minimum energy column name.")] = "MinEnergy"
89
+ max_energy: A[str, Field(description="Maximum energy column name.")] = "MaxEnergy"
90
+ poly_deg: list[str] = Field(
91
+ description="Polynomial column names.", default_factory=lambda: DEFAULT_POLY_DEG
92
+ )
93
+ mode_name_convert: dict[str, str] = Field(
94
+ description="When processing polarisation mode values, map their alias values to a real value.",
95
+ default_factory=lambda: MODE_NAME_CONVERT,
96
+ )
97
+
98
+
99
+ class EnergyCoverageEntry(BaseModel):
100
+ model_config = ConfigDict(arbitrary_types_allowed=True) # So np.poly1d can be used.
101
+ min_energy: float
102
+ max_energy: float
103
+ poly: np.poly1d
104
+
105
+ @field_validator("poly", mode="before")
106
+ @classmethod
107
+ def validate_and_convert_poly(
108
+ cls: type[Self], value: np.poly1d | list
109
+ ) -> np.poly1d:
110
+ """If reading from serialized data, it will be using a list. Convert to np.poly1d"""
111
+ if isinstance(value, list):
112
+ return np.poly1d(value)
113
+ return value
114
+
115
+ @field_serializer("poly", mode="plain")
116
+ def serialize_poly(self, value: np.poly1d) -> list:
117
+ """Allow np.poly1d to work when serializing."""
118
+ return value.coefficients.tolist()
119
+
120
+
121
+ class EnergyCoverage(BaseModel):
122
+ energy_entries: list[EnergyCoverageEntry]
123
+
124
+ @classmethod
125
+ def generate(
126
+ cls: type[Self],
127
+ min_energies: list[float],
128
+ max_energies: list[float],
129
+ poly1d_params: list[list[float]],
130
+ ) -> Self:
131
+ energy_entries = [
132
+ EnergyCoverageEntry(
133
+ min_energy=min_energy,
134
+ max_energy=max_energy,
135
+ poly=np.poly1d(poly_params),
136
+ )
137
+ for min_energy, max_energy, poly_params in zip(
138
+ min_energies, max_energies, poly1d_params, strict=True
139
+ )
140
+ ]
141
+ return cls(energy_entries=energy_entries)
142
+
143
+ @property
144
+ def min_energy(self) -> float:
145
+ return min(e.min_energy for e in self.energy_entries)
146
+
147
+ @property
148
+ def max_energy(self) -> float:
149
+ return max(e.max_energy for e in self.energy_entries)
150
+
151
+ def get_poly(self, energy: float) -> np.poly1d:
152
+ """
153
+ Return the numpy.poly1d polynomial applicable for the given energy.
154
+
155
+ Parameters:
156
+ -----------
157
+ energy:
158
+ Energy value in the same units used to create the lookup table.
159
+ """
160
+ # Cache initial values so don't do unnecessary work again
161
+ min_energy = self.min_energy
162
+ max_energy = self.max_energy
163
+ if energy < min_energy or energy > max_energy:
164
+ raise ValueError(
165
+ f"Demanding energy must lie between {min_energy} and {max_energy}!"
166
+ )
167
+ else:
168
+ for energy_coverage in self.energy_entries:
169
+ if (
170
+ energy >= energy_coverage.min_energy
171
+ and energy < energy_coverage.max_energy
172
+ ):
173
+ return energy_coverage.poly
174
+ raise ValueError(
175
+ "Cannot find polynomial coefficients for your requested energy."
176
+ + " There might be gap in the calibration lookup table."
177
+ )
178
+
179
+
180
+ class LookupTable(RootModel[dict[Pol, EnergyCoverage]]):
181
+ """
182
+ Specialised lookup table for insertion devices to relate the energy and polarisation
183
+ values to Apple2 motor positions.
184
+ """
185
+
186
+ # Allow to auto specify a dict if one not provided
187
+ def __init__(self, root: dict[Pol, EnergyCoverage] | None = None):
188
+ super().__init__(root=root or {})
189
+
190
+ @classmethod
191
+ def generate(
192
+ cls: type[Self],
193
+ pols: list[Pol],
194
+ energy_coverage: list[EnergyCoverage],
195
+ ) -> Self:
196
+ """Generate a LookupTable containing multiple EnergyCoverage
197
+ for provided polarisations."""
198
+ lut = cls()
199
+ for i in range(len(pols)):
200
+ lut.root[pols[i]] = energy_coverage[i]
201
+ return lut
202
+
203
+ def get_poly(
204
+ self,
205
+ energy: float,
206
+ pol: Pol,
207
+ ) -> np.poly1d:
208
+ """
209
+ Return the numpy.poly1d polynomial applicable for the given energy and polarisation.
210
+
211
+ Parameters:
212
+ -----------
213
+ energy:
214
+ Energy value in the same units used to create the lookup table.
215
+ pol:
216
+ Polarisation mode (Pol enum).
217
+ """
218
+ return self.root[pol].get_poly(energy)
219
+
220
+
221
+ def convert_csv_to_lookup(
222
+ file_contents: str,
223
+ lut_config: LookupTableColumnConfig,
224
+ skip_line_start_with: str = "#",
225
+ ) -> LookupTable:
226
+ """
227
+ Convert CSV content into the Apple2 lookup-table dictionary.
228
+
229
+ Parameters:
230
+ -----------
231
+ file_contents:
232
+ The CSV file contents as string.
233
+ lut_config:
234
+ The configuration that how to process the file_contents into a LookupTable.
235
+ skip_line_start_with
236
+ Lines beginning with this prefix are skipped (default "#").
237
+
238
+ Returns:
239
+ -----------
240
+ LookupTable
241
+ """
242
+
243
+ def process_row(row: dict[str, Any], lut: LookupTable) -> None:
244
+ """Process a single row from the CSV file and update the lookup table."""
245
+ raw_mode_value = str(row[lut_config.mode]).lower()
246
+ mode_value = Pol(
247
+ lut_config.mode_name_convert.get(raw_mode_value, raw_mode_value)
248
+ )
249
+
250
+ # Create polynomial object for energy-to-gap/phase conversion
251
+ coefficients = np.poly1d([float(row[coef]) for coef in lut_config.poly_deg])
252
+
253
+ energy_entry = EnergyCoverageEntry(
254
+ min_energy=float(row[lut_config.min_energy]),
255
+ max_energy=float(row[lut_config.max_energy]),
256
+ poly=coefficients,
257
+ )
258
+ if mode_value not in lut.root:
259
+ lut.root[mode_value] = EnergyCoverage(energy_entries=[energy_entry])
260
+ else:
261
+ lut.root[mode_value].energy_entries.append(energy_entry)
262
+
263
+ reader = csv.DictReader(read_file_and_skip(file_contents, skip_line_start_with))
264
+ lut = LookupTable()
265
+
266
+ for row in reader:
267
+ source = lut_config.source
268
+ # If there are multiple source only convert requested.
269
+ if source is None or row[source.column] == source.value:
270
+ process_row(row=row, lut=lut)
271
+
272
+ # Check if our LookupTable is empty after processing, raise error if it is.
273
+ if not lut.root:
274
+ raise RuntimeError(
275
+ "LookupTable content is empty, failed to convert the file contents to "
276
+ "a LookupTable!"
277
+ )
278
+ return lut
279
+
280
+
281
+ def read_file_and_skip(file: str, skip_line_start_with: str = "#") -> Generator[str]:
282
+ """Yield non-comment lines from the CSV content string."""
283
+ for line in io.StringIO(file):
284
+ if line.startswith(skip_line_start_with):
285
+ continue
286
+ else:
287
+ yield line
dodal/devices/ipin.py CHANGED
@@ -1,5 +1,22 @@
1
- from ophyd_async.core import StandardReadable, StandardReadableFormat
2
- from ophyd_async.epics.core import epics_signal_r
1
+ from ophyd_async.core import StandardReadable, StandardReadableFormat, SubsetEnum
2
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
3
+
4
+
5
+ class IPinGain(SubsetEnum):
6
+ GAIN_10E3_LOW_NOISE = "10^3 low noise"
7
+ GAIN_10E4_LOW_NOISE = "10^4 low noise"
8
+ GAIN_10E5_LOW_NOISE = "10^5 low noise"
9
+ GAIN_10E6_LOW_NOISE = "10^6 low noise"
10
+ GAIN_10E7_LOW_NOISE = "10^7 low noise"
11
+ GAIN_10E8_LOW_NOISE = "10^8 low noise"
12
+ GAIN_10E9_LOW_NOISE = "10^9 low noise"
13
+ GAIN_10E5_HIGH_SPEED = "10^5 high speed"
14
+ GAIN_10E6_HIGH_SPEED = "10^6 high speed"
15
+ GAIN_10E7_HIGH_SPEED = "10^7 high speed"
16
+ GAIN_10E8_HIGH_SPEED = "10^8 high speed"
17
+ GAIN_10E9_HIGH_SPEED = "10^9 high speed"
18
+ GAIN_10E10_HIGH_SPEED = "10^10 high spd"
19
+ GAIN_10E11_HIGH_SPEED = "10^11 high spd"
3
20
 
4
21
 
5
22
  class IPin(StandardReadable):
@@ -10,4 +27,5 @@ class IPin(StandardReadable):
10
27
  format=StandardReadableFormat.HINTED_SIGNAL
11
28
  ):
12
29
  self.pin_readback = epics_signal_r(float, prefix + "I")
30
+ self.gain = epics_signal_rw(IPinGain, prefix + "GAIN")
13
31
  super().__init__(name)
dodal/devices/motors.py CHANGED
@@ -7,6 +7,8 @@ from ophyd_async.epics.motor import Motor
7
7
 
8
8
  _X, _Y, _Z = "X", "Y", "Z"
9
9
 
10
+ _OMEGA = "OMEGA"
11
+
10
12
 
11
13
  class Stage(StandardReadable, ABC):
12
14
  """
@@ -77,6 +79,21 @@ class XYZThetaStage(XYZStage):
77
79
  super().__init__(prefix, name, x_infix, y_infix, z_infix)
78
80
 
79
81
 
82
+ class XYZOmegaStage(XYZStage):
83
+ def __init__(
84
+ self,
85
+ prefix: str,
86
+ name: str = "",
87
+ x_infix: str = _X,
88
+ y_infix: str = _Y,
89
+ z_infix: str = _Z,
90
+ omega_infix: str = _OMEGA,
91
+ ) -> None:
92
+ with self.add_children_as_readables():
93
+ self.omega = Motor(prefix + omega_infix)
94
+ super().__init__(prefix, name, x_infix, y_infix, z_infix)
95
+
96
+
80
97
  class XYPhiStage(XYStage):
81
98
  def __init__(
82
99
  self,
@@ -105,6 +122,20 @@ class XYPitchStage(XYStage):
105
122
  super().__init__(prefix, name, x_infix, y_infix)
106
123
 
107
124
 
125
+ class XYRollStage(XYStage):
126
+ def __init__(
127
+ self,
128
+ prefix: str,
129
+ x_infix: str = _X,
130
+ y_infix: str = _Y,
131
+ roll_infix: str = "ROLL",
132
+ name: str = "",
133
+ ) -> None:
134
+ with self.add_children_as_readables():
135
+ self.roll = Motor(prefix + roll_infix)
136
+ super().__init__(prefix, name, x_infix, y_infix)
137
+
138
+
108
139
  class XYZPitchYawStage(XYZStage):
109
140
  def __init__(
110
141
  self,
@@ -141,7 +172,7 @@ class XYZPitchYawRollStage(XYZStage):
141
172
  super().__init__(prefix, name, x_infix, y_infix, z_infix)
142
173
 
143
174
 
144
- class SixAxisGonio(XYZStage):
175
+ class SixAxisGonio(XYZOmegaStage):
145
176
  def __init__(
146
177
  self,
147
178
  prefix: str,
@@ -151,7 +182,7 @@ class SixAxisGonio(XYZStage):
151
182
  z_infix: str = _Z,
152
183
  kappa_infix: str = "KAPPA",
153
184
  phi_infix: str = "PHI",
154
- omega_infix: str = "OMEGA",
185
+ omega_infix: str = _OMEGA,
155
186
  ):
156
187
  """Six-axis goniometer with a standard xyz stage and three axes of rotation:
157
188
  kappa, phi and omega.
@@ -159,7 +190,6 @@ class SixAxisGonio(XYZStage):
159
190
  with self.add_children_as_readables():
160
191
  self.kappa = Motor(prefix + kappa_infix)
161
192
  self.phi = Motor(prefix + phi_infix)
162
- self.omega = Motor(prefix + omega_infix)
163
193
  super().__init__(prefix, name, x_infix, y_infix, z_infix)
164
194
 
165
195
  self.vertical_in_lab_space = create_axis_perp_to_rotation(
@@ -10,6 +10,8 @@ from ophyd_async.epics.motor import Motor
10
10
 
11
11
  from dodal.common.beamlines.beamline_parameters import GDABeamlineParameters
12
12
 
13
+ _BEAMSTOP_OUT_DELTA_Y_MM = -2
14
+
13
15
 
14
16
  class BeamstopPositions(StrictEnum):
15
17
  """
@@ -28,6 +30,7 @@ class BeamstopPositions(StrictEnum):
28
30
  """
29
31
 
30
32
  DATA_COLLECTION = "Data Collection"
33
+ OUT_OF_BEAM = "Out"
31
34
  UNKNOWN = "Unknown"
32
35
 
33
36
 
@@ -63,6 +66,10 @@ class Beamstop(StandardReadable):
63
66
  float(beamline_parameters[f"in_beam_{axis}_STANDARD"])
64
67
  for axis in ("x", "y", "z")
65
68
  ]
69
+
70
+ self._out_of_beam_xyz_mm = list(self._in_beam_xyz_mm)
71
+ self._out_of_beam_xyz_mm[1] += _BEAMSTOP_OUT_DELTA_Y_MM
72
+
66
73
  self._xyz_tolerance_mm = [
67
74
  float(beamline_parameters[f"bs_{axis}_tolerance"])
68
75
  for axis in ("x", "y", "z")
@@ -72,24 +79,36 @@ class Beamstop(StandardReadable):
72
79
 
73
80
  def _get_selected_position(self, x: float, y: float, z: float) -> BeamstopPositions:
74
81
  current_pos = [x, y, z]
75
- if all(
76
- isclose(axis_pos, axis_in_beam, abs_tol=axis_tolerance)
77
- for axis_pos, axis_in_beam, axis_tolerance in zip(
78
- current_pos, self._in_beam_xyz_mm, self._xyz_tolerance_mm, strict=False
79
- )
80
- ):
82
+ if self._is_near_position(current_pos, self._in_beam_xyz_mm):
81
83
  return BeamstopPositions.DATA_COLLECTION
84
+ elif self._is_near_position(current_pos, self._out_of_beam_xyz_mm):
85
+ return BeamstopPositions.OUT_OF_BEAM
82
86
  else:
83
87
  return BeamstopPositions.UNKNOWN
84
88
 
89
+ def _is_near_position(
90
+ self, current_pos: list[float], target_pos: list[float]
91
+ ) -> bool:
92
+ return all(
93
+ isclose(axis_pos, axis_in_beam, abs_tol=axis_tolerance)
94
+ for axis_pos, axis_in_beam, axis_tolerance in zip(
95
+ current_pos, target_pos, self._xyz_tolerance_mm, strict=False
96
+ )
97
+ )
98
+
85
99
  async def _set_selected_position(self, position: BeamstopPositions) -> None:
86
100
  match position:
87
101
  case BeamstopPositions.DATA_COLLECTION:
88
- # Move z first as it could be under the table
89
- await self.z_mm.set(self._in_beam_xyz_mm[2])
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
- )
102
+ await self._safe_move_above_table(self._in_beam_xyz_mm)
103
+ case BeamstopPositions.OUT_OF_BEAM:
104
+ await self._safe_move_above_table(self._out_of_beam_xyz_mm)
94
105
  case _:
95
106
  raise ValueError(f"Cannot set beamstop to position {position}")
107
+
108
+ async def _safe_move_above_table(self, pos: list[float]):
109
+ # Move z first as it could be under the table
110
+ await self.z_mm.set(pos[2])
111
+ await asyncio.gather(
112
+ self.x_mm.set(pos[0]),
113
+ self.y_mm.set(pos[1]),
114
+ )
@@ -7,6 +7,9 @@ def camera_coordinates_to_xyz_mm(
7
7
  omega: float,
8
8
  microns_per_i_pixel: float,
9
9
  microns_per_j_pixel: float,
10
+ x_horizontal_sign: int,
11
+ y_vertical_sign: int,
12
+ z_vertical_sign: int,
10
13
  ) -> np.ndarray:
11
14
  """
12
15
  Converts from (horizontal,vertical) pixel measurements from the OAV camera into to (x, y, z) motor coordinates in millimetres.
@@ -18,13 +21,16 @@ def camera_coordinates_to_xyz_mm(
18
21
  omega (float): The omega angle of the smargon that the horizontal, vertical measurements were obtained at.
19
22
  microns_per_i_pixel (float): The number of microns per i pixel, adjusted for the zoom level horizontal was measured at.
20
23
  microns_per_j_pixel (float): The number of microns per j pixel, adjusted for the zoom level vertical was measured at.
24
+ x_horizontal_sign (int): Direction mapping for x, positive means the oav and motor are on same direction, default from hyperion
25
+ y_vertical_sign (int): Direction mapping for y
26
+ z_vertical_sign (int): Direction mapping for z
21
27
  """
22
28
  # Convert the vertical and horizontal into mm.
23
29
  horizontal *= microns_per_i_pixel * 1e-3
24
30
  vertical *= microns_per_j_pixel * 1e-3
25
31
 
26
32
  # +ve x in the OAV camera becomes -ve x in the smargon motors.
27
- x = -horizontal
33
+ x = x_horizontal_sign * horizontal
28
34
 
29
35
  # Rotating the camera causes the position on the vertical horizontal to change by raising or lowering the centre.
30
36
  # We can negate this change by multiplying sin and cosine of the omega.
@@ -33,9 +39,8 @@ def camera_coordinates_to_xyz_mm(
33
39
  sine = np.sin(radians)
34
40
 
35
41
  # +ve y in the OAV camera becomes -ve y in the smargon motors/
36
- y = -vertical * cosine
37
-
38
- z = vertical * sine
42
+ y = y_vertical_sign * vertical * cosine
43
+ z = z_vertical_sign * vertical * sine
39
44
  return np.array([x, y, z], dtype=np.float64)
40
45
 
41
46
 
@@ -46,9 +46,14 @@ class NullZoomController(BaseZoomController):
46
46
  def __init__(self):
47
47
  self.level = soft_signal_rw(str, "1.0x")
48
48
  self.percentage = soft_signal_rw(float, 100)
49
+ super().__init__()
49
50
 
50
- def set(self, value):
51
- raise Exception("Attempting to set zoom level of a null zoom controller")
51
+ @AsyncStatus.wrap
52
+ async def set(self, value: str) -> None:
53
+ if value != "1.0x":
54
+ raise Exception("Attempting to set zoom level of a null zoom controller")
55
+ else:
56
+ await self.level.set(value, wait=True)
52
57
 
53
58
 
54
59
  class ZoomController(BaseZoomController):
@@ -74,6 +79,16 @@ class ZoomController(BaseZoomController):
74
79
 
75
80
 
76
81
  class OAV(StandardReadable):
82
+ """
83
+ Class for oav device
84
+
85
+ x_direction(int): Should only be 1 or -1, with 1 indicating the oav x direction is the same with motor x
86
+ y_direction(int): Same with x_direction but for motor y
87
+ z_direction(int): Same with x_direction but for motor z
88
+ mjpg_x_size_pv(str): PV infix for x_size in mjpg
89
+ mjpg_y_size_pv(str): PV infix for y_size in mjpg
90
+ """
91
+
77
92
  beam_centre_i: SignalR[int]
78
93
  beam_centre_j: SignalR[int]
79
94
 
@@ -82,7 +97,13 @@ class OAV(StandardReadable):
82
97
  prefix: str,
83
98
  config: OAVConfigBase,
84
99
  name: str = "",
100
+ mjpeg_prefix: str = "MJPG",
85
101
  zoom_controller: BaseZoomController | None = None,
102
+ x_direction: int = -1,
103
+ y_direction: int = -1,
104
+ z_direction: int = 1,
105
+ mjpg_x_size_pv: str = "ArraySize1_RBV",
106
+ mjpg_y_size_pv: str = "ArraySize2_RBV",
86
107
  ):
87
108
  self.oav_config = config
88
109
  self._prefix = prefix
@@ -98,9 +119,15 @@ class OAV(StandardReadable):
98
119
 
99
120
  self.cam = Cam(f"{prefix}CAM:", name=name)
100
121
  with self.add_children_as_readables():
101
- self.grid_snapshot = SnapshotWithGrid(f"{prefix}MJPG:", name)
122
+ self.grid_snapshot = SnapshotWithGrid(
123
+ f"{prefix}{mjpeg_prefix}:", name, mjpg_x_size_pv, mjpg_y_size_pv
124
+ )
102
125
 
103
126
  self.sizes = [self.grid_snapshot.x_size, self.grid_snapshot.y_size]
127
+ with self.add_children_as_readables():
128
+ self.x_direction = soft_signal_rw(int, x_direction, name="x_direction")
129
+ self.y_direction = soft_signal_rw(int, y_direction, name="y_direction")
130
+ self.z_direction = soft_signal_rw(int, z_direction, name="z_direction")
104
131
 
105
132
  with self.add_children_as_readables():
106
133
  self.microns_per_pixel_x = derived_signal_r(
@@ -116,7 +143,7 @@ class OAV(StandardReadable):
116
143
  coord=soft_signal_rw(datatype=int, initial_value=Coords.Y.value),
117
144
  )
118
145
  self.snapshot = Snapshot(
119
- f"{self._prefix}MJPG:",
146
+ f"{self._prefix}{mjpeg_prefix}:",
120
147
  self._name,
121
148
  )
122
149
 
@@ -143,9 +170,16 @@ class OAV(StandardReadable):
143
170
 
144
171
 
145
172
  class OAVBeamCentreFile(OAV):
146
- """OAV device that reads its beam centre values from a file. The config parameter
173
+ """
174
+ OAV device that reads its beam centre values from a file. The config parameter
147
175
  must be a OAVConfigBeamCentre object, as this contains a filepath to where the beam
148
176
  centre values are stored.
177
+
178
+ x_direction(int): Should only be 1 or -1, with 1 indicating the oav x direction is the same with motor x
179
+ y_direction(int): Same with x_direction but for motor y
180
+ z_direction(int): Same with x_direction but for motor z
181
+ mjpg_x_size_pv(str): PV infix for x_size in mjpg
182
+ mjpg_y_size_pv(str): PV infix for y_size in mjpg
149
183
  """
150
184
 
151
185
  def __init__(
@@ -153,9 +187,26 @@ class OAVBeamCentreFile(OAV):
153
187
  prefix: str,
154
188
  config: OAVConfigBeamCentre,
155
189
  name: str = "",
190
+ mjpeg_prefix: str = "MJPG",
156
191
  zoom_controller: BaseZoomController | None = None,
192
+ mjpg_x_size_pv: str = "ArraySize1_RBV",
193
+ mjpg_y_size_pv: str = "ArraySize2_RBV",
194
+ x_direction: int = -1,
195
+ y_direction: int = -1,
196
+ z_direction: int = 1,
157
197
  ):
158
- super().__init__(prefix, config, name, zoom_controller)
198
+ super().__init__(
199
+ prefix=prefix,
200
+ config=config,
201
+ name=name,
202
+ mjpeg_prefix=mjpeg_prefix,
203
+ zoom_controller=zoom_controller,
204
+ mjpg_x_size_pv=mjpg_x_size_pv,
205
+ mjpg_y_size_pv=mjpg_y_size_pv,
206
+ x_direction=x_direction,
207
+ y_direction=y_direction,
208
+ z_direction=z_direction,
209
+ )
159
210
 
160
211
  with self.add_children_as_readables():
161
212
  self.beam_centre_i = derived_signal_r(
@@ -189,6 +240,7 @@ class OAVBeamCentrePV(OAV):
189
240
  prefix: str,
190
241
  config: OAVConfig,
191
242
  name: str = "",
243
+ mjpeg_prefix: str = "MJPG",
192
244
  zoom_controller: BaseZoomController | None = None,
193
245
  overlay_channel: int = 1,
194
246
  ):
@@ -199,4 +251,10 @@ class OAVBeamCentrePV(OAV):
199
251
  self.beam_centre_j = epics_signal_r(
200
252
  int, prefix + f"OVER:{overlay_channel}:CenterY"
201
253
  )
202
- super().__init__(prefix, config, name, zoom_controller)
254
+ super().__init__(
255
+ prefix=prefix,
256
+ config=config,
257
+ name=name,
258
+ mjpeg_prefix=mjpeg_prefix,
259
+ zoom_controller=zoom_controller,
260
+ )
@@ -92,8 +92,10 @@ class OAVParameters:
92
92
  self.preprocess_K_size: int = update(
93
93
  "preProcessKSize", int
94
94
  ) # length scale for blur preprocessing
95
+ self.preprocess_iter: int = update("preProcessIteration", int, default=5)
95
96
  self.detection_script_filename: str = update("filename", str)
96
- self.close_ksize: int = update("close_ksize", int, default=11)
97
+ self.open_ksize: int = update("open_ksize", int, default=0)
98
+ self.close_ksize: int = update("close_ksize", int, default=5)
97
99
  self.min_callback_time: float = update("min_callback_time", float, default=0.08)
98
100
  self.direction: int = update("direction", int)
99
101
  self.max_tip_distance: float = update("max_tip_distance", float, default=300)