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
@@ -0,0 +1,45 @@
1
+ from ophyd_async.core import Reference, derived_signal_r
2
+
3
+ from dodal.devices.aperturescatterguard import ApertureScatterguard
4
+ from dodal.devices.beamsize.beamsize import BeamsizeBase
5
+ from dodal.devices.i04.transfocator import Transfocator
6
+
7
+
8
+ class Beamsize(BeamsizeBase):
9
+ def __init__(
10
+ self,
11
+ transfocator: Transfocator,
12
+ aperture_scatterguard: ApertureScatterguard,
13
+ name="",
14
+ ):
15
+ super().__init__(name=name)
16
+ self._transfocator_ref = Reference(transfocator)
17
+ self._aperture_scatterguard_ref = Reference(aperture_scatterguard)
18
+
19
+ with self.add_children_as_readables():
20
+ self.x_um = derived_signal_r(
21
+ self._get_beamsize_x,
22
+ transfocator_size_x=self._transfocator_ref().current_horizontal_size_rbv,
23
+ aperture_radius=self._aperture_scatterguard_ref().radius,
24
+ derived_units="µm",
25
+ )
26
+ self.y_um = derived_signal_r(
27
+ self._get_beamsize_y,
28
+ transfocator_size_y=self._transfocator_ref().current_vertical_size_rbv,
29
+ aperture_radius=self._aperture_scatterguard_ref().radius,
30
+ derived_units="µm",
31
+ )
32
+
33
+ def _get_beamsize_x(
34
+ self,
35
+ transfocator_size_x: float,
36
+ aperture_radius: float,
37
+ ) -> float:
38
+ return min(transfocator_size_x, aperture_radius)
39
+
40
+ def _get_beamsize_y(
41
+ self,
42
+ transfocator_size_y: float,
43
+ aperture_radius: float,
44
+ ) -> float:
45
+ return min(transfocator_size_y, aperture_radius)
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import pickle
3
+ import time
3
4
  from dataclasses import dataclass
4
5
  from enum import Enum
5
6
  from typing import TypedDict
@@ -21,6 +22,7 @@ from dodal.devices.oav.oav_calculations import (
21
22
  from dodal.log import LOGGER
22
23
 
23
24
  NO_MURKO_RESULT = (-1, -1)
25
+ RESULTS_COMPLETE_MESSAGE = "murko_results_complete"
24
26
 
25
27
 
26
28
  class MurkoMetadata(TypedDict):
@@ -71,7 +73,8 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
71
73
  solutions for y and z can be calculated using numpy's linear algebra library.
72
74
  """
73
75
 
74
- TIMEOUT_S = 2
76
+ GET_MESSAGE_TIMEOUT_S = 2
77
+ RESULTS_COMPLETE_TIMEOUT_S = 5
75
78
  PERCENTAGE_TO_USE = 25
76
79
  LEFTMOST_PIXEL_TO_USE = 10
77
80
  NUMBER_OF_WRONG_RESULTS_TO_LOG = 5
@@ -82,7 +85,6 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
82
85
  redis_password=RedisConstants.REDIS_PASSWORD,
83
86
  redis_db=RedisConstants.MURKO_REDIS_DB,
84
87
  name="",
85
- stop_angle=350,
86
88
  ):
87
89
  self.redis_client = StrictRedis(
88
90
  host=redis_host,
@@ -91,7 +93,6 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
91
93
  )
92
94
  self.pubsub = self.redis_client.pubsub()
93
95
  self.sample_id = soft_signal_rw(str) # Should get from redis
94
- self.stop_angle = stop_angle
95
96
 
96
97
  self._reset()
97
98
 
@@ -103,7 +104,7 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
103
104
  super().__init__(name=name)
104
105
 
105
106
  def _reset(self):
106
- self._last_omega = 0
107
+ self._last_omega = None
107
108
  self._results: list[MurkoResult] = []
108
109
 
109
110
  @AsyncStatus.wrap
@@ -120,14 +121,27 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
120
121
 
121
122
  @AsyncStatus.wrap
122
123
  async def trigger(self):
123
- # Wait for results
124
124
  sample_id = await self.sample_id.get_value()
125
- while self._last_omega < self.stop_angle:
125
+ t_last_result = time.time()
126
+ while True:
127
+ if time.time() - t_last_result > self.RESULTS_COMPLETE_TIMEOUT_S:
128
+ LOGGER.warning(
129
+ f"Time since last result > {self.RESULTS_COMPLETE_TIMEOUT_S}, expected to receive {RESULTS_COMPLETE_MESSAGE}"
130
+ )
131
+ break
126
132
  # waits here for next batch to be received
127
- message = await self.pubsub.get_message(timeout=self.TIMEOUT_S)
128
- if message is None:
129
- continue
130
- await self.process_batch(message, sample_id)
133
+ message = await self.pubsub.get_message(timeout=self.GET_MESSAGE_TIMEOUT_S)
134
+ if message and message["type"] == "message":
135
+ t_last_result = time.time()
136
+ data = pickle.loads(message["data"])
137
+
138
+ if data == RESULTS_COMPLETE_MESSAGE:
139
+ LOGGER.info(
140
+ f"Received results complete message: {RESULTS_COMPLETE_MESSAGE}"
141
+ )
142
+ break
143
+
144
+ await self.process_batch(data, sample_id)
131
145
 
132
146
  if not self._results:
133
147
  raise NoResultsFoundError("No results retrieved from Murko")
@@ -155,22 +169,18 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
155
169
  f"murko:{sample_id}:metadata", result.uuid, json.dumps(result.metadata)
156
170
  )
157
171
 
158
- async def process_batch(self, message: dict | None, sample_id: str):
159
- if message and message["type"] == "message":
160
- batch_results: list[dict] = pickle.loads(message["data"])
161
- for results in batch_results:
162
- for uuid, result in results.items():
163
- if metadata_str := await self.redis_client.hget( # type: ignore
164
- f"murko:{sample_id}:metadata", uuid
165
- ):
166
- LOGGER.info(
167
- f"Found metadata for uuid {uuid}, processing result"
168
- )
169
- self.process_result(
170
- result, MurkoMetadata(json.loads(metadata_str))
171
- )
172
- else:
173
- LOGGER.info(f"Found no metadata for uuid {uuid}")
172
+ async def process_batch(
173
+ self, batch_results: list[tuple[str, dict]], sample_id: str
174
+ ):
175
+ for result_with_uuid in batch_results:
176
+ uuid, result = result_with_uuid
177
+ if metadata_str := await self.redis_client.hget( # type: ignore
178
+ f"murko:{sample_id}:metadata", uuid
179
+ ):
180
+ LOGGER.info(f"Found metadata for uuid {uuid}, processing result")
181
+ self.process_result(result, MurkoMetadata(json.loads(metadata_str)))
182
+ else:
183
+ LOGGER.info(f"Found no metadata for uuid {uuid}")
174
184
 
175
185
  def process_result(self, result: dict, metadata: MurkoMetadata):
176
186
  """Uses the 'most_likely_click' coordinates from Murko to calculate the
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- import math
3
2
 
4
3
  from ophyd_async.core import (
5
4
  AsyncStatus,
@@ -9,6 +8,7 @@ from ophyd_async.core import (
9
8
  )
10
9
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
11
10
 
11
+ from dodal.common.device_utils import periodic_reminder
12
12
  from dodal.log import LOGGER
13
13
 
14
14
 
@@ -23,15 +23,15 @@ class Transfocator(StandardReadable):
23
23
  """
24
24
 
25
25
  def __init__(self, prefix: str, name: str = ""):
26
+ self._vert_size_calc_sp = epics_signal_rw(float, prefix + "VERT_REQ")
27
+ self._num_lenses_calc_rbv = epics_signal_r(float, prefix + "LENS_PRED")
28
+ self.start = epics_signal_rw(int, prefix + "START.PROC")
29
+ self.start_rbv = epics_signal_r(int, prefix + "START_RBV")
30
+
26
31
  with self.add_children_as_readables():
27
- self.beamsize_set_microns = epics_signal_rw(float, prefix + "VERT_REQ")
28
- self.predicted_vertical_num_lenses = epics_signal_rw(
29
- float, prefix + "LENS_PRED"
30
- )
31
32
  self.number_filters_sp = epics_signal_rw(int, prefix + "NUM_FILTERS")
32
- self.start = epics_signal_rw(int, prefix + "START.PROC")
33
- self.start_rbv = epics_signal_r(int, prefix + "START_RBV")
34
- self.vertical_lens_rbv = epics_signal_r(float, prefix + "VER")
33
+ self.current_horizontal_size_rbv = epics_signal_r(float, prefix + "HOR")
34
+ self.current_vertical_size_rbv = epics_signal_r(float, prefix + "VER")
35
35
 
36
36
  self.TIMEOUT = 120
37
37
 
@@ -41,14 +41,10 @@ class Transfocator(StandardReadable):
41
41
  # We can only put an integer number of lenses in the beam but the
42
42
  # calculation in the IOC returns the theoretical float number of lenses
43
43
  value = round(value)
44
- LOGGER.info(f"Transfocator setting {value} filters")
45
44
  await self.number_filters_sp.set(value)
46
45
  await self.start.set(1)
47
- LOGGER.info("Waiting for start_rbv to change to 1")
48
46
  await wait_for_value(self.start_rbv, 1, self.TIMEOUT)
49
- LOGGER.info("Waiting for start_rbv to change to 0")
50
47
  await wait_for_value(self.start_rbv, 0, self.TIMEOUT)
51
- self.latest_pred_vertical_num_lenses = value
52
48
 
53
49
  @AsyncStatus.wrap
54
50
  async def set(self, value: float):
@@ -59,29 +55,27 @@ class Transfocator(StandardReadable):
59
55
  4. Start the device moving
60
56
  5. Wait for the start_rbv goes high and low again
61
57
  """
62
- self.latest_pred_vertical_num_lenses = (
63
- await self.predicted_vertical_num_lenses.get_value()
64
- )
65
-
66
58
  LOGGER.info(f"Transfocator setting {value} beamsize")
67
59
 
68
- if await self.beamsize_set_microns.get_value() != value:
69
- # Logic in the IOC calculates predicted_vertical_num_lenses when beam_set_microns changes
60
+ # Logic in the IOC calculates _num_lenses_calc_rbv when _vert_size_calc_sp changes
61
+
62
+ # Register an observer before setting _vert_size_calc_sp to ensure we don't miss changes
63
+ num_lenses_calc_iterator = observe_value(
64
+ self._num_lenses_calc_rbv, timeout=self.TIMEOUT
65
+ )
66
+
67
+ await anext(num_lenses_calc_iterator)
68
+ await self._vert_size_calc_sp.set(value)
69
+ calc_lenses = await anext(num_lenses_calc_iterator)
70
70
 
71
- # Register an observer before setting beamsize_set_microns to ensure we don't miss changes
72
- predicted_vertical_num_lenses_iterator = observe_value(
73
- self.predicted_vertical_num_lenses, timeout=self.TIMEOUT
74
- )
75
- # Keep initial prediction before setting to later compare with change after setting
76
- current_prediction = await anext(predicted_vertical_num_lenses_iterator)
77
- await self.beamsize_set_microns.set(value)
78
- accepted_prediction = await anext(predicted_vertical_num_lenses_iterator)
79
- if not math.isclose(current_prediction, accepted_prediction, abs_tol=1e-8):
80
- await self.set_based_on_prediction(accepted_prediction)
71
+ async with periodic_reminder(
72
+ f"Waiting for transfocator to insert {calc_lenses} into beam"
73
+ ):
74
+ await self.set_based_on_prediction(calc_lenses)
81
75
 
82
76
  number_filters_rbv, vertical_lens_size_rbv = await asyncio.gather(
83
77
  self.number_filters_sp.get_value(),
84
- self.vertical_lens_rbv.get_value(),
78
+ self.current_vertical_size_rbv.get_value(),
85
79
  )
86
80
 
87
81
  LOGGER.info(
@@ -0,0 +1,38 @@
1
+ import numpy as np
2
+
3
+ from dodal.devices.undulator import UndulatorInKeV, UndulatorOrder
4
+ from dodal.devices.util.lookup_tables import energy_distance_table
5
+
6
+
7
+ class InsertionDevice(UndulatorInKeV):
8
+ """
9
+ Insertion device for i07 including beamline-specific energy-gap lookup behaviour
10
+ """
11
+
12
+ def __init__(
13
+ self,
14
+ name: str,
15
+ prefix: str,
16
+ harmonic: UndulatorOrder,
17
+ id_gap_lookup_table_path: str = "/dls_sw/i07/software/gda/config/lookupTables/"
18
+ + "IIDCalibrationTable.txt",
19
+ ) -> None:
20
+ super().__init__(prefix, id_gap_lookup_table_path, name=name)
21
+ self.harmonic = harmonic
22
+
23
+ async def _get_gap_to_match_energy(self, energy_kev: float) -> float:
24
+ """
25
+ i07's energy scans remain on a particular harmonic while changing energy. The
26
+ calibration table has one row for each harmonic, row contains max and min
27
+ energies and their corresponding ID gaps. The requested energy is used to
28
+ interpolate between these values, assuming a linear relationship on the relevant
29
+ scale.
30
+ """
31
+ energy_to_distance_table: np.ndarray = await energy_distance_table(
32
+ self.id_gap_lookup_table_path, comments="#", skiprows=2
33
+ )
34
+ harmonic_value: int = await self.harmonic.value.get_value()
35
+
36
+ row: np.ndarray = energy_to_distance_table[harmonic_value - 1, :]
37
+ gap = np.interp(energy_kev, [row[1], row[2]], [row[3], row[4]])
38
+ return gap
@@ -1,3 +1,7 @@
1
- from .hard_undulator_functions import calculate_gap_i09_hu, get_hu_lut_as_dict
1
+ from .hard_undulator_functions import (
2
+ calculate_energy_i09_hu,
3
+ calculate_gap_i09_hu,
4
+ get_hu_lut_as_dict,
5
+ )
2
6
 
3
- __all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict"]
7
+ __all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict", "calculate_energy_i09_hu"]
@@ -14,11 +14,16 @@ RING_ENERGY_COLUMN = 1
14
14
  MAGNET_FIELD_COLUMN = 2
15
15
  MIN_ENERGY_COLUMN = 3
16
16
  MAX_ENERGY_COLUMN = 4
17
+ MIN_GAP_COLUMN = 5
18
+ MAX_GAP_COLUMN = 6
17
19
  GAP_OFFSET_COLUMN = 7
18
20
 
21
+ MAGNET_BLOCKS_PER_PERIOD = 4
22
+ MAGNTE_BLOCK_HEIGHT_MM = 16
19
23
 
20
- async def get_hu_lut_as_dict(lut_path: str) -> dict:
21
- lut_dict: dict = {}
24
+
25
+ async def get_hu_lut_as_dict(lut_path: str) -> dict[int, np.ndarray]:
26
+ lut_dict: dict[int, np.ndarray] = {}
22
27
  _lookup_table: np.ndarray = await energy_distance_table(
23
28
  lut_path,
24
29
  comments=LUT_COMMENTS,
@@ -26,13 +31,44 @@ async def get_hu_lut_as_dict(lut_path: str) -> dict:
26
31
  )
27
32
  for i in range(_lookup_table.shape[0]):
28
33
  lut_dict[_lookup_table[i][0]] = _lookup_table[i]
29
- LOGGER.debug(f"Loaded lookup table:\n {lut_dict}")
34
+ LOGGER.debug(f"Loaded lookup table: {lut_dict}")
30
35
  return lut_dict
31
36
 
32
37
 
38
+ def _validate_order(order: int, look_up_table: dict[int, "np.ndarray"]) -> None:
39
+ """Validate that the harmonic order exists in the lookup table."""
40
+ if order not in look_up_table.keys():
41
+ raise ValueError(f"Order parameter {order} not found in lookup table")
42
+
43
+
44
+ def _calculate_gamma(look_up_table: dict[int, "np.ndarray"], order: int) -> float:
45
+ """Calculate the Lorentz factor gamma from the lookup table."""
46
+ return 1000 * look_up_table[order][RING_ENERGY_COLUMN] / ELECTRON_REST_ENERGY_MEV
47
+
48
+
49
+ def _calculate_undulator_parameter_max(
50
+ magnet_field: float, undulator_period_mm: int
51
+ ) -> float:
52
+ """
53
+ Calculate the maximum undulator parameter.
54
+ """
55
+ return (
56
+ (
57
+ 2
58
+ * 0.0934
59
+ * undulator_period_mm
60
+ * magnet_field
61
+ * MAGNET_BLOCKS_PER_PERIOD
62
+ / np.pi
63
+ )
64
+ * np.sin(np.pi / MAGNET_BLOCKS_PER_PERIOD)
65
+ * (1 - np.exp(-2 * np.pi * MAGNTE_BLOCK_HEIGHT_MM / undulator_period_mm))
66
+ )
67
+
68
+
33
69
  def calculate_gap_i09_hu(
34
70
  photon_energy_kev: float,
35
- look_up_table: dict[int, "np.ndarray"],
71
+ look_up_table: dict[int, np.ndarray],
36
72
  order: int = 1,
37
73
  gap_offset: float = 0.0,
38
74
  undulator_period_mm: int = 27,
@@ -52,13 +88,9 @@ def calculate_gap_i09_hu(
52
88
  Returns:
53
89
  float: Calculated undulator gap in millimeters.
54
90
  """
55
- magnet_blocks_per_period = 4
56
- magnet_block_height_mm = 16
57
91
 
58
- if order not in look_up_table.keys():
59
- raise ValueError(f"Order parameter {order} not found in lookup table")
60
-
61
- gamma = 1000 * look_up_table[order][RING_ENERGY_COLUMN] / ELECTRON_REST_ENERGY_MEV
92
+ _validate_order(order, look_up_table)
93
+ gamma = _calculate_gamma(look_up_table, order)
62
94
 
63
95
  # Constructive interference of radiation emitted at different poles
64
96
  # lamda = (lambda_u/2*gamma^2)*(1+K^2/2 + gamma^2*theta^2)/n for n=1,2,3...
@@ -83,17 +115,8 @@ def calculate_gap_i09_hu(
83
115
  # leading to K = 0.934*B0[T]*lambda_u[cm]*exp(-pi*gap/lambda_u) or
84
116
  # K = undulator_parameter_max*exp(-pi*gap/lambda_u)
85
117
  # Calculating undulator_parameter_max gives:
86
- undulator_parameter_max = (
87
- (
88
- 2
89
- * 0.0934
90
- * undulator_period_mm
91
- * look_up_table[order][MAGNET_FIELD_COLUMN]
92
- * magnet_blocks_per_period
93
- / np.pi
94
- )
95
- * np.sin(np.pi / magnet_blocks_per_period)
96
- * (1 - np.exp(-2 * np.pi * magnet_block_height_mm / undulator_period_mm))
118
+ undulator_parameter_max = _calculate_undulator_parameter_max(
119
+ look_up_table[order][MAGNET_FIELD_COLUMN], undulator_period_mm
97
120
  )
98
121
 
99
122
  # Finnaly, rearranging the equation:
@@ -109,3 +132,44 @@ def calculate_gap_i09_hu(
109
132
  )
110
133
 
111
134
  return gap
135
+
136
+
137
+ def calculate_energy_i09_hu(
138
+ gap: float,
139
+ look_up_table: dict[int, "np.ndarray"],
140
+ order: int = 1,
141
+ gap_offset: float = 0.0,
142
+ undulator_period_mm: int = 27,
143
+ ) -> float:
144
+ """
145
+ Calculate the photon energy produced by the undulator at a given gap and harmonic order.
146
+ Reverse of the calculate_gap_i09_hu function.
147
+
148
+ Args:
149
+ gap (float): Undulator gap in millimeters.
150
+ look_up_table (dict[int, np.ndarray]): Lookup table containing undulator and beamline parameters for each harmonic order.
151
+ order (int, optional): Harmonic order for which to calculate the energy. Defaults to 1.
152
+ gap_offset (float, optional): Additional gap offset to apply (in mm). Defaults to 0.0.
153
+ undulator_period_mm (int, optional): Undulator period in mm. Defaults to 27.
154
+
155
+ Returns:
156
+ float: Calculated photon energy in keV.
157
+ """
158
+ _validate_order(order, look_up_table)
159
+
160
+ gamma = _calculate_gamma(look_up_table, order)
161
+ undulator_parameter_max = _calculate_undulator_parameter_max(
162
+ look_up_table[order][MAGNET_FIELD_COLUMN], undulator_period_mm
163
+ )
164
+
165
+ undulator_parameter = undulator_parameter_max / np.exp(
166
+ (gap - look_up_table[order][GAP_OFFSET_COLUMN] - gap_offset)
167
+ / (undulator_period_mm / np.pi)
168
+ )
169
+ energy_kev = (
170
+ 4.959368e-6
171
+ * order
172
+ * np.square(gamma)
173
+ / (undulator_period_mm * (np.square(undulator_parameter) + 2))
174
+ )
175
+ return energy_kev