Skip to content

Commit

Permalink
Add option to set lmfit boundaries as a delta
Browse files Browse the repository at this point in the history
lmfit does not allow a delta to be used, but instead a min/max must
be specified. We can, however, allow a delta to be used in the GUI,
and then set the min/max on the lmfit parameters based upon this delta.
That is what is being done in this PR.

Using delta for boundaries is more intuitive for some workflows.

Signed-off-by: Patrick Avery <[email protected]>
  • Loading branch information
psavery committed Jun 10, 2024
1 parent 73ff4e4 commit c1c6f99
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 35 deletions.
198 changes: 163 additions & 35 deletions hexrdgui/calibration/calibration_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from hexrdgui.ui_loader import UiLoader
from hexrdgui.utils.dialog import add_help_url
from hexrdgui.utils.guess_instrument_type import guess_instrument_type

import hexrdgui.resources.calibration

Expand Down Expand Up @@ -65,6 +66,12 @@ def __init__(self, instr, params_dict, format_extra_params_func=None,
self.format_extra_params_func = format_extra_params_func
self.engineering_constraints = engineering_constraints

instr_type = guess_instrument_type(instr.detectors)
# Use delta boundaries by default for anything other than TARDIS
# and PXRDIP. We might want to change this to a whitelist later.
use_delta_boundaries = instr_type not in ('TARDIS', 'PXRDIP')
self.delta_boundaries = use_delta_boundaries

self.initialize_advanced_options()

self.load_tree_view_mapping()
Expand All @@ -79,6 +86,8 @@ def setup_connections(self):
self.ui.draw_picks.toggled.connect(self.on_draw_picks_toggled)
self.ui.engineering_constraints.currentIndexChanged.connect(
self.on_engineering_constraints_changed)
self.ui.delta_boundaries.toggled.connect(
self.on_delta_boundaries_toggled)
self.ui.edit_picks_button.clicked.connect(self.on_edit_picks_clicked)
self.ui.save_picks_button.clicked.connect(self.on_save_picks_clicked)
self.ui.load_picks_button.clicked.connect(self.on_load_picks_clicked)
Expand Down Expand Up @@ -166,6 +175,11 @@ def on_draw_picks_toggled(self, b):
self.draw_picks_toggled.emit(b)

def on_run_button_clicked(self):
if self.delta_boundaries:
# If delta boundaries are being used, set the min/max according to
# the delta boundaries. Lmfit requires min/max to run.
self.apply_delta_boundaries()

try:
self.validate_parameters()
except Exception as e:
Expand All @@ -182,6 +196,27 @@ def on_undo_run_button_clicked(self):
def finish(self):
self.finished.emit()

def apply_delta_boundaries(self):
# lmfit only uses min/max, not delta
# So if we used a delta, apply that to the min/max

if not self.delta_boundaries:
# We don't actually need to apply delta boundaries...
return

def recurse(cur):
for k, v in cur.items():
if '_param' in v:
param = v['_param']
# There should be a delta.
# We want an exception if it is missing.
param.min = param.value - param.delta
param.max = param.value + param.delta
elif isinstance(v, dict):
recurse(v)

recurse(self.tree_view.model().config)

def validate_parameters(self):
# Recursively look through the tree dict, and add on errors
config = self.tree_view.model().config
Expand All @@ -197,6 +232,11 @@ def recurse(cur):
full_path = '->'.join(path)
msg = f'{full_path}: min is greater than max'
errors.append(msg)
elif param.min == param.max:
# Slightly modify these to prevent lmfit
# from raising an exception.
param.min -= 1e-8
param.max += 1e-8
elif isinstance(v, dict):
recurse(v)
path.pop(-1)
Expand Down Expand Up @@ -237,6 +277,14 @@ def engineering_constraints(self, v):

w.setCurrentText(v)

@property
def delta_boundaries(self):
return self.ui.delta_boundaries.isChecked()

@delta_boundaries.setter
def delta_boundaries(self, b):
self.ui.delta_boundaries.setChecked(b)

def on_edit_picks_clicked(self):
self.edit_picks_clicked.emit()

Expand All @@ -263,6 +311,10 @@ def tth_distortion(self, v):
def on_engineering_constraints_changed(self):
self.engineering_constraints_changed.emit(self.engineering_constraints)

def on_delta_boundaries_toggled(self, b):
# The columns have changed, so we need to reinitialize the tree view
self.reinitialize_tree_view()

def update_from_calibrator(self, calibrator):
self.engineering_constraints = calibrator.engineering_constraints
self.tth_distortion = calibrator.tth_distortion
Expand All @@ -286,13 +338,28 @@ def tree_view_dict_of_params(self):

def create_param_item(param):
used_params.append(param.name)
return {
d = {
'_param': param,
'_value': param.value,
'_vary': bool(param.vary),
'_min': param.min,
'_max': param.max,
}
if self.delta_boundaries:
if not hasattr(param, 'delta'):
# We store the delta on the param object
# Default the delta to the minimum of the differences
diffs = [
abs(param.min - param.value),
abs(param.max - param.value),
]
param.delta = min(diffs)

d['_delta'] = param.delta
else:
d.update(**{
'_min': param.min,
'_max': param.max,
})
return d

# Treat these root keys specially
special_cases = [
Expand Down Expand Up @@ -395,15 +462,31 @@ def initialize_tree_view(self):
return

tree_dict = self.tree_view_dict_of_params
self.tree_view = MultiColumnDictTreeView(tree_dict, TREE_VIEW_COLUMNS,
parent=self.parent(),
model_class=TreeItemModel)
self.tree_view = MultiColumnDictTreeView(
tree_dict,
self.tree_view_columns,
parent=self.parent(),
model_class=self.tree_view_model_class,
)
self.tree_view.check_selection_index = 2
self.ui.tree_view_layout.addWidget(self.tree_view)

# Make the key section a little larger
self.tree_view.header().resizeSection(0, 300)

def reinitialize_tree_view(self):
# Keep the same scroll position
scrollbar = self.tree_view.verticalScrollBar()
scroll_value = scrollbar.value()

self.ui.tree_view_layout.removeWidget(self.tree_view)
self.tree_view.deleteLater()
del self.tree_view
self.initialize_tree_view()

# Restore scroll bar position
self.tree_view.verticalScrollBar().setValue(scroll_value)

def update_tree_view(self):
tree_dict = self.tree_view_dict_of_params
self.tree_view.model().config = tree_dict
Expand All @@ -422,37 +505,70 @@ def clear_polar_view_tth_correction(self, show_warning=True):
QMessageBox.information(self.parent(), 'HEXRD', msg)
editor.apply_to_polar_view = False

@property
def tree_view_columns(self):
return self.tree_view_model_class.COLUMNS

TREE_VIEW_COLUMNS = {
'Value': '_value',
'Vary': '_vary',
'Minimum': '_min',
'Maximum': '_max',
}
TREE_VIEW_COLUMN_INDICES = {
'Key': 0,
**{
k: list(TREE_VIEW_COLUMNS).index(k) + 1 for k in TREE_VIEW_COLUMNS
@property
def tree_view_model_class(self):
if self.delta_boundaries:
return DeltaTreeItemModel
else:
return DefaultTreeItemModel


def _tree_columns_to_indices(columns):
return {
'Key': 0,
**{
k: list(columns).index(k) + 1 for k in columns
}
}
}
VALUE_IDX = TREE_VIEW_COLUMN_INDICES['Value']
MAX_IDX = TREE_VIEW_COLUMN_INDICES['Maximum']
MIN_IDX = TREE_VIEW_COLUMN_INDICES['Minimum']
BOUND_INDICES = (VALUE_IDX, MAX_IDX, MIN_IDX)


class TreeItemModel(MultiColumnDictTreeItemModel):
"""Subclass the tree item model so we can customize some behavior"""

def set_config_val(self, path, value):
super().set_config_val(path, value)
# Now set the parameter too
param_path = path[:-1] + ['_param']
try:
param = self.config_val(param_path)
except KeyError:
raise Exception('Failed to set parameter!', param_path)

# Now set the attribute on the param
attribute = path[-1].removeprefix('_')

setattr(param, attribute, value)


class DefaultTreeItemModel(TreeItemModel):
"""This model uses minimum/maximum for the boundary constraints"""
COLUMNS = {
'Value': '_value',
'Vary': '_vary',
'Minimum': '_min',
'Maximum': '_max',
}
COLUMN_INDICES = _tree_columns_to_indices(COLUMNS)

VALUE_IDX = COLUMN_INDICES['Value']
MAX_IDX = COLUMN_INDICES['Maximum']
MIN_IDX = COLUMN_INDICES['Minimum']
BOUND_INDICES = (VALUE_IDX, MAX_IDX, MIN_IDX)

def data(self, index, role):
if role == Qt.ForegroundRole and index.column() in BOUND_INDICES:
if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES:
# If a value hit the boundary, color both the boundary and the
# value red.
item = self.get_item(index)
if not item.child_items:
atol = 1e-3
pairs = [
(VALUE_IDX, MAX_IDX),
(VALUE_IDX, MIN_IDX),
(self.VALUE_IDX, self.MAX_IDX),
(self.VALUE_IDX, self.MIN_IDX),
]
for pair in pairs:
if index.column() not in pair:
Expand All @@ -463,18 +579,30 @@ def data(self, index, role):

return super().data(index, role)

def set_config_val(self, path, value):
super().set_config_val(path, value)
# Now set the parameter too
param_path = path[:-1] + ['_param']
try:
param = self.config_val(param_path)
except KeyError:
raise Exception('Failed to set parameter!', param_path)

# Now set the attribute on the param
attribute = path[-1].removeprefix('_')
setattr(param, attribute, value)
class DeltaTreeItemModel(TreeItemModel):
"""This model uses the delta for the parameters"""
COLUMNS = {
'Value': '_value',
'Vary': '_vary',
'Delta': '_delta',
}
COLUMN_INDICES = _tree_columns_to_indices(COLUMNS)

VALUE_IDX = COLUMN_INDICES['Value']
DELTA_IDX = COLUMN_INDICES['Delta']
BOUND_INDICES = (VALUE_IDX, DELTA_IDX)

def data(self, index, role):
if role == Qt.ForegroundRole and index.column() in self.BOUND_INDICES:
# If a delta is zero, color both the delta and the value red.
item = self.get_item(index)
if not item.child_items:
atol = 1e-3
if abs(item.data(self.DELTA_IDX)) < atol:
return QColor('red')

return super().data(index, role)


TILT_LABELS_EULER = {
Expand Down
7 changes: 7 additions & 0 deletions hexrdgui/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ class LLNLTransform:
'IMAGE-PLATE-3',
'IMAGE-PLATE-4',
],
'PXRDIP': [
'IMAGE-PLATE-B',
'IMAGE-PLATE-D',
'IMAGE-PLATE-L',
'IMAGE-PLATE-R',
'IMAGE-PLATE-U',
],
}

KEY_ROTATE_ANGLE_FINE = 0.00175
Expand Down
9 changes: 9 additions & 0 deletions hexrdgui/resources/ui/calibration_dialog.ui
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QCheckBox" name="delta_boundaries">
<property name="text">
<string>Use delta for boundaries</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
Expand Down Expand Up @@ -431,6 +438,7 @@ See scipy.optimize.least_squares for more details.</string>
<tabstops>
<tabstop>draw_picks</tabstop>
<tabstop>engineering_constraints</tabstop>
<tabstop>delta_boundaries</tabstop>
<tabstop>edit_picks_button</tabstop>
<tabstop>save_picks_button</tabstop>
<tabstop>load_picks_button</tabstop>
Expand All @@ -442,6 +450,7 @@ See scipy.optimize.least_squares for more details.</string>
<tabstop>max_nfev</tabstop>
<tabstop>jac</tabstop>
<tabstop>method</tabstop>
<tabstop>undo_run_button</tabstop>
<tabstop>run_button</tabstop>
</tabstops>
<resources/>
Expand Down

0 comments on commit c1c6f99

Please sign in to comment.