dls-dodal 1.58.0__py3-none-any.whl → 1.59.1__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 (58) hide show
  1. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.dist-info}/METADATA +2 -1
  2. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.dist-info}/RECORD +58 -43
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/b07.py +10 -5
  5. dodal/beamlines/b07_1.py +10 -5
  6. dodal/beamlines/b21.py +22 -0
  7. dodal/beamlines/i02_1.py +80 -0
  8. dodal/beamlines/i03.py +5 -3
  9. dodal/beamlines/i04.py +5 -3
  10. dodal/beamlines/i09.py +10 -9
  11. dodal/beamlines/i09_1.py +10 -5
  12. dodal/beamlines/i10-1.py +25 -0
  13. dodal/beamlines/i10.py +17 -1
  14. dodal/beamlines/i11.py +0 -17
  15. dodal/beamlines/i19_2.py +11 -0
  16. dodal/beamlines/i21.py +27 -0
  17. dodal/beamlines/i22.py +12 -2
  18. dodal/beamlines/i24.py +32 -3
  19. dodal/beamlines/k07.py +31 -0
  20. dodal/beamlines/p60.py +10 -9
  21. dodal/common/watcher_utils.py +1 -1
  22. dodal/devices/apple2_undulator.py +18 -142
  23. dodal/devices/attenuator/attenuator.py +48 -2
  24. dodal/devices/attenuator/filter.py +3 -0
  25. dodal/devices/attenuator/filter_selections.py +26 -0
  26. dodal/devices/eiger.py +2 -1
  27. dodal/devices/electron_analyser/__init__.py +4 -0
  28. dodal/devices/electron_analyser/abstract/base_driver_io.py +30 -18
  29. dodal/devices/electron_analyser/energy_sources.py +101 -0
  30. dodal/devices/electron_analyser/specs/detector.py +6 -6
  31. dodal/devices/electron_analyser/specs/driver_io.py +7 -15
  32. dodal/devices/electron_analyser/vgscienta/detector.py +6 -6
  33. dodal/devices/electron_analyser/vgscienta/driver_io.py +7 -14
  34. dodal/devices/fast_grid_scan.py +130 -64
  35. dodal/devices/focusing_mirror.py +30 -0
  36. dodal/devices/i02_1/__init__.py +0 -0
  37. dodal/devices/i02_1/fast_grid_scan.py +61 -0
  38. dodal/devices/i02_1/sample_motors.py +19 -0
  39. dodal/devices/i04/murko_results.py +69 -23
  40. dodal/devices/i10/i10_apple2.py +282 -140
  41. dodal/devices/i21/__init__.py +3 -0
  42. dodal/devices/i21/enums.py +8 -0
  43. dodal/devices/i22/nxsas.py +2 -0
  44. dodal/devices/i24/commissioning_jungfrau.py +114 -0
  45. dodal/devices/smargon.py +0 -56
  46. dodal/devices/temperture_controller/__init__.py +3 -0
  47. dodal/devices/temperture_controller/lakeshore/__init__.py +0 -0
  48. dodal/devices/temperture_controller/lakeshore/lakeshore.py +204 -0
  49. dodal/devices/temperture_controller/lakeshore/lakeshore_io.py +112 -0
  50. dodal/devices/tetramm.py +38 -16
  51. dodal/devices/v2f.py +39 -0
  52. dodal/devices/zebra/zebra.py +1 -0
  53. dodal/devices/zebra/zebra_constants_mapping.py +1 -1
  54. dodal/parameters/experiment_parameter_base.py +1 -5
  55. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.dist-info}/WHEEL +0 -0
  56. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.dist-info}/entry_points.txt +0 -0
  57. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.dist-info}/licenses/LICENSE +0 -0
  58. {dls_dodal-1.58.0.dist-info → dls_dodal-1.59.1.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,3 @@
1
+ from dodal.devices.i21.enums import Grating
2
+
3
+ __all__ = ["Grating"]
@@ -0,0 +1,8 @@
1
+ from ophyd_async.core import StrictEnum
2
+
3
+
4
+ class Grating(StrictEnum):
5
+ VPG1 = "VPG1"
6
+ VPG2 = "VPG2"
7
+ VPG3 = "VPG3"
8
+ VPG4 = "VPG4"
@@ -7,6 +7,7 @@ from bluesky.protocols import Reading
7
7
  from event_model.documents.event_descriptor import DataKey
8
8
  from ophyd_async.core import PathProvider
9
9
  from ophyd_async.epics.adaravis import AravisDetector
10
+ from ophyd_async.epics.adcore import NDPluginBaseIO
10
11
  from ophyd_async.epics.adpilatus import PilatusDetector
11
12
 
12
13
  ValueAndUnits = tuple[float, str]
@@ -106,6 +107,7 @@ class NXSasPilatus(PilatusDetector):
106
107
  fileio_suffix: str,
107
108
  metadata_holder: NXSasMetadataHolder,
108
109
  name: str = "",
110
+ plugins: dict[str, NDPluginBaseIO] | None = None,
109
111
  ):
110
112
  """Extends detector with configuration metadata required or desired
111
113
  to comply with the NXsas application definition.
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ from collections.abc import AsyncGenerator, AsyncIterator
3
+ from pathlib import Path
4
+
5
+ from bluesky.protocols import StreamAsset
6
+ from event_model import DataKey # type: ignore
7
+ from ophyd_async.core import (
8
+ AutoIncrementingPathProvider,
9
+ DetectorWriter,
10
+ StandardDetector,
11
+ StandardReadable,
12
+ StaticPathProvider,
13
+ observe_value,
14
+ wait_for_value,
15
+ )
16
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_rw_rbv
17
+ from ophyd_async.fastcs.jungfrau._controller import JungfrauController
18
+ from ophyd_async.fastcs.jungfrau._signals import JungfrauDriverIO
19
+
20
+ from dodal.log import LOGGER
21
+
22
+
23
+ class JunfrauCommissioningWriter(DetectorWriter, StandardReadable):
24
+ """Implementation of the temporary filewriter used for Jungfrau commissioning on i24.
25
+
26
+ The PVs on this device are responsible for writing files of a specified name
27
+ to a specified path, marking itself as "ready to write", and having a counter of
28
+ frames written, which must be zero'd at the ophyd level
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ prefix,
34
+ path_provider: AutoIncrementingPathProvider | StaticPathProvider,
35
+ name="",
36
+ ) -> None:
37
+ with self.add_children_as_readables():
38
+ self._path_info = path_provider
39
+ self.frame_counter = epics_signal_rw(int, f"{prefix}NumCaptured")
40
+ self.file_name = epics_signal_rw_rbv(str, f"{prefix}FileName")
41
+ self.file_path = epics_signal_rw_rbv(str, f"{prefix}FilePath")
42
+ self.writer_ready = epics_signal_r(int, f"{prefix}Ready_RBV")
43
+ super().__init__(name)
44
+
45
+ async def open(self, name: str, exposures_per_event: int = 1) -> dict[str, DataKey]:
46
+ self._exposures_per_event = exposures_per_event
47
+ _path_info = self._path_info()
48
+
49
+ # Commissioning Jungfrau plans allow you to override path, so check to see if file exists
50
+ requested_filepath = Path(_path_info.directory_path) / _path_info.filename
51
+ if requested_filepath.exists():
52
+ raise FileExistsError(
53
+ f"Jungfrau was requested to write to {requested_filepath}, but this file already exists!"
54
+ )
55
+
56
+ await asyncio.gather(
57
+ self.file_name.set(_path_info.filename),
58
+ self.file_path.set(str(_path_info.directory_path)),
59
+ self.frame_counter.set(0),
60
+ )
61
+ LOGGER.info(
62
+ f"Jungfrau writing to folder {_path_info.directory_path} with filename {_path_info.filename}"
63
+ )
64
+ await wait_for_value(self.writer_ready, 1, timeout=10)
65
+ return await self._describe()
66
+
67
+ async def _describe(self) -> dict[str, DataKey]:
68
+ # Dummy function, doesn't actually describe the dataset
69
+
70
+ return {
71
+ "data": DataKey(
72
+ source="Commissioning writer",
73
+ shape=[-1],
74
+ dtype="array",
75
+ dtype_numpy="<u2",
76
+ external="STREAM:",
77
+ )
78
+ }
79
+
80
+ async def observe_indices_written(
81
+ self, timeout: float
82
+ ) -> AsyncGenerator[int, None]:
83
+ timeout = timeout * 2 # This filewriter is slow
84
+ async for num_captured in observe_value(self.frame_counter, timeout):
85
+ yield num_captured // (self._exposures_per_event)
86
+
87
+ async def get_indices_written(self) -> int:
88
+ return await self.frame_counter.get_value() // self._exposures_per_event
89
+
90
+ def collect_stream_docs(
91
+ self, name: str, indices_written: int
92
+ ) -> AsyncIterator[StreamAsset]:
93
+ raise NotImplementedError()
94
+
95
+ async def close(self) -> None: ...
96
+
97
+
98
+ class CommissioningJungfrau(
99
+ StandardDetector[JungfrauController, JunfrauCommissioningWriter]
100
+ ):
101
+ """Ophyd-async implementation of a Jungfrau 9M Detector, using a temporary
102
+ filewriter in place of Odin"""
103
+
104
+ def __init__(
105
+ self,
106
+ prefix: str,
107
+ writer_prefix: str,
108
+ path_provider: AutoIncrementingPathProvider | StaticPathProvider,
109
+ name="",
110
+ ):
111
+ self.drv = JungfrauDriverIO(prefix)
112
+ writer = JunfrauCommissioningWriter(writer_prefix, path_provider)
113
+ controller = JungfrauController(self.drv)
114
+ super().__init__(controller, writer, name=name)