dls-dodal 1.65.0__py3-none-any.whl → 1.66.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 (59) hide show
  1. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/METADATA +3 -4
  2. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/RECORD +56 -50
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/aithre.py +21 -2
  5. dodal/beamlines/i03.py +23 -3
  6. dodal/beamlines/i04.py +18 -3
  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 +7 -2
  11. dodal/beamlines/i10_optics.py +18 -8
  12. dodal/beamlines/i18.py +3 -3
  13. dodal/beamlines/i22.py +3 -3
  14. dodal/beamlines/p38.py +3 -3
  15. dodal/devices/aithre_lasershaping/goniometer.py +26 -9
  16. dodal/devices/aperturescatterguard.py +3 -2
  17. dodal/devices/apple2_undulator.py +89 -44
  18. dodal/devices/areadetector/plugins/mjpg.py +10 -3
  19. dodal/devices/beamsize/__init__.py +0 -0
  20. dodal/devices/beamsize/beamsize.py +6 -0
  21. dodal/devices/detector/det_resolution.py +4 -2
  22. dodal/devices/fast_grid_scan.py +14 -2
  23. dodal/devices/i03/beamsize.py +35 -0
  24. dodal/devices/i03/constants.py +7 -0
  25. dodal/devices/i03/undulator_dcm.py +2 -2
  26. dodal/devices/i04/beamsize.py +45 -0
  27. dodal/devices/i04/murko_results.py +36 -26
  28. dodal/devices/i04/transfocator.py +23 -29
  29. dodal/devices/i07/id.py +38 -0
  30. dodal/devices/i09_1_shared/__init__.py +6 -2
  31. dodal/devices/i09_1_shared/hard_undulator_functions.py +85 -21
  32. dodal/devices/i10/i10_apple2.py +22 -316
  33. dodal/devices/i17/i17_apple2.py +7 -4
  34. dodal/devices/ipin.py +20 -2
  35. dodal/devices/motors.py +19 -3
  36. dodal/devices/mx_phase1/beamstop.py +31 -12
  37. dodal/devices/oav/oav_calculations.py +9 -4
  38. dodal/devices/oav/oav_detector.py +65 -7
  39. dodal/devices/oav/oav_parameters.py +3 -1
  40. dodal/devices/oav/oav_to_redis_forwarder.py +18 -15
  41. dodal/devices/oav/pin_image_recognition/__init__.py +5 -1
  42. dodal/devices/oav/pin_image_recognition/utils.py +23 -1
  43. dodal/devices/oav/snapshots/snapshot_with_grid.py +8 -2
  44. dodal/devices/oav/utils.py +16 -6
  45. dodal/devices/robot.py +17 -7
  46. dodal/devices/scintillator.py +36 -14
  47. dodal/devices/smargon.py +2 -3
  48. dodal/devices/thawer.py +7 -45
  49. dodal/devices/undulator.py +152 -68
  50. dodal/devices/util/lookup_tables_apple2.py +390 -0
  51. dodal/plans/load_panda_yaml.py +9 -0
  52. dodal/plans/verify_undulator_gap.py +2 -2
  53. dodal/beamline_specific_utils/i03.py +0 -17
  54. dodal/testing/__init__.py +0 -3
  55. dodal/testing/setup.py +0 -67
  56. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/WHEEL +0 -0
  57. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/entry_points.txt +0 -0
  58. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/licenses/LICENSE +0 -0
  59. {dls_dodal-1.65.0.dist-info → dls_dodal-1.66.0.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,5 @@
1
1
  import os
2
+ from abc import ABC, abstractmethod
2
3
 
3
4
  import numpy as np
4
5
  from bluesky.protocols import Locatable, Location, Movable
@@ -39,111 +40,177 @@ def _get_gap_for_energy(
39
40
  )
40
41
 
41
42
 
42
- class Undulator(StandardReadable, Movable[float]):
43
+ class BaseUndulator(StandardReadable, Movable[float], ABC):
43
44
  """
44
- An Undulator-type insertion device, used to control photon emission at a given
45
- beam energy.
45
+ Base class for undulator devices providing gap control and access management.
46
+ This class expects target gap value [mm] passed in set method.
46
47
  """
47
48
 
48
49
  def __init__(
49
50
  self,
50
51
  prefix: str,
51
- id_gap_lookup_table_path: str = os.devnull,
52
- name: str = "",
53
52
  poles: int | None = None,
54
53
  length: float | None = None,
54
+ undulator_period: int | None = None,
55
55
  baton: Baton | None = None,
56
+ name: str = "",
56
57
  ) -> None:
57
- """Constructor
58
-
58
+ """
59
59
  Args:
60
60
  prefix: PV prefix
61
- poles (int): Number of magnetic poles built into the undulator
62
- length (float): Length of the undulator in meters
61
+ poles (int, optional): Number of magnetic poles built into the undulator
62
+ length (float, optional): Length of the undulator in meters
63
+ undulator_period(int, optional): Undulator period
64
+ baton (optional): Baton object if provided.
63
65
  name (str, optional): Name for device. Defaults to "".
64
66
  """
65
-
66
67
  self.baton_ref = Reference(baton) if baton else None
67
- self.id_gap_lookup_table_path = id_gap_lookup_table_path
68
+
68
69
  with self.add_children_as_readables():
70
+ self.gap_access = epics_signal_r(EnabledDisabledUpper, prefix + "IDBLENA")
69
71
  self.gap_motor = Motor(prefix + "BLGAPMTR")
70
72
  self.current_gap = epics_signal_r(float, prefix + "CURRGAPD")
71
- self.gap_access = epics_signal_r(EnabledDisabledUpper, prefix + "IDBLENA")
72
73
 
73
74
  with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
74
75
  self.gap_discrepancy_tolerance_mm, _ = soft_signal_r_and_setter(
75
76
  float,
76
77
  initial_value=UNDULATOR_DISCREPANCY_THRESHOLD_MM,
77
78
  )
78
- if poles is not None:
79
- self.poles, _ = soft_signal_r_and_setter(
80
- int,
81
- initial_value=poles,
82
- )
83
- else:
84
- self.poles = None
85
-
86
- if length is not None:
87
- self.length, _ = soft_signal_r_and_setter(
88
- float,
89
- initial_value=length,
90
- )
91
- else:
92
- self.length = None
93
-
94
- super().__init__(name)
79
+ self.poles = self._make_signal_if_not_none(poles, int)
80
+ self.length = self._make_signal_if_not_none(length, float)
81
+ self.undulator_period = self._make_signal_if_not_none(undulator_period, int)
95
82
 
83
+ super().__init__(name=name)
84
+
85
+ def _make_signal_if_not_none(self, initial_value, type):
86
+ if initial_value is None:
87
+ return None
88
+ signal, _ = soft_signal_r_and_setter(type, initial_value=initial_value)
89
+ return signal
90
+
91
+ @abstractmethod
96
92
  @AsyncStatus.wrap
97
- async def set(self, value: float):
93
+ async def set(self, value: float) -> None:
98
94
  """
99
- Set the undulator gap to a given energy in keV
95
+ Move undulator to a given position.
96
+ Abstract method - must be implemented by subclasses.
100
97
 
101
98
  Args:
102
- value: energy in keV
99
+ value: target position - units depend on implementation
103
100
  """
104
- await self._set_undulator_gap(value)
101
+ ...
105
102
 
106
- async def raise_if_not_enabled(self):
107
- access_level = await self.gap_access.get_value()
108
- commissioning_mode = await self._is_commissioning_mode_enabled()
109
- if access_level is EnabledDisabledUpper.DISABLED and not commissioning_mode:
110
- raise AccessError("Undulator gap access is disabled. Contact Control Room")
103
+ async def _set_gap(self, value: float) -> None:
104
+ """
105
+ Set the undulator gap to a given value in mm.
106
+
107
+ Args:
108
+ value: gap in mm
109
+ """
110
+ await self.raise_if_not_enabled() # Check access
111
+ if await self._check_gap_within_threshold(value):
112
+ LOGGER.debug(
113
+ "Gap is already in the correct place, no need to ask it to move"
114
+ )
115
+ return
111
116
 
112
- async def _set_undulator_gap(self, energy_kev: float) -> None:
113
- await self.raise_if_not_enabled()
114
- target_gap = await self._get_gap_to_match_energy(energy_kev)
115
117
  LOGGER.info(
116
- f"Setting undulator gap to {target_gap:.3f}mm based on {energy_kev:.2f}kev"
118
+ f"Undulator gap mismatch. Moving gap to nominal value, {value:.3f}mm"
117
119
  )
120
+ commissioning_mode = await self._is_commissioning_mode_enabled()
121
+ if not commissioning_mode:
122
+ # Only move if the gap is sufficiently different to the value from the
123
+ # DCM lookup table AND we're not in commissioning mode
124
+ await self.gap_motor.set(
125
+ value,
126
+ timeout=STATUS_TIMEOUT_S,
127
+ )
128
+ else:
129
+ LOGGER.warning("In test mode, not moving ID gap")
118
130
 
119
- # Check if undulator gap is close enough to the value from the DCM
131
+ async def _check_gap_within_threshold(self, target_gap: float) -> bool:
132
+ """
133
+ Check if the undulator gap is within the acceptable threshold of the target gap.
134
+
135
+ Args:
136
+ target_gap: target gap in mm
137
+ Returns:
138
+ True if the gap is within the threshold, False otherwise
139
+ """
120
140
  current_gap = await self.current_gap.get_value()
121
141
  tolerance = await self.gap_discrepancy_tolerance_mm.get_value()
122
- difference = abs(target_gap - current_gap)
123
- if difference > tolerance:
124
- LOGGER.info(
125
- f"Undulator gap mismatch. {difference:.3f}mm is outside tolerance.\
126
- Moving gap to nominal value, {target_gap:.3f}mm"
127
- )
128
- commissioning_mode = await self._is_commissioning_mode_enabled()
129
- if not commissioning_mode:
130
- # Only move if the gap is sufficiently different to the value from the
131
- # DCM lookup table AND we're not in commissioning mode
132
- await self.gap_motor.set(
133
- target_gap,
134
- timeout=STATUS_TIMEOUT_S,
135
- )
136
- else:
137
- LOGGER.warning("In test mode, not moving ID gap")
138
- else:
139
- LOGGER.debug(
140
- "Gap is already in the correct place for the new energy value "
141
- f"{energy_kev}, no need to ask it to move"
142
- )
142
+ return abs(target_gap - current_gap) <= tolerance
143
143
 
144
- async def _is_commissioning_mode_enabled(self):
144
+ async def _is_commissioning_mode_enabled(self) -> bool | None:
145
+ """
146
+ Asynchronously checks if commissioning mode is enabled via the baton reference.
147
+ """
145
148
  return self.baton_ref and await self.baton_ref().commissioning.get_value()
146
149
 
150
+ async def raise_if_not_enabled(self) -> AccessError | None:
151
+ """
152
+ Asynchronously raises AccessError if gap access is disabled and not in commissioning mode.
153
+ """
154
+ access_level = await self.gap_access.get_value()
155
+ commissioning_mode = await self._is_commissioning_mode_enabled()
156
+ if access_level is EnabledDisabledUpper.DISABLED and not commissioning_mode:
157
+ raise AccessError("Undulator gap access is disabled. Contact Control Room")
158
+
159
+
160
+ class UndulatorInKeV(BaseUndulator):
161
+ """
162
+ An Undulator-type insertion device, used to control photon emission at a given beam energy.
163
+ This class expects energy [keV] passed in set method and does conversion to gap
164
+ internally, for which it requires path to lookup table file in constructor.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ prefix: str,
170
+ id_gap_lookup_table_path: str = os.devnull,
171
+ poles: int | None = None,
172
+ length: float | None = None,
173
+ undulator_period: int | None = None,
174
+ baton: Baton | None = None,
175
+ name: str = "",
176
+ ) -> None:
177
+ """Constructor
178
+
179
+ Args:
180
+ prefix: PV prefix
181
+ id_gap_lookup_table_path (str): Path to a lookup table file
182
+ poles (int, optional): Number of magnetic poles built into the undulator
183
+ length (float, optional): Length of the undulator in meters
184
+ undulator_period(int, optional): Undulator period
185
+ baton (optional): Baton object if provided.
186
+ name (str, optional): Name for device. Defaults to "".
187
+ """
188
+
189
+ self.id_gap_lookup_table_path = id_gap_lookup_table_path
190
+ super().__init__(
191
+ prefix=prefix,
192
+ poles=poles,
193
+ length=length,
194
+ undulator_period=undulator_period,
195
+ baton=baton,
196
+ name=name,
197
+ )
198
+
199
+ @AsyncStatus.wrap
200
+ async def set(self, value: float):
201
+ """
202
+ Check conditions and Set undulator gap to a given energy in keV
203
+
204
+ Args:
205
+ value: energy in keV
206
+ """
207
+ # Convert energy in keV to gap in mm first
208
+ target_gap = await self._get_gap_to_match_energy(value)
209
+ LOGGER.info(
210
+ f"Setting undulator gap to {target_gap:.3f}mm based on {value:.2f}kev"
211
+ )
212
+ await self._set_gap(target_gap)
213
+
147
214
  async def _get_gap_to_match_energy(self, energy_kev: float) -> float:
148
215
  """
149
216
  get a 2d np.array from lookup table that
@@ -160,6 +227,23 @@ class Undulator(StandardReadable, Movable[float]):
160
227
  )
161
228
 
162
229
 
230
+ class UndulatorInMm(BaseUndulator):
231
+ """
232
+ An Undulator-type insertion device, used to control photon emission.
233
+ This class expects gap [mm] passed in set method.
234
+ """
235
+
236
+ @AsyncStatus.wrap
237
+ async def set(self, value: float):
238
+ """
239
+ Check conditions and Set undulator gap to a given value in mm
240
+
241
+ Args:
242
+ value: value in mm
243
+ """
244
+ await self._set_gap(value)
245
+
246
+
163
247
  class UndulatorOrder(StandardReadable, Locatable[int]):
164
248
  """
165
249
  Represents the order of an undulator device. Allows setting and locating the order.
@@ -171,17 +255,17 @@ class UndulatorOrder(StandardReadable, Locatable[int]):
171
255
  name: Name for device. Defaults to ""
172
256
  """
173
257
  with self.add_children_as_readables():
174
- self._value = soft_signal_rw(int, initial_value=3)
258
+ self.value = soft_signal_rw(int, initial_value=3)
175
259
  super().__init__(name=name)
176
260
 
177
261
  @AsyncStatus.wrap
178
262
  async def set(self, value: int) -> None:
179
263
  if (value >= 0) and isinstance(value, int):
180
- await self._value.set(value)
264
+ await self.value.set(value)
181
265
  else:
182
266
  raise ValueError(
183
267
  f"Undulator order must be a positive integer. Requested value: {value}"
184
268
  )
185
269
 
186
270
  async def locate(self) -> Location[int]:
187
- return await self._value.locate()
271
+ return await self.value.locate()
@@ -0,0 +1,390 @@
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
+ "energies": {
14
+ "<min_energy>": {
15
+ "low": <float>,
16
+ "high": <float>,
17
+ "poly": <numpy.poly1d>
18
+ },
19
+ ...
20
+ },
21
+ "limit": {
22
+ "minimum": <float>,
23
+ "maximum": <float>
24
+ }
25
+ },
26
+ }
27
+
28
+ """
29
+
30
+ import csv
31
+ import io
32
+ from collections.abc import Generator
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+ from daq_config_server.client import ConfigServer
37
+ from pydantic import (
38
+ BaseModel,
39
+ ConfigDict,
40
+ Field,
41
+ RootModel,
42
+ field_serializer,
43
+ field_validator,
44
+ )
45
+
46
+ from dodal.devices.apple2_undulator import Pol
47
+ from dodal.log import LOGGER
48
+
49
+ DEFAULT_GAP_FILE = "IDEnergy2GapCalibrations.csv"
50
+ DEFAULT_PHASE_FILE = "IDEnergy2PhaseCalibrations.csv"
51
+
52
+
53
+ DEFAULT_POLY_DEG = [
54
+ "7th-order",
55
+ "6th-order",
56
+ "5th-order",
57
+ "4th-order",
58
+ "3rd-order",
59
+ "2nd-order",
60
+ "1st-order",
61
+ "b",
62
+ ]
63
+
64
+ MODE_NAME_CONVERT = {"CR": "pc", "CL": "nc"}
65
+
66
+
67
+ class LookupTableConfig(BaseModel):
68
+ source: tuple[str, str] | None = None
69
+ mode: str = "Mode"
70
+ min_energy: str = "MinEnergy"
71
+ max_energy: str = "MaxEnergy"
72
+ poly_deg: list[str] = Field(default_factory=lambda: DEFAULT_POLY_DEG)
73
+ mode_name_convert: dict[str, str] = Field(default_factory=lambda: MODE_NAME_CONVERT)
74
+
75
+
76
+ class EnergyMinMax(BaseModel):
77
+ minimum: float
78
+ maximum: float
79
+
80
+
81
+ class EnergyCoverageEntry(BaseModel):
82
+ model_config = ConfigDict(arbitrary_types_allowed=True) # So np.poly1d can be used.
83
+ low: float
84
+ high: float
85
+ poly: np.poly1d
86
+
87
+ @field_validator("poly", mode="before")
88
+ @classmethod
89
+ def validate_and_convert_poly(cls, value):
90
+ """If reading from serialized data, it will be using a list. Convert to np.poly1d"""
91
+ if isinstance(value, list):
92
+ return np.poly1d(value)
93
+ return value
94
+
95
+ @field_serializer("poly", mode="plain")
96
+ def serialize_poly(self, value: np.poly1d) -> list:
97
+ """Allow np.poly1d to work when serializing."""
98
+ return value.coefficients.tolist()
99
+
100
+
101
+ class EnergyCoverage(RootModel[dict[float, EnergyCoverageEntry]]):
102
+ pass
103
+
104
+
105
+ class LookupTableEntries(BaseModel):
106
+ energies: EnergyCoverage
107
+ limit: EnergyMinMax
108
+
109
+
110
+ class LookupTable(RootModel[dict[Pol, LookupTableEntries]]):
111
+ # Allow to auto specify a dict if one not provided
112
+ def __init__(self, root: dict[Pol, LookupTableEntries] | None = None):
113
+ super().__init__(root=root or {})
114
+
115
+
116
+ class GapPhaseLookupTables(BaseModel):
117
+ gap: LookupTable = Field(default_factory=lambda: LookupTable())
118
+ phase: LookupTable = Field(default_factory=lambda: LookupTable())
119
+
120
+
121
+ def convert_csv_to_lookup(
122
+ file_contents: str,
123
+ lut_config: LookupTableConfig,
124
+ skip_line_start_with: str = "#",
125
+ ) -> LookupTable:
126
+ """
127
+ Convert CSV content into the Apple2 lookup-table dictionary.
128
+
129
+ Parameters:
130
+ -----------
131
+ file_contents:
132
+ The CSV file contents as string.
133
+ lut_config:
134
+ The configuration that how to process the file_contents into a LookupTable.
135
+ skip_line_start_with
136
+ Lines beginning with this prefix are skipped (default "#").
137
+
138
+ Returns:
139
+ -----------
140
+ LookupTable
141
+ """
142
+
143
+ def process_row(row: dict, lut: LookupTable):
144
+ """Process a single row from the CSV file and update the lookup table."""
145
+ mode_value = str(row[lut_config.mode]).lower()
146
+ if mode_value in lut_config.mode_name_convert:
147
+ mode_value = lut_config.mode_name_convert[f"{mode_value}"]
148
+ mode_value = Pol(mode_value)
149
+
150
+ # Create polynomial object for energy-to-gap/phase conversion
151
+ coefficients = [float(row[coef]) for coef in lut_config.poly_deg]
152
+ if mode_value not in lut.root:
153
+ lut.root[mode_value] = generate_lookup_table_entry(
154
+ min_energy=float(row[lut_config.min_energy]),
155
+ max_energy=float(row[lut_config.max_energy]),
156
+ poly1d_param=coefficients,
157
+ )
158
+
159
+ else:
160
+ lut.root[mode_value].energies.root[float(row[lut_config.min_energy])] = (
161
+ EnergyCoverageEntry(
162
+ low=float(row[lut_config.min_energy]),
163
+ high=float(row[lut_config.max_energy]),
164
+ poly=np.poly1d(coefficients),
165
+ )
166
+ )
167
+
168
+ # Update energy limits
169
+ lut.root[mode_value].limit.minimum = min(
170
+ lut.root[mode_value].limit.minimum,
171
+ float(row[lut_config.min_energy]),
172
+ )
173
+ lut.root[mode_value].limit.maximum = max(
174
+ lut.root[mode_value].limit.maximum,
175
+ float(row[lut_config.max_energy]),
176
+ )
177
+ return lut
178
+
179
+ reader = csv.DictReader(read_file_and_skip(file_contents, skip_line_start_with))
180
+ lut = LookupTable()
181
+
182
+ for row in reader:
183
+ # If there are multiple source only convert requested.
184
+ if lut_config.source is not None:
185
+ if row[lut_config.source[0]] == lut_config.source[1]:
186
+ process_row(row=row, lut=lut)
187
+ else:
188
+ process_row(row=row, lut=lut)
189
+
190
+ # Check if our LookupTable is empty after processing, raise error if it is.
191
+ if not lut.root:
192
+ raise RuntimeError(
193
+ "LookupTable content is empty, failed to convert the file contents to "
194
+ "a LookupTable!"
195
+ )
196
+ return lut
197
+
198
+
199
+ def read_file_and_skip(file: str, skip_line_start_with: str = "#") -> Generator[str]:
200
+ """Yield non-comment lines from the CSV content string."""
201
+ for line in io.StringIO(file):
202
+ if line.startswith(skip_line_start_with):
203
+ continue
204
+ else:
205
+ yield line
206
+
207
+
208
+ def get_poly(
209
+ energy: float,
210
+ pol: Pol,
211
+ lookup_table: LookupTable,
212
+ ) -> np.poly1d:
213
+ """
214
+ Return the numpy.poly1d polynomial applicable for the given energy and polarisation.
215
+
216
+ Parameters:
217
+ -----------
218
+ energy:
219
+ Energy value in the same units used to create the lookup table (eV).
220
+ pol:
221
+ Polarisation mode (Pol enum).
222
+ lookup_table:
223
+ The converted lookup table dictionary for either 'gap' or 'phase'.
224
+ """
225
+ if (
226
+ energy < lookup_table.root[pol].limit.minimum
227
+ or energy > lookup_table.root[pol].limit.maximum
228
+ ):
229
+ raise ValueError(
230
+ "Demanding energy must lie between"
231
+ + f" {lookup_table.root[pol].limit.minimum}"
232
+ + f" and {lookup_table.root[pol].limit.maximum} eV!"
233
+ )
234
+ else:
235
+ for energy_range in lookup_table.root[pol].energies.root.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
+
245
+ def generate_lookup_table_entry(
246
+ min_energy: float, max_energy: float, poly1d_param: list[float]
247
+ ) -> LookupTableEntries:
248
+ return LookupTableEntries(
249
+ energies=EnergyCoverage(
250
+ {
251
+ min_energy: EnergyCoverageEntry(
252
+ low=min_energy,
253
+ high=max_energy,
254
+ poly=np.poly1d(poly1d_param),
255
+ )
256
+ }
257
+ ),
258
+ limit=EnergyMinMax(
259
+ minimum=float(min_energy),
260
+ maximum=float(max_energy),
261
+ ),
262
+ )
263
+
264
+
265
+ def generate_lookup_table(
266
+ pol: Pol, min_energy: float, max_energy: float, poly1d_param: list[float]
267
+ ) -> LookupTable:
268
+ return LookupTable(
269
+ {pol: generate_lookup_table_entry(min_energy, max_energy, poly1d_param)}
270
+ )
271
+
272
+
273
+ def make_phase_tables(
274
+ pols: list[Pol],
275
+ min_energies: list[float],
276
+ max_energies: list[float],
277
+ poly1d_params: list[list[float]],
278
+ ) -> LookupTable:
279
+ """Generate a dictionary containing multiple lookuptable entries
280
+ for provided polarisations."""
281
+ lookuptable_phase = LookupTable()
282
+ for i in range(len(pols)):
283
+ lookuptable_phase.root[pols[i]] = generate_lookup_table_entry(
284
+ min_energy=min_energies[i],
285
+ max_energy=max_energies[i],
286
+ poly1d_param=poly1d_params[i],
287
+ )
288
+
289
+ return lookuptable_phase
290
+
291
+
292
+ class EnergyMotorLookup:
293
+ """
294
+ Handles lookup tables for Apple2 ID, converting energy and polarisation to gap
295
+ and phase. Fetches and parses lookup tables from a config server, supports dynamic
296
+ updates, and validates input. If custom logic is required for lookup tables, sub
297
+ classes should override the _update_gap_lut and _update_phase_lut methods.
298
+
299
+ After update_lookuptable() has populated the 'gap' and 'phase' tables,
300
+ `get_motor_from_energy()` can be used to compute (gap, phase) for a requested
301
+ (energy, pol) pair.
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ config_client: ConfigServer,
307
+ lut_config: LookupTableConfig,
308
+ gap_path: Path,
309
+ phase_path: Path,
310
+ ):
311
+ """Initialise the EnergyMotorLookup class with lookup table headers provided.
312
+
313
+ Parameters:
314
+ -----------
315
+ config_client:
316
+ The config server client to fetch the look up table data.
317
+ lut_config:
318
+ Configuration that defines how to process file contents into a LookupTable
319
+ gap_path:
320
+ File path to the gap lookup table.
321
+ phase_path:
322
+ File path to the phase lookup table.
323
+ """
324
+ self.lookup_tables = GapPhaseLookupTables()
325
+ self.config_client = config_client
326
+ self.lut_config = lut_config
327
+ self.gap_path = gap_path
328
+ self.phase_path = phase_path
329
+ self._available_pol = []
330
+
331
+ @property
332
+ def available_pol(self) -> list[Pol]:
333
+ return self._available_pol
334
+
335
+ @available_pol.setter
336
+ def available_pol(self, value: list[Pol]) -> None:
337
+ self._available_pol = value
338
+
339
+ def _update_gap_lut(self) -> None:
340
+ file_contents = self.config_client.get_file_contents(
341
+ self.gap_path, reset_cached_result=True
342
+ )
343
+ self.lookup_tables.gap = convert_csv_to_lookup(
344
+ file_contents, lut_config=self.lut_config
345
+ )
346
+ self.available_pol = list(self.lookup_tables.gap.root.keys())
347
+
348
+ def _update_phase_lut(self) -> None:
349
+ file_contents = self.config_client.get_file_contents(
350
+ self.phase_path, reset_cached_result=True
351
+ )
352
+ self.lookup_tables.phase = convert_csv_to_lookup(
353
+ file_contents, lut_config=self.lut_config
354
+ )
355
+
356
+ def update_lookuptables(self):
357
+ """
358
+ Update lookup tables from files and validate their format.
359
+ """
360
+ LOGGER.info("Updating lookup table from file for gap.")
361
+ self._update_gap_lut()
362
+ LOGGER.info("Updating lookup table from file for phase.")
363
+ self._update_phase_lut()
364
+
365
+ def get_motor_from_energy(self, energy: float, pol: Pol) -> tuple[float, float]:
366
+ """
367
+ Convert energy and polarisation to gap and phase motor positions.
368
+
369
+ Parameters:
370
+ -----------
371
+ energy : float
372
+ Desired energy in eV.
373
+ pol : Pol
374
+ Polarisation mode.
375
+
376
+ Returns:
377
+ ----------
378
+ tuple[float, float]
379
+ (gap, phase) motor positions.
380
+ """
381
+ if self.available_pol == []:
382
+ self.update_lookuptables()
383
+
384
+ gap_poly = get_poly(lookup_table=self.lookup_tables.gap, energy=energy, pol=pol)
385
+ phase_poly = get_poly(
386
+ lookup_table=self.lookup_tables.phase,
387
+ energy=energy,
388
+ pol=pol,
389
+ )
390
+ return gap_poly(energy), phase_poly(energy)
@@ -0,0 +1,9 @@
1
+ from ophyd_async.core import YamlSettingsProvider
2
+ from ophyd_async.fastcs.panda import HDFPanda
3
+ from ophyd_async.plan_stubs import apply_panda_settings, retrieve_settings
4
+
5
+
6
+ def load_panda_from_yaml(yaml_directory: str, yaml_file_name: str, panda: HDFPanda):
7
+ provider = YamlSettingsProvider(yaml_directory)
8
+ settings = yield from retrieve_settings(provider, yaml_file_name, panda)
9
+ yield from apply_panda_settings(settings)