Skip to content

Commit 4bdbb05

Browse files
Villtordeir17846oliwenmandiamond
authored
i09 energy to gap function (#1660)
* add gap calcualation functions and tests * remove scipy import - not found * relocate to i09_1_shared --------- Co-authored-by: eir17846 <[email protected]> Co-authored-by: oliwenmandiamond <[email protected]>
1 parent 4a296b0 commit 4bdbb05

File tree

8 files changed

+210
-2
lines changed

8 files changed

+210
-2
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .hard_undulator_functions import calculate_gap_i09_hu, get_hu_lut_as_dict
2+
3+
__all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict"]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import numpy as np
2+
3+
from dodal.devices.util.lookup_tables import energy_distance_table
4+
from dodal.log import LOGGER
5+
6+
LUT_COMMENTS = ["#"]
7+
HU_SKIP_ROWS = 3
8+
9+
# Physics constants
10+
ELECTRON_REST_ENERGY_MEV = 0.510999
11+
12+
# Columns in the lookup table
13+
RING_ENERGY_COLUMN = 1
14+
MAGNET_FIELD_COLUMN = 2
15+
MIN_ENERGY_COLUMN = 3
16+
MAX_ENERGY_COLUMN = 4
17+
GAP_OFFSET_COLUMN = 7
18+
19+
20+
async def get_hu_lut_as_dict(lut_path: str) -> dict:
21+
lut_dict: dict = {}
22+
_lookup_table: np.ndarray = await energy_distance_table(
23+
lut_path,
24+
comments=LUT_COMMENTS,
25+
skiprows=HU_SKIP_ROWS,
26+
)
27+
for i in range(_lookup_table.shape[0]):
28+
lut_dict[_lookup_table[i][0]] = _lookup_table[i]
29+
LOGGER.debug(f"Loaded lookup table:\n {lut_dict}")
30+
return lut_dict
31+
32+
33+
def calculate_gap_i09_hu(
34+
photon_energy_kev: float,
35+
look_up_table: dict[int, "np.ndarray"],
36+
order: int = 1,
37+
gap_offset: float = 0.0,
38+
undulator_period_mm: int = 27,
39+
) -> float:
40+
"""
41+
Calculate the undulator gap required to produce a given energy at a given harmonic order.
42+
This algorithm was provided by the I09 beamline scientists, and is based on the physics of undulator radiation.
43+
https://cxro.lbl.gov//PDF/X-Ray-Data-Booklet.pdf
44+
45+
Args:
46+
photon_energy_kev (float): Requested photon energy in keV.
47+
look_up_table (dict[int, np.ndarray]): Lookup table containing undulator and beamline parameters for each harmonic order.
48+
order (int, optional): Harmonic order for which to calculate the gap. Defaults to 1.
49+
gap_offset (float, optional): Additional gap offset to apply (in mm). Defaults to 0.0.
50+
undulator_period_mm (int, optional): Undulator period in mm. Defaults to 27.
51+
52+
Returns:
53+
float: Calculated undulator gap in millimeters.
54+
"""
55+
magnet_blocks_per_period = 4
56+
magnet_block_height_mm = 16
57+
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
62+
63+
# Constructive interference of radiation emitted at different poles
64+
# lamda = (lambda_u/2*gamma^2)*(1+K^2/2 + gamma^2*theta^2)/n for n=1,2,3...
65+
# theta is the observation angle, assumed to be 0 here.
66+
# Rearranging for K (the undulator parameter, related to magnetic field and gap)
67+
# gives K^2 = 2*((2*n*gamma^2*lamda/lambda_u)-1)
68+
69+
undulator_parameter_sqr = (
70+
4.959368e-6
71+
* (order * gamma * gamma / (undulator_period_mm * photon_energy_kev))
72+
- 2
73+
)
74+
if undulator_parameter_sqr < 0:
75+
raise ValueError(
76+
f"Diffraction parameter squared must be positive! Calculated value {undulator_parameter_sqr}."
77+
)
78+
undulator_parameter = np.sqrt(undulator_parameter_sqr)
79+
80+
# Undulator_parameter K is also defined as K = 0.934*B0[T]*lambda_u[cm],
81+
# where B0[T] is a peak magnetic field that must depend on gap,
82+
# but in our LUT it is does not depend on gap, so it's a factor,
83+
# leading to K = 0.934*B0[T]*lambda_u[cm]*exp(-pi*gap/lambda_u) or
84+
# K = undulator_parameter_max*exp(-pi*gap/lambda_u)
85+
# 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))
97+
)
98+
99+
# Finnaly, rearranging the equation:
100+
# undulator_parameter = undulator_parameter_max*exp(-pi*gap/lambda_u) for gap gives
101+
gap = (
102+
(undulator_period_mm / np.pi)
103+
* np.log(undulator_parameter_max / undulator_parameter)
104+
+ look_up_table[order][GAP_OFFSET_COLUMN]
105+
+ gap_offset
106+
)
107+
LOGGER.debug(
108+
f"Calculated gap is {gap}mm for energy {photon_energy_kev}keV at order {order}"
109+
)
110+
111+
return gap

src/dodal/devices/util/lookup_tables.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@
1313
from dodal.log import LOGGER
1414

1515

16-
async def energy_distance_table(lookup_table_path: str) -> np.ndarray:
16+
async def energy_distance_table(
17+
lookup_table_path: str,
18+
comments: Sequence[str] = ["#", "Units"],
19+
skiprows: int = 0,
20+
) -> np.ndarray:
1721
"""
1822
Returns a numpy formatted lookup table for required positions of an ID gap to
1923
provide emission at a given beam energy.
2024
2125
Args:
2226
lookup_table_path: Path to lookup table
27+
comments: Lines starting with any of these strings will be ignored
28+
skiprows: Number of rows to skip at the start of the file
2329
2430
Returns:
2531
ndarray: Lookup table
@@ -29,7 +35,7 @@ async def energy_distance_table(lookup_table_path: str) -> np.ndarray:
2935
# decodes the text
3036
async with aiofiles.open(lookup_table_path) as stream:
3137
raw_table = await stream.read()
32-
return loadtxt(StringIO(raw_table), comments=["#", "Units"])
38+
return loadtxt(StringIO(raw_table), comments=comments, skiprows=skiprows)
3339

3440

3541
def parse_lookup_table(filename: str) -> list[Sequence]:

tests/devices/i09_1/__init__.py

Whitespace-only changes.

tests/devices/i09_1_shared/__init__.py

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from os.path import join
2+
from pathlib import Path
3+
4+
TEST_DATA_PATH = Path(__file__).parent
5+
TEST_HARD_UNDULATOR_LUT = join(TEST_DATA_PATH, "test_lookuptable_i09_hu.txt")
6+
__all__ = [
7+
"TEST_DATA_PATH",
8+
"TEST_HARD_UNDULATOR_LUT",
9+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#I09 Hard X-ray ID calibration parameters for I09 undulator
2+
ScannableNames n Ee Br Epmin Epmax Gmin Gmax Goffset
3+
ScannableUnits ONE GeV T KeV Kev mm mm
4+
1 3.00089 0.98928 2.12 3.05 14.2650 23.7200 0.0
5+
2 3.04129 1.02504 2.50 2.80 5.05165 8.88007 0.0
6+
3 3.05798 1.03065 2.40 4.30 5.20000 8.99036 0.0
7+
4 3.03635 1.02332 3.20 5.70 5.26183 8.99640 0.0
8+
5 3.06334 1.03294 4.00 7.20 5.22735 9.02065 0.0
9+
6 3.04963 1.02913 4.70 8.60 5.13939 9.02527 0.0
10+
7 3.06515 1.03339 5.50 10.1 5.12684 9.02602 0.0
11+
8 3.05775 1.03223 6.30 11.5 5.16289 9.02873 0.0
12+
9 3.06829 1.03468 7.10 13.0 5.16357 9.03049 0.0
13+
10 3.06164 1.03328 7.90 14.4 5.17205 9.02845 0.0
14+
11 3.07056 1.03557 8.60 15.9 5.11350 9.04750 0.0
15+
12 3.06627 1.03482 9.40 17.3 5.12051 9.02826 0.0
16+
13 3.07176 1.03623 10.2 18.3 5.13027 8.84940 0.0
17+
14 3.06964 1.03587 11.0 18.3 5.13985 8.30146 0.0
18+
15 3.06515 1.03391 11.8 18.3 5.14643 7.82380 0.0
19+
16 3.08317 1.04191 12.6 18.3 5.15437 7.39118 0.0
20+
17 3.08294 1.04240 13.4 18.3 5.15906 7.00038 0.0
21+
18 3.09725 1.04819 14.2 18.3 5.16369 6.64312 0.0
22+
19 3.09824 1.04921 15.0 18.3 5.16749 6.31454 0.0
23+
20 3.11042 1.05404 15.7 18.3 5.13513 6.00821 0.0
24+
21 3.11112 1.05472 16.5 18.3 5.13986 5.72405 0.0
25+
22 3.11920 1.05799 17.3 18.3 5.14342 5.45745 0.0
26+
23 3.10218 1.05042 18.1 18.3 5.14564 5.20688 0.0
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import re
2+
3+
import pytest
4+
5+
from dodal.devices.i09_1_shared import calculate_gap_i09_hu, get_hu_lut_as_dict
6+
from tests.devices.i09_1_shared.test_data import TEST_HARD_UNDULATOR_LUT
7+
8+
9+
@pytest.fixture
10+
async def lut_dictionary() -> dict:
11+
return await get_hu_lut_as_dict(TEST_HARD_UNDULATOR_LUT)
12+
13+
14+
@pytest.mark.parametrize(
15+
"energy, order, expected_gap",
16+
[
17+
(2.13, 1, 12.81),
18+
(2.78, 3, 6.05),
19+
(6.24, 5, 7.95),
20+
],
21+
)
22+
async def test_calculate_gap_from_energy(
23+
energy: float,
24+
order: int,
25+
expected_gap: float,
26+
lut_dictionary: dict,
27+
):
28+
assert calculate_gap_i09_hu(energy, lut_dictionary, order) == pytest.approx(
29+
expected_gap, abs=0.01
30+
)
31+
32+
33+
async def test_calculate_gap_from_energy_wrong_order(
34+
lut_dictionary: dict,
35+
):
36+
wrong_order = 100
37+
with pytest.raises(
38+
ValueError,
39+
match=re.escape(f"Order parameter {wrong_order} not found in lookup table"),
40+
):
41+
calculate_gap_i09_hu(30, lut_dictionary, wrong_order)
42+
43+
44+
async def test_calculate_gap_from_energy_wrong_k(
45+
lut_dictionary: dict,
46+
):
47+
with pytest.raises(
48+
ValueError,
49+
match=re.escape(
50+
"Diffraction parameter squared must be positive! Calculated value -1.78"
51+
),
52+
):
53+
calculate_gap_i09_hu(30, lut_dictionary, 1)

0 commit comments

Comments
 (0)