Skip to content

Commit 2631aed

Browse files
committed
Add units and conversion capabilities to WPPF
This adds the basic capabilities for units and unit conversion to the WPPF module, and a few initial examples, including for lattice parameters. The lattice parameter lengths are automatically converted from nanometers to Angstroms for display (and back when the user modifies them). It also adds Angstrom units to the lattice lengths, and degree units to the lattice angles. We'll use this same infrastructure for other unit conversions. Signed-off-by: Patrick Avery <[email protected]>
1 parent b70947d commit 2631aed

File tree

5 files changed

+237
-18
lines changed

5 files changed

+237
-18
lines changed

hexrdgui/calibration/tree_item_models.py

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import numpy as np
2+
13
from PySide6.QtCore import Qt
24
from PySide6.QtGui import QColor
35

@@ -34,27 +36,61 @@ def set_config_val(self, path, value):
3436
# Now set the attribute on the param
3537
attribute = path[-1].removeprefix('_')
3638

39+
if attribute in ('value', 'min', 'max', 'delta'):
40+
# Check if there is a conversion we need to make before proceeding
41+
config = self.config_path(path[:-1])
42+
if config.get('_conversion_funcs'):
43+
# Apply the conversion
44+
value = config['_conversion_funcs']['from_display'](value)
45+
# Swap the min/max if they ought to be swapped
46+
# (due to the conversion resulting in an inverse proportionality)
47+
if (
48+
config.get('_min_max_inverted') and
49+
attribute in ('min', 'max')
50+
):
51+
attribute = 'max' if attribute == 'min' else 'min'
52+
3753
if attribute == 'value':
3854
# Make sure the min/max are shifted to accomodate this value
3955
if value < param.min or value > param.max:
56+
config = self.config_path(path[:-1])
57+
conversion_funcs = config.get('_conversion_funcs')
58+
min_key = '_min'
59+
max_key = '_max'
60+
if conversion_funcs and config.get('_min_max_inverted'):
61+
min_key, max_key = max_key, min_key
62+
63+
def convert_if_needed(v):
64+
if conversion_funcs is None:
65+
return v
66+
67+
return conversion_funcs['to_display'](v)
68+
4069
# Shift the min/max to accomodate, because lmfit won't
4170
# let us set the value otherwise.
4271
param.min = value - (param.value - param.min)
4372
param.max = value + (param.max - param.value)
44-
super().set_config_val(path[:-1] + ['_min'], param.min)
45-
super().set_config_val(path[:-1] + ['_max'], param.max)
73+
super().set_config_val(
74+
path[:-1] + [min_key], convert_if_needed(param.min),
75+
)
76+
super().set_config_val(
77+
path[:-1] + [max_key], convert_if_needed(param.max),
78+
)
4679

4780
col = list(self.COLUMNS.values()).index(path[-1]) + 1
4881
index = self.create_index(path[:-1], col)
4982
self.dict_modified.emit(index)
5083

5184
if '_min' in self.COLUMNS.values():
5285
# Get the GUI to update
53-
for name in ('_min', '_max'):
86+
for name, key in zip(('_min', '_max'), (min_key, max_key)):
5487
col = list(self.COLUMNS.values()).index(name) + 1
5588
index = self.create_index(path[:-1], col)
5689
item = self.get_item(index)
57-
item.set_data(index.column(), getattr(param, name[1:]))
90+
item.set_data(
91+
index.column(),
92+
convert_if_needed(getattr(param, key[1:])),
93+
)
5894
self.dataChanged.emit(index, index)
5995

6096
setattr(param, attribute, value)
@@ -82,7 +118,29 @@ def data(self, index, role):
82118

83119
return QColor(color)
84120

85-
return super().data(index, role)
121+
data = super().data(index, role)
122+
123+
if (
124+
role in (Qt.DisplayRole, Qt.EditRole) and
125+
index.column() in self.BOUND_INDICES and
126+
data is not None
127+
):
128+
# Check if there are any units that should be displayed
129+
item = self.get_item(index)
130+
path = self.path_to_item(item)
131+
config = self.config_path(path)
132+
133+
if role == Qt.DisplayRole and config.get('_units'):
134+
if isinstance(data, float):
135+
# Make sure it is rounded to 3 decimal places
136+
data = round(data, 3)
137+
138+
# Don't attach units to infinity
139+
is_inf = isinstance(data, float) and np.isinf(data)
140+
if not is_inf:
141+
data = f"{data}{config['_units']}"
142+
143+
return data
86144

87145

88146
class DefaultCalibrationTreeItemModel(CalibrationTreeItemModel):
@@ -116,7 +174,12 @@ def data(self, index, role):
116174
if index.column() not in pair:
117175
continue
118176

119-
if abs(item.data(pair[0]) - item.data(pair[1])) < atol:
177+
data0 = item.data(pair[0])
178+
data1 = item.data(pair[1])
179+
if (
180+
np.all([np.isinf(x) for x in (data0, data1)]) or
181+
abs(data0 - data1) < atol
182+
):
120183
return QColor('red')
121184

122185
return super().data(index, role)

hexrdgui/calibration/wppf_options_dialog.py

Lines changed: 156 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import copy
22
from functools import partial
33
from pathlib import Path
4+
import re
45
import sys
56
import time
67

78
import h5py
89
import lmfit
910
import matplotlib.pyplot as plt
1011
import numpy as np
11-
import re
1212
import yaml
1313

1414
from PySide6.QtCore import QObject, Signal
@@ -1158,14 +1158,45 @@ def tree_view_dict_of_params(self):
11581158
# Keep track of which params have been used.
11591159
used_params = []
11601160

1161-
def create_param_item(param):
1161+
def create_param_item(param, units=None, conversion_funcs=None,
1162+
min_max_inverted=False):
1163+
1164+
# Convert to display units if needed
1165+
def convert_if_needed(x):
1166+
if conversion_funcs is None:
1167+
return x
1168+
1169+
return conversion_funcs['to_display'](x)
1170+
1171+
def convert_stderr_if_needed(x):
1172+
if x == '--':
1173+
return x
1174+
1175+
if conversion_funcs is None:
1176+
return x
1177+
1178+
if 'to_display_stderr' in conversion_funcs:
1179+
# Special conversion needed
1180+
return conversion_funcs['to_display_stderr'](
1181+
param.value,
1182+
x,
1183+
)
1184+
1185+
# Default to the regular conversion
1186+
return convert_if_needed(x)
1187+
11621188
used_params.append(param.name)
1189+
stderr = stderr_values.get(param.name, '--')
11631190
d = {
11641191
'_param': param,
1165-
'_value': param.value,
1192+
'_value': convert_if_needed(param.value),
11661193
'_vary': bool(param.vary),
1167-
'_stderr': stderr_values.get(param.name, '--'),
1194+
'_stderr': convert_stderr_if_needed(stderr),
1195+
'_units': units,
1196+
'_conversion_funcs': conversion_funcs,
1197+
'_min_max_inverted': min_max_inverted,
11681198
}
1199+
11691200
if self.delta_boundaries:
11701201
if not hasattr(param, 'delta'):
11711202
# We store the delta on the param object
@@ -1176,12 +1207,15 @@ def create_param_item(param):
11761207
]
11771208
param.delta = min(diffs)
11781209

1179-
d['_delta'] = param.delta
1210+
d['_delta'] = convert_if_needed(param.delta)
11801211
else:
11811212
d.update(**{
1182-
'_min': param.min,
1183-
'_max': param.max,
1213+
'_min': convert_if_needed(param.min),
1214+
'_max': convert_if_needed(param.max),
11841215
})
1216+
if min_max_inverted:
1217+
# Swap the min and max
1218+
d['_min'], d['_max'] = d['_max'], d['_min']
11851219

11861220
# Make a callback for when `vary` gets modified by the user.
11871221
f = partial(self.on_param_vary_modified, param=param)
@@ -1211,6 +1245,10 @@ def recursively_set_items(this_config, this_template):
12111245
else:
12121246
# Assume it is a string. Grab it if in the params.
12131247
if v in params:
1248+
units = None
1249+
if v == 'zero_error':
1250+
units = '°'
1251+
12141252
this_config[k] = create_param_item(params[v])
12151253
param_set = True
12161254

@@ -1321,8 +1359,42 @@ def recursively_format_mat(mat, this_config, this_template):
13211359
if '{mat}' in v:
13221360
v = v.format(mat=sanitized_mat)
13231361

1362+
# Determine if units and conversion funcs are needed
1363+
# We can't have a global dict of these, because some
1364+
# labels are the same. For example, 'α' is also in the
1365+
# stacking fault parameters.
1366+
units = None
1367+
conversion_funcs = None
1368+
min_max_inverted = False
1369+
prefix = sanitized_mat
1370+
if v == f'{prefix}_X':
1371+
wlen = HexrdConfig().beam_wavelength
1372+
conversion_funcs = mat_lx_to_p_funcs_factory(wlen)
1373+
min_max_inverted = True
1374+
elif v == f'{prefix}_Y':
1375+
units = '%'
1376+
conversion_funcs = mat_ly_to_s_funcs
1377+
elif v == f'{prefix}_P':
1378+
wlen = HexrdConfig().beam_wavelength
1379+
conversion_funcs = mat_gp_to_p_funcs_factory(wlen)
1380+
min_max_inverted = True
1381+
elif v in [f'{prefix}_{k}' for k in ('a', 'b', 'c')]:
1382+
units = ' Å'
1383+
conversion_funcs = nm_to_angstroms_funcs
1384+
elif v in [f'{prefix}_{k}' for k in ('α', 'β', 'γ')]:
1385+
units = '°'
1386+
elif re.search(rf'^{prefix}_s\d\d\d$', v):
1387+
# It is a stacking parameter
1388+
conversion_funcs = shkl_to_angstroms_minus_4_funcs
1389+
units = ' Å⁻⁴'
1390+
13241391
if v in params:
1325-
this_config[k] = create_param_item(params[v])
1392+
this_config[k] = create_param_item(
1393+
params[v],
1394+
units,
1395+
conversion_funcs,
1396+
min_max_inverted,
1397+
)
13261398

13271399
mat_dict = tree_dict.setdefault('Materials', {})
13281400
for mat in self.selected_materials:
@@ -2381,6 +2453,82 @@ def changed_signal(w):
23812453
return w.valueChanged
23822454

23832455

2456+
nm_to_angstroms_funcs = {
2457+
'to_display': lambda x: x * 10,
2458+
'from_display': lambda x: x / 10,
2459+
}
2460+
2461+
2462+
shkl_to_angstroms_minus_4_funcs = {
2463+
'to_display': lambda x: x / 1000,
2464+
'from_display': lambda x: x * 1000,
2465+
}
2466+
2467+
2468+
mat_ly_to_s_funcs = {
2469+
'to_display': lambda ly: ly * 100 * np.pi / 18000,
2470+
'from_display': lambda s: s / 100 / np.pi * 18000,
2471+
'to_display_stderr': lambda ly, ly_stderr: 100 * np.pi / 18000 * ly_stderr
2472+
}
2473+
2474+
2475+
def mat_lx_to_p_funcs_factory(wlen: float) -> dict:
2476+
k = 0.91
2477+
2478+
def to_display(lx: float):
2479+
if abs(lx) <= 1e-8:
2480+
return np.inf
2481+
elif np.isinf(lx):
2482+
return 0
2483+
2484+
return 18000 * k * wlen / np.pi / lx
2485+
2486+
def from_display(p: float):
2487+
if abs(p) <= 1e-8:
2488+
return np.inf
2489+
elif np.isinf(p):
2490+
return 0
2491+
2492+
return 18000 * k * wlen / np.pi / p
2493+
2494+
def to_display_stderr(lx: float, lx_stderr: float) -> float:
2495+
return 18000 * k * wlen / np.pi / (lx**2) * lx_stderr
2496+
2497+
return {
2498+
'to_display': to_display,
2499+
'from_display': from_display,
2500+
'to_display_stderr': to_display_stderr,
2501+
}
2502+
2503+
2504+
def mat_gp_to_p_funcs_factory(wlen: float) -> dict:
2505+
k = 0.91
2506+
def to_display(gp: float) -> float:
2507+
if abs(gp) <= 1e-8:
2508+
return np.inf
2509+
elif np.isinf(gp):
2510+
return 0
2511+
2512+
return 18000 * k * wlen / np.pi / np.sqrt(gp)
2513+
2514+
def from_display(p: float) -> float:
2515+
if abs(p) <= 1e-8:
2516+
return np.inf
2517+
elif np.isinf(p):
2518+
return 0
2519+
2520+
return (18000 * k * wlen / np.pi / p)**2
2521+
2522+
def to_display_stderr(gp: float, gp_stderr: float) -> float:
2523+
return 9000 * k * wlen / np.pi / (gp**1.5) * gp_stderr
2524+
2525+
return {
2526+
'to_display': to_display,
2527+
'from_display': from_display,
2528+
'to_display_stderr': to_display_stderr,
2529+
}
2530+
2531+
23842532
if __name__ == '__main__':
23852533
from PySide6.QtWidgets import QApplication
23862534

hexrdgui/resources/wppf/tree_views/LeBail.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Materials:
1414
α: '{mat}_sf_alpha'
1515
β: '{mat}_twin_beta'
1616
Peak Broadening:
17-
Lorentzian Scherrer Broadening: '{mat}_X'
18-
Gaussian Scherrer Broadening: '{mat}_P'
17+
Lorentzian Particle Size: '{mat}_X'
18+
Gaussian Particle Size: '{mat}_P'
1919
Microstrain: '{mat}_Y'
2020
Lattice Constants:
2121
a: '{mat}_a'

hexrdgui/resources/wppf/tree_views/Rietveld.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ Materials:
1818
β: '{mat}_twin_beta'
1919
Phase Fraction: '{mat}_phase_fraction'
2020
Peak Broadening:
21-
Lorentzian Scherrer Broadening: '{mat}_X'
22-
Gaussian Scherrer Broadening: '{mat}_P'
21+
Lorentzian Particle Size: '{mat}_X'
22+
Gaussian Particle Size: '{mat}_P'
2323
Microstrain: '{mat}_Y'
2424
Lattice Constants:
2525
a: '{mat}_a'

hexrdgui/tree_views/multi_column_dict_tree_view.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,14 @@ def state_changed(self):
324324

325325
def createEditor(self, parent, option, index):
326326
editor = super().createEditor(parent, option, index)
327+
328+
if isinstance(editor, ScientificDoubleSpinBox):
329+
item = self.model.get_item(index)
330+
path = self.model.path_to_item(item)
331+
config = self.model.config_path(path)
332+
if config.get('_units'):
333+
editor.setSuffix(config['_units'])
334+
327335
if self.tree_view.has_disabled_editors:
328336
item = self.model.get_item(index)
329337
path = self.model.path_to_item(item) + [index.column()]

0 commit comments

Comments
 (0)