dls-dodal 1.58.0__py3-none-any.whl → 1.60.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 (71) hide show
  1. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/METADATA +3 -3
  2. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/RECORD +71 -47
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/__init__.py +1 -0
  5. dodal/beamlines/b07.py +10 -5
  6. dodal/beamlines/b07_1.py +10 -5
  7. dodal/beamlines/b21.py +22 -0
  8. dodal/beamlines/i02_1.py +80 -0
  9. dodal/beamlines/i03.py +5 -3
  10. dodal/beamlines/i04.py +5 -3
  11. dodal/beamlines/i09.py +10 -9
  12. dodal/beamlines/i09_1.py +10 -5
  13. dodal/beamlines/i10-1.py +25 -0
  14. dodal/beamlines/i10.py +17 -1
  15. dodal/beamlines/i11.py +0 -17
  16. dodal/beamlines/i15.py +242 -0
  17. dodal/beamlines/i15_1.py +156 -0
  18. dodal/beamlines/i19_1.py +3 -1
  19. dodal/beamlines/i19_2.py +12 -1
  20. dodal/beamlines/i21.py +27 -0
  21. dodal/beamlines/i22.py +12 -2
  22. dodal/beamlines/i24.py +32 -3
  23. dodal/beamlines/k07.py +31 -0
  24. dodal/beamlines/p60.py +10 -9
  25. dodal/common/watcher_utils.py +1 -1
  26. dodal/devices/apple2_undulator.py +18 -142
  27. dodal/devices/attenuator/attenuator.py +48 -2
  28. dodal/devices/attenuator/filter.py +3 -0
  29. dodal/devices/attenuator/filter_selections.py +26 -0
  30. dodal/devices/eiger.py +2 -1
  31. dodal/devices/electron_analyser/__init__.py +4 -0
  32. dodal/devices/electron_analyser/abstract/base_driver_io.py +30 -18
  33. dodal/devices/electron_analyser/energy_sources.py +101 -0
  34. dodal/devices/electron_analyser/specs/detector.py +6 -6
  35. dodal/devices/electron_analyser/specs/driver_io.py +7 -15
  36. dodal/devices/electron_analyser/vgscienta/detector.py +6 -6
  37. dodal/devices/electron_analyser/vgscienta/driver_io.py +7 -14
  38. dodal/devices/fast_grid_scan.py +130 -64
  39. dodal/devices/focusing_mirror.py +30 -0
  40. dodal/devices/i02_1/__init__.py +0 -0
  41. dodal/devices/i02_1/fast_grid_scan.py +61 -0
  42. dodal/devices/i02_1/sample_motors.py +19 -0
  43. dodal/devices/i04/murko_results.py +69 -23
  44. dodal/devices/i10/i10_apple2.py +282 -140
  45. dodal/devices/i15/dcm.py +77 -0
  46. dodal/devices/i15/focussing_mirror.py +71 -0
  47. dodal/devices/i15/jack.py +39 -0
  48. dodal/devices/i15/laue.py +18 -0
  49. dodal/devices/i15/motors.py +27 -0
  50. dodal/devices/i15/multilayer_mirror.py +25 -0
  51. dodal/devices/i15/rail.py +17 -0
  52. dodal/devices/i21/__init__.py +3 -0
  53. dodal/devices/i21/enums.py +8 -0
  54. dodal/devices/i22/nxsas.py +2 -0
  55. dodal/devices/i24/commissioning_jungfrau.py +114 -0
  56. dodal/devices/motors.py +52 -1
  57. dodal/devices/slits.py +18 -0
  58. dodal/devices/smargon.py +0 -56
  59. dodal/devices/temperture_controller/__init__.py +3 -0
  60. dodal/devices/temperture_controller/lakeshore/__init__.py +0 -0
  61. dodal/devices/temperture_controller/lakeshore/lakeshore.py +204 -0
  62. dodal/devices/temperture_controller/lakeshore/lakeshore_io.py +112 -0
  63. dodal/devices/tetramm.py +38 -16
  64. dodal/devices/v2f.py +39 -0
  65. dodal/devices/zebra/zebra.py +1 -0
  66. dodal/devices/zebra/zebra_constants_mapping.py +1 -1
  67. dodal/parameters/experiment_parameter_base.py +1 -5
  68. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/WHEEL +0 -0
  69. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/entry_points.txt +0 -0
  70. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/licenses/LICENSE +0 -0
  71. {dls_dodal-1.58.0.dist-info → dls_dodal-1.60.0.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,13 @@
1
1
  import asyncio
2
2
  import csv
3
+ import io
3
4
  from dataclasses import dataclass
4
5
  from pathlib import Path
5
6
  from typing import Any, SupportsFloat
6
7
 
7
8
  import numpy as np
8
9
  from bluesky.protocols import Movable
10
+ from daq_config_server.client import ConfigServer
9
11
  from ophyd_async.core import (
10
12
  AsyncStatus,
11
13
  Device,
@@ -15,13 +17,14 @@ from ophyd_async.core import (
15
17
  soft_signal_r_and_setter,
16
18
  soft_signal_rw,
17
19
  )
20
+ from pydantic import BaseModel, ConfigDict, RootModel
18
21
 
19
22
  from dodal.log import LOGGER
20
23
 
21
24
  from ..apple2_undulator import (
22
25
  Apple2,
23
26
  Apple2Val,
24
- Lookuptable,
27
+ EnergyMotorConvertor,
25
28
  Pol,
26
29
  UndulatorGap,
27
30
  UndulatorJawPhase,
@@ -34,6 +37,7 @@ MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
34
37
  MAXIMUM_GAP_MOTOR_POSITION = 100
35
38
  DEFAULT_JAW_PHASE_POLY_PARAMS = [1.0 / 7.5, -120.0 / 7.5]
36
39
  ALPHA_OFFSET = 180
40
+ MAXIMUM_MOVE_TIME = 550 # There is no useful movements take longer than this.
37
41
 
38
42
 
39
43
  # data class to store the lookup table configuration that is use in convert_csv_to_lookup
@@ -53,36 +57,75 @@ class LookupTableConfig:
53
57
  poly_deg: list | None
54
58
 
55
59
 
56
- class I10Apple2(Apple2):
57
- """I10Apple2 is the i10 version of Apple2 ID, set and update_lookuptable function
58
- should be the only part that is I10 specific.
60
+ class EnergyMinMax(BaseModel):
61
+ Minimum: float
62
+ Maximum: float
59
63
 
60
- A pair of look up tables are needed to provide the conversion betwApple 2 ID/undulator has 4 extra degrees of freedom compare to the standard Undulator,
61
- each bank of magnet can move independently to each other,
62
- which allow the production of different x-ray polarisation as well as energy.
63
- This type of ID is use on I10, I21, I09, I17 and I06 for soft x-ray.een motor position and energy.
64
64
 
65
- Set is in energy(eV).
65
+ class EnergyCoverageEntry(BaseModel):
66
+ model_config = ConfigDict(arbitrary_types_allowed=True)
67
+ Low: float
68
+ High: float
69
+ Poly: np.poly1d
70
+
71
+
72
+ class EnergyCoverage(RootModel):
73
+ root: dict[str, EnergyCoverageEntry]
74
+
75
+
76
+ class LookupTableEntries(BaseModel):
77
+ Energies: EnergyCoverage
78
+ Limit: EnergyMinMax
79
+
80
+
81
+ class Lookuptable(RootModel):
82
+ """BaseModel class for the lookup table.
83
+ Apple2 lookup table should be in this format.
84
+
85
+ {mode: {'Energies': {Any: {'Low': float,
86
+ 'High': float,
87
+ 'Poly':np.poly1d
88
+ }
89
+ }
90
+ 'Limit': {'Minimum': float,
91
+ 'Maximum': float
92
+ }
93
+ }
94
+ }
95
+ """
96
+
97
+ root: dict[str, LookupTableEntries]
98
+
99
+
100
+ class I10EnergyMotorLookup:
101
+ """
102
+ Handles lookup tables for I10 Apple2 ID, converting energy and polarisation to gap
103
+ and phase. Fetches and parses lookup tables from a config server, supports dynamic
104
+ updates, and validates input.
66
105
  """
67
106
 
68
107
  def __init__(
69
108
  self,
70
109
  look_up_table_dir: str,
71
110
  source: tuple[str, str],
72
- prefix: str,
111
+ config_client: ConfigServer,
73
112
  mode: str = "Mode",
74
113
  min_energy: str = "MinEnergy",
75
114
  max_energy: str = "MaxEnergy",
115
+ gap_file_name: str = "IDEnergy2GapCalibrations.csv",
116
+ phase_file_name: str = "IDEnergy2PhaseCalibrations.csv",
76
117
  poly_deg: list | None = None,
77
- name: str = "",
78
- ) -> None:
79
- """
118
+ ):
119
+ """Initialise the I10EnergyMotorLookup class with lookup table headers provided.
120
+
80
121
  Parameters
81
122
  ----------
82
123
  look_up_table_dir:
83
124
  The path to look up table.
84
125
  source:
85
- The column name and the name of the source in look up table. e.g. ("source", "idu")
126
+ The column name and the name of the source in look up table. e.g. ( "source", "idu")
127
+ config_client:
128
+ The config server client to fetch the look up table.
86
129
  mode:
87
130
  The column name of the mode in look up table.
88
131
  min_energy:
@@ -91,17 +134,14 @@ class I10Apple2(Apple2):
91
134
  The column name that contain the maximum energy in look up table.
92
135
  poly_deg:
93
136
  The column names for the parameters for the energy conversion polynomial, starting with the least significant.
94
- prefix:
95
- epic pv for id
96
- Name:
97
- Name of the device
98
- """
99
137
 
100
- energy_gap_table_path = Path(look_up_table_dir, "IDEnergy2GapCalibrations.csv")
101
- energy_phase_table_path = Path(
102
- look_up_table_dir, "IDEnergy2PhaseCalibrations.csv"
103
- )
104
- # A dataclass contains the path to the look up table and the expected column names.
138
+ """
139
+ self.lookup_tables: dict[str, dict[str | None, dict[str, dict[str, Any]]]] = {
140
+ "Gap": {},
141
+ "Phase": {},
142
+ }
143
+ energy_gap_table_path = Path(look_up_table_dir, gap_file_name)
144
+ energy_phase_table_path = Path(look_up_table_dir, phase_file_name)
105
145
  self.lookup_table_config = LookupTableConfig(
106
146
  path=LookupPath(Gap=energy_gap_table_path, Phase=energy_phase_table_path),
107
147
  source=source,
@@ -110,6 +150,212 @@ class I10Apple2(Apple2):
110
150
  max_energy=max_energy,
111
151
  poly_deg=poly_deg,
112
152
  )
153
+ self.config_client = config_client
154
+ self._available_pol = []
155
+
156
+ @property
157
+ def available_pol(self) -> list[str | None]:
158
+ return self._available_pol
159
+
160
+ @available_pol.setter
161
+ def available_pol(self, value: list[str | None]) -> None:
162
+ self._available_pol = value
163
+
164
+ def update_lookuptable(self):
165
+ """
166
+ Update lookup tables from files and validate their format.
167
+ """
168
+ LOGGER.info("Updating lookup dictionary from file.")
169
+ for key, path in self.lookup_table_config.path.__dict__.items():
170
+ self.lookup_tables[key] = self.convert_csv_to_lookup(
171
+ file=path,
172
+ source=self.lookup_table_config.source,
173
+ mode=self.lookup_table_config.mode,
174
+ min_energy=self.lookup_table_config.min_energy,
175
+ max_energy=self.lookup_table_config.max_energy,
176
+ poly_deg=self.lookup_table_config.poly_deg,
177
+ )
178
+ Lookuptable.model_validate(self.lookup_tables[key])
179
+
180
+ self.available_pol = list(self.lookup_tables["Gap"].keys())
181
+
182
+ def get_motor_from_energy(self, energy: float, pol: Pol) -> tuple[float, float]:
183
+ """
184
+ Convert energy and polarisation to gap and phase motor positions.
185
+
186
+ Parameters
187
+ ----------
188
+ energy : float
189
+ Desired energy in eV.
190
+ pol : Pol
191
+ Polarisation mode.
192
+
193
+ Returns
194
+ -------
195
+ tuple[float, float]
196
+ (gap, phase) motor positions.
197
+
198
+ """
199
+ if self.available_pol == []:
200
+ self.update_lookuptable()
201
+
202
+ gap_poly = self._get_poly(
203
+ lookup_table=self.lookup_tables["Gap"], energy=energy, pol=pol
204
+ )
205
+ phase_poly = self._get_poly(
206
+ lookup_table=self.lookup_tables["Phase"], energy=energy, pol=pol
207
+ )
208
+ return gap_poly(energy), phase_poly(energy)
209
+
210
+ def _get_poly(
211
+ self,
212
+ energy: float,
213
+ pol: Pol,
214
+ lookup_table: dict[str | None, dict[str, dict[str, Any]]],
215
+ ) -> np.poly1d:
216
+ """
217
+ Get polynomial for a given energy and polarisation.
218
+
219
+ Raises
220
+ ------
221
+ ValueError
222
+ If energy is out of bounds or coefficients are missing.
223
+ """
224
+ if (
225
+ energy < lookup_table[pol]["Limit"]["Minimum"]
226
+ or energy > lookup_table[pol]["Limit"]["Maximum"]
227
+ ):
228
+ raise ValueError(
229
+ "Demanding energy must lie between {} and {} eV!".format(
230
+ lookup_table[pol]["Limit"]["Minimum"],
231
+ lookup_table[pol]["Limit"]["Maximum"],
232
+ )
233
+ )
234
+ else:
235
+ for energy_range in lookup_table[pol]["Energies"].values():
236
+ if energy >= energy_range["Low"] and energy < energy_range["High"]:
237
+ return energy_range["Poly"]
238
+
239
+ raise ValueError(
240
+ """Cannot find polynomial coefficients for your requested energy.
241
+ There might be gap in the calibration lookup table."""
242
+ )
243
+
244
+ def convert_csv_to_lookup(
245
+ self,
246
+ file: str,
247
+ source: tuple[str, str],
248
+ mode: str | None = "Mode",
249
+ min_energy: str | None = "MinEnergy",
250
+ max_energy: str | None = "MaxEnergy",
251
+ poly_deg: list | None = None,
252
+ ) -> dict[str | None, dict[str, dict[str, dict[str, Any]]]]:
253
+ """
254
+ Convert a CSV file to a lookup table dictionary.
255
+
256
+ Returns
257
+ -------
258
+ dict
259
+ Dictionary in Apple2 lookup table format.
260
+
261
+ Raises
262
+ ------
263
+ RuntimeError
264
+ If the CSV cannot be converted.
265
+
266
+ """
267
+ if poly_deg is None:
268
+ poly_deg = [
269
+ "7th-order",
270
+ "6th-order",
271
+ "5th-order",
272
+ "4th-order",
273
+ "3rd-order",
274
+ "2nd-order",
275
+ "1st-order",
276
+ "b",
277
+ ]
278
+ lookup_table = {}
279
+ polarisations = set()
280
+
281
+ def process_row(row: dict) -> None:
282
+ """Process a single row from the CSV file and update the lookup table."""
283
+ mode_value = row[mode]
284
+ if mode_value not in polarisations:
285
+ polarisations.add(mode_value)
286
+ lookup_table[mode_value] = {
287
+ "Energies": {},
288
+ "Limit": {
289
+ "Minimum": float(row[min_energy]),
290
+ "Maximum": float(row[max_energy]),
291
+ },
292
+ }
293
+
294
+ # Create polynomial object for energy-to-gap/phase conversion
295
+ coefficients = [float(row[coef]) for coef in poly_deg]
296
+ polynomial = np.poly1d(coefficients)
297
+
298
+ lookup_table[mode_value]["Energies"][row[min_energy]] = {
299
+ "Low": float(row[min_energy]),
300
+ "High": float(row[max_energy]),
301
+ "Poly": polynomial,
302
+ }
303
+
304
+ # Update energy limits
305
+ lookup_table[mode_value]["Limit"]["Minimum"] = min(
306
+ lookup_table[mode_value]["Limit"]["Minimum"], float(row[min_energy])
307
+ )
308
+ lookup_table[mode_value]["Limit"]["Maximum"] = max(
309
+ lookup_table[mode_value]["Limit"]["Maximum"], float(row[max_energy])
310
+ )
311
+
312
+ csv_file = self.config_client.get_file_contents(file, reset_cached_result=True)
313
+ reader = csv.DictReader(io.StringIO(csv_file))
314
+ for row in reader:
315
+ # If there are multiple source only convert requested.
316
+ if row[source[0]] == source[1]:
317
+ process_row(row=row)
318
+ if not lookup_table:
319
+ raise RuntimeError(f"Unable to convert lookup table:\t{file}")
320
+ return lookup_table
321
+
322
+
323
+ class I10Apple2(Apple2):
324
+ """I10Apple2 is the i10 version of Apple2 ID, set and energy_motor_convertor
325
+ should be the only part that is I10 specific.
326
+
327
+ A EnergyMotorConvertor function is needed to provide the conversion between
328
+ x-ray motor position and energy.
329
+
330
+ Set is in energy(eV).
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ prefix: str,
336
+ energy_motor_convertor: EnergyMotorConvertor,
337
+ name: str = "",
338
+ ) -> None:
339
+ """
340
+ Parameters
341
+ ----------
342
+ look_up_table_dir:
343
+ The path to look up table.
344
+ source:
345
+ The column name and the name of the source in look up table. e.g. ("source", "idu")
346
+ mode:
347
+ The column name of the mode in look up table.
348
+ min_energy:
349
+ The column name that contain the maximum energy in look up table.
350
+ max_energy:
351
+ The column name that contain the maximum energy in look up table.
352
+ poly_deg:
353
+ The column names for the parameters for the energy conversion polynomial, starting with the least significant.
354
+ prefix:
355
+ epic pv for id
356
+ Name:
357
+ Name of the device
358
+ """
113
359
 
114
360
  with self.add_children_as_readables():
115
361
  super().__init__(
@@ -122,6 +368,7 @@ class I10Apple2(Apple2):
122
368
  btm_inner="RPQ3",
123
369
  btm_outer="RPQ4",
124
370
  ),
371
+ energy_motor_convertor=energy_motor_convertor,
125
372
  name=name,
126
373
  )
127
374
  self.id_jaw_phase = UndulatorJawPhase(
@@ -150,7 +397,7 @@ class I10Apple2(Apple2):
150
397
  )
151
398
 
152
399
  self._set_pol_setpoint(pol)
153
- gap, phase = await self._get_id_gap_phase(value)
400
+ gap, phase = self.energy_to_motor(energy=value, pol=pol)
154
401
  phase3 = phase * (-1 if pol == Pol.LA else 1)
155
402
  id_set_val = Apple2Val(
156
403
  top_outer=f"{phase:.6f}",
@@ -167,29 +414,6 @@ class I10Apple2(Apple2):
167
414
  await self.id_jaw_phase.set_move.set(1)
168
415
  LOGGER.info(f"Energy set to {value} eV successfully.")
169
416
 
170
- def update_lookuptable(self):
171
- """
172
- Update the stored lookup tabled from file.
173
-
174
- """
175
- LOGGER.info("Updating lookup dictionary from file.")
176
- for key, path in self.lookup_table_config.path.__dict__.items():
177
- if path.exists():
178
- self.lookup_tables[key] = convert_csv_to_lookup(
179
- file=path,
180
- source=self.lookup_table_config.source,
181
- mode=self.lookup_table_config.mode,
182
- min_energy=self.lookup_table_config.min_energy,
183
- max_energy=self.lookup_table_config.max_energy,
184
- poly_deg=self.lookup_table_config.poly_deg,
185
- )
186
- # ensure the importing lookup table is the correct format
187
- Lookuptable.model_validate(self.lookup_tables[key])
188
- else:
189
- raise FileNotFoundError(f"{key} look up table is not in path: {path}")
190
-
191
- self._available_pol = list(self.lookup_tables["Gap"].keys())
192
-
193
417
 
194
418
  class EnergySetter(StandardReadable, Movable[float]):
195
419
  """
@@ -250,7 +474,8 @@ class I10Apple2Pol(StandardReadable, Movable[Pol]):
250
474
  @AsyncStatus.wrap
251
475
  async def set(self, value: Pol) -> None:
252
476
  LOGGER.info(f"Changing f{self.name} polarisation to {value}.")
253
- await self.id_ref().polarisation.set(value)
477
+ # Timeout is determined internally by the set method later, so we set it to max here.
478
+ await self.id_ref().polarisation.set(value, timeout=MAXIMUM_MOVE_TIME)
254
479
 
255
480
 
256
481
  class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]):
@@ -320,6 +545,7 @@ class I10Id(Device):
320
545
  prefix: str,
321
546
  look_up_table_dir: str,
322
547
  source: tuple[str, str],
548
+ config_client: ConfigServer,
323
549
  jaw_phase_limit=12.0,
324
550
  jaw_phase_poly_param=DEFAULT_JAW_PHASE_POLY_PARAMS,
325
551
  angle_threshold_deg=30.0,
@@ -341,12 +567,16 @@ class I10Id(Device):
341
567
  linear_arbitrary_angle : LinearArbitraryAngle
342
568
  A device for controlling the linear arbitrary polarization angle.
343
569
  """
570
+ self.lookup_table_client = I10EnergyMotorLookup(
571
+ look_up_table_dir=look_up_table_dir,
572
+ source=source,
573
+ config_client=config_client,
574
+ )
344
575
  self.energy = EnergySetter(
345
576
  id=I10Apple2(
346
- look_up_table_dir=look_up_table_dir,
347
- name="id_energy",
348
- source=source,
349
577
  prefix=prefix,
578
+ energy_motor_convertor=self.lookup_table_client.get_motor_from_energy,
579
+ name="id_energy",
350
580
  ),
351
581
  pgm=pgm,
352
582
  name="energy",
@@ -361,91 +591,3 @@ class I10Id(Device):
361
591
  )
362
592
 
363
593
  super().__init__(name=name)
364
-
365
-
366
- def convert_csv_to_lookup(
367
- file: str,
368
- source: tuple[str, str],
369
- mode: str | None = "Mode",
370
- min_energy: str | None = "MinEnergy",
371
- max_energy: str | None = "MaxEnergy",
372
- poly_deg: list | None = None,
373
- ) -> dict[str | None, dict[str, dict[str, dict[str, Any]]]]:
374
- """
375
- Convert a CSV file to a dictionary compatible with the Apple2 lookup table format.
376
-
377
- Parameters
378
- ----------
379
- file : str
380
- Path to the CSV file.
381
- source : tuple[str, str]
382
- Tuple specifying the column name and source name (e.g., ("Source", "idu")).
383
- mode : str, optional
384
- Column name for the available modes (e.g., "lv", "lh", "pc", "nc"), by default "Mode".
385
- min_energy : str, optional
386
- Column name for the minimum energy, by default "MinEnergy".
387
- max_energy : str, optional
388
- Column name for the maximum energy, by default "MaxEnergy".
389
- poly_deg : list, optional
390
- Column names for polynomial coefficients, starting with the least significant term.
391
-
392
- Returns
393
- -------
394
- dict
395
- A dictionary conforming to the Apple2 lookup table format.
396
-
397
- """
398
- if poly_deg is None:
399
- poly_deg = [
400
- "7th-order",
401
- "6th-order",
402
- "5th-order",
403
- "4th-order",
404
- "3rd-order",
405
- "2nd-order",
406
- "1st-order",
407
- "b",
408
- ]
409
- lookup_table = {}
410
- polarisations = set()
411
-
412
- def process_row(row: dict) -> None:
413
- """Process a single row from the CSV file and update the lookup table."""
414
- mode_value = row[mode]
415
- if mode_value not in polarisations:
416
- polarisations.add(mode_value)
417
- lookup_table[mode_value] = {
418
- "Energies": {},
419
- "Limit": {
420
- "Minimum": float(row[min_energy]),
421
- "Maximum": float(row[max_energy]),
422
- },
423
- }
424
-
425
- # Create polynomial object for energy-to-gap/phase conversion
426
- coefficients = [float(row[coef]) for coef in poly_deg]
427
- polynomial = np.poly1d(coefficients)
428
-
429
- lookup_table[mode_value]["Energies"][row[min_energy]] = {
430
- "Low": float(row[min_energy]),
431
- "High": float(row[max_energy]),
432
- "Poly": polynomial,
433
- }
434
-
435
- # Update energy limits
436
- lookup_table[mode_value]["Limit"]["Minimum"] = min(
437
- lookup_table[mode_value]["Limit"]["Minimum"], float(row[min_energy])
438
- )
439
- lookup_table[mode_value]["Limit"]["Maximum"] = max(
440
- lookup_table[mode_value]["Limit"]["Maximum"], float(row[max_energy])
441
- )
442
-
443
- with open(file, newline="") as csvfile:
444
- reader = csv.DictReader(csvfile)
445
- for row in reader:
446
- # If there are multiple source only convert requested.
447
- if row[source[0]] == source[1]:
448
- process_row(row=row)
449
- if not lookup_table:
450
- raise RuntimeError(f"Unable to convert lookup table:/n/t{file}")
451
- return lookup_table
@@ -0,0 +1,77 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from ophyd_async.core import StandardReadable
4
+ from ophyd_async.epics.motor import Motor
5
+
6
+ from dodal.devices.common_dcm import (
7
+ StationaryCrystal,
8
+ )
9
+
10
+
11
+ class ThetaYCrystal(StationaryCrystal):
12
+ def __init__(self, prefix):
13
+ with self.add_children_as_readables():
14
+ self.theta = Motor(prefix + "THETA")
15
+ self.y = Motor(prefix + "Y")
16
+ super().__init__(prefix)
17
+
18
+
19
+ class ThetaRollYZCrystal(ThetaYCrystal):
20
+ def __init__(self, prefix):
21
+ with self.add_children_as_readables():
22
+ self.roll = Motor(prefix + "ROLL")
23
+ self.z = Motor(prefix + "Z")
24
+ super().__init__(prefix)
25
+
26
+
27
+ Xtal_1 = TypeVar("Xtal_1", bound=StationaryCrystal)
28
+ Xtal_2 = TypeVar("Xtal_2", bound=StationaryCrystal)
29
+
30
+
31
+ class DualCrystalMonoSimple(StandardReadable, Generic[Xtal_1, Xtal_2]):
32
+ """
33
+ Device for simple double crystal monochromators (DCM), which only allow energy of the beam to be selected.
34
+
35
+ Features common across all DCM's should include virtual motors to set energy/wavelength and contain two crystals,
36
+ each of which can be movable. Some DCM's contain crystals with roll motors, and some contain crystals with roll and pitch motors.
37
+ This base device accounts for all combinations of this.
38
+
39
+ This device is more able to act as a parent for beamline-specific DCM's, in which any other missing signals can be added,
40
+ as it doesn't assume WAVELENGTH, BRAGG and OFFSET are available for all DCM deivces, as BaseDCM does.
41
+
42
+ Bluesky plans using DCM's should be typed to specify which types of crystals are required. For example, a plan
43
+ which only requires one crystal which can roll should be typed 'def my_plan(dcm: BaseDCM[RollCrystal, StationaryCrystal])`
44
+ """
45
+
46
+ def __init__(
47
+ self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2], name: str = ""
48
+ ) -> None:
49
+ with self.add_children_as_readables():
50
+ # Virtual motor PV's which set the physical motors so that the DCM produces requested
51
+ # wavelength/energy
52
+ self.energy_in_kev = Motor(prefix + "ENERGY")
53
+ self._make_crystals(prefix, xtal_1, xtal_2)
54
+
55
+ super().__init__(name)
56
+
57
+ # Prefix convention is different depending on whether there are one or two controllable crystals
58
+ def _make_crystals(self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2]):
59
+ if StationaryCrystal not in [xtal_1, xtal_2]:
60
+ self.xtal_1 = xtal_1(f"{prefix}XTAL1:")
61
+ self.xtal_2 = xtal_2(f"{prefix}XTAL2:")
62
+ else:
63
+ self.xtal_1 = xtal_1(prefix)
64
+ self.xtal_2 = xtal_2(prefix)
65
+
66
+
67
+ class DCM(DualCrystalMonoSimple[ThetaRollYZCrystal, ThetaYCrystal]):
68
+ """
69
+ A double crystal monocromator device, used to select the beam energy.
70
+ """
71
+
72
+ def __init__(self, prefix: str, name: str = "") -> None:
73
+ with self.add_children_as_readables():
74
+ self.calibrated_energy_in_kev = Motor(prefix + "CAL")
75
+ self.x1 = Motor(prefix + "X1")
76
+
77
+ super().__init__(prefix, ThetaRollYZCrystal, ThetaYCrystal, name)
@@ -0,0 +1,71 @@
1
+ from ophyd_async.core import StandardReadable
2
+ from ophyd_async.epics.motor import Motor
3
+
4
+
5
+ class FocusingMirrorBase(StandardReadable):
6
+ """Focusing Mirror with curve, ellip & pitch"""
7
+
8
+ def __init__(
9
+ self,
10
+ prefix: str,
11
+ name: str = "",
12
+ ):
13
+ with self.add_children_as_readables():
14
+ self.curve = Motor(prefix + "CURVE")
15
+ self.ellipticity = Motor(prefix + "ELLIP")
16
+ self.pitch = Motor(prefix + "PITCH")
17
+
18
+ super().__init__(name)
19
+
20
+
21
+ class FocusingMirrorHorizontal(FocusingMirrorBase):
22
+ """Focusing Mirror with curve, ellip, pitch & X"""
23
+
24
+ def __init__(
25
+ self,
26
+ prefix: str,
27
+ name: str = "",
28
+ ):
29
+ with self.add_children_as_readables():
30
+ self.x = Motor(prefix + "X")
31
+
32
+ super().__init__(prefix, name)
33
+
34
+
35
+ class FocusingMirrorVertical(FocusingMirrorBase):
36
+ """Focusing Mirror with curve, ellip, pitch & Y"""
37
+
38
+ def __init__(
39
+ self,
40
+ prefix: str,
41
+ name: str = "",
42
+ ):
43
+ with self.add_children_as_readables():
44
+ self.y = Motor(prefix + "Y")
45
+
46
+ super().__init__(prefix, name)
47
+
48
+
49
+ class FocusingMirror(FocusingMirrorBase):
50
+ """Focusing Mirror with curve, ellip, pitch, yaw, X & Y"""
51
+
52
+ def __init__(
53
+ self,
54
+ prefix: str,
55
+ name: str = "",
56
+ ):
57
+ with self.add_children_as_readables():
58
+ self.yaw = Motor(prefix + "YAW")
59
+ self.x = Motor(prefix + "X")
60
+ self.y = Motor(prefix + "Y")
61
+
62
+ super().__init__(prefix, name)
63
+
64
+
65
+ class FocusingMirrorWithRoll(FocusingMirror):
66
+ """Focusing Mirror with curve, ellip, pitch, roll, yaw, X & Y"""
67
+
68
+ def __init__(self, prefix: str, name: str = "") -> None:
69
+ with self.add_children_as_readables():
70
+ self.roll = Motor(prefix + "ROLL")
71
+ super().__init__(prefix, name)