11import logging
2- from typing import Optional
2+ from typing import Any , Optional , Sequence
33
44import lmfit
55import numpy as np
6+ from numpy .typing import NDArray
67
78from .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+
2843class 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