Skip to content

Commit 7a0f1f9

Browse files
committed
Add MultiInstrumentCalibrator
This can calibrate multiple instruments simultaneously, while only allowing a small subset of instrument parameters to vary, or grain/material parameters to vary. Currently, the HEDM sample stage parameters (sample stage translation and chi) are the only instrument parameters which can vary between instruments. Otherwise, the detector tilts/translations and other parameters are shared and stay fixed between instruments. Even though only the sample stage parameters can currently be varied separately, it is relatively straightforward to extend this to other detector parameters too, as needed (we just need a unique prefix per detector). This is being used for a particular experiment done at CHESS, where a 3-grain ruby sample was scanned at 50 different sample stage locations. In this case, the detector parameters should all remain constant, and only the sample stage location vary between them. It is actually going to be very helpful for determining a good standard set of relative tilts/translations between subpanels for the Eiger. Signed-off-by: Patrick Avery <patrick.avery@kitware.com>
1 parent 90c7b97 commit 7a0f1f9

5 files changed

Lines changed: 485 additions & 40 deletions

File tree

hexrd/core/fitting/calibration/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .instrument import InstrumentCalibrator
1+
from .instrument import InstrumentCalibrator, MultiInstrumentCalibrator
22
from .laue import LaueCalibrator
33
from .lmfit_param_handling import fix_detector_y
44
from .powder import PowderCalibrator
@@ -10,6 +10,7 @@
1010
__all__ = [
1111
'fix_detector_y',
1212
'InstrumentCalibrator',
13+
'MultiInstrumentCalibrator',
1314
'LaueCalibrator',
1415
'PowderCalibrator',
1516
'StructurelessCalibrator',

hexrd/core/fitting/calibration/instrument.py

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import logging
2-
from typing import Optional
2+
from typing import Any, Optional, Sequence
33

44
import lmfit
55
import numpy as np
6+
from numpy.typing import NDArray
67

78
from .lmfit_param_handling import (
89
add_engineering_constraints,
@@ -25,6 +26,20 @@ def _normalized_ssqr(resd):
2526
return np.sum(resd * resd) / len(resd)
2627

2728

29+
def _params_equal(a: Any, b: Any) -> bool:
30+
# Recursively compare relative-constraint params, which may nest dicts of
31+
# numpy arrays (a plain ``==`` raises on array truthiness).
32+
if isinstance(a, dict) and isinstance(b, dict):
33+
if a.keys() != b.keys():
34+
return False
35+
return all(_params_equal(a[k], b[k]) for k in a)
36+
37+
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
38+
return np.array_equal(a, b)
39+
40+
return a == b
41+
42+
2843
class InstrumentCalibrator:
2944
def __init__(
3045
self,
@@ -215,3 +230,213 @@ def run_calibration(self, odict):
215230
self.update_all_from_params(self.params)
216231

217232
return result
233+
234+
235+
class MultiInstrumentCalibrator:
236+
"""Calibrate several instruments simultaneously with a shared geometry.
237+
238+
Each entry is an ``InstrumentCalibrator`` describing one dataset (for
239+
example, one rotation scan), with its own instrument and its own
240+
sub-calibrators (grains, powder rings, ...). The detector geometry (panel
241+
tilts/translations/distortion and the beam) is shared across every
242+
instrument, while the oscillation stage (``instr.chi`` and ``instr.tvec``)
243+
and the sub-calibrator parameters (grains, lattice, ...) remain independent
244+
per dataset.
245+
246+
Sharing is achieved through lmfit parameter names: the geometry parameters
247+
are created once (from the first calibrator) and every other instrument
248+
reads them by the same name. The stage parameters are created per dataset
249+
with a unique prefix so each dataset keeps its own rotation axis and sample
250+
offset. All residuals are stacked into a single vector and minimized
251+
together.
252+
"""
253+
254+
def __init__(
255+
self,
256+
calibrators: Sequence['InstrumentCalibrator'],
257+
labels: Optional[Sequence[str]] = None,
258+
engineering_constraints: Optional[str] = None,
259+
):
260+
assert len(calibrators) > 0, "must have at least one calibrator"
261+
self.calibrators = list(calibrators)
262+
263+
# The geometry (detectors + beam) is created only from calibrators[0]
264+
# and every other instrument reads it back by the same (unprefixed)
265+
# detector key / beam name. A mismatch would KeyError deep in the fit,
266+
# so require an identical detector layout across all calibrators.
267+
reference_detectors = set(self.calibrators[0].instr.detectors)
268+
reference_beams = set(self.calibrators[0].instr.beam_dict)
269+
for i, calib in enumerate(self.calibrators[1:], start=1):
270+
detectors = set(calib.instr.detectors)
271+
if detectors != reference_detectors:
272+
extra = detectors - reference_detectors
273+
missing = reference_detectors - detectors
274+
raise ValueError(
275+
f"calibrator {i} has a different detector layout than "
276+
f"calibrator 0; extra detectors: {sorted(extra)}, "
277+
f"missing detectors: {sorted(missing)}"
278+
)
279+
280+
beams = set(calib.instr.beam_dict)
281+
if beams != reference_beams:
282+
extra = beams - reference_beams
283+
missing = reference_beams - beams
284+
raise ValueError(
285+
f"calibrator {i} has a different beam layout than "
286+
f"calibrator 0; extra beams: {sorted(extra)}, "
287+
f"missing beams: {sorted(missing)}"
288+
)
289+
290+
# Only calibrators[0]'s relative_constraints actually take effect for
291+
# the shared geometry, so require every calibrator to agree on them.
292+
reference_constraints = self.calibrators[0].relative_constraints
293+
for i, calib in enumerate(self.calibrators[1:], start=1):
294+
constraints = calib.relative_constraints
295+
if type(constraints) is not type(
296+
reference_constraints
297+
) or not _params_equal(
298+
constraints.params, reference_constraints.params
299+
):
300+
raise ValueError(
301+
f"calibrator {i} has different relative_constraints than "
302+
f"calibrator 0; all calibrators must share the same "
303+
f"relative_constraints"
304+
)
305+
306+
if labels is None:
307+
labels = [f'scan_{i}' for i in range(len(self.calibrators))]
308+
assert len(labels) == len(self.calibrators), (
309+
"must have one label per calibrator"
310+
)
311+
if len(set(labels)) != len(labels):
312+
raise ValueError(f"labels must be unique, got: {labels}")
313+
self.labels = list(labels)
314+
315+
self._engineering_constraints = engineering_constraints
316+
317+
self.params = self.make_lmfit_params()
318+
319+
# Sync every instrument to the shared (first calibrator) geometry so
320+
# the initial residual is consistent before the first minimizer step.
321+
self.update_all_from_params(self.params)
322+
323+
self.fitter = lmfit.Minimizer(
324+
self.minimizer_function, self.params, nan_policy='omit'
325+
)
326+
327+
def _stage_prefix(self, index: int) -> str:
328+
return f'{self.labels[index]}_'
329+
330+
def make_lmfit_params(self) -> lmfit.Parameters:
331+
all_params = []
332+
for i, calib in enumerate(self.calibrators):
333+
# Geometry (beam + detectors) is shared, so it is only created for
334+
# the first instrument. Every other instrument references it by the
335+
# same parameter names.
336+
instr_params = create_instr_params(
337+
calib.instr,
338+
euler_convention=calib.euler_convention,
339+
relative_constraints=calib.relative_constraints,
340+
include_geometry=(i == 0),
341+
stage_prefix=self._stage_prefix(i),
342+
)
343+
all_params += instr_params
344+
345+
# Sub-calibrator params (grains, lattice, ...). Threading the
346+
# accumulated list means shared material params dedupe while
347+
# per-dataset grain params stay unique.
348+
for sub_calibrator in calib.calibrators:
349+
all_params += sub_calibrator.create_lmfit_params(all_params)
350+
351+
validate_params_list(all_params)
352+
353+
params_dict = lmfit.Parameters()
354+
params_dict.add_many(*all_params)
355+
356+
add_engineering_constraints(params_dict, self.engineering_constraints)
357+
return params_dict
358+
359+
def update_all_from_params(self, params: lmfit.Parameters) -> None:
360+
for i, calib in enumerate(self.calibrators):
361+
update_instrument_from_params(
362+
calib.instr,
363+
params,
364+
calib.euler_convention,
365+
calib.relative_constraints,
366+
stage_prefix=self._stage_prefix(i),
367+
)
368+
369+
for sub_calibrator in calib.calibrators:
370+
sub_calibrator.update_from_lmfit_params(params)
371+
372+
def minimizer_function(
373+
self, params: lmfit.Parameters
374+
) -> NDArray[np.float64]:
375+
self.update_all_from_params(params)
376+
return self.residual()
377+
378+
def residual(self) -> NDArray[np.float64]:
379+
return np.hstack([calib.residual() for calib in self.calibrators])
380+
381+
def minimize(
382+
self,
383+
method: str = 'least_squares',
384+
odict: Optional[dict[str, Any]] = None,
385+
) -> lmfit.minimizer.MinimizerResult:
386+
if odict is None:
387+
odict = {}
388+
389+
if method == 'least_squares':
390+
odict = {
391+
"ftol": 1e-8,
392+
"xtol": 1e-8,
393+
"gtol": 1e-8,
394+
"verbose": 2,
395+
"max_nfev": 1000,
396+
"x_scale": "jac",
397+
"method": "trf",
398+
"jac": "3-point",
399+
**odict,
400+
}
401+
402+
result = self.fitter.least_squares(self.params, **odict)
403+
else:
404+
result = self.fitter.scalar_minimize(
405+
method=method, params=self.params, max_nfev=50000, **odict
406+
)
407+
408+
return result
409+
410+
@property
411+
def engineering_constraints(self) -> Optional[str]:
412+
return self._engineering_constraints
413+
414+
def reset_lmfit_params(self) -> None:
415+
self.params = self.make_lmfit_params()
416+
417+
def run_calibration(
418+
self, odict: Optional[dict[str, Any]] = None
419+
) -> lmfit.minimizer.MinimizerResult:
420+
resd0 = self.residual()
421+
nrm_ssr_0 = _normalized_ssqr(resd0)
422+
423+
result = self.minimize(odict=odict)
424+
425+
resd1 = self.residual()
426+
nrm_ssr_1 = _normalized_ssqr(resd1)
427+
428+
delta_r = 1.0 - nrm_ssr_1 / nrm_ssr_0
429+
430+
if delta_r > 0:
431+
logger.debug('OPTIMIZATION SUCCESSFUL')
432+
else:
433+
logger.warning('no improvement in residual')
434+
435+
logger.info('normalized initial ssr: %.4e' % nrm_ssr_0)
436+
logger.info('normalized final ssr: %.4e' % nrm_ssr_1)
437+
logger.info('change in resdiual: %.4e' % delta_r)
438+
439+
self.params = result.params
440+
self.update_all_from_params(self.params)
441+
442+
return result

0 commit comments

Comments
 (0)