Skip to content

Commit 89c1322

Browse files
authored
Merge pull request #101 from yucongalicechen/mud
estimate mu*D from z-scan file
2 parents adee9b2 + 0013d35 commit 89c1322

File tree

6 files changed

+197
-0
lines changed

6 files changed

+197
-0
lines changed

src/diffpy/labpdfproc/labpdfprocapp.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,12 @@ def get_args(override_cli_inputs=None):
124124
),
125125
default=None,
126126
)
127+
p.add_argument(
128+
"-z",
129+
"--z-scan-file",
130+
help="Path to the z-scan file to be loaded to determine the mu*D value",
131+
default=None,
132+
)
127133
args = p.parse_args(override_cli_inputs)
128134
return args
129135

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import numpy as np
2+
from scipy.optimize import dual_annealing
3+
from scipy.signal import convolve
4+
5+
from diffpy.utils.parsers.loaddata import loadData
6+
7+
8+
def _top_hat(x, slit_width):
9+
"""
10+
create a top-hat function, return 1.0 for values within the specified slit width and 0 otherwise
11+
"""
12+
return np.where((x >= -slit_width) & (x <= slit_width), 1.0, 0)
13+
14+
15+
def _model_function(x, diameter, x0, I0, mud, slope):
16+
"""
17+
compute the model function with the following steps:
18+
1. Recenter x to h by subtracting x0 (so that the circle is centered at 0 and it is easier to compute length l)
19+
2. Compute length l that is the effective length for computing intensity I = I0 * e^{-mu * l}:
20+
- For h within the diameter range, l is the chord length of the circle at position h
21+
- For h outside this range, l = 0
22+
3. Apply a linear adjustment to I0 by taking I0 as I0 - slope * x
23+
"""
24+
min_radius = -diameter / 2
25+
max_radius = diameter / 2
26+
h = x - x0
27+
length = np.piecewise(
28+
h,
29+
[h < min_radius, (min_radius <= h) & (h <= max_radius), h > max_radius],
30+
[0, lambda h: 2 * np.sqrt((diameter / 2) ** 2 - h**2), 0],
31+
)
32+
return (I0 - slope * x) * np.exp(-mud / diameter * length)
33+
34+
35+
def _extend_x_and_convolve(x, diameter, slit_width, x0, I0, mud, slope):
36+
"""
37+
extend x values and I values for padding (so that we don't have tails in convolution), then perform convolution
38+
(note that the convolved I values are the same as modeled I values if slit width is close to 0)
39+
"""
40+
n_points = len(x)
41+
x_left_pad = np.linspace(x.min() - n_points * (x[1] - x[0]), x.min(), n_points)
42+
x_right_pad = np.linspace(x.max(), x.max() + n_points * (x[1] - x[0]), n_points)
43+
x_extended = np.concatenate([x_left_pad, x, x_right_pad])
44+
I_extended = _model_function(x_extended, diameter, x0, I0, mud, slope)
45+
kernel = _top_hat(x_extended - x_extended.mean(), slit_width)
46+
I_convolved = I_extended # this takes care of the case where slit width is close to 0
47+
if kernel.sum() != 0:
48+
kernel /= kernel.sum()
49+
I_convolved = convolve(I_extended, kernel, mode="same")
50+
padding_length = len(x_left_pad)
51+
return I_convolved[padding_length:-padding_length]
52+
53+
54+
def _objective_function(params, x, observed_data):
55+
"""
56+
compute the objective function for fitting a model to the observed/experimental data
57+
by minimizing the sum of squared residuals between the observed data and the convolved model data
58+
"""
59+
diameter, slit_width, x0, I0, mud, slope = params
60+
convolved_model_data = _extend_x_and_convolve(x, diameter, slit_width, x0, I0, mud, slope)
61+
residuals = observed_data - convolved_model_data
62+
return np.sum(residuals**2)
63+
64+
65+
def _compute_single_mud(x_data, I_data):
66+
"""
67+
perform dual annealing optimization and extract the parameters
68+
"""
69+
bounds = [
70+
(1e-5, x_data.max() - x_data.min()), # diameter: [small positive value, upper bound]
71+
(0, (x_data.max() - x_data.min()) / 2), # slit width: [0, upper bound]
72+
(x_data.min(), x_data.max()), # x0: [min x, max x]
73+
(1e-5, I_data.max()), # I0: [small positive value, max observed intensity]
74+
(1e-5, 20), # muD: [small positive value, upper bound]
75+
(-10000, 10000), # slope: [lower bound, upper bound]
76+
]
77+
result = dual_annealing(_objective_function, bounds, args=(x_data, I_data))
78+
diameter, slit_width, x0, I0, mud, slope = result.x
79+
convolved_fitted_signal = _extend_x_and_convolve(x_data, diameter, slit_width, x0, I0, mud, slope)
80+
residuals = I_data - convolved_fitted_signal
81+
rmse = np.sqrt(np.mean(residuals**2))
82+
return mud, rmse
83+
84+
85+
def compute_mud(filepath):
86+
"""
87+
compute the best-fit mu*D value from a z-scan file
88+
89+
Parameters
90+
----------
91+
filepath str
92+
the path to the z-scan file
93+
94+
Returns
95+
-------
96+
a float contains the best-fit mu*D value
97+
"""
98+
x_data, I_data = loadData(filepath, unpack=True)
99+
best_mud, _ = min((_compute_single_mud(x_data, I_data) for _ in range(10)), key=lambda pair: pair[1])
100+
return best_mud

src/diffpy/labpdfproc/tools.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import copy
22
from pathlib import Path
33

4+
from diffpy.labpdfproc.mud_calculator import compute_mud
45
from diffpy.utils.tools import get_package_info, get_user_info
56

67
WAVELENGTHS = {"Mo": 0.71, "Ag": 0.59, "Cu": 1.54}
@@ -134,6 +135,28 @@ def set_wavelength(args):
134135
return args
135136

136137

138+
def set_mud(args):
139+
"""
140+
Set the mud based on the given input arguments
141+
142+
Parameters
143+
----------
144+
args argparse.Namespace
145+
the arguments from the parser
146+
147+
Returns
148+
-------
149+
args argparse.Namespace
150+
"""
151+
if args.z_scan_file:
152+
filepath = Path(args.z_scan_file).resolve()
153+
if not filepath.is_file():
154+
raise FileNotFoundError(f"Cannot find {args.z_scan_file}. Please specify a valid file path.")
155+
args.z_scan_file = str(filepath)
156+
args.mud = compute_mud(filepath)
157+
return args
158+
159+
137160
def _load_key_value_pair(s):
138161
items = s.split("=")
139162
key = items[0].strip()
@@ -234,6 +257,7 @@ def preprocessing_args(args):
234257
args = set_input_lists(args)
235258
args.output_directory = set_output_directory(args)
236259
args = set_wavelength(args)
260+
args = set_mud(args)
237261
args = load_user_metadata(args)
238262
return args
239263

tests/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def user_filesystem(tmp_path):
1111
input_dir.mkdir(parents=True, exist_ok=True)
1212
home_dir = base_dir / "home_dir"
1313
home_dir.mkdir(parents=True, exist_ok=True)
14+
test_dir = base_dir / "test_dir"
15+
test_dir.mkdir(parents=True, exist_ok=True)
1416

1517
chi_data = "dataformat = twotheta\n mode = xray\n # chi_Q chi_I\n 1 2\n 3 4\n 5 6\n 7 8\n"
1618
xy_data = "1 2\n 3 4\n 5 6\n 7 8"
@@ -51,4 +53,19 @@ def user_filesystem(tmp_path):
5153
with open(home_dir / "diffpyconfig.json", "w") as f:
5254
json.dump(home_config_data, f)
5355

56+
z_scan_data = """
57+
-1.00000000 100000.00000000
58+
-0.77777778 100000.00000000
59+
-0.55555556 100000.00000000
60+
-0.33333333 10687.79256604
61+
-0.11111111 5366.53289631
62+
0.11111111 5366.53289631
63+
0.33333333 10687.79256604
64+
0.55555556 100000.00000000
65+
0.77777778 100000.00000000
66+
1.00000000 100000.00000000
67+
"""
68+
with open(test_dir / "testfile.xy", "w") as f:
69+
f.write(z_scan_data)
70+
5471
yield tmp_path

tests/test_mud_calculator.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
import pytest
5+
6+
from diffpy.labpdfproc.mud_calculator import _extend_x_and_convolve, compute_mud
7+
8+
9+
def test_compute_mud(tmp_path):
10+
diameter, slit_width, x0, I0, mud, slope = 1, 0.1, 0, 1e5, 3, 0
11+
x_data = np.linspace(-1, 1, 50)
12+
convolved_I_data = _extend_x_and_convolve(x_data, diameter, slit_width, x0, I0, mud, slope)
13+
14+
directory = Path(tmp_path)
15+
file = directory / "testfile"
16+
with open(file, "w") as f:
17+
for x, I in zip(x_data, convolved_I_data):
18+
f.write(f"{x}\t{I}\n")
19+
20+
expected_mud = 3
21+
actual_mud = compute_mud(file)
22+
assert actual_mud == pytest.approx(expected_mud, rel=1e-4, abs=0.1)

tests/test_tools.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
load_user_metadata,
1414
preprocessing_args,
1515
set_input_lists,
16+
set_mud,
1617
set_output_directory,
1718
set_wavelength,
1819
)
@@ -188,6 +189,32 @@ def test_set_wavelength_bad(inputs, msg):
188189
actual_args = set_wavelength(actual_args)
189190

190191

192+
def test_set_mud(user_filesystem):
193+
cli_inputs = ["2.5", "data.xy"]
194+
actual_args = get_args(cli_inputs)
195+
actual_args = set_mud(actual_args)
196+
assert actual_args.mud == pytest.approx(2.5, rel=1e-4, abs=0.1)
197+
assert actual_args.z_scan_file is None
198+
199+
cwd = Path(user_filesystem)
200+
test_dir = cwd / "test_dir"
201+
os.chdir(cwd)
202+
inputs = ["--z-scan-file", "test_dir/testfile.xy"]
203+
expected = [3, str(test_dir / "testfile.xy")]
204+
cli_inputs = ["2.5", "data.xy"] + inputs
205+
actual_args = get_args(cli_inputs)
206+
actual_args = set_mud(actual_args)
207+
assert actual_args.mud == pytest.approx(expected[0], rel=1e-4, abs=0.1)
208+
assert actual_args.z_scan_file == expected[1]
209+
210+
211+
def test_set_mud_bad():
212+
cli_inputs = ["2.5", "data.xy", "--z-scan-file", "invalid file"]
213+
actual_args = get_args(cli_inputs)
214+
with pytest.raises(FileNotFoundError, match="Cannot find invalid file. Please specify a valid file path."):
215+
actual_args = set_mud(actual_args)
216+
217+
191218
params5 = [
192219
([], []),
193220
(
@@ -317,5 +344,6 @@ def test_load_metadata(mocker, user_filesystem):
317344
"username": "cli_username",
318345
"email": "[email protected]",
319346
"package_info": {"diffpy.labpdfproc": "1.2.3", "diffpy.utils": "3.3.0"},
347+
"z_scan_file": None,
320348
}
321349
assert actual_metadata == expected_metadata

0 commit comments

Comments
 (0)