ChessAnalysisPipeline 0.0.14__py3-none-any.whl → 0.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ChessAnalysisPipeline might be problematic. Click here for more details.

Files changed (38) hide show
  1. CHAP/__init__.py +1 -1
  2. CHAP/common/__init__.py +13 -0
  3. CHAP/common/models/integration.py +29 -26
  4. CHAP/common/models/map.py +395 -224
  5. CHAP/common/processor.py +1725 -93
  6. CHAP/common/reader.py +265 -28
  7. CHAP/common/writer.py +191 -18
  8. CHAP/edd/__init__.py +9 -2
  9. CHAP/edd/models.py +886 -665
  10. CHAP/edd/processor.py +2592 -936
  11. CHAP/edd/reader.py +889 -0
  12. CHAP/edd/utils.py +846 -292
  13. CHAP/foxden/__init__.py +6 -0
  14. CHAP/foxden/processor.py +42 -0
  15. CHAP/foxden/writer.py +65 -0
  16. CHAP/giwaxs/__init__.py +8 -0
  17. CHAP/giwaxs/models.py +100 -0
  18. CHAP/giwaxs/processor.py +520 -0
  19. CHAP/giwaxs/reader.py +5 -0
  20. CHAP/giwaxs/writer.py +5 -0
  21. CHAP/pipeline.py +48 -10
  22. CHAP/runner.py +161 -72
  23. CHAP/tomo/models.py +31 -29
  24. CHAP/tomo/processor.py +169 -118
  25. CHAP/utils/__init__.py +1 -0
  26. CHAP/utils/fit.py +1292 -1315
  27. CHAP/utils/general.py +411 -53
  28. CHAP/utils/models.py +594 -0
  29. CHAP/utils/parfile.py +10 -2
  30. ChessAnalysisPipeline-0.0.16.dist-info/LICENSE +60 -0
  31. {ChessAnalysisPipeline-0.0.14.dist-info → ChessAnalysisPipeline-0.0.16.dist-info}/METADATA +1 -1
  32. ChessAnalysisPipeline-0.0.16.dist-info/RECORD +62 -0
  33. {ChessAnalysisPipeline-0.0.14.dist-info → ChessAnalysisPipeline-0.0.16.dist-info}/WHEEL +1 -1
  34. CHAP/utils/scanparsers.py +0 -1431
  35. ChessAnalysisPipeline-0.0.14.dist-info/LICENSE +0 -21
  36. ChessAnalysisPipeline-0.0.14.dist-info/RECORD +0 -54
  37. {ChessAnalysisPipeline-0.0.14.dist-info → ChessAnalysisPipeline-0.0.16.dist-info}/entry_points.txt +0 -0
  38. {ChessAnalysisPipeline-0.0.14.dist-info → ChessAnalysisPipeline-0.0.16.dist-info}/top_level.txt +0 -0
CHAP/edd/processor.py CHANGED
@@ -18,19 +18,17 @@ import numpy as np
18
18
  # Local modules
19
19
  from CHAP.processor import Processor
20
20
 
21
+ # Current good detector channels for the 23 channel EDD detector:
22
+ # 0, 2, 3, 5, 6, 7, 8, 10, 13, 14, 16, 17, 18, 19, 21, 22
21
23
 
22
24
  class DiffractionVolumeLengthProcessor(Processor):
23
25
  """A Processor using a steel foil raster scan to calculate the
24
26
  length of the diffraction volume for an EDD setup.
25
27
  """
26
28
 
27
- def process(self,
28
- data,
29
- config=None,
30
- save_figures=False,
31
- inputdir='.',
32
- outputdir='.',
33
- interactive=False):
29
+ def process(
30
+ self, data, config=None, save_figures=False, inputdir='.',
31
+ outputdir='.', interactive=False):
34
32
  """Return the calculated value of the DV length.
35
33
 
36
34
  :param data: Input configuration for the raw scan data & DVL
@@ -41,17 +39,17 @@ class DiffractionVolumeLengthProcessor(Processor):
41
39
  `None`.
42
40
  :type config: dict, optional
43
41
  :param save_figures: Save .pngs of plots for checking inputs &
44
- outputs of this Processor, defaults to False.
42
+ outputs of this Processor, defaults to `False`.
45
43
  :type save_figures: bool, optional
46
44
  :param outputdir: Directory to which any output figures will
47
- be saved, defaults to '.'
45
+ be saved, defaults to `'.'`.
48
46
  :type outputdir: str, optional
49
47
  :param inputdir: Input directory, used only if files in the
50
48
  input configuration are not absolute paths,
51
- defaults to '.'.
49
+ defaults to `'.'`.
52
50
  :type inputdir: str, optional
53
51
  :param interactive: Allows for user interactions, defaults to
54
- False.
52
+ `False`.
55
53
  :type interactive: bool, optional
56
54
  :raises RuntimeError: Unable to get a valid DVL configuration.
57
55
  :return: Complete DVL configuraiton dictionary.
@@ -85,12 +83,9 @@ class DiffractionVolumeLengthProcessor(Processor):
85
83
 
86
84
  return dvl_config.dict()
87
85
 
88
- def measure_dvl(self,
89
- dvl_config,
90
- detector,
91
- save_figures=False,
92
- outputdir='.',
93
- interactive=False):
86
+ def measure_dvl(
87
+ self, dvl_config, detector, save_figures=False, outputdir='.',
88
+ interactive=False):
94
89
  """Return a measured value for the length of the diffraction
95
90
  volume. Use the iron foil raster scan data provided in
96
91
  `dvl_config` and fit a gaussian to the sum of all MCA channel
@@ -105,13 +100,13 @@ class DiffractionVolumeLengthProcessor(Processor):
105
100
  :type detector:
106
101
  CHAP.edd.models.MCAElementDiffractionVolumeLengthConfig
107
102
  :param save_figures: Save .pngs of plots for checking inputs &
108
- outputs of this Processor, defaults to False.
103
+ outputs of this Processor, defaults to `False`.
109
104
  :type save_figures: bool, optional
110
105
  :param outputdir: Directory to which any output figures will
111
- be saved, defaults to '.'.
106
+ be saved, defaults to `'.'`.
112
107
  :type outputdir: str, optional
113
108
  :param interactive: Allows for user interactions, defaults to
114
- False.
109
+ `False`.
115
110
  :type interactive: bool, optional
116
111
  :raises ValueError: No value provided for included bin ranges
117
112
  for the MCA detector element.
@@ -126,42 +121,46 @@ class DiffractionVolumeLengthProcessor(Processor):
126
121
  )
127
122
 
128
123
  # Get raw MCA data from raster scan
124
+ raise RuntimeError('DiffractionVolumeLengthProcessor not updated yet')
129
125
  mca_data = dvl_config.mca_data(detector)
130
126
 
131
- # Interactively set mask, if needed & possible.
132
- if interactive or save_figures:
133
- # Third party modules
134
- import matplotlib.pyplot as plt
127
+ # Blank out data below bin 500 (~25keV) as well as the last bin
128
+ # RV Not backward compatible with old detector
129
+ energy_mask = np.ones(detector.num_bins, dtype=np.int16)
130
+ energy_mask[:500] = 0
131
+ energy_mask[-1] = 0
132
+ mca_data = mca_data*energy_mask
135
133
 
136
- self.logger.info(
137
- 'Interactively select a mask in the matplotlib figure')
138
-
139
- fig, mask, include_bin_ranges = select_mask_1d(
134
+ # Interactively set or update mask, if needed & possible.
135
+ if interactive or save_figures:
136
+ if interactive:
137
+ self.logger.info(
138
+ 'Interactively select a mask in the matplotlib figure')
139
+ if save_figures:
140
+ filename = os.path.join(
141
+ outputdir, f'{detector.detector_name}_dvl_mask.png')
142
+ else:
143
+ filename = None
144
+ _, detector.include_bin_ranges = select_mask_1d(
140
145
  np.sum(mca_data, axis=0),
141
- x=np.linspace(0, detector.max_energy_kev, detector.num_bins),
146
+ x=np.arange(detector.num_bins, dtype=np.int16),
142
147
  label='Sum of MCA spectra over all scan points',
143
148
  preselected_index_ranges=detector.include_bin_ranges,
144
149
  title='Click and drag to select data range to include when '
145
150
  'measuring diffraction volume length',
146
- xlabel='Uncalibrated Energy (keV)',
151
+ xlabel='Uncalibrated energy (keV)',
147
152
  ylabel='MCA intensity (counts)',
148
153
  min_num_index_ranges=1,
149
- interactive=interactive)
150
- detector.include_energy_ranges = detector.get_energy_ranges(
151
- include_bin_ranges)
154
+ interactive=interactive, filename=filename)
152
155
  self.logger.debug(
153
- 'Mask selected. Including detector energy ranges: '
154
- + str(detector.include_energy_ranges))
155
- if save_figures:
156
- fig.savefig(os.path.join(
157
- outputdir, f'{detector.detector_name}_dvl_mask.png'))
158
- plt.close()
159
- if not detector.include_energy_ranges:
156
+ 'Mask selected. Including detector bin ranges: '
157
+ + str(detector.include_bin_ranges))
158
+ if not detector.include_bin_ranges:
160
159
  raise ValueError(
161
- 'No value provided for include_energy_ranges. '
162
- + 'Provide them in the Diffraction Volume Length '
163
- + 'Measurement Configuration, or re-run the pipeline '
164
- + 'with the --interactive flag.')
160
+ 'No value provided for include_bin_ranges. '
161
+ 'Provide them in the Diffraction Volume Length '
162
+ 'Measurement Configuration, or re-run the pipeline '
163
+ 'with the --interactive flag.')
165
164
 
166
165
  # Reduce the raw MCA data in 3 ways:
167
166
  # 1) sum of intensities in all detector bins
@@ -195,17 +194,18 @@ class DiffractionVolumeLengthProcessor(Processor):
195
194
  detector.fit_sigma = fit.best_values['sigma']
196
195
  if detector.measurement_mode == 'manual':
197
196
  if interactive:
198
- _, _, dvl_bounds = select_mask_1d(
197
+ _, dvl_bounds = select_mask_1d(
199
198
  masked_sum, x=x,
200
199
  label='Total (masked & normalized)',
201
200
  preselected_index_ranges=[
202
201
  (index_nearest(x, -dvl/2), index_nearest(x, dvl/2))],
203
202
  title=('Click and drag to indicate the boundary '
204
203
  'of the diffraction volume'),
205
- xlabel=('Beam Direction (offset from scan "center")'),
204
+ xlabel=('Beam direction (offset from scan "center")'),
206
205
  ylabel='MCA intensity (normalized)',
207
206
  min_num_index_ranges=1,
208
- max_num_index_ranges=1)
207
+ max_num_index_ranges=1,
208
+ interactive=interactive)
209
209
  dvl_bounds = dvl_bounds[0]
210
210
  dvl = abs(x[dvl_bounds[1]] - x[dvl_bounds[0]])
211
211
  else:
@@ -220,7 +220,7 @@ class DiffractionVolumeLengthProcessor(Processor):
220
220
 
221
221
  fig, ax = plt.subplots()
222
222
  ax.set_title(f'Diffraction Volume ({detector.detector_name})')
223
- ax.set_xlabel('Beam Direction (offset from scan "center")')
223
+ ax.set_xlabel('Beam direction (offset from scan "center")')
224
224
  ax.set_ylabel('MCA intensity (normalized)')
225
225
  ax.plot(x, masked_sum, label='total (masked & normalized)')
226
226
  ax.plot(x, fit.best_fit, label='gaussian fit (to total)')
@@ -252,19 +252,27 @@ class DiffractionVolumeLengthProcessor(Processor):
252
252
 
253
253
  class LatticeParameterRefinementProcessor(Processor):
254
254
  """Processor to get a refined estimate for a sample's lattice
255
- parameters"""
256
- def process(self,
257
- data,
258
- config=None,
259
- save_figures=False,
260
- outputdir='.',
261
- inputdir='.',
262
- interactive=False):
255
+ parameters."""
256
+ def process(
257
+ self, data, config=None, save_figures=False, outputdir='.',
258
+ inputdir='.', interactive=False):
263
259
  """Given a strain analysis configuration, return a copy
264
260
  contining refined values for the materials' lattice
265
261
  parameters."""
266
- ceria_calibration_config = self.get_config(
267
- data, 'edd.models.MCACeriaCalibrationConfig', inputdir=inputdir)
262
+ # Third party modules
263
+ from nexusformat.nexus import (
264
+ NXdata,
265
+ NXentry,
266
+ NXsubentry,
267
+ NXprocess,
268
+ NXroot,
269
+ )
270
+
271
+ # Local modules
272
+ from CHAP.common import MapProcessor
273
+
274
+ calibration_config = self.get_config(
275
+ data, 'edd.models.MCATthCalibrationConfig', inputdir=inputdir)
268
276
  try:
269
277
  strain_analysis_config = self.get_config(
270
278
  data, 'edd.models.StrainAnalysisConfig', inputdir=inputdir)
@@ -285,70 +293,295 @@ class LatticeParameterRefinementProcessor(Processor):
285
293
  self.logger.error('Not implemented for multiple materials')
286
294
  raise NotImplementedError(msg)
287
295
 
288
- lattice_parameters = self.refine_lattice_parameters(
289
- strain_analysis_config, ceria_calibration_config, 0,
290
- interactive, save_figures, outputdir)
291
- self.logger.debug(f'Refined lattice parameters: {lattice_parameters}')
296
+ # Collect the raw MCA data
297
+ raise RuntimeError(
298
+ 'LatticeParameterRefinementProcessor not updated yet')
299
+ self.logger.debug(f'Reading data ...')
300
+ mca_data = strain_analysis_config.mca_data()
301
+ self.logger.debug(f'... done')
302
+ self.logger.debug(f'mca_data.shape: {mca_data.shape}')
303
+ if mca_data.ndim == 2:
304
+ mca_data_summed = mca_data
305
+ else:
306
+ mca_data_summed = np.mean(
307
+ mca_data, axis=tuple(np.arange(1, mca_data.ndim-1)))
308
+ self.logger.debug(f'mca_data_summed.shape: {mca_data_summed.shape}')
292
309
 
293
- strain_analysis_config.materials[0].lattice_parameters = \
294
- lattice_parameters
295
- return strain_analysis_config.dict()
310
+ # Create the NXroot object
311
+ nxroot = NXroot()
312
+ nxentry = NXentry()
313
+ nxroot.entry = nxentry
314
+ nxentry.set_default()
315
+ nxsubentry = NXsubentry()
316
+ nxentry.nexus_output = nxsubentry
317
+ nxsubentry.attrs['schema'] = 'h5'
318
+ nxsubentry.attrs['filename'] = 'lattice_parameter_map.nxs'
319
+ map_config = strain_analysis_config.map_config
320
+ nxsubentry[map_config.title] = MapProcessor.get_nxentry(map_config)
321
+ nxsubentry[f'{map_config.title}_lat_par_refinement'] = NXprocess()
322
+ nxprocess = nxsubentry[f'{map_config.title}_lat_par_refinement']
323
+ nxprocess.strain_analysis_config = dumps(
324
+ strain_analysis_config.dict())
325
+ nxprocess.calibration_config = dumps(calibration_config.dict())
326
+
327
+ lattice_parameters = []
328
+ for i, detector in enumerate(strain_analysis_config.detectors):
329
+ lattice_parameters.append(self.refine_lattice_parameters(
330
+ strain_analysis_config, calibration_config, detector,
331
+ mca_data[i], mca_data_summed[i], nxsubentry, interactive,
332
+ save_figures, outputdir))
333
+ lattice_parameters_mean = np.asarray(lattice_parameters).mean(axis=0)
334
+ self.logger.debug(
335
+ 'Lattice parameters refinement averaged over all detectors: '
336
+ f'{lattice_parameters_mean}')
337
+ strain_analysis_config.materials[0].lattice_parameters = [
338
+ float(v) for v in lattice_parameters_mean]
339
+ nxprocess.lattice_parameters = lattice_parameters_mean
340
+
341
+ nxentry.lat_par_output = NXsubentry()
342
+ nxentry.lat_par_output.attrs['schema'] = 'yaml'
343
+ nxentry.lat_par_output.attrs['filename'] = 'lattice_parameters.yaml'
344
+ nxentry.lat_par_output.data = dumps(
345
+ strain_analysis_config.dict())
346
+
347
+ return nxroot
296
348
 
297
349
  def refine_lattice_parameters(
298
- self, strain_analysis_config, ceria_calibration_config,
299
- detector_i, interactive, save_figures, outputdir):
350
+ self, strain_analysis_config, calibration_config, detector,
351
+ mca_data, mca_data_summed, nxsubentry, interactive, save_figures,
352
+ outputdir):
300
353
  """Return refined values for the lattice parameters of the
301
354
  materials indicated in `strain_analysis_config`. Method: given
302
- a scan of a material, fit the peaks of each MCA
303
- spectrum. Based on those fitted peak locations, calculate the
304
- lattice parameters that would produce them. Return the
305
- avearaged value of the calculated lattice parameters across
306
- all spectra.
307
-
308
- :param strain_analysis_config: Strain analysis configuration
309
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
310
- :param detector_i: Index of the detector in
311
- `strain_analysis_config` whose data will be used for the
312
- refinement
313
- :type detector_i: int
355
+ a scan of a material, fit the peaks of each MCA spectrum for a
356
+ given detector. Based on those fitted peak locations,
357
+ calculate the lattice parameters that would produce them.
358
+ Return the averaged value of the calculated lattice parameters
359
+ across all spectra.
360
+
361
+ :param strain_analysis_config: Strain analysis configuration.
362
+ :type strain_analysis_config:
363
+ CHAP.edd.models.StrainAnalysisConfig
364
+ :param calibration_config: Energy calibration configuration.
365
+ :type calibration_config: edd.models.MCATthCalibrationConfig
366
+ :param detector: A single MCA detector element configuration.
367
+ :type detector: CHAP.edd.models.MCAElementStrainAnalysisConfig
368
+ :param mca_data: Raw specta for the current MCA detector.
369
+ :type mca_data: numpy.ndarray
370
+ :param mca_data_summed: Raw specta for the current MCA detector
371
+ summed over all data point.
372
+ :type mca_data_summed: numpy.ndarray
373
+ :param nxsubentry: NeXus subentry to store the detailed refined
374
+ lattice parameters for each detector.
375
+ :type nxsubentry: nexusformat.nexus.NXprocess
314
376
  :param interactive: Boolean to indicate whether interactive
315
- matplotlib figures should be presented
377
+ matplotlib figures should be presented.
316
378
  :type interactive: bool
317
379
  :param save_figures: Boolean to indicate whether figures
318
- indicating the selection should be saved
380
+ indicating the selection should be saved.
319
381
  :type save_figures: bool
320
382
  :param outputdir: Where to save figures (if `save_figures` is
321
- `True`)
383
+ `True`).
322
384
  :type outputdir: str
323
385
  :returns: List of refined lattice parameters for materials in
324
386
  `strain_analysis_config`
325
387
  :rtype: list[numpy.ndarray]
326
388
  """
327
- import numpy as np
328
- from CHAP.edd.utils import get_unique_hkls_ds, get_spectra_fits
389
+ # Third party modules
390
+ from nexusformat.nexus import (
391
+ NXcollection,
392
+ NXdata,
393
+ NXdetector,
394
+ NXfield,
395
+ )
396
+ from scipy.constants import physical_constants
329
397
 
330
- self.add_detector_calibrations(
331
- strain_analysis_config, ceria_calibration_config)
398
+ # Local modules
399
+ from CHAP.edd.utils import (
400
+ get_peak_locations,
401
+ get_spectra_fits,
402
+ get_unique_hkls_ds,
403
+ )
332
404
 
333
- detector = strain_analysis_config.detectors[detector_i]
334
- mca_bin_energies = self.get_mca_bin_energies(strain_analysis_config)
335
- mca_data = strain_analysis_config.mca_data()
405
+ def linkdims(nxgroup, field_dims=[]):
406
+ if isinstance(field_dims, dict):
407
+ field_dims = [field_dims]
408
+ if map_config.map_type == 'structured':
409
+ axes = deepcopy(map_config.dims)
410
+ for dims in field_dims:
411
+ axes.append(dims['axes'])
412
+ else:
413
+ axes = ['map_index']
414
+ for dims in field_dims:
415
+ axes.append(dims['axes'])
416
+ nxgroup.attrs[f'map_index_indices'] = 0
417
+ for dim in map_config.dims:
418
+ nxgroup.makelink(nxsubentry[map_config.title].data[dim])
419
+ if f'{dim}_indices' in nxsubentry[map_config.title].data.attrs:
420
+ nxgroup.attrs[f'{dim}_indices'] = \
421
+ nxsubentry[map_config.title].data.attrs[
422
+ f'{dim}_indices']
423
+ nxgroup.attrs['axes'] = axes
424
+ for dims in field_dims:
425
+ nxgroup.attrs[f'{dims["axes"]}_indices'] = dims['index']
426
+
427
+ # Get and add the calibration info to the detector
428
+ calibration = [
429
+ d for d in calibration_config.detectors \
430
+ if d.detector_name == detector.detector_name][0]
431
+ detector.add_calibration(calibration)
432
+
433
+ # Get the MCA bin energies
434
+ mca_bin_energies = detector.energies
435
+
436
+ # Blank out data below 25 keV as well as the last bin
437
+ energy_mask = np.where(mca_bin_energies >= 25.0, 1, 0)
438
+ energy_mask[-1] = 0
439
+
440
+ # Subtract the baseline
441
+ if detector.baseline:
442
+ # Local modules
443
+ from CHAP.edd.models import BaselineConfig
444
+ from CHAP.common.processor import ConstructBaseline
445
+
446
+ if isinstance(detector.baseline, bool):
447
+ detector.baseline = BaselineConfig()
448
+ if save_figures:
449
+ filename = os.path.join(
450
+ outputdir,
451
+ f'{detector.detector_name}_lat_param_refinement_'
452
+ 'baseline.png')
453
+ else:
454
+ filename = None
455
+ baseline, baseline_config = \
456
+ ConstructBaseline.construct_baseline(
457
+ mca_data_summed, mask=energy_mask,
458
+ tol=detector.baseline.tol, lam=detector.baseline.lam,
459
+ max_iter=detector.baseline.max_iter,
460
+ title=
461
+ f'Baseline for detector {detector.detector_name}',
462
+ xlabel='Energy (keV)', ylabel='Intensity (counts)',
463
+ interactive=interactive, filename=filename)
464
+
465
+ mca_data_summed -= baseline
466
+ detector.baseline.lam = baseline_config['lambda']
467
+ detector.baseline.attrs['num_iter'] = \
468
+ baseline_config['num_iter']
469
+ detector.baseline.attrs['error'] = baseline_config['error']
470
+
471
+ # Get the unique HKLs and lattice spacings for the strain
472
+ # analysis materials
336
473
  hkls, ds = get_unique_hkls_ds(
337
474
  strain_analysis_config.materials,
338
475
  tth_tol=detector.hkl_tth_tol,
339
476
  tth_max=detector.tth_max)
340
477
 
341
- self.select_material_params(
342
- strain_analysis_config, detector_i, mca_data, mca_bin_energies,
343
- interactive, save_figures, outputdir)
344
- self.logger.debug(
345
- 'Starting lattice parameters: '
346
- + str(strain_analysis_config.materials[0].lattice_parameters))
347
- self.select_fit_mask_hkls(
348
- strain_analysis_config, detector_i, mca_data, mca_bin_energies,
349
- hkls, ds,
350
- interactive, save_figures, outputdir)
478
+ if interactive or save_figures:
479
+ # Local modules
480
+ from CHAP.edd.utils import (
481
+ select_material_params,
482
+ select_mask_and_hkls,
483
+ )
484
+
485
+ # Interactively adjust the initial material parameters
486
+ tth = detector.tth_calibrated
487
+ if save_figures:
488
+ filename = os.path.join(
489
+ outputdir,
490
+ f'{detector.detector_name}_lat_param_refinement_'
491
+ 'initial_material_config.png')
492
+ else:
493
+ filename = None
494
+ strain_analysis_config.materials = select_material_params(
495
+ mca_bin_energies, mca_data_summed*energy_mask, tth,
496
+ preselected_materials=strain_analysis_config.materials,
497
+ label='Sum of all spectra in the map',
498
+ interactive=interactive, filename=filename)
499
+ self.logger.debug(
500
+ f'materials: {strain_analysis_config.materials}')
501
+
502
+ # Interactively adjust the mask and HKLs used in the
503
+ # lattice parameter refinement
504
+ if save_figures:
505
+ filename = os.path.join(
506
+ outputdir,
507
+ f'{detector.detector_name}_lat_param_refinement_'
508
+ 'fit_mask_hkls.png')
509
+ else:
510
+ filename = None
511
+ include_bin_ranges, hkl_indices = \
512
+ select_mask_and_hkls(
513
+ mca_bin_energies, mca_data_summed*energy_mask,
514
+ hkls, ds, detector.tth_calibrated,
515
+ preselected_bin_ranges=detector.include_bin_ranges,
516
+ preselected_hkl_indices=detector.hkl_indices,
517
+ detector_name=detector.detector_name,
518
+ ref_map=mca_data*energy_mask,
519
+ calibration_bin_ranges=detector.calibration_bin_ranges,
520
+ label='Sum of all spectra in the map',
521
+ interactive=interactive, filename=filename)
522
+ detector.include_energy_ranges = \
523
+ detector.get_include_energy_ranges(include_bin_ranges)
524
+ detector.hkl_indices = hkl_indices
525
+ self.logger.debug(
526
+ f'include_energy_ranges for detector {detector.detector_name}:'
527
+ f' {detector.include_energy_ranges}')
528
+ self.logger.debug(
529
+ f'hkl_indices for detector {detector.detector_name}:'
530
+ f' {detector.hkl_indices}')
531
+ if not detector.include_energy_ranges:
532
+ raise ValueError(
533
+ 'No value provided for include_energy_ranges. '
534
+ 'Provide them in the MCA Tth Calibration Configuration, '
535
+ 'or re-run the pipeline with the --interactive flag.')
536
+ if not detector.hkl_indices:
537
+ raise ValueError(
538
+ 'No value provided for hkl_indices. Provide them in '
539
+ 'the detector\'s MCA Tth Calibration Configuration, or'
540
+ ' re-run the pipeline with the --interactive flag.')
351
541
 
542
+ effective_map_shape = mca_data.shape[:-1]
543
+ mask = detector.mca_mask()
544
+ energies = mca_bin_energies[mask]
545
+ intensities = np.empty(
546
+ (*effective_map_shape, len(energies)), dtype=np.float64)
547
+ for map_index in np.ndindex(effective_map_shape):
548
+ if detector.baseline:
549
+ intensities[map_index] = \
550
+ (mca_data[map_index]-baseline).astype(
551
+ np.float64)[mask]
552
+ else:
553
+ intensities[map_index] = \
554
+ mca_data[map_index].astype(np.float64)[mask]
555
+ mean_intensity = np.mean(
556
+ intensities, axis=tuple(range(len(effective_map_shape))))
557
+ hkls_fit = np.asarray([hkls[i] for i in detector.hkl_indices])
558
+ ds_fit = np.asarray([ds[i] for i in detector.hkl_indices])
559
+ Rs = np.sqrt(np.sum(hkls_fit**2, 1))
560
+ peak_locations = get_peak_locations(
561
+ ds_fit, detector.tth_calibrated)
562
+
563
+ map_config = strain_analysis_config.map_config
564
+ nxprocess = nxsubentry[f'{map_config.title}_lat_par_refinement']
565
+ nxprocess[detector.detector_name] = NXdetector()
566
+ nxdetector = nxprocess[detector.detector_name]
567
+ nxdetector.local_name = detector.detector_name
568
+ nxdetector.detector_config = dumps(detector.dict())
569
+ nxdetector.data = NXdata()
570
+ det_nxdata = nxdetector.data
571
+ linkdims(
572
+ det_nxdata,
573
+ {'axes': 'energy', 'index': len(effective_map_shape)})
574
+ det_nxdata.energy = NXfield(value=energies, attrs={'units': 'keV'})
575
+ det_nxdata.intensity = NXfield(
576
+ value=intensities,
577
+ shape=(*effective_map_shape, len(energies)),
578
+ dtype=np.float64,
579
+ attrs={'units': 'counts'})
580
+ det_nxdata.mean_intensity = mean_intensity
581
+
582
+ # Get the interplanar spacings measured for each fit HKL peak
583
+ # at every point in the map to get the refined estimate
584
+ # for the material's lattice parameter
352
585
  (uniform_fit_centers, uniform_fit_centers_errors,
353
586
  uniform_fit_amplitudes, uniform_fit_amplitudes_errors,
354
587
  uniform_fit_sigmas, uniform_fit_sigmas_errors,
@@ -359,311 +592,941 @@ class LatticeParameterRefinementProcessor(Processor):
359
592
  unconstrained_fit_sigmas, unconstrained_fit_sigmas_errors,
360
593
  unconstrained_best_fit, unconstrained_residuals,
361
594
  unconstrained_redchi, unconstrained_success) = \
362
- self.get_spectra_fits(
363
- strain_analysis_config, detector_i,
364
- mca_data, mca_bin_energies, hkls, ds)
595
+ get_spectra_fits(
596
+ intensities, energies, peak_locations, detector)
597
+ Rs_map = Rs.repeat(np.prod(effective_map_shape))
598
+ d_uniform = get_peak_locations(
599
+ np.asarray(uniform_fit_centers), detector.tth_calibrated)
600
+ a_uniform = (Rs_map * d_uniform.flatten()).reshape(d_uniform.shape)
601
+ a_uniform = a_uniform.mean(axis=0)
602
+ a_uniform_mean = float(a_uniform.mean())
603
+ d_unconstrained = get_peak_locations(
604
+ unconstrained_fit_centers, detector.tth_calibrated)
605
+ a_unconstrained = (
606
+ Rs_map * d_unconstrained.flatten()).reshape(d_unconstrained.shape)
607
+ a_unconstrained = np.moveaxis(a_unconstrained, 0, -1)
608
+ a_unconstrained_mean = float(a_unconstrained.mean())
609
+ self.logger.warning(
610
+ 'Lattice parameter refinement assumes cubic lattice')
611
+ self.logger.info(
612
+ f'Refined lattice parameter from uniform fit: {a_uniform_mean}')
613
+ self.logger.info(
614
+ 'Refined lattice parameter from unconstrained fit: '
615
+ f'{a_unconstrained_mean}')
616
+ nxdetector.lat_pars = NXcollection()
617
+ nxdetector.lat_pars.uniform = NXdata()
618
+ nxdata = nxdetector.lat_pars.uniform
619
+ nxdata.nxsignal = NXfield(
620
+ value=a_uniform, name='a_uniform', attrs={'units': r'\AA'})
621
+ linkdims(nxdata)
622
+ nxdetector.lat_pars.a_uniform_mean = float(a_uniform.mean())
623
+ nxdetector.lat_pars.unconstrained = NXdata()
624
+ nxdata = nxdetector.lat_pars.unconstrained
625
+ nxdata.nxsignal = NXfield(
626
+ value=a_unconstrained, name='a_unconstrained',
627
+ attrs={'units': r'\AA'})
628
+ nxdata.hkl_index = detector.hkl_indices
629
+ linkdims(
630
+ nxdata,
631
+ {'axes': 'hkl_index', 'index': len(effective_map_shape)})
632
+ nxdetector.lat_pars.a_unconstrained_mean = a_unconstrained_mean
365
633
 
366
634
  # Get the interplanar spacings measured for each fit HKL peak
367
- # at every point in the map.
368
- from scipy.constants import physical_constants
369
- hc = 1e7 * physical_constants['Planck constant in eV/Hz'][0] \
370
- * physical_constants['speed of light in vacuum'][0]
371
- d_measured = hc / \
372
- (2.0 \
373
- * unconstrained_fit_centers \
374
- * np.sin(np.radians(detector.tth_calibrated / 2.0)))
375
- # Convert interplanar spacings to lattice parameters
376
- self.logger.warning('Implemented for cubic materials only!')
377
- fit_hkls = np.asarray([hkls[i] for i in detector.hkl_indices])
378
- Rs = np.sqrt(np.sum(fit_hkls**2, 1))
379
- a_measured = Rs[:, None] * d_measured
380
- # Average all computed lattice parameters for every fit HKL
381
- # peak at every point in the map to get the refined estimate
382
- # for the material's lattice parameter
383
- a_refined = float(np.mean(a_measured))
384
- return [a_refined, a_refined, a_refined, 90.0, 90.0, 90.0]
635
+ # at the spectrum averaged over every point in the map to get
636
+ # the refined estimate for the material's lattice parameter
637
+ (uniform_fit_centers, uniform_fit_centers_errors,
638
+ uniform_fit_amplitudes, uniform_fit_amplitudes_errors,
639
+ uniform_fit_sigmas, uniform_fit_sigmas_errors,
640
+ uniform_best_fit, uniform_residuals,
641
+ uniform_redchi, uniform_success,
642
+ unconstrained_fit_centers, unconstrained_fit_centers_errors,
643
+ unconstrained_fit_amplitudes, unconstrained_fit_amplitudes_errors,
644
+ unconstrained_fit_sigmas, unconstrained_fit_sigmas_errors,
645
+ unconstrained_best_fit, unconstrained_residuals,
646
+ unconstrained_redchi, unconstrained_success) = \
647
+ get_spectra_fits(
648
+ mean_intensity, energies, peak_locations, detector)
649
+ d_uniform = get_peak_locations(
650
+ np.asarray(uniform_fit_centers), detector.tth_calibrated)
651
+ d_unconstrained = get_peak_locations(
652
+ np.asarray(unconstrained_fit_centers), detector.tth_calibrated)
653
+ a_uniform = float((Rs * d_uniform).mean())
654
+ a_unconstrained = (Rs * d_unconstrained)
655
+ self.logger.warning(
656
+ 'Lattice parameter refinement assumes cubic lattice')
657
+ self.logger.info(
658
+ 'Refined lattice parameter from uniform fit over averaged '
659
+ f'spectrum: {a_uniform}')
660
+ self.logger.info(
661
+ 'Refined lattice parameter from unconstrained fit over averaged '
662
+ f'spectrum: {a_unconstrained}')
663
+ nxdetector.lat_pars_mean_intensity = NXcollection()
664
+ nxdetector.lat_pars_mean_intensity.a_uniform = a_uniform
665
+ nxdetector.lat_pars_mean_intensity.a_unconstrained = a_unconstrained
666
+ nxdetector.lat_pars_mean_intensity.a_unconstrained_mean = \
667
+ float(a_unconstrained.mean())
385
668
 
386
- def get_mca_bin_energies(self, strain_analysis_config):
387
- """Return a list of the MCA bin energies for each detector.
669
+ if interactive or save_figures:
670
+ # Third party modules
671
+ import matplotlib.pyplot as plt
388
672
 
389
- :param strain_analysis_config: Strain analysis configuration
390
- containing a list of detectors to return the bin energies
391
- for.
392
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
393
- :returns: List of MCA bin energies
394
- :rtype: list[numpy.ndarray]
673
+ fig, ax = plt.subplots(figsize=(11, 8.5))
674
+ ax.set_title(f'Detector {detector.detector_name}: '
675
+ 'Lattice Parameter Refinement')
676
+ ax.set_xlabel('Detector energy (keV)')
677
+ ax.set_ylabel('Mean intensity (a.u.)')
678
+ ax.plot(energies, mean_intensity, 'k.', label='MCA data')
679
+ ax.plot(energies, uniform_best_fit, 'r', label='Best uniform fit')
680
+ ax.plot(
681
+ energies, unconstrained_best_fit, 'b',
682
+ label='Best unconstrained fit')
683
+ ax.legend()
684
+ for i, loc in enumerate(peak_locations):
685
+ ax.axvline(loc, c='k', ls='--')
686
+ ax.text(loc, 1, str(hkls_fit[i])[1:-1],
687
+ ha='right', va='top', rotation=90,
688
+ transform=ax.get_xaxis_transform())
689
+ if save_figures:
690
+ fig.tight_layout()#rect=(0, 0, 1, 0.95))
691
+ figfile = os.path.join(
692
+ outputdir, f'{detector.detector_name}_lat_param_fits.png')
693
+ plt.savefig(figfile)
694
+ self.logger.info(f'Saved figure to {figfile}')
695
+ if interactive:
696
+ plt.show()
697
+
698
+ return [
699
+ a_uniform, a_uniform, a_uniform, 90., 90., 90.]
700
+
701
+
702
+ class MCAEnergyCalibrationProcessor(Processor):
703
+ """Processor to return parameters for linearly transforming MCA
704
+ channel indices to energies (in keV). Procedure: provide a
705
+ spectrum from the MCA element to be calibrated and the theoretical
706
+ location of at least one peak present in that spectrum (peak
707
+ locations must be given in keV). It is strongly recommended to use
708
+ the location of fluorescence peaks whenever possible, _not_
709
+ diffraction peaks, as this Processor does not account for
710
+ 2&theta."""
711
+ def process(
712
+ self, data, config=None, centers_range=20, fwhm_min=None,
713
+ fwhm_max=None, max_energy_kev=200.0, background=None,
714
+ baseline=False, save_figures=False, interactive=False,
715
+ inputdir='.', outputdir='.'):
716
+ """For each detector in the `MCAEnergyCalibrationConfig`
717
+ provided with `data`, fit the specified peaks in the MCA
718
+ spectrum specified. Using the difference between the provided
719
+ peak locations and the fit centers of those peaks, compute
720
+ the correction coefficients to convert uncalibrated MCA
721
+ channel energies to calibrated channel energies. For each
722
+ detector, set `energy_calibration_coeffs` in the calibration
723
+ config provided to these values and return the updated
724
+ configuration.
725
+
726
+ :param data: An energy Calibration configuration.
727
+ :type data: PipelineData
728
+ :param config: Initialization parameters for an instance of
729
+ CHAP.edd.models.MCAEnergyCalibrationConfig, defaults to
730
+ `None`.
731
+ :type config: dict, optional
732
+ :param centers_range: Set boundaries on the peak centers in
733
+ MCA channels when performing the fit. The min/max
734
+ possible values for the peak centers will be the initial
735
+ values ± `centers_range`. Defaults to `20`.
736
+ :type centers_range: int, optional
737
+ :param fwhm_min: Lower bound on the peak FWHM in MCA channels
738
+ when performing the fit, defaults to `None`.
739
+ :type fwhm_min: float, optional
740
+ :param fwhm_max: Lower bound on the peak FWHM in MCA channels
741
+ when performing the fit, defaults to `None`.
742
+ :type fwhm_max: float, optional
743
+ :param max_energy_kev: Maximum channel energy of the MCA in
744
+ keV, defaults to `200.0`.
745
+ :type max_energy_kev: float, optional
746
+ :param background: Background model for peak fitting.
747
+ :type background: str, list[str], optional
748
+ :param baseline: Automated baseline subtraction configuration,
749
+ defaults to `False`.
750
+ :type baseline: bool, BaselineConfig, optional
751
+ :param save_figures: Save .pngs of plots for checking inputs &
752
+ outputs of this Processor, defaults to `False`.
753
+ :type save_figures: bool, optional
754
+ :param interactive: Allows for user interactions, defaults to
755
+ `False`.
756
+ :type interactive: bool, optional
757
+ :param inputdir: Input directory, used only if files in the
758
+ input configuration are not absolute paths,
759
+ defaults to `'.'`.
760
+ :type inputdir: str, optional
761
+ :param outputdir: Directory to which any output figures will
762
+ be saved, defaults to `'.'`.
763
+ :type outputdir: str, optional
764
+ :returns: Dictionary representing the energy-calibrated
765
+ version of the calibrated configuration.
766
+ :rtype: dict
395
767
  """
396
- mca_bin_energies = []
397
- for i, detector in enumerate(strain_analysis_config.detectors):
398
- mca_bin_energies.append(
399
- detector.slope_calibrated
400
- * np.linspace(0, detector.max_energy_kev, detector.num_bins)
401
- + detector.intercept_calibrated)
402
- return mca_bin_energies
403
-
404
- def add_detector_calibrations(
405
- self, strain_analysis_config, ceria_calibration_config):
406
- """Add calibrated quantities to the detectors configured in
407
- `strain_analysis_config`, modifying `strain_analysis_config`
408
- in place.
409
-
410
- :param strain_analysis_config: Strain analysisi configuration
411
- containing a list of detectors to add calibration values
412
- to.
413
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
414
- :param ceria_calibration_config: Configuration of a completed
415
- ceria calibration containing a list of detector swith the
416
- same names as those in `strain_analysis_config`
417
- :returns: None"""
418
- for detector in strain_analysis_config.detectors:
419
- calibration = [
420
- d for d in ceria_calibration_config.detectors \
421
- if d.detector_name == detector.detector_name][0]
422
- detector.add_calibration(calibration)
423
-
424
- def select_fit_mask_hkls(
425
- self, strain_analysis_config, detector_i,
426
- mca_data, mca_bin_energies, hkls, ds,
427
- interactive, save_figures, outputdir):
428
- """Select the maks & HKLs to use for fitting for each
429
- detector. Update `strain_analysis_config` with new values for
430
- `hkl_indices` and `include_bin_ranges` if needed.
431
-
432
- :param strain_analysis_config: Strain analysis configuration
433
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
434
- :param detector_i: Index of the detector in
435
- `strain_analysis_config` to select mask & HKLs for.
436
- :type detector_i: int
437
- :param mca_data: List of maps of MCA spectra for all detectors
438
- in `strain_analysis_config`
439
- :type mca_data: list[numpy.ndarray]
440
- :param mca_bin_energies: List of MCA bin energies for all
441
- detectors in `strain_analysis_config`
442
- :type mca_bin_energies: list[numpy.ndarray]
443
- :param hkls: Nominal HKL peak energy locations for the
444
- material in `strain_analysis_config`
445
- :type hkls: list[float]
446
- :param ds: Nominal d-spacing for the material in
447
- `strain_analysis_config`
448
- :type ds: list[float]
449
- :param interactive: Boolean to indicate whether interactive
450
- matplotlib figures should be presented
451
- :type interactive: bool
452
- :param save_figures: Boolean to indicate whether figures
453
- indicating the selection should be saved
768
+ # Third party modules
769
+ from nexusformat.nexus import (
770
+ NXentry,
771
+ NXroot,
772
+ )
773
+
774
+ # Local modules
775
+ from CHAP.edd.models import (
776
+ BaselineConfig,
777
+ MCAElementCalibrationConfig,
778
+ )
779
+ from CHAP.utils.general import (
780
+ is_int,
781
+ is_num,
782
+ is_str_series,
783
+ )
784
+
785
+ # Load the detector data
786
+ # FIX input a numpy and create/use NXobject to numpy proc
787
+ # FIX right now spec info is lost in output yaml, add to it?
788
+ nxroot = self.get_data(data, 'SpecReader')
789
+ if not isinstance(nxroot, NXroot):
790
+ raise RuntimeError('No valid NXroot data in input pipeline data')
791
+ nxentry = nxroot[nxroot.default]
792
+
793
+ # Load the validated energy calibration configuration
794
+ try:
795
+ calibration_config = self.get_config(
796
+ data, 'edd.models.MCAEnergyCalibrationConfig',
797
+ inputdir=inputdir)
798
+ except Exception as data_exc:
799
+ self.logger.info('No valid calibration config in input pipeline '
800
+ 'data, using config parameter instead.')
801
+ try:
802
+ # Local modules
803
+ from CHAP.edd.models import MCAEnergyCalibrationConfig
804
+
805
+ calibration_config = MCAEnergyCalibrationConfig(
806
+ **config, inputdir=inputdir)
807
+ except Exception as dict_exc:
808
+ raise RuntimeError from dict_exc
809
+
810
+ # Validate the detector configuration
811
+ available_detector_indices = [
812
+ int(d) for d in nxentry.detector_names]
813
+ if calibration_config.detectors is None:
814
+ calibration_config.detectors = [
815
+ MCAElementCalibrationConfig(detector_name=d)
816
+ for d in nxentry.detector_names]
817
+ else:
818
+ for detector in deepcopy(calibration_config.detectors):
819
+ index = int(detector.detector_name)
820
+ if index not in available_detector_indices:
821
+ self.logger.warning(
822
+ f'Skipping detector {int} (no raw data)')
823
+ calibration_config.detectors.remove(detector)
824
+
825
+ # Validate the fit index range
826
+ if calibration_config.fit_index_ranges is None and not interactive:
827
+ raise RuntimeError(
828
+ 'If `fit_index_ranges` is not explicitly provided, '
829
+ + self.__class__.__name__
830
+ + ' must be run with `interactive=True`.')
831
+
832
+ # Validate the optional inputs
833
+ if not is_int(centers_range, gt=0, log=False):
834
+ raise RuntimeError(
835
+ f'Invalid centers_range parameter ({centers_range})')
836
+ if fwhm_min is not None and not is_int(fwhm_min, gt=0, log=False):
837
+ raise RuntimeError(f'Invalid fwhm_min parameter ({fwhm_min})')
838
+ if fwhm_max is not None and not is_int(fwhm_max, gt=0, log=False):
839
+ raise RuntimeError(f'Invalid fwhm_max parameter ({fwhm_max})')
840
+ if not is_num(max_energy_kev, gt=0, log=False):
841
+ raise RuntimeError(
842
+ f'Invalid max_energy_kev parameter ({max_energy_kev})')
843
+ if background is not None:
844
+ if isinstance(background, str):
845
+ background = [background]
846
+ elif not is_str_series(background, log=False):
847
+ raise RuntimeError(
848
+ f'Invalid background parameter ({background})')
849
+ if isinstance(baseline, bool):
850
+ if baseline:
851
+ baseline = BaselineConfig()
852
+ else:
853
+ try:
854
+ baseline = BaselineConfig(**baseline)
855
+ except Exception as dict_exc:
856
+ raise RuntimeError from dict_exc
857
+
858
+ # Collect and sum the detector data
859
+ mca_data = []
860
+ for scan_name in nxentry.spec_scans:
861
+ for scan_number, scan_data in nxentry.spec_scans[scan_name].items():
862
+ mca_data.append(scan_data.data.data.nxdata)
863
+ summed_detector_data = np.asarray(mca_data).sum(axis=(0,1))
864
+
865
+ # Get the detectors' num_bins parameter
866
+ for detector in calibration_config.detectors:
867
+ if detector.num_bins is None:
868
+ detector.num_bins = summed_detector_data.shape[-1]
869
+
870
+ # Check each detector's include_energy_ranges field against the
871
+ # flux file, if available.
872
+ if calibration_config.flux_file is not None:
873
+ flux = np.loadtxt(flux_file)
874
+ flux_file_energies = flux[:,0]/1.e3
875
+ flux_e_min = flux_file_energies.min()
876
+ flux_e_max = flux_file_energies.max()
877
+ for detector in calibration_config.detectors:
878
+ for i, (det_e_min, det_e_max) in enumerate(
879
+ deepcopy(detector.include_energy_ranges)):
880
+ if det_e_min < flux_e_min or det_e_max > flux_e_max:
881
+ energy_range = [float(max(det_e_min, flux_e_min)),
882
+ float(min(det_e_max, flux_e_max))]
883
+ print(
884
+ f'WARNING: include_energy_ranges[{i}] out of range'
885
+ f' ({detector.include_energy_ranges[i]}): adjusted'
886
+ f' to {energy_range}')
887
+ detector.include_energy_ranges[i] = energy_range
888
+
889
+ # Calibrate detector channel energies based on fluorescence peaks
890
+ for detector in calibration_config.detectors:
891
+ index = available_detector_indices.index(
892
+ int(detector.detector_name))
893
+ if background is not None:
894
+ detector.background = background.copy()
895
+ if baseline:
896
+ detector.baseline = baseline.model_copy()
897
+ detector.energy_calibration_coeffs = self.calibrate(
898
+ calibration_config, detector, summed_detector_data[index],
899
+ centers_range, fwhm_min, fwhm_max, max_energy_kev,
900
+ save_figures, interactive, outputdir)
901
+
902
+ return calibration_config.dict()
903
+
904
+ def calibrate(
905
+ self, calibration_config, detector, spectrum, centers_range,
906
+ fwhm_min, fwhm_max, max_energy_kev, save_figures, interactive,
907
+ outputdir):
908
+ """Return energy_calibration_coeffs (a, b, and c) for
909
+ quadratically converting the current detector's MCA channels
910
+ to bin energies.
911
+
912
+ :param calibration_config: Energy calibration configuration.
913
+ :type calibration_config: MCAEnergyCalibrationConfig
914
+ :param detector: Configuration of the current detector.
915
+ :type detector: MCAElementCalibrationConfig
916
+ :param spectrum: Summed MCA spectrum for the current detector.
917
+ :type spectrum: numpy.ndarray
918
+ :param centers_range: Set boundaries on the peak centers in
919
+ MCA channels when performing the fit. The min/max
920
+ possible values for the peak centers will be the initial
921
+ values &pm; `centers_range`. Defaults to `20`.
922
+ :type centers_range: int, optional
923
+ :param fwhm_min: Lower bound on the peak FWHM in MCA channels
924
+ when performing the fit, defaults to `None`.
925
+ :type fwhm_min: float, optional
926
+ :param fwhm_max: Lower bound on the peak FWHM in MCA channels
927
+ when performing the fit, defaults to `None`.
928
+ :type fwhm_max: float, optional
929
+ :param max_energy_kev: Maximum channel energy of the MCA in
930
+ keV, defaults to `200.0`.
931
+ :type max_energy_kev: float
932
+ :param save_figures: Save .pngs of plots for checking inputs &
933
+ outputs of this Processor.
454
934
  :type save_figures: bool
455
- :param outputdir: Where to save figures (if `save_figures` is
456
- `True`)
935
+ :param interactive: Allows for user interactions.
936
+ :type interactive: bool
937
+ :param outputdir: Directory to which any output figures will
938
+ be saved.
457
939
  :type outputdir: str
458
- :returns: None
940
+ :returns: Slope and intercept for linearly correcting the
941
+ detector's MCA channels to bin energies.
942
+ :rtype: tuple[float, float]
459
943
  """
460
- if not interactive and not save_figures:
461
- return
462
- import matplotlib.pyplot as plt
463
- import numpy as np
464
- from CHAP.edd.utils import select_mask_and_hkls
465
-
466
- detector = strain_analysis_config.detectors[detector_i]
467
- fig, include_bin_ranges, hkl_indices = \
468
- select_mask_and_hkls(
469
- mca_bin_energies[detector_i],
470
- np.sum(mca_data[detector_i], axis=0),
471
- hkls, ds,
472
- detector.tth_calibrated,
473
- detector.include_bin_ranges, detector.hkl_indices,
474
- detector.detector_name, mca_data[detector_i],
475
- calibration_bin_ranges=detector.calibration_bin_ranges,
476
- label='Sum of all spectra in the map',
477
- interactive=interactive)
478
- detector.include_energy_ranges = detector.get_energy_ranges(
479
- include_bin_ranges)
480
- detector.hkl_indices = hkl_indices
944
+ # Third party modules
945
+ from nexusformat.nexus import (
946
+ NXdata,
947
+ NXfield,
948
+ )
949
+
950
+ # Local modules
951
+ from CHAP.utils.fit import FitProcessor
952
+ from CHAP.utils.general import (
953
+ index_nearest,
954
+ index_nearest_down,
955
+ index_nearest_up,
956
+ select_mask_1d,
957
+ )
958
+
959
+ self.logger.info(f'Calibrating detector {detector.detector_name}')
960
+
961
+ # Get the MCA bin energies
962
+ uncalibrated_energies = np.linspace(
963
+ 0, max_energy_kev, detector.num_bins)
964
+ bins = np.arange(detector.num_bins, dtype=np.int16)
965
+
966
+ # Blank out data below 25keV as well as the last bin
967
+ energy_mask = np.where(uncalibrated_energies >= 25.0, 1, 0)
968
+ energy_mask[-1] = 0
969
+ spectrum = spectrum*energy_mask
970
+
971
+ # Subtract the baseline
972
+ if detector.baseline:
973
+ # Local modules
974
+ from CHAP.common.processor import ConstructBaseline
975
+
976
+ if save_figures:
977
+ filename = os.path.join(outputdir,
978
+ f'{detector.detector_name}_energy_'
979
+ 'calibration_baseline.png')
980
+ else:
981
+ filename = None
982
+ baseline, baseline_config = ConstructBaseline.construct_baseline(
983
+ spectrum, mask=energy_mask, tol=detector.baseline.tol,
984
+ lam=detector.baseline.lam, max_iter=detector.baseline.max_iter,
985
+ title=f'Baseline for detector {detector.detector_name}',
986
+ xlabel='Energy (keV)', ylabel='Intensity (counts)',
987
+ interactive=interactive, filename=filename)
988
+
989
+ spectrum -= baseline
990
+ detector.baseline.lam = baseline_config['lambda']
991
+ detector.baseline.attrs['num_iter'] = baseline_config['num_iter']
992
+ detector.baseline.attrs['error'] = baseline_config['error']
993
+
994
+ # Select the mask/detector channel ranges for fitting
481
995
  if save_figures:
482
- fig.savefig(os.path.join(
996
+ filename = os.path.join(
483
997
  outputdir,
484
- f'{detector.detector_name}_strainanalysis_'
485
- 'fit_mask_hkls.png'))
486
- plt.close()
487
-
488
- def select_material_params(self, strain_analysis_config, detector_i,
489
- mca_data, mca_bin_energies,
490
- interactive, save_figures, outputdir):
491
- """Select initial material parameters to use for determining
492
- nominal HKL peak locations. Modify `strain_analysis_config` in
493
- place if needed.
494
-
495
- :param strain_analysis_config: Strain analysis configuration
496
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
497
- :param detector_i: Index of the detector in
498
- `strain_analysis_config` to select mask & HKLs for.
499
- :type detector_i: int
500
- :param mca_data: List of maps of MCA spectra for all detectors
501
- in `strain_analysis_config`
502
- :type mca_data: list[numpy.ndarray]
503
- :param mca_bin_energies: List of MCA bin energies for all
504
- detectors in `strain_analysis_config`
505
- :type mca_bin_energies: list[numpy.ndarray]
506
- :param interactive: Boolean to indicate whether interactive
507
- matplotlib figures should be presented
508
- :type interactive: bool
509
- :param save_figures: Boolean to indicate whether figures
510
- indicating the selection should be saved
511
- :type save_figures: bool
512
- :param outputdir: Where to save figures (if `save_figures` is
513
- `True`)
514
- :type outputdir: str
515
- :returns: None
516
- """
517
- if not interactive and not save_figures:
518
- return
519
- from CHAP.edd.utils import select_material_params
520
- import matplotlib.pyplot as plt
521
- import numpy as np
522
- fig, strain_analysis_config.materials = select_material_params(
523
- mca_bin_energies[detector_i], np.sum(mca_data[detector_i], axis=0),
524
- strain_analysis_config.detectors[detector_i].tth_calibrated,
525
- strain_analysis_config.materials,
526
- label='Sum of all spectra in the map', interactive=interactive)
998
+ f'{detector.detector_name}_mca_energy_calibration_mask.png')
999
+ else:
1000
+ filename = None
1001
+ mask, fit_index_ranges = select_mask_1d(
1002
+ spectrum, x=bins,
1003
+ preselected_index_ranges=calibration_config.fit_index_ranges,
1004
+ xlabel='Detector channel', ylabel='Intensity',
1005
+ min_num_index_ranges=1, interactive=interactive,
1006
+ filename=filename)
527
1007
  self.logger.debug(
528
- f'materials: {strain_analysis_config.materials}')
1008
+ f'Selected index ranges to fit: {fit_index_ranges}')
1009
+
1010
+ # Get the intial peak positions for fitting
1011
+ max_peak_energy = calibration_config.peak_energies[
1012
+ calibration_config.max_peak_index]
1013
+ peak_energies = list(np.sort(calibration_config.peak_energies))
1014
+ max_peak_index = peak_energies.index(max_peak_energy)
529
1015
  if save_figures:
530
- detector_name = \
531
- strain_analysis_config.detectors[detector_i].detector_name
532
- fig.savefig(os.path.join(
1016
+ filename = os.path.join(
533
1017
  outputdir,
534
- f'{detector_name}_strainanalysis_'
535
- 'material_config.png'))
1018
+ f'{detector.detector_name}'
1019
+ '_mca_energy_calibration_initial_peak_positions.png')
1020
+ else:
1021
+ filename = None
1022
+ input_indices = [index_nearest(uncalibrated_energies, energy)
1023
+ for energy in peak_energies]
1024
+ initial_peak_indices = self._get_initial_peak_positions(
1025
+ spectrum*np.asarray(mask).astype(np.int32), fit_index_ranges,
1026
+ input_indices, max_peak_index, interactive, filename,
1027
+ detector.detector_name)
1028
+
1029
+ # Construct the fit model
1030
+ models = []
1031
+ if detector.background is not None:
1032
+ if isinstance(detector.background, str):
1033
+ models.append(
1034
+ {'model': detector.background, 'prefix': 'bkgd_'})
1035
+ else:
1036
+ for model in detector.background:
1037
+ models.append({'model': model, 'prefix': f'{model}_'})
1038
+ models.append(
1039
+ {'model': 'multipeak', 'centers': initial_peak_indices,
1040
+ 'centers_range': centers_range, 'fwhm_min': fwhm_min,
1041
+ 'fwhm_max': fwhm_max})
1042
+ self.logger.debug('Fitting spectrum')
1043
+ fit = FitProcessor()
1044
+ spectrum_fit = fit.process(
1045
+ NXdata(NXfield(spectrum[mask], 'y'), NXfield(bins[mask], 'x')),
1046
+ {'models': models, 'method': 'trf'})
1047
+
1048
+ fit_peak_indices = sorted([
1049
+ spectrum_fit.best_values[f'peak{i+1}_center']
1050
+ for i in range(len(initial_peak_indices))])
1051
+ self.logger.debug(f'Fit peak centers: {fit_peak_indices}')
1052
+
1053
+ #RV for now stick with a linear energy correction
1054
+ fit = FitProcessor()
1055
+ energy_fit = fit.process(
1056
+ NXdata(
1057
+ NXfield(peak_energies, 'y'),
1058
+ NXfield(fit_peak_indices, 'x')),
1059
+ {'models': [{'model': 'linear'}]})
1060
+ a = 0.0
1061
+ b = float(energy_fit.best_values['slope'])
1062
+ c = float(energy_fit.best_values['intercept'])
1063
+
1064
+ # Reference plot to see fit results:
1065
+ if interactive or save_figures:
1066
+ # Third part modules
1067
+ import matplotlib.pyplot as plt
1068
+
1069
+ fig, axs = plt.subplots(1, 2, figsize=(11, 4.25))
1070
+ fig.suptitle(
1071
+ f'Detector {detector.detector_name} Energy Calibration')
1072
+ # Left plot: raw MCA data & best fit of peaks
1073
+ axs[0].set_title(f'MCA Spectrum Peak Fit')
1074
+ axs[0].set_xlabel('Detector channel')
1075
+ axs[0].set_ylabel('Intensity (a.u)')
1076
+ axs[0].plot(bins[mask], spectrum[mask], 'b.', label='MCA data')
1077
+ axs[0].plot(
1078
+ bins[mask], spectrum_fit.best_fit, 'r', label='Best fit')
1079
+ axs[0].legend()
1080
+ # Right plot: linear fit of theoretical peak energies vs
1081
+ # fit peak locations
1082
+ axs[1].set_title(
1083
+ 'Channel Energies vs. Channel Indices')
1084
+ axs[1].set_xlabel('Detector channel')
1085
+ axs[1].set_ylabel('Channel energy (keV)')
1086
+ axs[1].plot(fit_peak_indices, peak_energies,
1087
+ c='b', marker='o', ms=6, mfc='none', ls='',
1088
+ label='Initial peak positions')
1089
+ axs[1].plot(fit_peak_indices, energy_fit.best_fit,
1090
+ c='k', marker='+', ms=6, ls='',
1091
+ label='Fitted peak positions')
1092
+ axs[1].plot(bins[mask], b*bins[mask] + c, 'r',
1093
+ label='Best linear fit')
1094
+ axs[1].legend()
1095
+ # Add text box showing computed values of linear E
1096
+ # correction parameters
1097
+ axs[1].text(
1098
+ 0.98, 0.02,
1099
+ 'Calibrated values:\n\n'
1100
+ f'Linear coefficient:\n {b:.5f} $keV$/channel\n\n'
1101
+ f'Constant offset:\n {c:.5f} $keV$',
1102
+ ha='right', va='bottom', ma='left',
1103
+ transform=axs[1].transAxes,
1104
+ bbox=dict(boxstyle='round',
1105
+ ec=(1., 0.5, 0.5),
1106
+ fc=(1., 0.8, 0.8, 0.8)))
1107
+
1108
+ fig.tight_layout()
1109
+
1110
+ if save_figures:
1111
+ figfile = os.path.join(
1112
+ outputdir,
1113
+ f'{detector.detector_name}_energy_calibration_fit.png')
1114
+ plt.savefig(figfile)
1115
+ self.logger.info(f'Saved figure to {figfile}')
1116
+ if interactive:
1117
+ plt.show()
1118
+
1119
+ return [a, b, c]
1120
+
1121
+ def _get_initial_peak_positions(
1122
+ self, y, index_ranges, input_indices, input_max_peak_index,
1123
+ interactive, filename, detector_name, reset_flag=0):
1124
+ # Third party modules
1125
+ import matplotlib.pyplot as plt
1126
+ from matplotlib.widgets import TextBox, Button
1127
+
1128
+ def change_fig_title(title):
1129
+ if fig_title:
1130
+ fig_title[0].remove()
1131
+ fig_title.pop()
1132
+ fig_title.append(plt.figtext(*title_pos, title, **title_props))
1133
+
1134
+ def change_error_text(error=''):
1135
+ if error_texts:
1136
+ error_texts[0].remove()
1137
+ error_texts.pop()
1138
+ error_texts.append(plt.figtext(*error_pos, error, **error_props))
1139
+
1140
+ def reset(event):
1141
+ """Callback function for the "Reset" button."""
1142
+ peak_indices.clear()
1143
+ plt.close()
1144
+
1145
+ def confirm(event):
1146
+ """Callback function for the "Confirm" button."""
1147
+ if error_texts:
1148
+ error_texts[0].remove()
1149
+ error_texts.pop()
1150
+ plt.close()
1151
+
1152
+ def find_peaks(min_height=0.05, min_width=5, tolerance=0.05):
1153
+ # Third party modules
1154
+ from scipy.signal import find_peaks as find_peaks_scipy
1155
+
1156
+ # Find peaks
1157
+ peaks = find_peaks_scipy(y, height=min_height,
1158
+ prominence=0.05*y.max(), width=min_width)
1159
+ available_peak_indices = list(peaks[0])
1160
+ max_peak_index = np.asarray(peaks[1]).argmax()
1161
+ ratio = (available_peak_indices[max_peak_index]
1162
+ / input_indices[input_max_peak_index])
1163
+ peak_indices = [-1]*num_peak
1164
+ peak_indices[input_max_peak_index] = \
1165
+ available_peak_indices[max_peak_index]
1166
+ available_peak_indices.pop(max_peak_index)
1167
+ for i, input_index in enumerate(input_indices):
1168
+ if i != input_max_peak_index:
1169
+ index_guess = int(input_index * ratio)
1170
+ for index in available_peak_indices.copy():
1171
+ if abs(index_guess-index) < tolerance*index:
1172
+ index_guess = index
1173
+ available_peak_indices.remove(index)
1174
+ break
1175
+ peak_indices[i] = index_guess
1176
+ return peak_indices
1177
+
1178
+ def select_peaks():
1179
+ """Manually select initial peak indices."""
1180
+ peak_indices = []
1181
+ while len(set(peak_indices)) < num_peak:
1182
+ change_fig_title(f'Select {num_peak} peak positions')
1183
+ peak_indices = [
1184
+ int(pt[0]) for pt in plt.ginput(num_peak, timeout=15)]
1185
+ if len(set(peak_indices)) < num_peak:
1186
+ error_text = f'Choose {num_peak} unique position'
1187
+ peak_indices.clear()
1188
+ outside_indices = []
1189
+ for index in peak_indices:
1190
+ if not any(True if low <= index <= upp else False
1191
+ for low, upp in index_ranges):
1192
+ outside_indices.append(index)
1193
+ if len(outside_indices) == 1:
1194
+ error_text = \
1195
+ f'Index {outside_indices[0]} outside of selection ' \
1196
+ f'region ({index_ranges}), try again'
1197
+ peak_indices.clear()
1198
+ elif outside_indices:
1199
+ error_text = \
1200
+ f'Indices {outside_indices} outside of selection ' \
1201
+ 'region, try again'
1202
+ peak_indices.clear()
1203
+ if not peak_indices:
1204
+ plt.close()
1205
+ fig, ax = plt.subplots(figsize=(11, 8.5))
1206
+ ax.set_xlabel('Detector channel', fontsize='x-large')
1207
+ ax.set_ylabel('Intensity', fontsize='x-large')
1208
+ ax.set_xlim(index_ranges[0][0], index_ranges[-1][1])
1209
+ fig.subplots_adjust(bottom=0.0, top=0.85)
1210
+ ax.plot(np.arange(y.size), y, color='k')
1211
+ fig.subplots_adjust(bottom=0.2)
1212
+ change_error_text(error_text)
1213
+ plt.draw()
1214
+ return peak_indices
1215
+
1216
+ peak_indices = []
1217
+ fig_title = []
1218
+ error_texts = []
1219
+
1220
+ y = np.asarray(y)
1221
+ if detector_name is None:
1222
+ detector_name = ''
1223
+ elif not isinstance(detector_name, str):
1224
+ raise ValueError(
1225
+ f'Invalid parameter `detector_name`: {detector_name}')
1226
+ elif not reset_flag:
1227
+ detector_name = f' on detector {detector_name}'
1228
+ num_peak = len(input_indices)
1229
+
1230
+ # Setup the Matplotlib figure
1231
+ title_pos = (0.5, 0.95)
1232
+ title_props = {'fontsize': 'xx-large', 'horizontalalignment': 'center',
1233
+ 'verticalalignment': 'bottom'}
1234
+ error_pos = (0.5, 0.90)
1235
+ error_props = {'fontsize': 'x-large', 'horizontalalignment': 'center',
1236
+ 'verticalalignment': 'bottom'}
1237
+ selected_peak_props = {
1238
+ 'color': 'red', 'linestyle': '-', 'linewidth': 2,
1239
+ 'marker': 10, 'markersize': 10, 'fillstyle': 'full'}
1240
+
1241
+ fig, ax = plt.subplots(figsize=(11, 8.5))
1242
+ ax.plot(np.arange(y.size), y, color='k')
1243
+ ax.set_xlabel('Detector channel', fontsize='x-large')
1244
+ ax.set_ylabel('Intensity', fontsize='x-large')
1245
+ ax.set_xlim(index_ranges[0][0], index_ranges[-1][1])
1246
+ fig.subplots_adjust(bottom=0.0, top=0.85)
1247
+
1248
+ if not interactive:
1249
+
1250
+ peak_indices += find_peaks()
1251
+
1252
+ for index in peak_indices:
1253
+ ax.axvline(index, **selected_peak_props)
1254
+ change_fig_title('Initial peak positions from peak finding '
1255
+ f'routine{detector_name}')
1256
+
1257
+ else:
1258
+
1259
+ fig.subplots_adjust(bottom=0.2)
1260
+
1261
+ # Get initial peak indices
1262
+ if not reset_flag:
1263
+ peak_indices += find_peaks()
1264
+ change_fig_title('Initial peak positions from peak finding '
1265
+ f'routine{detector_name}')
1266
+ if peak_indices:
1267
+ for index in peak_indices:
1268
+ if not any(True if low <= index <= upp else False
1269
+ for low, upp in index_ranges):
1270
+ peak_indices.clear
1271
+ break
1272
+ if not peak_indices:
1273
+ peak_indices += select_peaks()
1274
+ change_fig_title(
1275
+ 'Selected initial peak positions{detector_name}')
1276
+
1277
+ for index in peak_indices:
1278
+ ax.axvline(index, **selected_peak_props)
1279
+
1280
+ # Setup "Reset" button
1281
+ if not reset_flag:
1282
+ reset_btn = Button(
1283
+ plt.axes([0.1, 0.05, 0.15, 0.075]), 'Manually select')
1284
+ else:
1285
+ reset_btn = Button(
1286
+ plt.axes([0.1, 0.05, 0.15, 0.075]), 'Reset')
1287
+ reset_cid = reset_btn.on_clicked(reset)
1288
+
1289
+ # Setup "Confirm" button
1290
+ confirm_btn = Button(
1291
+ plt.axes([0.75, 0.05, 0.15, 0.075]), 'Confirm')
1292
+ confirm_cid = confirm_btn.on_clicked(confirm)
1293
+
1294
+ plt.show()
1295
+
1296
+ # Disconnect all widget callbacks when figure is closed
1297
+ reset_btn.disconnect(reset_cid)
1298
+ confirm_btn.disconnect(confirm_cid)
1299
+
1300
+ # ... and remove the buttons before returning the figure
1301
+ reset_btn.ax.remove()
1302
+ confirm_btn.ax.remove()
1303
+
1304
+ if filename is not None:
1305
+ fig_title[0].set_in_layout(True)
1306
+ fig.tight_layout(rect=(0, 0, 1, 0.95))
1307
+ fig.savefig(filename)
536
1308
  plt.close()
537
1309
 
538
- def get_spectra_fits(
539
- self, strain_analysis_config, detector_i,
540
- mca_data, mca_bin_energies, hkls, ds):
541
- """Return uniform and unconstrained fit results for all
542
- spectra from a single detector.
543
-
544
- :param strain_analysis_config: Strain analysis configuration
545
- :type strain_analysis_config: CHAP.edd.models.StrainAnalysisConfig
546
- :param detector_i: Index of the detector in
547
- `strain_analysis_config` to select mask & HKLs for.
548
- :type detector_i: int
549
- :param mca_data: List of maps of MCA spectra for all detectors
550
- in `strain_analysis_config`
551
- :type mca_data: list[numpy.ndarray]
552
- :param mca_bin_energies: List of MCA bin energies for all
553
- detectors in `strain_analysis_config`
554
- :type mca_bin_energies: list[numpy.ndarray]
555
- :param hkls: Nominal HKL peak energy locations for the
556
- material in `strain_analysis_config`
557
- :type hkls: list[float]
558
- :param ds: Nominal d-spacing for the material in
559
- `strain_analysis_config`
560
- :type ds: list[float]
561
- :returns: Uniform and unconstrained centers, amplitdues,
562
- sigmas (and errors for all three), best fits, residuals
563
- between the best fits and the input spectra, reduced chi,
564
- and fit success statuses.
565
- :rtype: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray,
566
- numpy.ndarray, numpy.ndarray, numpy.ndarray,
567
- numpy.ndarray, numpy.ndarray, numpy.ndarray,
568
- numpy.ndarray, numpy.ndarray, numpy.ndarray,
569
- numpy.ndarray, numpy.ndarray, numpy.ndarray,
570
- numpy.ndarray, numpy.ndarray, numpy.ndarray,
571
- numpy.ndarray, numpy.ndarray]
572
- """
573
- from CHAP.edd.utils import get_peak_locations, get_spectra_fits
574
- detector = strain_analysis_config.detectors[detector_i]
575
- self.logger.debug(
576
- f'Fitting spectra from detector {detector.detector_name}')
577
- mask = detector.mca_mask()
578
- energies = mca_bin_energies[detector_i][mask]
579
- intensities = np.empty(
580
- (*strain_analysis_config.map_config.shape, len(energies)),
581
- dtype='uint16')
582
- for j, map_index in \
583
- enumerate(np.ndindex(strain_analysis_config.map_config.shape)):
584
- intensities[map_index] = \
585
- mca_data[detector_i][j].astype('uint16')[mask]
586
- fit_hkls = np.asarray([hkls[i] for i in detector.hkl_indices])
587
- fit_ds = np.asarray([ds[i] for i in detector.hkl_indices])
588
- peak_locations = get_peak_locations(
589
- fit_ds, detector.tth_calibrated)
590
- return get_spectra_fits(
591
- intensities, energies, peak_locations, detector)
1310
+ if interactive and len(peak_indices) != num_peak:
1311
+ reset_flag += 1
1312
+ return self._get_initial_peak_positions(
1313
+ y, index_ranges, input_indices, input_max_peak_index,
1314
+ interactive, filename, detector_name, reset_flag=reset_flag)
1315
+ return peak_indices
592
1316
 
593
1317
 
594
- class MCACeriaCalibrationProcessor(Processor):
595
- """A Processor using a CeO2 scan to obtain tuned values for the
596
- bragg diffraction angle and linear correction parameters for MCA
597
- channel energies for an EDD experimental setup.
1318
+ class MCATthCalibrationProcessor(Processor):
1319
+ """Processor to calibrate the 2&theta angle and fine tune the
1320
+ energy calibration coefficients for an EDD experimental setup.
598
1321
  """
599
1322
 
600
- def process(self,
601
- data,
602
- config=None,
603
- save_figures=False,
604
- inputdir='.',
605
- outputdir='.',
606
- interactive=False):
607
- """Return tuned values for 2&theta and linear correction
608
- parameters for the MCA channel energies.
1323
+ def process(
1324
+ self, data, config=None, tth_initial_guess=None,
1325
+ include_energy_ranges=None, calibration_method='iterate_tth',
1326
+ quadratic_energy_calibration=False, centers_range=20,
1327
+ fwhm_min=None, fwhm_max=None, background=None, baseline=False,
1328
+ save_figures=False, inputdir='.', outputdir='.',
1329
+ interactive=False):
1330
+ """Return the calibrated 2&theta value and the fine tuned
1331
+ energy calibration coefficients to convert MCA channel
1332
+ indices to MCA channel energies.
609
1333
 
610
1334
  :param data: Input configuration for the raw data & tuning
611
1335
  procedure.
612
1336
  :type data: list[PipelineData]
613
1337
  :param config: Initialization parameters for an instance of
614
- CHAP.edd.models.MCACeriaCalibrationConfig, defaults to
615
- None.
1338
+ CHAP.edd.models.MCATthCalibrationConfig,
1339
+ defaults to `None`.
616
1340
  :type config: dict, optional
1341
+ :param tth_initial_guess: Initial guess for 2&theta to supercede
1342
+ the values from the energy calibration detector cofiguration
1343
+ on each of the detectors.
1344
+ :type tth_initial_guess: float, optional
1345
+ :param include_energy_ranges: List of MCA channel energy ranges
1346
+ in keV whose data should be included after applying a mask
1347
+ (bounds are inclusive). If specified, these supercede the
1348
+ values from the energy calibration detector cofiguration on
1349
+ each of the detectors.
1350
+ :type include_energy_ranges: list[[float, float]], optional
1351
+ :param calibration_method: Type of calibration method,
1352
+ defaults to `'iterate_tth'`.
1353
+ :type calibration_method:
1354
+ Union['direct_fit_residual', 'direct_fit_peak_energies',
1355
+ 'direct_fit_combined', 'iterate_tth'], optional
1356
+ :param quadratic_energy_calibration: Adds a quadratic term to
1357
+ the detector channel index to energy conversion, defaults
1358
+ to `False` (linear only).
1359
+ :type quadratic_energy_calibration: bool, optional
1360
+ :param centers_range: Set boundaries on the peak centers in
1361
+ MCA channels when performing the fit. The min/max
1362
+ possible values for the peak centers will be the initial
1363
+ values &pm; `centers_range`. Defaults to `20`.
1364
+ :type centers_range: int, optional
1365
+ :param fwhm_min: Lower bound on the peak FWHM in MCA channels
1366
+ when performing the fit, defaults to `None`.
1367
+ :type fwhm_min: float, optional
1368
+ :param fwhm_max: Lower bound on the peak FWHM in MCA channels
1369
+ when performing the fit, defaults to `None`.
1370
+ :type fwhm_max: float, optional
1371
+ :param background: Background model for peak fitting.
1372
+ :type background: str, list[str], optional
1373
+ :param baseline: Automated baseline subtraction configuration,
1374
+ defaults to `False`.
1375
+ :type baseline: bool, BaselineConfig, optional
617
1376
  :param save_figures: Save .pngs of plots for checking inputs &
618
- outputs of this Processor, defaults to False.
1377
+ outputs of this Processor, defaults to `False`.
619
1378
  :type save_figures: bool, optional
620
1379
  :param outputdir: Directory to which any output figures will
621
- be saved, defaults to '.'.
1380
+ be saved, defaults to `'.'`.
622
1381
  :type outputdir: str, optional
623
1382
  :param inputdir: Input directory, used only if files in the
624
1383
  input configuration are not absolute paths,
625
- defaults to '.'.
1384
+ defaults to `'.'`.
626
1385
  :type inputdir: str, optional
627
- :param interactive: Allows for user interactions, defaults to
628
- False.
1386
+ :param interactive: Allows for user interactions,
1387
+ defaults to `False`.
629
1388
  :type interactive: bool, optional
630
1389
  :raises RuntimeError: Invalid or missing input configuration.
631
1390
  :return: Original configuration with the tuned values for
632
1391
  2&theta and the linear correction parameters added.
633
1392
  :rtype: dict[str,float]
634
1393
  """
1394
+ # Third party modules
1395
+ from nexusformat.nexus import (
1396
+ NXentry,
1397
+ NXroot,
1398
+ )
1399
+
1400
+ # Local modules
1401
+ from CHAP.edd.models import BaselineConfig
1402
+ from CHAP.utils.general import (
1403
+ is_int,
1404
+ is_str_series,
1405
+ )
1406
+
1407
+ # Load the detector data
1408
+ # FIX input a numpy and create/use NXobject to numpy proc
1409
+ # FIX right now spec info is lost in output yaml, add to it?
1410
+ nxroot = self.get_data(data, 'SpecReader')
1411
+ if not isinstance(nxroot, NXroot):
1412
+ raise RuntimeError('No valid NXroot data in input pipeline data')
1413
+ nxentry = nxroot[nxroot.default]
1414
+
1415
+ # Load the validated 2&theta calibration configuration
635
1416
  try:
636
1417
  calibration_config = self.get_config(
637
- data, 'edd.models.MCACeriaCalibrationConfig',
1418
+ data, 'edd.models.MCATthCalibrationConfig',
1419
+ calibration_method=calibration_method,
638
1420
  inputdir=inputdir)
639
1421
  except Exception as data_exc:
640
1422
  self.logger.info('No valid calibration config in input pipeline '
641
1423
  'data, using config parameter instead.')
642
1424
  try:
643
1425
  # Local modules
644
- from CHAP.edd.models import MCACeriaCalibrationConfig
1426
+ from CHAP.edd.models import MCATthCalibrationConfig
645
1427
 
646
- calibration_config = MCACeriaCalibrationConfig(
647
- **config, inputdir=inputdir)
1428
+ calibration_config = MCATthCalibrationConfig(
1429
+ **config, calibration_method=calibration_method,
1430
+ inputdir=inputdir)
648
1431
  except Exception as dict_exc:
649
1432
  raise RuntimeError from dict_exc
650
1433
 
1434
+ # Validate the detector configuration
1435
+ if calibration_config.detectors is None:
1436
+ raise RuntimeError('No available calibrated detectors')
1437
+ available_detector_indices = [int(d) for d in nxentry.detector_names]
1438
+ detectors = []
651
1439
  for detector in calibration_config.detectors:
652
- tth, slope, intercept = self.calibrate(
653
- calibration_config, detector, save_figures=save_figures,
654
- interactive=interactive, outputdir=outputdir)
655
- detector.tth_calibrated = tth
656
- detector.slope_calibrated = slope
657
- detector.intercept_calibrated = intercept
1440
+ index = int(detector.detector_name)
1441
+ if index in available_detector_indices:
1442
+ detectors.append(detector)
1443
+ else:
1444
+ self.logger.warning(
1445
+ f'Skipping detector {name} (no energy calibration data)')
1446
+
1447
+ # Validate the fit index range
1448
+ if calibration_config.fit_index_ranges is None and not interactive:
1449
+ raise RuntimeError(
1450
+ 'If `fit_index_ranges` is not explicitly provided, '
1451
+ + self.__class__.__name__
1452
+ + ' must be run with `interactive=True`.')
1453
+
1454
+ # Validate the optional inputs
1455
+ if not is_int(centers_range, gt=0, log=False):
1456
+ RuntimeError(f'Invalid centers_range parameter ({centers_range})')
1457
+ if fwhm_min is not None and not is_int(fwhm_min, gt=0, log=False):
1458
+ RuntimeError(f'Invalid fwhm_min parameter ({fwhm_min})')
1459
+ if fwhm_max is not None and not is_int(fwhm_max, gt=0, log=False):
1460
+ RuntimeError(f'Invalid fwhm_max parameter ({fwhm_max})')
1461
+ if background is not None:
1462
+ if isinstance(background, str):
1463
+ background = [background]
1464
+ elif not is_str_series(background, log=False):
1465
+ RuntimeError(f'Invalid background parameter ({background})')
1466
+ if isinstance(baseline, bool):
1467
+ if baseline:
1468
+ baseline = BaselineConfig()
1469
+ else:
1470
+ try:
1471
+ baseline = BaselineConfig(**baseline)
1472
+ except Exception as dict_exc:
1473
+ raise RuntimeError from dict_exc
1474
+
1475
+ # Collect and sum the detector data
1476
+ mca_data = []
1477
+ for scan_name in nxentry.spec_scans:
1478
+ for scan_number, scan_data in nxentry.spec_scans[scan_name].items():
1479
+ mca_data.append(scan_data.data.data.nxdata)
1480
+ summed_detector_data = np.asarray(mca_data).sum(axis=(0,1))
1481
+
1482
+ # Get the detectors' num_bins parameter
1483
+ for detector in detectors:
1484
+ if detector.num_bins is None:
1485
+ detector.num_bins = summed_detector_data.shape[-1]
1486
+
1487
+ # Check each detector's include_energy_ranges field against the
1488
+ # flux file, if available.
1489
+ if calibration_config.flux_file is not None:
1490
+ flux = np.loadtxt(flux_file)
1491
+ flux_file_energies = flux[:,0]/1.e3
1492
+ flux_e_min = flux_file_energies.min()
1493
+ flux_e_max = flux_file_energies.max()
1494
+ for detector in detectors:
1495
+ for i, (det_e_min, det_e_max) in enumerate(
1496
+ deepcopy(detector.include_energy_ranges)):
1497
+ if det_e_min < flux_e_min or det_e_max > flux_e_max:
1498
+ energy_range = [float(max(det_e_min, flux_e_min)),
1499
+ float(min(det_e_max, flux_e_max))]
1500
+ print(
1501
+ f'WARNING: include_energy_ranges[{i}] out of range'
1502
+ f' ({detector.include_energy_ranges[i]}): adjusted'
1503
+ f' to {energy_range}')
1504
+ detector.include_energy_ranges[i] = energy_range
1505
+
1506
+ # Calibrate detector channel energies
1507
+ for detector in detectors:
1508
+ index = available_detector_indices.index(
1509
+ int(detector.detector_name))
1510
+ if tth_initial_guess is not None:
1511
+ detector.tth_initial_guess = tth_initial_guess
1512
+ if include_energy_ranges is not None:
1513
+ detector.include_energy_ranges = include_energy_ranges
1514
+ if background is not None:
1515
+ detector.background = background.copy()
1516
+ if baseline:
1517
+ detector.baseline = baseline
1518
+ self.calibrate(
1519
+ calibration_config, detector, summed_detector_data[index],
1520
+ quadratic_energy_calibration, centers_range, fwhm_min,
1521
+ fwhm_max, save_figures, interactive, outputdir)
658
1522
 
659
1523
  return calibration_config.dict()
660
1524
 
661
- def calibrate(self,
662
- calibration_config,
663
- detector,
664
- save_figures=False,
665
- outputdir='.',
666
- interactive=False):
1525
+ def calibrate(
1526
+ self, calibration_config, detector, spectrum,
1527
+ quadratic_energy_calibration=False, centers_range=20,
1528
+ fwhm_min=None, fwhm_max=None, save_figures=False,
1529
+ interactive=False, outputdir='.'):
667
1530
  """Iteratively calibrate 2&theta by fitting selected peaks of
668
1531
  an MCA spectrum until the computed strain is sufficiently
669
1532
  small. Use the fitted peak locations to determine linear
@@ -672,247 +1535,810 @@ class MCACeriaCalibrationProcessor(Processor):
672
1535
  :param calibration_config: Object configuring the CeO2
673
1536
  calibration procedure for an MCA detector.
674
1537
  :type calibration_config:
675
- CHAP.edd.models.MCACeriaCalibrationConfig
1538
+ CHAP.edd.models.MCATthCalibrationConfig
676
1539
  :param detector: A single MCA detector element configuration.
677
1540
  :type detector: CHAP.edd.models.MCAElementCalibrationConfig
1541
+ :param spectrum: Summed MCA spectrum for the current detector.
1542
+ :type spectrum: numpy.ndarray
1543
+ :param quadratic_energy_calibration: Adds a quadratic term to
1544
+ the detector channel index to energy conversion, defaults
1545
+ to `False` (linear only).
1546
+ :type quadratic_energy_calibration: bool, optional
1547
+ :param centers_range: Set boundaries on the peak centers in
1548
+ MCA channels when performing the fit. The min/max
1549
+ possible values for the peak centers will be the initial
1550
+ values &pm; `centers_range`. Defaults to `20`.
1551
+ :type centers_range: int, optional
1552
+ :param fwhm_min: Lower bound on the peak FWHM in MCA channels
1553
+ when performing the fit, defaults to `None`.
1554
+ :type fwhm_min: float, optional
1555
+ :param fwhm_max: Lower bound on the peak FWHM in MCA channels
1556
+ when performing the fit, defaults to `None`.
1557
+ :type fwhm_max: float, optional
678
1558
  :param save_figures: Save .pngs of plots for checking inputs &
679
- outputs of this Processor, defaults to False.
1559
+ outputs of this Processor, defaults to `False`.
680
1560
  :type save_figures: bool, optional
681
- :param outputdir: Directory to which any output figures will
682
- be saved, defaults to '.'.
683
- :type outputdir: str, optional
684
1561
  :param interactive: Allows for user interactions, defaults to
685
- False.
1562
+ `False`.
686
1563
  :type interactive: bool, optional
1564
+ :param outputdir: Directory to which any output figures will
1565
+ be saved, defaults to `'.'`.
1566
+ :type outputdir: str, optional
687
1567
  :raises ValueError: No value provided for included bin ranges
688
1568
  or the fitted HKLs for the MCA detector element.
689
- :return: Calibrated values of 2&theta and the linear correction
690
- parameters for MCA channel energies: tth, slope, intercept.
691
- :rtype: float, float, float
692
1569
  """
693
- # Local modules
694
- from CHAP.edd.utils import get_peak_locations
695
- from CHAP.utils.fit import Fit
1570
+ # System modules
1571
+ from sys import float_info
696
1572
 
697
- # Get the unique HKLs and lattice spacings for the calibration
698
- # material
699
- hkls, ds = calibration_config.material.unique_hkls_ds(
700
- tth_tol=detector.hkl_tth_tol, tth_max=detector.tth_max)
1573
+ # Third party modules
1574
+ from nexusformat.nexus import NXdata, NXfield
1575
+ from scipy.constants import physical_constants
701
1576
 
702
- # Collect raw MCA data of interest
703
- mca_bin_energies = np.linspace(
704
- 0, detector.max_energy_kev, detector.num_bins)
705
- mca_data = calibration_config.mca_data(detector)
1577
+ # Local modules
706
1578
  if interactive or save_figures:
707
- # Third party modules
708
- import matplotlib.pyplot as plt
709
-
710
- # Local modules
711
1579
  from CHAP.edd.utils import (
712
1580
  select_tth_initial_guess,
713
1581
  select_mask_and_hkls,
714
1582
  )
1583
+ from CHAP.edd.utils import get_peak_locations
1584
+ from CHAP.utils.fit import FitProcessor
1585
+ from CHAP.utils.general import index_nearest
1586
+
1587
+ self.logger.info(f'Calibrating detector {detector.detector_name}')
1588
+
1589
+ calibration_method = calibration_config.calibration_method
1590
+
1591
+ # Get the unique HKLs and lattice spacings for the calibration
1592
+ # material
1593
+ hkls, ds = calibration_config.material.unique_hkls_ds(
1594
+ tth_tol=detector.hkl_tth_tol, tth_max=detector.tth_max)
715
1595
 
716
- # Adjust initial tth guess
717
- fig, detector.tth_initial_guess = select_tth_initial_guess(
718
- mca_bin_energies, mca_data, hkls, ds,
719
- detector.tth_initial_guess, interactive)
720
- if save_figures:
721
- fig.savefig(os.path.join(
722
- outputdir,
723
- f'{detector.detector_name}_calibration_'
724
- 'tth_initial_guess.png'))
725
- plt.close()
1596
+ # Get the MCA bin energies
1597
+ mca_bin_energies = detector.energies
1598
+
1599
+ # Blank out data below 25 keV as well as the last bin
1600
+ energy_mask = np.where(mca_bin_energies >= 25.0, 1, 0)
1601
+ energy_mask[-1] = 0
1602
+ spectrum = spectrum*energy_mask
1603
+
1604
+ # Subtract the baseline
1605
+ if detector.baseline:
1606
+ # Local modules
1607
+ from CHAP.common.processor import ConstructBaseline
726
1608
 
727
- # Select mask & HKLs for fitting
728
- fig, include_bin_ranges, hkl_indices = select_mask_and_hkls(
729
- mca_bin_energies, mca_data, hkls, ds,
730
- detector.tth_initial_guess, detector.include_bin_ranges,
731
- detector.hkl_indices, detector.detector_name,
732
- flux_energy_range=calibration_config.flux_file_energy_range,
733
- label='MCA data',
734
- interactive=interactive)
735
- detector.include_energy_ranges = detector.get_energy_ranges(
736
- include_bin_ranges)
737
- detector.hkl_indices = hkl_indices
738
1609
  if save_figures:
739
- fig.savefig(os.path.join(
740
- outputdir,
741
- f'{detector.detector_name}_calibration_fit_mask_hkls.png'))
742
- plt.close()
1610
+ filename = os.path.join(outputdir,
1611
+ f'{detector.detector_name}_tth_'
1612
+ 'calibration_baseline.png')
1613
+ else:
1614
+ filename = None
1615
+ baseline, baseline_config = ConstructBaseline.construct_baseline(
1616
+ spectrum, mask=energy_mask, tol=detector.baseline.tol,
1617
+ lam=detector.baseline.lam, max_iter=detector.baseline.max_iter,
1618
+ title=f'Baseline for detector {detector.detector_name}',
1619
+ xlabel='Energy (keV)', ylabel='Intensity (counts)',
1620
+ interactive=interactive, filename=filename)
1621
+
1622
+ spectrum -= baseline
1623
+ detector.baseline.lam = baseline_config['lambda']
1624
+ detector.baseline.attrs['num_iter'] = baseline_config['num_iter']
1625
+ detector.baseline.attrs['error'] = baseline_config['error']
1626
+
1627
+ # Adjust initial tth guess
1628
+ if save_figures:
1629
+ filename = os.path.join(
1630
+ outputdir,
1631
+ f'{detector.detector_name}_calibration_tth_initial_guess.png')
1632
+ else:
1633
+ filename = None
1634
+ tth_init = select_tth_initial_guess(
1635
+ mca_bin_energies, spectrum, hkls, ds,
1636
+ detector.tth_initial_guess, interactive, filename)
1637
+ detector.tth_initial_guess = tth_init
743
1638
  self.logger.debug(f'tth_initial_guess = {detector.tth_initial_guess}')
1639
+
1640
+ # Select the mask and HKLs for the Bragg peaks
1641
+ if save_figures:
1642
+ filename = os.path.join(
1643
+ outputdir,
1644
+ f'{detector.detector_name}_calibration_fit_mask_hkls.png')
1645
+ if calibration_method == 'iterate_tth':
1646
+ num_hkl_min = 2
1647
+ else:
1648
+ num_hkl_min = 1
1649
+ include_bin_ranges, hkl_indices = select_mask_and_hkls(
1650
+ mca_bin_energies, spectrum, hkls, ds,
1651
+ detector.tth_initial_guess,
1652
+ preselected_bin_ranges=detector.include_bin_ranges,
1653
+ num_hkl_min=num_hkl_min, detector_name=detector.detector_name,
1654
+ flux_energy_range=calibration_config.flux_file_energy_range(),
1655
+ label='MCA data', interactive=interactive, filename=filename)
1656
+
1657
+ # Add the mask for the fluorescence peaks
1658
+ if calibration_method != 'iterate_tth':
1659
+ include_bin_ranges = (
1660
+ calibration_config.fit_index_ranges + include_bin_ranges)
1661
+
1662
+ # Apply the mask
1663
+ detector.include_energy_ranges = detector.get_include_energy_ranges(
1664
+ include_bin_ranges)
1665
+ detector.set_hkl_indices(hkl_indices)
744
1666
  self.logger.debug(
745
1667
  f'include_energy_ranges = {detector.include_energy_ranges}')
1668
+ self.logger.debug(
1669
+ f'hkl_indices = {detector.hkl_indices}')
746
1670
  if not detector.include_energy_ranges:
747
1671
  raise ValueError(
748
1672
  'No value provided for include_energy_ranges. '
749
- 'Provide them in the MCA Ceria Calibration Configuration '
1673
+ 'Provide them in the MCA Tth Calibration Configuration '
750
1674
  'or re-run the pipeline with the --interactive flag.')
751
1675
  if not detector.hkl_indices:
752
1676
  raise ValueError(
753
- 'No value provided for hkl_indices. Provide them in '
754
- 'the detector\'s MCA Ceria Calibration Configuration or '
1677
+ 'Unable to get values for hkl_indices for the provided '
1678
+ 'value of include_energy_ranges. Change its value in '
1679
+ 'the detector\'s MCA Tth Calibration Configuration or '
755
1680
  're-run the pipeline with the --interactive flag.')
756
1681
  mca_mask = detector.mca_mask()
757
- fit_mca_energies = mca_bin_energies[mca_mask]
758
- fit_mca_intensities = mca_data[mca_mask]
1682
+ spectrum_fit = spectrum[mca_mask]
759
1683
 
760
1684
  # Correct raw MCA data for variable flux at different energies
761
1685
  flux_correct = \
762
1686
  calibration_config.flux_correction_interpolation_function()
763
- mca_intensity_weights = flux_correct(fit_mca_energies)
764
- fit_mca_intensities = fit_mca_intensities / mca_intensity_weights
765
-
766
- # Get the HKLs and lattice spacings that will be used for
767
- # fitting
768
- # Restrict the range for the centers with some margin to
769
- # prevent having centers near the edge of the fitting range
770
- delta = 0.1 * (fit_mca_energies[-1]-fit_mca_energies[0])
771
- centers_range = (
772
- max(0.0, fit_mca_energies[0]-delta), fit_mca_energies[-1]+delta)
773
- fit_hkls = np.asarray([hkls[i] for i in detector.hkl_indices])
774
- fit_ds = np.asarray([ds[i] for i in detector.hkl_indices])
775
- c_1 = fit_hkls[:,0]**2 + fit_hkls[:,1]**2 + fit_hkls[:,2]**2
776
- tth = detector.tth_initial_guess
777
- fit_E0 = get_peak_locations(fit_ds, tth)
778
- for iter_i in range(calibration_config.max_iter):
779
- self.logger.debug(
780
- f'Tuning tth: iteration no. {iter_i}, starting value = {tth} ')
781
-
782
- # Perform the uniform fit first
783
-
784
- # Get expected peak energy locations for this iteration's
785
- # starting value of tth
786
- _fit_E0 = get_peak_locations(fit_ds, tth)
787
-
788
- # Run the uniform fit
789
- fit = Fit(fit_mca_intensities, x=fit_mca_energies)
790
- fit.create_multipeak_model(
791
- _fit_E0, fit_type='uniform', background=detector.background,
792
- centers_range=centers_range)
793
- fit.fit()
794
-
795
- # Extract values of interest from the best values for the
796
- # uniform fit parameters
797
- uniform_best_fit = fit.best_fit
798
- uniform_residual = fit.residual
799
- uniform_fit_centers = [
800
- fit.best_values[f'peak{i+1}_center']
801
- for i in range(len(fit_hkls))]
802
- uniform_a = fit.best_values['scale_factor']
803
- uniform_strain = np.log(
804
- (uniform_a
805
- / calibration_config.material.lattice_parameters)) # CeO2 is cubic, so this is fine here.
806
-
807
- # Next, perform the unconstrained fit
808
-
809
- # Use the peak parameters from the uniform fit as the
810
- # initial guesses for peak locations in the unconstrained
811
- # fit
812
- fit.create_multipeak_model(fit_type='unconstrained')
813
- fit.fit()
814
-
815
- # Extract values of interest from the best values for the
816
- # unconstrained fit parameters
817
- unconstrained_best_fit = fit.best_fit
818
- unconstrained_residual = fit.residual
819
- unconstrained_fit_centers = np.array(
820
- [fit.best_values[f'peak{i+1}_center']
821
- for i in range(len(fit_hkls))])
822
- unconstrained_a = np.sqrt(c_1)*abs(get_peak_locations(
823
- unconstrained_fit_centers, tth))
824
- unconstrained_strains = np.log(
825
- (unconstrained_a
1687
+ if flux_correct is not None:
1688
+ mca_intensity_weights = flux_correct(
1689
+ mca_bin_energies[mca_mask])
1690
+ spectrum_fit = spectrum_fit / mca_intensity_weights
1691
+
1692
+ # Get the fluorescence peak info
1693
+ e_xrf = calibration_config.peak_energies
1694
+ num_xrf = len(e_xrf)
1695
+
1696
+ # Get the Bragg peak HKLs, lattice spacings and energies
1697
+ hkls_fit = np.asarray([hkls[i] for i in detector.hkl_indices])
1698
+ ds_fit = np.asarray([ds[i] for i in detector.hkl_indices])
1699
+ c_1_fit = hkls_fit[:,0]**2 + hkls_fit[:,1]**2 + hkls_fit[:,2]**2
1700
+ e_bragg_init = get_peak_locations(ds_fit, tth_init)
1701
+ num_bragg = len(e_bragg_init)
1702
+
1703
+ # Perform the fit
1704
+ if calibration_method == 'direct_fit_residual':
1705
+
1706
+ # Get the initial free fit parameters
1707
+ tth_init = np.radians(tth_init)
1708
+ a_init, b_init, c_init = detector.energy_calibration_coeffs
1709
+
1710
+ # For testing: hardwired limits:
1711
+ if False:
1712
+ min_value = None
1713
+ tth_min = None
1714
+ tth_max = None
1715
+ b_min = None
1716
+ b_max = None
1717
+ sig_min = None
1718
+ sig_max = None
1719
+ else:
1720
+ min_value = float_info.min
1721
+ tth_min = 0.9*tth_init
1722
+ tth_max = 1.1*tth_init
1723
+ b_min = 0.1*b_init
1724
+ b_max = 10.0*b_init
1725
+ if isinstance(fwhm_min, (int,float)):
1726
+ sig_min = fwhm_min/2.35482
1727
+ else:
1728
+ sig_min = None
1729
+ if isinstance(fwhm_max, (int,float)):
1730
+ sig_max = fwhm_max/2.35482
1731
+ else:
1732
+ sig_max = None
1733
+
1734
+ # Construct the free fit parameters
1735
+ parameters = [
1736
+ {'name': 'tth', 'value': tth_init, 'min': tth_min,
1737
+ 'max': tth_max}]
1738
+ if quadratic_energy_calibration:
1739
+ parameters.append({'name': 'a', 'value': a_init})
1740
+ parameters.append(
1741
+ {'name': 'b', 'value': b_init, 'min': b_min, 'max': b_max})
1742
+ parameters.append({'name': 'c', 'value': c_init})
1743
+
1744
+ # Construct the fit model
1745
+ models = []
1746
+
1747
+ # Add the background
1748
+ if detector.background is not None:
1749
+ if isinstance(detector.background, str):
1750
+ models.append(
1751
+ {'model': detector.background, 'prefix': 'bkgd_'})
1752
+ else:
1753
+ for model in detector.background:
1754
+ models.append({'model': model, 'prefix': f'{model}_'})
1755
+
1756
+ # Add the fluorescent peaks
1757
+ for i, e_peak in enumerate(e_xrf):
1758
+ expr = f'({e_peak}-c)/b'
1759
+ if quadratic_energy_calibration:
1760
+ expr = '(' + expr + f')*(1.0-a*(({e_peak}-c)/(b*b)))'
1761
+ models.append(
1762
+ {'model': 'gaussian', 'prefix': f'xrf{i+1}_',
1763
+ 'parameters': [
1764
+ {'name': 'amplitude', 'min': min_value},
1765
+ {'name': 'center', 'expr': expr},
1766
+ {'name': 'sigma', 'min': sig_min, 'max': sig_max}]})
1767
+
1768
+ # Add the Bragg peaks
1769
+ hc = 1.e7 * physical_constants['Planck constant in eV/Hz'][0] \
1770
+ * physical_constants['speed of light in vacuum'][0]
1771
+ for i, (e_peak, ds) in enumerate(zip(e_bragg_init, ds_fit)):
1772
+ norm = 0.5*hc/ds
1773
+ expr = f'(({norm}/sin(0.5*tth))-c)/b'
1774
+ if quadratic_energy_calibration:
1775
+ expr = '(' + expr \
1776
+ + f')*(1.0-a*((({norm}/sin(0.5*tth))-c)/(b*b)))'
1777
+ models.append(
1778
+ {'model': 'gaussian', 'prefix': f'peak{i+1}_',
1779
+ 'parameters': [
1780
+ {'name': 'amplitude', 'min': min_value},
1781
+ {'name': 'center', 'expr': expr},
1782
+ {'name': 'sigma', 'min': sig_min, 'max': sig_max}]})
1783
+
1784
+ # Perform the fit
1785
+ fit = FitProcessor()
1786
+ result = fit.process(
1787
+ NXdata(NXfield(spectrum_fit, 'y'),
1788
+ NXfield(np.arange(detector.num_bins)[mca_mask], 'x')),
1789
+ {'parameters': parameters, 'models': models, 'method': 'trf'})
1790
+
1791
+ # Extract values of interest from the best values
1792
+ best_fit_uniform = result.best_fit
1793
+ residual_uniform = result.residual
1794
+ tth_fit = np.degrees(result.best_values['tth'])
1795
+ if quadratic_energy_calibration:
1796
+ a_fit = result.best_values['a']
1797
+ else:
1798
+ a_fit = 0.0
1799
+ b_fit = result.best_values['b']
1800
+ c_fit = result.best_values['c']
1801
+ peak_indices_fit = np.asarray(
1802
+ [result.best_values[f'xrf{i+1}_center'] for i in range(num_xrf)]
1803
+ + [result.best_values[f'peak{i+1}_center']
1804
+ for i in range(num_bragg)])
1805
+ peak_energies_fit = ((a_fit*peak_indices_fit + b_fit)
1806
+ * peak_indices_fit + c_fit)
1807
+ e_bragg_uniform = peak_energies_fit[num_xrf:]
1808
+ a_uniform = np.sqrt(c_1_fit) * abs(
1809
+ get_peak_locations(e_bragg_uniform, tth_fit))
1810
+ strains_uniform = np.log(
1811
+ (a_uniform
826
1812
  / calibration_config.material.lattice_parameters))
827
- unconstrained_strain = np.mean(unconstrained_strains)
828
- unconstrained_tth = tth * (1.0 + unconstrained_strain)
1813
+ strain_uniform = np.mean(strains_uniform)
829
1814
 
830
- # Update tth for the next iteration of tuning
831
- prev_tth = tth
832
- tth = unconstrained_tth
833
-
834
- # Stop tuning tth at this iteration if differences are
835
- # small enough
836
- if abs(tth - prev_tth) < calibration_config.tune_tth_tol:
837
- break
838
-
839
- # Fit line to expected / computed peak locations from the last
840
- # unconstrained fit.
841
- fit = Fit.fit_data(
842
- fit_E0, 'linear', x=unconstrained_fit_centers, nan_policy='omit')
843
- slope = fit.best_values['slope']
844
- intercept = fit.best_values['intercept']
1815
+ elif calibration_method == 'direct_fit_peak_energies':
1816
+ # Third party modules
1817
+ from scipy.optimize import minimize
1818
+
1819
+ def cost_function(
1820
+ pars, quadratic_energy_calibration, ds_fit,
1821
+ indices_unconstrained, e_xrf):
1822
+ tth = pars[0]
1823
+ b = pars[1]
1824
+ c = pars[2]
1825
+ if quadratic_energy_calibration:
1826
+ a = pars[3]
1827
+ else:
1828
+ a = 0.0
1829
+ energies_unconstrained = (
1830
+ (a*indices_unconstrained + b) * indices_unconstrained + c)
1831
+ target_energies = np.concatenate(
1832
+ (e_xrf, get_peak_locations(ds_fit, tth)))
1833
+ return np.sqrt(np.sum(
1834
+ (energies_unconstrained-target_energies)**2))
1835
+
1836
+ # Get the initial free fit parameters
1837
+ a_init, b_init, c_init = detector.energy_calibration_coeffs
1838
+
1839
+ # Perform an unconstrained fit in terms of MCA bin index
1840
+ mca_bins_fit = np.arange(detector.num_bins)[mca_mask]
1841
+ centers = [index_nearest(mca_bin_energies, e_peak)
1842
+ for e_peak in np.concatenate((e_xrf, e_bragg_init))]
1843
+ models = []
1844
+ if detector.background is not None:
1845
+ if isinstance(detector.background, str):
1846
+ models.append(
1847
+ {'model': detector.background, 'prefix': 'bkgd_'})
1848
+ else:
1849
+ for model in detector.background:
1850
+ models.append({'model': model, 'prefix': f'{model}_'})
1851
+ models.append(
1852
+ {'model': 'multipeak', 'centers': centers,
1853
+ 'centers_range': centers_range,
1854
+ 'fwhm_min': fwhm_min, 'fwhm_max': fwhm_max})
1855
+ fit = FitProcessor()
1856
+ result = fit.process(
1857
+ NXdata(NXfield(spectrum_fit, 'y'),
1858
+ NXfield(mca_bins_fit, 'x')),
1859
+ {'models': models, 'method': 'trf'})
1860
+
1861
+ # Extract the peak properties from the fit
1862
+ indices_unconstrained = np.asarray(
1863
+ [result.best_values[f'peak{i+1}_center']
1864
+ for i in range(num_xrf+num_bragg)])
1865
+
1866
+ # Perform a peak center fit using the theoretical values
1867
+ # for the fluorescense peaks and Bragg's law for the Bragg
1868
+ # peaks for given material properties and a freely
1869
+ # adjustable 2&theta angle and MCA energy axis calibration
1870
+ pars_init = [tth_init, b_init, c_init]
1871
+ if quadratic_energy_calibration:
1872
+ pars_init.append(a_init)
1873
+ # For testing: hardwired limits:
1874
+ if True:
1875
+ bounds = [
1876
+ (0.9*tth_init, 1.1*tth_init),
1877
+ (0.1*b_init, 10.*b_init),
1878
+ (0.1*c_init, 10.*c_init)]
1879
+ if quadratic_energy_calibration:
1880
+ if a_init:
1881
+ bounds.append((0.1*a_init, 10.0*a_init))
1882
+ else:
1883
+ bounds.append((None, None))
1884
+ else:
1885
+ bounds = None
1886
+ result = minimize(
1887
+ cost_function, pars_init,
1888
+ args=(
1889
+ quadratic_energy_calibration, ds_fit,
1890
+ indices_unconstrained, e_xrf),
1891
+ method='Nelder-Mead', bounds=bounds)
1892
+
1893
+ # Extract values of interest from the best values
1894
+ best_fit_uniform = None
1895
+ residual_uniform = None
1896
+ tth_fit = float(result['x'][0])
1897
+ b_fit = float(result['x'][1])
1898
+ c_fit = float(result['x'][2])
1899
+ if quadratic_energy_calibration:
1900
+ a_fit = float(result['x'][3])
1901
+ else:
1902
+ a_fit = 0.0
1903
+ e_bragg_fit = get_peak_locations(ds_fit, tth_fit)
1904
+ peak_energies_fit = [
1905
+ (a_fit*i + b_fit) * i + c_fit
1906
+ for i in indices_unconstrained[:num_xrf]] \
1907
+ + list(e_bragg_fit)
1908
+
1909
+ fit_uniform = None
1910
+ residual_uniform = None
1911
+ e_bragg_uniform = e_bragg_fit
1912
+ strain_uniform = None
1913
+
1914
+ elif calibration_method == 'direct_fit_combined':
1915
+ # Third party modules
1916
+ from scipy.optimize import minimize
1917
+
1918
+ def gaussian(x, a, b, c, amp, sig, e_peak):
1919
+ sig2 = 2.*sig**2
1920
+ norm = sig*np.sqrt(2.0*np.pi)
1921
+ cen = (e_peak-c) * (1.0 - a * (e_peak-c) / b**2) / b
1922
+ return amp*np.exp(-(x-cen)**2/sig2)/norm
1923
+
1924
+ def cost_function_combined(
1925
+ pars, x, y, quadratic_energy_calibration, ds_fit,
1926
+ indices_unconstrained, e_xrf):
1927
+ tth = pars[0]
1928
+ b = pars[1]
1929
+ c = pars[2]
1930
+ amplitudes = pars[3::2]
1931
+ sigmas = pars[4::2]
1932
+ if quadratic_energy_calibration:
1933
+ a = pars[-1]
1934
+ else:
1935
+ a = 0.0
1936
+ energies_unconstrained = (
1937
+ (a*indices_unconstrained + b) * indices_unconstrained + c)
1938
+ target_energies = np.concatenate(
1939
+ (e_xrf, get_peak_locations(ds_fit, tth)))
1940
+ y_fit = np.zeros((x.size))
1941
+ for i, e_peak in enumerate(target_energies):
1942
+ y_fit += gaussian(
1943
+ x, a, b, c, amplitudes[i], sigmas[i], e_peak)
1944
+ target_energies_error = np.sqrt(
1945
+ np.sum(
1946
+ (energies_unconstrained
1947
+ - np.asarray(target_energies))**2)
1948
+ / len(target_energies))
1949
+ residual_error = np.sqrt(
1950
+ np.sum((y-y_fit)**2)
1951
+ / (np.sum(y**2) * len(target_energies)))
1952
+ return target_energies_error+residual_error
1953
+
1954
+ # Get the initial free fit parameters
1955
+ a_init, b_init, c_init = detector.energy_calibration_coeffs
1956
+
1957
+ # Perform an unconstrained fit in terms of MCS bin index
1958
+ mca_bins_fit = np.arange(detector.num_bins)[mca_mask]
1959
+ centers = [index_nearest(mca_bin_energies, e_peak)
1960
+ for e_peak in np.concatenate((e_xrf, e_bragg_init))]
1961
+ models = []
1962
+ if detector.background is not None:
1963
+ if isinstance(detector.background, str):
1964
+ models.append(
1965
+ {'model': detector.background, 'prefix': 'bkgd_'})
1966
+ else:
1967
+ for model in detector.background:
1968
+ models.append({'model': model, 'prefix': f'{model}_'})
1969
+ models.append(
1970
+ {'model': 'multipeak', 'centers': centers,
1971
+ 'centers_range': centers_range,
1972
+ 'fwhm_min': fwhm_min, 'fwhm_max': fwhm_max})
1973
+ fit = FitProcessor()
1974
+ result = fit.process(
1975
+ NXdata(NXfield(spectrum_fit, 'y'),
1976
+ NXfield(mca_bins_fit, 'x')),
1977
+ {'models': models, 'method': 'trf'})
1978
+
1979
+ # Extract the peak properties from the fit
1980
+ num_peak = num_xrf+num_bragg
1981
+ indices_unconstrained = np.asarray(
1982
+ [result.best_values[f'peak{i+1}_center']
1983
+ for i in range(num_peak)])
1984
+ amplitudes_init = np.asarray(
1985
+ [result.best_values[f'peak{i+1}_amplitude']
1986
+ for i in range(num_peak)])
1987
+ sigmas_init = np.asarray(
1988
+ [result.best_values[f'peak{i+1}_sigma']
1989
+ for i in range(num_peak)])
1990
+
1991
+ # Perform a peak center fit using the theoretical values
1992
+ # for the fluorescense peaks and Bragg's law for the Bragg
1993
+ # peaks for given material properties and a freely
1994
+ # adjustable 2&theta angle and MCA energy axis calibration
1995
+ norm = spectrum_fit.max()
1996
+ pars_init = [tth_init, b_init, c_init]
1997
+ for amp, sig in zip(amplitudes_init, sigmas_init):
1998
+ pars_init += [amp/norm, sig]
1999
+ if quadratic_energy_calibration:
2000
+ pars_init += [a_init]
2001
+ # For testing: hardwired limits:
2002
+ if True:
2003
+ bounds = [
2004
+ (0.9*tth_init, 1.1*tth_init),
2005
+ (0.1*b_init, 10.*b_init),
2006
+ (0.1*c_init, 10.*c_init)]
2007
+ for amp, sig in zip(amplitudes_init, sigmas_init):
2008
+ bounds += [
2009
+ (0.9*amp/norm, 1.1*amp/norm), (0.9*sig, 1.1*sig)]
2010
+ if quadratic_energy_calibration:
2011
+ if a_init:
2012
+ bounds += [(0.1*a_init, 10.*a_init)]
2013
+ else:
2014
+ bounds += [(None, None)]
2015
+ else:
2016
+ bounds = None
2017
+ result = minimize(
2018
+ cost_function_combined, pars_init,
2019
+ args=(
2020
+ mca_bins_fit, spectrum_fit/norm,
2021
+ quadratic_energy_calibration, ds_fit,
2022
+ indices_unconstrained, e_xrf),
2023
+ method='Nelder-Mead', bounds=bounds)
2024
+
2025
+ # Extract values of interest from the best values
2026
+ tth_fit = float(result['x'][0])
2027
+ b_fit = float(result['x'][1])
2028
+ c_fit = float(result['x'][2])
2029
+ amplitudes_fit = norm * result['x'][3::2]
2030
+ sigmas_fit = result['x'][4::2]
2031
+ if quadratic_energy_calibration:
2032
+ a_fit = float(result['x'][-1])
2033
+ else:
2034
+ a_fit = 0.0
2035
+ e_bragg_fit = get_peak_locations(ds_fit, tth_fit)
2036
+ peak_energies_fit = [
2037
+ (a_fit*i + b_fit) * i + c_fit
2038
+ for i in indices_unconstrained[:num_xrf]] \
2039
+ + list(e_bragg_fit)
2040
+
2041
+ best_fit_uniform = np.zeros((mca_bins_fit.size))
2042
+ for i, e_peak in enumerate(peak_energies_fit):
2043
+ best_fit_uniform += gaussian(
2044
+ mca_bins_fit, a_fit, b_fit, c_fit, amplitudes_fit[i],
2045
+ sigmas_fit[i], e_peak)
2046
+ residual_uniform = spectrum_fit - best_fit_uniform
2047
+ e_bragg_uniform = e_bragg_fit
2048
+ strain_uniform = 0.0
2049
+
2050
+ elif calibration_method == 'iterate_tth':
2051
+
2052
+ tth_fit = tth_init
2053
+ e_bragg_fit = e_bragg_init
2054
+ mca_bin_energies_fit = mca_bin_energies[mca_mask]
2055
+ a_init, b_init, c_init = detector.energy_calibration_coeffs
2056
+ if isinstance(fwhm_min, (int, float)):
2057
+ fwhm_min = fwhm_min*b_init
2058
+ else:
2059
+ fwhm_min = None
2060
+ if isinstance(fwhm_max, (int, float)):
2061
+ fwhm_max = fwhm_max*b_init
2062
+ else:
2063
+ fwhm_max = None
2064
+ for iter_i in range(calibration_config.max_iter):
2065
+ self.logger.debug(f'Tuning tth: iteration no. {iter_i}, '
2066
+ f'starting value = {tth_fit} ')
2067
+
2068
+ # Construct the fit model
2069
+ models = []
2070
+ if detector.background is not None:
2071
+ if isinstance(detector.background, str):
2072
+ models.append(
2073
+ {'model': detector.background, 'prefix': 'bkgd_'})
2074
+ else:
2075
+ for model in detector.background:
2076
+ models.append(
2077
+ {'model': model, 'prefix': f'{model}_'})
2078
+ models.append(
2079
+ {'model': 'multipeak', 'centers': list(e_bragg_fit),
2080
+ 'fit_type': 'uniform',
2081
+ 'centers_range': centers_range*b_init,
2082
+ 'fwhm_min': fwhm_min, 'fwhm_max': fwhm_max})
2083
+
2084
+ # Perform the uniform
2085
+ fit = FitProcessor()
2086
+ uniform_fit = fit.process(
2087
+ NXdata(
2088
+ NXfield(spectrum_fit, 'y'),
2089
+ NXfield(mca_bin_energies_fit, 'x')),
2090
+ {'models': models, 'method': 'trf'})
2091
+
2092
+ # Extract values of interest from the best values for
2093
+ # the uniform fit parameters
2094
+ best_fit_uniform = uniform_fit.best_fit
2095
+ residual_uniform = uniform_fit.residual
2096
+ e_bragg_uniform = [
2097
+ uniform_fit.best_values[f'peak{i+1}_center']
2098
+ for i in range(num_bragg)]
2099
+ strain_uniform = -np.log(
2100
+ uniform_fit.best_values['scale_factor'])
2101
+
2102
+ # Next, perform the unconstrained fit
2103
+
2104
+ # Use the peak parameters from the uniform fit as
2105
+ # the initial guesses for peak locations in the
2106
+ # unconstrained fit
2107
+ models[-1]['fit_type'] = 'unconstrained'
2108
+ unconstrained_fit = fit.process(
2109
+ uniform_fit, {'models': models, 'method': 'trf'})
2110
+
2111
+ # Extract values of interest from the best values for
2112
+ # the unconstrained fit parameters
2113
+ best_fit_unconstrained = unconstrained_fit.best_fit
2114
+ residual_unconstrained = unconstrained_fit.residual
2115
+ e_bragg_unconstrained = np.array(
2116
+ [unconstrained_fit.best_values[f'peak{i+1}_center']
2117
+ for i in range(num_bragg)])
2118
+ a_unconstrained = np.sqrt(c_1_fit)*abs(get_peak_locations(
2119
+ e_bragg_unconstrained, tth_fit))
2120
+ strains_unconstrained = np.log(
2121
+ (a_unconstrained
2122
+ / calibration_config.material.lattice_parameters))
2123
+ strain_unconstrained = np.mean(strains_unconstrained)
2124
+ tth_unconstrained = tth_fit * (1.0 + strain_unconstrained)
2125
+
2126
+ # Update tth for the next iteration of tuning
2127
+ prev_tth = tth_fit
2128
+ tth_fit = float(tth_unconstrained)
2129
+
2130
+ # Update the peak energy locations for this iteration
2131
+ e_bragg_fit = get_peak_locations(ds_fit, tth_fit)
2132
+
2133
+ # Stop tuning tth at this iteration if differences are
2134
+ # small enough
2135
+ if abs(tth_fit - prev_tth) < calibration_config.tune_tth_tol:
2136
+ break
2137
+
2138
+ # Fit line to expected / computed peak locations from the
2139
+ # last unconstrained fit.
2140
+ if quadratic_energy_calibration:
2141
+ fit = FitProcessor()
2142
+ result = fit.process(
2143
+ NXdata(
2144
+ NXfield(e_bragg_fit, 'y'),
2145
+ NXfield(e_bragg_unconstrained, 'x')),
2146
+ {'models': [{'model': 'quadratic'}]})
2147
+ a = result.best_values['a']
2148
+ b = result.best_values['b']
2149
+ c = result.best_values['c']
2150
+ else:
2151
+ fit = FitProcessor()
2152
+ result = fit.process(
2153
+ NXdata(
2154
+ NXfield(e_bragg_fit, 'y'),
2155
+ NXfield(e_bragg_unconstrained, 'x')),
2156
+ {'models': [{'model': 'linear'}]})
2157
+ a = 0.0
2158
+ b = result.best_values['slope']
2159
+ c = result.best_values['intercept']
2160
+ # The following assumes that a_init = 0
2161
+ if a_init:
2162
+ raise NotImplemented(
2163
+ f'A linear energy calibration is required at this time')
2164
+ a_fit = float(a*b_init**2)
2165
+ b_fit = float(2*a*b_init*c_init + b*b_init)
2166
+ c_fit = float(a*c_init**2 + b*c_init + c)
2167
+ peak_energies_fit = ((a*e_bragg_unconstrained + b)
2168
+ * e_bragg_unconstrained + c)
2169
+
2170
+ # Store the results in the detector object
2171
+ detector.tth_calibrated = float(tth_fit)
2172
+ detector.energy_calibration_coeffs = [
2173
+ float(a_fit), float(b_fit), float(c_fit)]
2174
+
2175
+ # Update the MCA channel energies with the newly calibrated
2176
+ # coefficients
2177
+ mca_bin_energies = detector.energies
845
2178
 
846
2179
  if interactive or save_figures:
847
2180
  # Third party modules
848
2181
  import matplotlib.pyplot as plt
849
2182
 
2183
+ # Update the peak energies and the MCA channel energies
2184
+ e_bragg_fit = get_peak_locations(ds_fit, tth_fit)
2185
+ mca_energies_fit = mca_bin_energies[mca_mask]
2186
+
2187
+ # Get an unconstrained fit
2188
+ if calibration_method != 'iterate_tth':
2189
+ if isinstance(fwhm_min, (int, float)):
2190
+ fwhm_min = fwhm_min*b_fit
2191
+ else:
2192
+ fwhm_min = None
2193
+ if isinstance(fwhm_max, (int, float)):
2194
+ fwhm_max = fwhm_max*b_fit
2195
+ else:
2196
+ fwhm_max = None
2197
+ models = []
2198
+ if detector.background is not None:
2199
+ if isinstance(detector.background, str):
2200
+ models.append(
2201
+ {'model': detector.background, 'prefix': 'bkgd_'})
2202
+ else:
2203
+ for model in detector.background:
2204
+ models.append(
2205
+ {'model': model, 'prefix': f'{model}_'})
2206
+ models.append(
2207
+ {'model': 'multipeak', 'centers': list(peak_energies_fit),
2208
+ 'centers_range': centers_range*b_fit,
2209
+ 'fwhm_min': fwhm_min, 'fwhm_max': fwhm_max})
2210
+ fit = FitProcessor()
2211
+ result = fit.process(
2212
+ NXdata(NXfield(spectrum_fit, 'y'),
2213
+ NXfield(mca_energies_fit, 'x')),
2214
+ {'models': models, 'method': 'trf'})
2215
+ best_fit_unconstrained = result.best_fit
2216
+ residual_unconstrained = result.residual
2217
+ e_bragg_unconstrained = np.sort(
2218
+ [result.best_values[f'peak{i+1}_center']
2219
+ for i in range(num_xrf, num_xrf+num_bragg)])
2220
+ a_unconstrained = np.sqrt(c_1_fit) * abs(
2221
+ get_peak_locations(e_bragg_unconstrained, tth_fit))
2222
+ strains_unconstrained = np.log(
2223
+ (a_unconstrained
2224
+ / calibration_config.material.lattice_parameters))
2225
+ strain_unconstrained = np.mean(strains_unconstrained)
2226
+
2227
+ # Create the figure
850
2228
  fig, axs = plt.subplots(2, 2, sharex='all', figsize=(11, 8.5))
2229
+ fig.suptitle(
2230
+ f'Detector {detector.detector_name} '
2231
+ r'2$\theta$ Calibration')
851
2232
 
852
- # Upper left axes: Input data & best fits
853
- axs[0,0].set_title('Ceria Calibration Fits')
2233
+ # Upper left axes: best fit with calibrated peak centers
2234
+ axs[0,0].set_title(r'2$\theta$ Calibration Fits')
854
2235
  axs[0,0].set_xlabel('Energy (keV)')
855
2236
  axs[0,0].set_ylabel('Intensity (a.u)')
856
- for i, hkl_E in enumerate(fit_E0):
857
- # KLS: annotate indicated HKLs w millier indices
858
- axs[0,0].axvline(hkl_E, color='k', linestyle='--')
859
- axs[0,0].text(hkl_E, 1, str(fit_hkls[i])[1:-1],
2237
+ for i, e_peak in enumerate(e_bragg_fit):
2238
+ axs[0,0].axvline(e_peak, c='k', ls='--')
2239
+ axs[0,0].text(e_peak, 1, str(hkls_fit[i])[1:-1],
860
2240
  ha='right', va='top', rotation=90,
861
2241
  transform=axs[0,0].get_xaxis_transform())
862
- axs[0,0].plot(fit_mca_energies, uniform_best_fit,
863
- label='Single Strain')
864
- axs[0,0].plot(fit_mca_energies, unconstrained_best_fit,
865
- label='Unconstrained')
866
- #axs[0,0].plot(fit_mca_energies, MISSING?, label='least squares')
867
- axs[0,0].plot(fit_mca_energies, fit_mca_intensities,
868
- label='Flux-Corrected & Masked MCA Data')
2242
+ if flux_correct is None:
2243
+ axs[0,0].plot(
2244
+ mca_energies_fit, spectrum_fit, marker='.', c='C2', ms=3,
2245
+ ls='', label='MCA data')
2246
+ else:
2247
+ axs[0,0].plot(
2248
+ mca_energies_fit, spectrum_fit, marker='.', c='C2', ms=3,
2249
+ ls='', label='Flux-corrected MCA data')
2250
+ if calibration_method == 'iterate_tth':
2251
+ label_unconstrained = 'Unconstrained'
2252
+ else:
2253
+ if quadratic_energy_calibration:
2254
+ label_unconstrained = \
2255
+ 'Unconstrained fit using calibrated a, b, and c'
2256
+ else:
2257
+ label_unconstrained = \
2258
+ 'Unconstrained fit using calibrated b and c'
2259
+ if best_fit_uniform is None:
2260
+ axs[0,0].plot(
2261
+ mca_energies_fit, best_fit_unconstrained, c='C1',
2262
+ label=label_unconstrained)
2263
+ else:
2264
+ axs[0,0].plot(
2265
+ mca_energies_fit, best_fit_uniform, c='C0',
2266
+ label='Single strain')
2267
+ axs[0,0].plot(
2268
+ mca_energies_fit, best_fit_unconstrained, c='C1', ls='--',
2269
+ label=label_unconstrained)
869
2270
  axs[0,0].legend()
870
2271
 
871
- # Lower left axes: fit residuals
2272
+ # Lower left axes: fit residual
872
2273
  axs[1,0].set_title('Fit Residuals')
873
2274
  axs[1,0].set_xlabel('Energy (keV)')
874
2275
  axs[1,0].set_ylabel('Residual (a.u)')
875
- axs[1,0].plot(fit_mca_energies,
876
- uniform_residual,
877
- label='Single Strain')
878
- axs[1,0].plot(fit_mca_energies,
879
- unconstrained_residual,
880
- label='Unconstrained')
2276
+ if residual_uniform is None:
2277
+ axs[1,0].plot(
2278
+ mca_energies_fit, residual_unconstrained, c='C1',
2279
+ label=label_unconstrained)
2280
+ else:
2281
+ axs[1,0].plot(
2282
+ mca_energies_fit, residual_uniform, c='C0',
2283
+ label='Single strain')
2284
+ axs[1,0].plot(
2285
+ mca_energies_fit, residual_unconstrained, c='C1', ls='--',
2286
+ label=label_unconstrained)
881
2287
  axs[1,0].legend()
882
2288
 
883
2289
  # Upper right axes: E vs strain for each fit
884
2290
  axs[0,1].set_title('HKL Energy vs. Microstrain')
885
2291
  axs[0,1].set_xlabel('Energy (keV)')
886
2292
  axs[0,1].set_ylabel('Strain (\u03BC\u03B5)')
887
- axs[0,1].axhline(uniform_strain * 1e6,
888
- linestyle='--', label='Single Strain')
889
- axs[0,1].plot(fit_E0, unconstrained_strains * 1e6,
890
- color='C1', marker='s', label='Unconstrained')
891
- axs[0,1].axhline(unconstrained_strain * 1e6,
892
- color='C1', linestyle='--',
893
- label='Unconstrained: Unweighted Mean')
2293
+ if strain_uniform is not None:
2294
+ axs[0,1].axhline(strain_uniform * 1e6,
2295
+ ls='--', label='Single strain')
2296
+ axs[0,1].plot(e_bragg_fit, strains_unconstrained * 1e6,
2297
+ marker='o', mfc='none', c='C1',
2298
+ label='Unconstrained')
2299
+ axs[0,1].axhline(strain_unconstrained* 1e6,
2300
+ ls='--', c='C1',
2301
+ label='Unconstrained: unweighted mean')
894
2302
  axs[0,1].legend()
895
2303
 
896
- # Lower right axes: theoretical HKL E vs fit HKL E for
897
- # each fit
898
- axs[1,1].set_title('Theoretical vs. Fit HKL Energies')
2304
+ # Lower right axes: theoretical E vs fitted E for all peaks
2305
+ axs[1,1].set_title('Theoretical vs. Fitted Peak Energies')
899
2306
  axs[1,1].set_xlabel('Energy (keV)')
900
2307
  axs[1,1].set_ylabel('Energy (keV)')
901
- axs[1,1].plot(fit_E0, uniform_fit_centers,
902
- marker='o', label='Single Strain')
903
- axs[1,1].plot(fit_E0, unconstrained_fit_centers,
904
- linestyle='', marker='o', label='Unconstrained')
905
- axs[1,1].plot(slope * unconstrained_fit_centers + intercept,fit_E0,
906
- color='C1', label='Unconstrained: Linear Fit')
2308
+ if calibration_method == 'iterate_tth':
2309
+ e_fit = e_bragg_fit
2310
+ e_unconstrained = e_bragg_unconstrained
2311
+ if quadratic_energy_calibration:
2312
+ label = 'Unconstrained: quadratic fit'
2313
+ else:
2314
+ label = 'Unconstrained: linear fit'
2315
+ else:
2316
+ e_fit = np.concatenate((e_xrf, e_bragg_fit))
2317
+ e_unconstrained = np.concatenate(
2318
+ (e_xrf, e_bragg_unconstrained))
2319
+ if quadratic_energy_calibration:
2320
+ label = 'Quadratic fit'
2321
+ else:
2322
+ label = 'Linear fit'
2323
+ axs[1,1].plot(
2324
+ e_bragg_fit, e_bragg_uniform, marker='x', ls='',
2325
+ label='Single strain')
2326
+ axs[1,1].plot(
2327
+ e_fit, e_unconstrained, marker='o', mfc='none', ls='',
2328
+ label='Unconstrained')
2329
+ axs[1,1].plot(
2330
+ e_fit, peak_energies_fit, c='C1', label=label)
907
2331
  axs[1,1].legend()
908
-
909
- # Add a text box showing final calibrated values
2332
+ txt = 'Calibrated values:' \
2333
+ f'\nTakeoff angle:\n {tth_fit:.5f}$^\circ$'
2334
+ if quadratic_energy_calibration:
2335
+ txt += '\nQuadratic coefficient (a):' \
2336
+ f'\n {a_fit:.5e} $keV$/channel$^2$'
2337
+ txt += '\nLinear coefficient (b):' \
2338
+ f'\n {b_fit:.5f} $keV$/channel' \
2339
+ f'\nConstant offset (c):\n {c_fit:.5f}'
910
2340
  axs[1,1].text(
911
- 0.98, 0.02,
912
- 'Calibrated Values:\n\n'
913
- + f'Takeoff Angle:\n {tth:.5f}$^\circ$\n\n'
914
- + f'Slope:\n {slope:.5f}\n\n'
915
- + f'Intercept:\n {intercept:.5f} $keV$',
2341
+ 0.98, 0.02, txt,
916
2342
  ha='right', va='bottom', ma='left',
917
2343
  transform=axs[1,1].transAxes,
918
2344
  bbox=dict(boxstyle='round',
@@ -922,169 +2348,30 @@ class MCACeriaCalibrationProcessor(Processor):
922
2348
  fig.tight_layout()
923
2349
 
924
2350
  if save_figures:
925
- figfile = os.path.join(outputdir, 'ceria_calibration_fits.png')
2351
+ figfile = os.path.join(
2352
+ outputdir,
2353
+ f'{detector.detector_name}_tth_calibration_fits.png')
926
2354
  plt.savefig(figfile)
927
2355
  self.logger.info(f'Saved figure to {figfile}')
928
2356
  if interactive:
929
2357
  plt.show()
930
2358
 
931
- return float(tth), float(slope), float(intercept)
932
-
933
-
934
- class MCAEnergyCalibrationProcessor(Processor):
935
- """Processor to return parameters for linearly transforming MCA
936
- channel indices to energies (in keV). Procedure: provide a
937
- spectrum from the MCA element to be calibrated and the theoretical
938
- location of at least one peak present in that spectrum (peak
939
- locations must be given in keV). It is strongly recommended to use
940
- the location of fluorescence peaks whenever possible, _not_
941
- diffraction peaks, as this Processor does not account for
942
- 2&theta."""
943
- def process(self,
944
- data,
945
- max_energy,
946
- peak_energies,
947
- peak_initial_guesses=None,
948
- peak_center_fit_delta=2.0,
949
- fit_ranges=None,
950
- save_figures=False,
951
- interactive=False,
952
- outputdir='.'):
953
- """Fit the specified peaks in the MCA spectrum provided. Using
954
- the difference between the provided peak locations and the fit
955
- centers of those peaks, compute linear correction parameters
956
- to convert MCA channel indices to energies in keV. Return
957
- those parameters as a dictionary.
958
-
959
- :param data: An MCA spectrum
960
- :type data: PipelineData
961
- :param max_energy: The (uncalibrated) maximum energy measured
962
- by the MCA spectrum provided.
963
- :type max_energy: float
964
- :param peak_energies: Theoretical locations of peaks to use
965
- for calibrating the MCA channel energies. It is _strongly_
966
- recommended to use fluorescence peaks.
967
- :type peak_energies: list[float]
968
- :param peak_initial_guesses: A list of values to use for the
969
- initial guesses for peak locations when performing the fit
970
- of the spectrum. Providing good values to this parameter
971
- can greatly improve the quality of the spectrum fit when
972
- the uncalibrated detector channel energies are too far off
973
- to use the values in `peak_energies` for the initial
974
- guesses for peak centers. Defaults to None.
975
- :type peak_inital_guesses: Optional[list[float]]
976
- :param peak_center_fit_delta: Set boundaries on the fit peak
977
- centers when performing the fit. The min/max possible
978
- values for the peak centers will be the values provided in
979
- `peak_energies` (or `peak_initial_guesses`, if used) &pm;
980
- `peak_center_fit_delta`. Defaults to 2.0.
981
- :type peak_center_fit_delta: float
982
- :param fit_ranges: Explicit ranges of MCA channel indices
983
- (_not_ energies) to include when performing a fit of the
984
- given peaks to the provied MCA spectrum. Use this
985
- parameter or select it interactively by running a pipeline
986
- with `config.interactive: True`. Defaults to []
987
- :type fit_ranges: Optional[list[tuple[int, int]]]
988
- :param save_figures: Save .pngs of plots for checking inputs &
989
- outputs of this Processor, defaults to False.
990
- :type save_figures: bool, optional
991
- :param interactive: Allows for user interactions, defaults to
992
- False.
993
- :type interactive: bool, optional
994
- :param outputdir: Directory to which any output figures will
995
- be saved, defaults to '.'.
996
- :type outputdir: str, optional
997
- :returns: Dictionary containing linear energy correction
998
- parameters for the MCA element
999
- :rtype: dict[str, float]
1000
- """
1001
- # Validate arguments: fit_ranges & interactive
1002
- if not (fit_ranges or interactive):
1003
- self.logger.exception(
1004
- RuntimeError(
1005
- 'If `fit_ranges` is not explicitly provided, '
1006
- + self.__class__.__name__
1007
- + ' must be run with `interactive=True`.'))
1008
- # Validate arguments: peak_energies & peak_initial_guesses
1009
- if peak_initial_guesses is None:
1010
- peak_initial_guesses = peak_energies
1011
- else:
1012
- from CHAP.utils.general import is_num_series
1013
- is_num_series(peak_initial_guesses, raise_error=True)
1014
- if len(peak_initial_guesses) != len(peak_energies):
1015
- self.logger.exception(
1016
- ValueError(
1017
- 'peak_initial_guesses must have the same number of '
1018
- + 'values as peak_energies'))
1019
-
1020
- import matplotlib.pyplot as plt
1021
- import numpy as np
1022
- from CHAP.utils.fit import Fit
1023
- from CHAP.utils.general import select_mask_1d
1024
-
1025
- spectrum = self.unwrap_pipelinedata(data)[0]
1026
- num_bins = len(spectrum)
1027
- uncalibrated_energies = np.linspace(0, max_energy, num_bins)
1028
-
1029
- fig, mask, fit_ranges = select_mask_1d(
1030
- spectrum, x=uncalibrated_energies,
1031
- preselected_index_ranges=fit_ranges,
1032
- xlabel='Uncalibrated Energy', ylabel='Intensity',
1033
- min_num_index_ranges=1, interactive=interactive)
1034
- if save_figures:
1035
- fig.savefig(os.path.join(
1036
- outputdir, 'mca_energy_calibration_mask.png'))
1037
- plt.close()
1038
- self.logger.debug(f'Selected index ranges to fit: {fit_ranges}')
1039
-
1040
- spectrum_fit = Fit(spectrum[mask], x=uncalibrated_energies[mask])
1041
- for i, (peak_energy, initial_guess) in enumerate(
1042
- zip(peak_energies, peak_initial_guesses)):
1043
- spectrum_fit.add_model(
1044
- 'gaussian', prefix=f'peak{i+1}_', parameters=(
1045
- {'name': 'amplitude', 'min': 0.0},
1046
- {'name': 'center', 'value': initial_guess,
1047
- 'min': initial_guess - peak_center_fit_delta,
1048
- 'max': initial_guess + peak_center_fit_delta}
1049
- ))
1050
- self.logger.debug('Fitting spectrum')
1051
- spectrum_fit.fit()
1052
- fit_peak_energies = [
1053
- spectrum_fit.best_values[f'peak{i+1}_center']
1054
- for i in range(len(peak_energies))]
1055
- self.logger.debug(f'Fit peak centers: {fit_peak_energies}')
1056
-
1057
- energy_fit = Fit.fit_data(
1058
- peak_energies, 'linear', x=fit_peak_energies, nan_policy='omit')
1059
- slope = energy_fit.best_values['slope']
1060
- intercept = energy_fit.best_values['intercept']
1061
-
1062
- # Rescale slope so results are a linear correction from
1063
- # channel indices -> calibrated energies, not uncalibrated
1064
- # energies -> calibrated energies
1065
- slope = (max_energy / num_bins) * slope
1066
- return({'slope': slope, 'intercept': intercept})
1067
-
1068
2359
 
1069
2360
  class MCADataProcessor(Processor):
1070
2361
  """A Processor to return data from an MCA, restuctured to
1071
2362
  incorporate the shape & metadata associated with a map
1072
2363
  configuration to which the MCA data belongs, and linearly
1073
- transformed according to the results of a ceria calibration.
2364
+ transformed according to the results of a energy/tth calibration.
1074
2365
  """
1075
2366
 
1076
- def process(self,
1077
- data,
1078
- config=None,
1079
- save_figures=False,
1080
- inputdir='.',
1081
- outputdir='.',
1082
- interactive=False):
2367
+ def process(
2368
+ self, data, config=None, save_figures=False, inputdir='.',
2369
+ outputdir='.', interactive=False):
1083
2370
  """Process configurations for a map and MCA detector(s), and
1084
2371
  return the calibrated MCA data collected over the map.
1085
2372
 
1086
- :param data: Input map configuration and results of ceria
1087
- calibration.
2373
+ :param data: Input map configuration and results of
2374
+ energy/tth calibration.
1088
2375
  :type data: list[dict[str,object]]
1089
2376
  :return: Calibrated and flux-corrected MCA data.
1090
2377
  :rtype: nexusformat.nexus.NXentry
@@ -1094,9 +2381,9 @@ class MCADataProcessor(Processor):
1094
2381
  exit('Done Here')
1095
2382
  map_config = self.get_config(
1096
2383
  data, 'common.models.map.MapConfig', inputdir=inputdir)
1097
- ceria_calibration_config = self.get_config(
1098
- data, 'edd.models.MCACeriaCalibrationConfig', inputdir=inputdir)
1099
- nxroot = self.get_nxroot(map_config, ceria_calibration_config)
2384
+ calibration_config = self.get_config(
2385
+ data, 'edd.models.MCATthCalibrationConfig', inputdir=inputdir)
2386
+ nxroot = self.get_nxroot(map_config, calibration_config)
1100
2387
 
1101
2388
  return nxroot
1102
2389
 
@@ -1111,7 +2398,7 @@ class MCADataProcessor(Processor):
1111
2398
  :type map_config: CHAP.common.models.MapConfig.
1112
2399
  :param calibration_config: The calibration configuration.
1113
2400
  :type calibration_config:
1114
- CHAP.edd.models.MCACeriaCalibrationConfig
2401
+ CHAP.edd.models.MCATthCalibrationConfig
1115
2402
  :return: A map of the calibrated and flux-corrected MCA data.
1116
2403
  :rtype: nexusformat.nexus.NXroot
1117
2404
  """
@@ -1171,39 +2458,137 @@ class MCADataProcessor(Processor):
1171
2458
  return nxroot
1172
2459
 
1173
2460
 
2461
+ class MCACalibratedDataPlotter(Processor):
2462
+ """Convenience Processor for quickly visualizing calibrated MCA
2463
+ data from a single scan. Returns None!"""
2464
+ def process(
2465
+ self, data, spec_file, scan_number, scan_step_index=None,
2466
+ material=None, save_figures=False, interactive=False,
2467
+ outputdir='.'):
2468
+ """Show a maplotlib figure of the MCA data fom the scan
2469
+ provided on a calibrated energy axis. If `scan_step_index` is
2470
+ None, a plot of the sum of all spectra across the whole scan
2471
+ will be shown.
2472
+
2473
+ :param data: PipelineData containing an MCA calibration.
2474
+ :type data: list[PipelineData]
2475
+ :param spec_file: SPEC file containing scan of interest.
2476
+ :type spec_file: str
2477
+ :param scan_number: Scan number of interest.
2478
+ :type scan_number: int
2479
+ :param scan_step_index: Scan step index of interest,
2480
+ defaults to `None`.
2481
+ :type scan_step_index: int, optional
2482
+ :param material: Material parameters to plot HKLs for.
2483
+ :type material: dict
2484
+ :param save_figures: Save .pngs of plots for checking inputs &
2485
+ outputs of this Processor.
2486
+ :type save_figures: bool
2487
+ :param interactive: Allows for user interactions.
2488
+ :type interactive: bool
2489
+ :param outputdir: Directory to which any output figures will
2490
+ be saved.
2491
+ :type outputdir: str
2492
+ :returns: None
2493
+ :rtype: None
2494
+ """
2495
+ # Third party modules
2496
+ import matplotlib.pyplot as plt
2497
+
2498
+ # Local modules
2499
+ from chess_scanparsers import SMBMCAScanParser as ScanParser
2500
+
2501
+ if material is not None:
2502
+ self.logger.warning('Plotting HKL lines is not supported yet.')
2503
+
2504
+ if scan_step_index is not None:
2505
+ if not isinstance(scan_step_index, int):
2506
+ try:
2507
+ scan_step_index = int(scan_step_index)
2508
+ except:
2509
+ msg = 'scan_step_index must be an int'
2510
+ self.logger.error(msg)
2511
+ raise TypeError(msg)
2512
+
2513
+ calibration_config = self.get_config(
2514
+ data, 'edd.models.MCATthCalibrationConfig')
2515
+ scanparser = ScanParser(spec_file, scan_number)
2516
+
2517
+ fig, ax = plt.subplots(1, 1, figsize=(11, 8.5))
2518
+ title = f'{scanparser.scan_title} MCA Data'
2519
+ if scan_step_index is None:
2520
+ title += ' (sum of all spectra in the scan)'
2521
+ ax.set_title(title)
2522
+ ax.set_xlabel('Calibrated energy (keV)')
2523
+ ax.set_ylabel('Intenstiy (a.u)')
2524
+ for detector in calibration_config.detectors:
2525
+ if scan_step_index is None:
2526
+ spectrum = np.sum(
2527
+ scanparser.get_all_detector_data(detector.detector_name),
2528
+ axis=0)
2529
+ else:
2530
+ spectrum = scanparser.get_detector_data(
2531
+ detector.detector_name, scan_step_index=scan_step_index)
2532
+ ax.plot(detector.energies, spectrum,
2533
+ label=f'Detector {detector.detector_name}')
2534
+ ax.legend()
2535
+ if interactive:
2536
+ plt.show()
2537
+ if save_figures:
2538
+ fig.savefig(os.path.join(
2539
+ outputdir, f'spectrum_{scanparser.scan_title}'))
2540
+ plt.close()
2541
+ return None
2542
+
2543
+
1174
2544
  class StrainAnalysisProcessor(Processor):
1175
2545
  """Processor that takes a map of MCA data and returns a map of
1176
2546
  sample strains
1177
2547
  """
1178
- def process(self,
1179
- data,
1180
- config=None,
1181
- save_figures=False,
1182
- inputdir='.',
1183
- outputdir='.',
1184
- interactive=False):
2548
+ def __init__(self):
2549
+ super().__init__()
2550
+ self._save_figures = False
2551
+ self._inputdir = '.'
2552
+ self._outputdir = '.'
2553
+ self._interactive = False
2554
+ self._detectors = []
2555
+ self._detector_indices = []
2556
+ self._nxdata = None
2557
+
2558
+ def process(
2559
+ self, data, config=None, find_peaks=False, skip_animation=False,
2560
+ save_figures=False, inputdir='.', outputdir='.',
2561
+ interactive=False):
1185
2562
  """Return strain analysis maps & associated metadata in an NXprocess.
1186
2563
 
1187
2564
  :param data: Input data containing configurations for a map,
1188
- completed ceria calibration, and parameters for strain
1189
- analysis
2565
+ completed energy/tth calibration, and parameters for strain
2566
+ analysis.
1190
2567
  :type data: list[PipelineData]
1191
2568
  :param config: Initialization parameters for an instance of
1192
2569
  CHAP.edd.models.StrainAnalysisConfig, defaults to
1193
- None.
2570
+ `None`.
1194
2571
  :type config: dict, optional
2572
+ :param find_peaks: Exclude peaks where the average spectrum
2573
+ is below the `rel_height_cutoff` (in the detector
2574
+ configuration) cutoff relative to the maximum value of the
2575
+ average spectrum, defaults to `False`.
2576
+ :type find_peaks: bool, optional
2577
+ :param skip_animation: Skip the animation and plotting of
2578
+ the strain analysis fits, defaults to `False`.
2579
+ :type skip_animation: bool, optional
1195
2580
  :param save_figures: Save .pngs of plots for checking inputs &
1196
- outputs of this Processor, defaults to False.
2581
+ outputs of this Processor, defaults to `False`.
1197
2582
  :type save_figures: bool, optional
1198
- :param outputdir: Directory to which any output figures will
1199
- be saved, defaults to '.'.
1200
- :type outputdir: str, optional
1201
2583
  :param inputdir: Input directory, used only if files in the
1202
2584
  input configuration are not absolute paths,
1203
- defaults to '.'.
2585
+ defaults to `'.'`.
1204
2586
  :type inputdir: str, optional
2587
+ :param outputdir: Directory to which any output figures will
2588
+ be saved, defaults to `'.'`.
2589
+ :type outputdir: str, optional
1205
2590
  :param interactive: Allows for user interactions, defaults to
1206
- False.
2591
+ `False`.
1207
2592
  :type interactive: bool, optional
1208
2593
  :raises RuntimeError: Unable to get a valid strain analysis
1209
2594
  configuration.
@@ -1213,67 +2598,122 @@ class StrainAnalysisProcessor(Processor):
1213
2598
  :rtype: nexusformat.nexus.NXprocess
1214
2599
 
1215
2600
  """
1216
- # Get required configuration models from input data
1217
- ceria_calibration_config = self.get_config(
1218
- data, 'edd.models.MCACeriaCalibrationConfig', inputdir=inputdir)
2601
+ # Third party modules
2602
+ from nexusformat.nexus import (
2603
+ NXentry,
2604
+ NXroot,
2605
+ )
2606
+
2607
+ self._save_figures = save_figures
2608
+ self._outputdir = outputdir
2609
+ self._interactive = interactive
2610
+
2611
+ # Load the detector data
2612
+ try:
2613
+ nxentry = self.get_data(data, 'MapProcessor')
2614
+ if not isinstance(nxentry, NXentry):
2615
+ raise RuntimeError(
2616
+ 'No valid NXentry data in MapProcessor pipeline data')
2617
+ except:
2618
+ try:
2619
+ try:
2620
+ nxroot = self.get_data(data, 'NexusReader')
2621
+ except:
2622
+ nxroot = self.get_data(data, 'NexusWriter')
2623
+ if not isinstance(nxroot, NXroot):
2624
+ raise RuntimeError(
2625
+ 'No valid NXroot data in NexusWriter pipeline data')
2626
+ nxentry = nxroot[nxroot.default]
2627
+ if not isinstance(nxentry, NXentry):
2628
+ raise RuntimeError(
2629
+ 'No valid NXentry data in NexusWriter pipeline data')
2630
+ except:
2631
+ raise RuntimeError(
2632
+ 'No valid detector data in input pipeline data')
2633
+
2634
+ # Load the validated calibration and strain analysis configuration
1219
2635
  try:
1220
2636
  strain_analysis_config = self.get_config(
1221
2637
  data, 'edd.models.StrainAnalysisConfig', inputdir=inputdir)
1222
2638
  except Exception as data_exc:
1223
- # Local modules
1224
- from CHAP.edd.models import StrainAnalysisConfig
1225
-
1226
2639
  self.logger.info('No valid strain analysis config in input '
1227
- + 'pipeline data, using config parameter instead')
2640
+ 'pipeline data, using config parameter instead')
1228
2641
  try:
2642
+ # Local modules
2643
+ from CHAP.edd.models import StrainAnalysisConfig
2644
+
1229
2645
  strain_analysis_config = StrainAnalysisConfig(
1230
2646
  **config, inputdir=inputdir)
1231
2647
  except Exception as dict_exc:
1232
2648
  raise RuntimeError from dict_exc
1233
2649
 
1234
- nxroot = self.get_nxroot(
1235
- strain_analysis_config.map_config,
1236
- ceria_calibration_config,
1237
- strain_analysis_config,
1238
- save_figures=save_figures,
1239
- outputdir=outputdir,
1240
- interactive=interactive)
1241
- self.logger.debug(nxroot.tree)
1242
-
1243
- return nxroot
1244
-
1245
- def get_nxroot(self,
1246
- map_config,
1247
- ceria_calibration_config,
1248
- strain_analysis_config,
1249
- save_figures=False,
1250
- outputdir='.',
1251
- interactive=False):
1252
- """Return NXroot containing strain maps.
1253
-
1254
-
1255
- :param map_config: The map configuration.
1256
- :type map_config: CHAP.common.models.map.MapConfig
1257
- :param ceria_calibration_config: The calibration configuration.
1258
- :type ceria_calibration_config:
1259
- 'CHAP.edd.models.MCACeriaCalibrationConfig'
2650
+ # Validate the detector configuration and load, validate and
2651
+ # add the calibration info to the detectors
2652
+ calibration_config = self.get_config(
2653
+ data, 'edd.models.MCATthCalibrationConfig', inputdir=inputdir)
2654
+ calibration_detector_indices = [
2655
+ int(d.detector_name) for d in calibration_config.detectors]
2656
+ if 'detector_names' in nxentry:
2657
+ available_detector_indices = [
2658
+ int(d) for d in nxentry.detector_names]
2659
+ else:
2660
+ available_detector_indices = calibration_detector_indices
2661
+ if strain_analysis_config.detectors is None:
2662
+ # Local modules:
2663
+ from CHAP.edd.models import MCAElementStrainAnalysisConfig
2664
+
2665
+ strain_analysis_config.detectors = [
2666
+ MCAElementStrainAnalysisConfig(**dict(d))
2667
+ for d in calibration_config.detectors
2668
+ if int(d.detector_name) in available_detector_indices]
2669
+ for detector in deepcopy(strain_analysis_config.detectors):
2670
+ index = int(detector.detector_name)
2671
+ if index not in available_detector_indices:
2672
+ self.logger.warning(
2673
+ f'Skipping detector {index} (no raw data)')
2674
+ strain_analysis_config.detectors.remove(detector)
2675
+ elif index in calibration_detector_indices:
2676
+ self._detectors.append(detector)
2677
+ self._detector_indices.append(
2678
+ available_detector_indices.index(index))
2679
+ calibration = [
2680
+ d for d in calibration_config.detectors
2681
+ if d.detector_name == detector.detector_name][0]
2682
+ detector.add_calibration(calibration)
2683
+ else:
2684
+ self.logger.warning(f'Skipping detector {index} '
2685
+ '(no energy/tth calibration data)')
2686
+ if not self._detectors:
2687
+ raise ValueError('Unable to match an available calibrated '
2688
+ 'detector for the strain analysis')
2689
+
2690
+ return self.strain_analysis(
2691
+ nxentry, strain_analysis_config, find_peaks, skip_animation)
2692
+
2693
+ def strain_analysis(
2694
+ self, nxentry, strain_analysis_config, find_peaks, skip_animation):
2695
+ """Return NXroot containing the strain maps.
2696
+
2697
+ :param nxentry: The strain analysis map, including the raw
2698
+ detector data.
2699
+ :type nxentry: nexusformat.nexus.NXentry
1260
2700
  :param strain_analysis_config: Strain analysis processing
1261
2701
  configuration.
1262
2702
  :type strain_analysis_config:
1263
2703
  CHAP.edd.models.StrainAnalysisConfig
1264
- :param save_figures: Save .pngs of plots for checking inputs &
1265
- outputs of this Processor, defaults to False.
1266
- :type save_figures: bool, optional
1267
- :param outputdir: Directory to which any output figures will
1268
- be saved, defaults to '.'.
1269
- :type outputdir: str, optional
1270
- :param interactive: Allows for user interactions, defaults to
1271
- False.
1272
- :type interactive: bool, optional
1273
- :return: NXroot containing strain maps.
2704
+ :param find_peaks: Exclude peaks where the average spectrum
2705
+ is below the `rel_height_cutoff` (in the detector
2706
+ configuration) cutoff relative to the maximum value of the
2707
+ average spectrum, defaults to `False`.
2708
+ :type find_peaks: bool, optional
2709
+ :param skip_animation: Skip the animation and plotting of
2710
+ the strain analysis fits, defaults to `False`.
2711
+ :type skip_animation: bool, optional
2712
+ :return: The strain maps.
1274
2713
  :rtype: nexusformat.nexus.NXroot
1275
2714
  """
1276
2715
  # Third party modules
2716
+ from json import loads
1277
2717
  from nexusformat.nexus import (
1278
2718
  NXcollection,
1279
2719
  NXdata,
@@ -1283,150 +2723,145 @@ class StrainAnalysisProcessor(Processor):
1283
2723
  NXprocess,
1284
2724
  NXroot,
1285
2725
  )
2726
+ from nexusformat.nexus.tree import NXlinkfield
1286
2727
 
1287
2728
  # Local modules
1288
2729
  from CHAP.common import MapProcessor
2730
+ from CHAP.common.models.map import MapConfig
1289
2731
  from CHAP.edd.utils import (
1290
2732
  get_peak_locations,
2733
+ get_spectra_fits,
1291
2734
  get_unique_hkls_ds,
1292
- get_spectra_fits
1293
2735
  )
1294
2736
 
1295
- def linkdims(nxgroup, field_dims=[]):
1296
- if isinstance(field_dims, dict):
1297
- field_dims = [field_dims]
1298
- if map_config.map_type == 'structured':
1299
- axes = deepcopy(map_config.dims)
1300
- for dims in field_dims:
1301
- axes.append(dims['axes'])
1302
- nxgroup.attrs['axes'] = axes
1303
- else:
1304
- axes = ['map_index']
1305
- for dims in field_dims:
1306
- axes.append(dims['axes'])
1307
- nxgroup.attrs['axes'] = axes
1308
- nxgroup.attrs[f'map_index_indices'] = 0
1309
- for dim in map_config.dims:
1310
- nxgroup.makelink(nxentry.data[dim])
1311
- if f'{dim}_indices' in nxentry.data.attrs:
1312
- nxgroup.attrs[f'{dim}_indices'] = \
1313
- nxentry.data.attrs[f'{dim}_indices']
2737
+ def linkdims(
2738
+ nxgroup, nxdata_source, field_dims=[], oversampling_axis={}):
2739
+ source_axes = nxdata_source.axes
2740
+ if isinstance(source_axes, str):
2741
+ source_axes = [source_axes]
2742
+ axes = []
2743
+ for dim in source_axes:
2744
+ axes.append(dim)
2745
+ if dim in oversampling_axis:
2746
+ bin_name = dim.replace('fly_', 'bin_')
2747
+ axes[axes.index(dim)] = bin_name
2748
+ nxgroup[bin_name] = NXfield(
2749
+ value=oversampling_axis[dim],
2750
+ units=nxdata_source[dim].units,
2751
+ attrs={
2752
+ 'long_name':
2753
+ f'oversampled {nxdata_source[dim].long_name}',
2754
+ 'data_type': nxdata_source[dim].data_type,
2755
+ 'local_name':
2756
+ f'oversampled {nxdata_source[dim].local_name}'})
2757
+ else:
2758
+ if isinstance(nxdata_source[dim], NXlinkfield):
2759
+ nxgroup[dim] = nxdata_source[dim]
2760
+ else:
2761
+ nxgroup.makelink(nxdata_source[dim])
2762
+ if f'{dim}_indices' in nxdata_source.attrs:
2763
+ nxgroup.attrs[f'{dim}_indices'] = \
2764
+ nxdata_source.attrs[f'{dim}_indices']
1314
2765
  for dims in field_dims:
1315
- nxgroup.attrs[f'{dims["axes"]}_indices'] = dims['index']
2766
+ axes.append(dims['axis'])
2767
+ nxgroup.attrs[f'{dims["axis"]}_indices'] = dims['index']
2768
+ nxgroup.attrs['axes'] = axes
1316
2769
 
1317
- if len(strain_analysis_config.detectors) != 1:
1318
- raise RuntimeError('Multiple detectors not tested')
1319
- for detector in strain_analysis_config.detectors:
1320
- calibration = [
1321
- d for d in ceria_calibration_config.detectors \
1322
- if d.detector_name == detector.detector_name][0]
1323
- detector.add_calibration(calibration)
2770
+ if not self._interactive and not strain_analysis_config.materials:
2771
+ raise ValueError(
2772
+ 'No material provided. Provide a material in the '
2773
+ 'StrainAnalysis Configuration, or re-run the pipeline with '
2774
+ 'the --interactive flag.')
2775
+
2776
+ # Get the map configuration
2777
+ map_config = MapConfig(**loads(str(nxentry.map_config)))
1324
2778
 
2779
+ # Create the NXroot object
1325
2780
  nxroot = NXroot()
1326
- nxroot[map_config.title] = MapProcessor.get_nxentry(map_config)
1327
- nxentry = nxroot[map_config.title]
2781
+ nxroot[map_config.title] = nxentry
1328
2782
  nxroot[f'{map_config.title}_strainanalysis'] = NXprocess()
1329
2783
  nxprocess = nxroot[f'{map_config.title}_strainanalysis']
1330
- nxprocess.strain_analysis_config = dumps(strain_analysis_config.dict())
2784
+ nxprocess.strain_analysis_config = dumps(
2785
+ strain_analysis_config.dict())
1331
2786
 
1332
2787
  # Setup plottable data group
1333
- nxprocess.data = NXdata()
1334
- nxprocess.default = 'data'
1335
- nxdata = nxprocess.data
1336
- linkdims(nxdata)
1337
-
1338
- # Collect raw MCA data of interest
1339
- mca_bin_energies = []
1340
- for i, detector in enumerate(strain_analysis_config.detectors):
1341
- mca_bin_energies.append(
1342
- detector.slope_calibrated
1343
- * np.linspace(0, detector.max_energy_kev, detector.num_bins)
1344
- + detector.intercept_calibrated)
1345
- mca_data = strain_analysis_config.mca_data()
1346
-
1347
- # Select interactive params / save figures
1348
- if interactive or save_figures:
1349
- # Third party modules
1350
- import matplotlib.pyplot as plt
1351
2788
 
2789
+ # Collect the raw MCA data
2790
+ if (strain_analysis_config.sum_axes
2791
+ and map_config.attrs['scan_type'] > 2):
2792
+ # FIX? Could make this a processor
2793
+ mca_data = self._get_sum_axes_data(
2794
+ nxentry[nxentry.default],
2795
+ map_config.attrs.get('fly_axis_labels', [])).astype(np.float64)
2796
+ nxprocess.data = self._nxdata
2797
+ nxdata = nxprocess.data
2798
+ else:
2799
+ mca_data = np.asarray(
2800
+ nxentry[nxentry.default].nxsignal[:,self._detector_indices,:],
2801
+ dtype=np.float64)
2802
+ nxprocess.data = NXdata()
2803
+ self._nxdata = nxprocess.data
2804
+ linkdims(self._nxdata, nxentry.data)
2805
+ self._nxdata.makelink(nxentry.data['detector_data'])
2806
+ self._nxdata.attrs['signal'] = 'detector_data'
2807
+ nxprocess.default = 'data'
2808
+ self.logger.debug(f'mca_data.shape: {mca_data.shape}')
2809
+ mca_data_mean = np.mean(mca_data, axis=0)
2810
+ self.logger.debug(f'mca_data_mean.shape: {mca_data_mean.shape}')
2811
+
2812
+ # Check for oversampling axis and create the binned coordinates
2813
+ oversampling_axis = {}
2814
+ if (map_config.attrs.get('scan_type') == 4
2815
+ and strain_analysis_config.sum_fly_axes):
1352
2816
  # Local modules
1353
- from CHAP.edd.utils import (
1354
- select_material_params,
1355
- select_mask_and_hkls,
1356
- )
1357
-
1358
- # Mask during calibration
1359
- if len(ceria_calibration_config.detectors) != 1:
1360
- raise RuntimeError('Multiple detectors not implemented')
1361
- for detector in ceria_calibration_config.detectors:
1362
- # calibration_mask = detector.mca_mask()
1363
- calibration_bin_ranges = detector.include_bin_ranges
1364
-
1365
- tth = strain_analysis_config.detectors[0].tth_calibrated
1366
- fig, strain_analysis_config.materials = select_material_params(
1367
- mca_bin_energies[0], np.sum(mca_data, axis=1)[0], tth,
1368
- materials=strain_analysis_config.materials,
1369
- label='Sum of all spectra in the map',
1370
- interactive=interactive)
1371
- self.logger.debug(
1372
- f'materials: {strain_analysis_config.materials}')
1373
- if save_figures:
1374
- fig.savefig(os.path.join(
1375
- outputdir,
1376
- f'{detector.detector_name}_strainanalysis_'
1377
- 'material_config.png'))
1378
- plt.close()
2817
+ from CHAP.utils.general import rolling_average
2818
+
2819
+ raise RuntimeError('oversampling needs testing')
2820
+ fly_axis = map_config.attrs.get('fly_axis_labels')[0]
2821
+ oversampling = strain_analysis_config.oversampling
2822
+ oversampling_axis[fly_axis] = rolling_average(
2823
+ nxdata[fly_axis].nxdata,
2824
+ start=oversampling.get('start', 0),
2825
+ end=oversampling.get('end'),
2826
+ width=oversampling.get('width'),
2827
+ stride=oversampling.get('stride'),
2828
+ num=oversampling.get('num'),
2829
+ mode=oversampling.get('mode', 'valid'))
2830
+
2831
+ # Get the energy masks
2832
+ energy_masks = self._get_energy_and_masks()
2833
+
2834
+ # Get and subtract the detector baselines
2835
+ baselines = self._get_baselines(mca_data_mean, energy_masks)
2836
+ if baselines:
2837
+ baselines = np.asarray(baselines)
2838
+ mca_data_mean -= baselines
2839
+ mca_data -= baselines
2840
+
2841
+ # Adjust the material properties
2842
+ self._adjust_material_props(
2843
+ mca_data_mean, strain_analysis_config.materials, energy_masks)
2844
+
2845
+ # Get the mask and HKLs used in the strain analysis
2846
+ self._get_mask_hkls(
2847
+ mca_data, mca_data_mean, strain_analysis_config.materials,
2848
+ energy_masks)
2849
+
2850
+ # Loop over the detectors to perform the strain analysis
2851
+ for index, detector in enumerate(self._detectors):
2852
+
2853
+ self.logger.info(f'Analysing detector {detector.detector_name}')
2854
+
2855
+ # Get the MCA bin energies
2856
+ mca_bin_energies = detector.energies
1379
2857
 
1380
- # ASK: can we assume same hkl_tth_tol and tth_max for
1381
- # every detector in this part?
1382
- hkls, ds = get_unique_hkls_ds(
1383
- strain_analysis_config.materials,
1384
- tth_tol=strain_analysis_config.detectors[0].hkl_tth_tol,
1385
- tth_max=strain_analysis_config.detectors[0].tth_max)
1386
- for i, detector in enumerate(strain_analysis_config.detectors):
1387
- fig, include_bin_ranges, hkl_indices = \
1388
- select_mask_and_hkls(
1389
- mca_bin_energies[i],
1390
- np.sum(mca_data[i], axis=0),
1391
- hkls, ds,
1392
- detector.tth_calibrated,
1393
- detector.include_bin_ranges, detector.hkl_indices,
1394
- detector.detector_name, mca_data[i],
1395
- # calibration_mask=calibration_mask,
1396
- calibration_bin_ranges=calibration_bin_ranges,
1397
- label='Sum of all spectra in the map',
1398
- interactive=interactive)
1399
- detector.include_energy_ranges = detector.get_energy_ranges(
1400
- include_bin_ranges)
1401
- detector.hkl_indices = hkl_indices
1402
- if save_figures:
1403
- fig.savefig(os.path.join(
1404
- outputdir,
1405
- f'{detector.detector_name}_strainanalysis_'
1406
- 'fit_mask_hkls.png'))
1407
- plt.close()
1408
- else:
1409
- # ASK: can we assume same hkl_tth_tol and tth_max for
1410
- # every detector in this part?
1411
2858
  # Get the unique HKLs and lattice spacings for the strain
1412
- # analysis materials (assume hkl_tth_tol and tth_max are the
1413
- # same for each detector)
2859
+ # analysis materials
1414
2860
  hkls, ds = get_unique_hkls_ds(
1415
2861
  strain_analysis_config.materials,
1416
- tth_tol=strain_analysis_config.detectors[0].hkl_tth_tol,
1417
- tth_max=strain_analysis_config.detectors[0].tth_max)
2862
+ tth_tol=detector.hkl_tth_tol,
2863
+ tth_max=detector.tth_max)
1418
2864
 
1419
- for i, detector in enumerate(strain_analysis_config.detectors):
1420
- if not detector.include_energy_ranges:
1421
- raise ValueError(
1422
- 'No value provided for include_energy_ranges. '
1423
- 'Provide them in the MCA Ceria Calibration Configuration, '
1424
- 'or re-run the pipeline with the --interactive flag.')
1425
- if not detector.hkl_indices:
1426
- raise ValueError(
1427
- 'No value provided for hkl_indices. Provide them in '
1428
- 'the detector\'s MCA Ceria Calibration Configuration, or'
1429
- ' re-run the pipeline with the --interactive flag.')
1430
2865
  # Setup NXdata group
1431
2866
  self.logger.debug(
1432
2867
  f'Setting up NXdata group for {detector.detector_name}')
@@ -1437,32 +2872,32 @@ class StrainAnalysisProcessor(Processor):
1437
2872
  nxdetector.data = NXdata()
1438
2873
  det_nxdata = nxdetector.data
1439
2874
  linkdims(
1440
- det_nxdata, {'axes': 'energy', 'index': len(map_config.shape)})
2875
+ det_nxdata, self._nxdata,
2876
+ [{'axis': 'energy', 'index': mca_data.ndim-1}],
2877
+ oversampling_axis=oversampling_axis)
1441
2878
  mask = detector.mca_mask()
1442
- energies = mca_bin_energies[i][mask]
1443
- det_nxdata.energy = NXfield(value=energies,
1444
- attrs={'units': 'keV'})
2879
+ energies = mca_bin_energies[mask]
2880
+ det_nxdata.energy = NXfield(value=energies, attrs={'units': 'keV'})
2881
+ det_nxdata.tth = NXfield(
2882
+ dtype=np.float64,
2883
+ shape=(mca_data.shape[0]),
2884
+ attrs={'units':'degrees', 'long_name': '2\u03B8 (degrees)'})
2885
+ det_nxdata.uniform_microstrain = NXfield(
2886
+ dtype=np.float64,
2887
+ shape=(mca_data.shape[0]),
2888
+ attrs={'long_name': 'Strain from uniform fit(\u03BC\u03B5)'})
2889
+ det_nxdata.unconstrained_microstrain = NXfield(
2890
+ dtype=np.float64,
2891
+ shape=(mca_data.shape[0]),
2892
+ attrs={'long_name':
2893
+ 'Strain from unconstrained fit(\u03BC\u03B5)'})
2894
+
2895
+ # Add detector data
1445
2896
  det_nxdata.intensity = NXfield(
1446
- dtype='uint16',
1447
- shape=(*map_config.shape, len(energies)),
2897
+ value=np.asarray([mca_data[i,index,:].astype(np.float64)[mask]
2898
+ for i in range(mca_data.shape[0])]),
1448
2899
  attrs={'units': 'counts'})
1449
- det_nxdata.tth = NXfield(
1450
- dtype='float64',
1451
- shape=map_config.shape,
1452
- attrs={'units':'degrees', 'long_name': '2\u03B8 (degrees)'}
1453
- )
1454
- det_nxdata.microstrain = NXfield(
1455
- dtype='float64',
1456
- shape=map_config.shape,
1457
- attrs={'long_name': 'Strain (\u03BC\u03B5)'})
1458
-
1459
- # Gather detector data
1460
- self.logger.debug(
1461
- f'Gathering detector data for {detector.detector_name}')
1462
- for j, map_index in enumerate(np.ndindex(map_config.shape)):
1463
- det_nxdata.intensity[map_index] = \
1464
- mca_data[i][j].astype('uint16')[mask]
1465
- det_nxdata.summed_intensity = det_nxdata.intensity.sum(axis=-1)
2900
+ det_nxdata.summed_intensity = det_nxdata.intensity.sum(axis=0)
1466
2901
 
1467
2902
  # Perform strain analysis
1468
2903
  self.logger.debug(
@@ -1470,11 +2905,55 @@ class StrainAnalysisProcessor(Processor):
1470
2905
 
1471
2906
  # Get the HKLs and lattice spacings that will be used for
1472
2907
  # fitting
1473
- fit_hkls = np.asarray([hkls[i] for i in detector.hkl_indices])
1474
- fit_ds = np.asarray([ds[i] for i in detector.hkl_indices])
2908
+ hkls_fit = np.asarray([hkls[i] for i in detector.hkl_indices])
2909
+ ds_fit = np.asarray([ds[i] for i in detector.hkl_indices])
1475
2910
  peak_locations = get_peak_locations(
1476
- fit_ds, detector.tth_calibrated)
2911
+ ds_fit, detector.tth_calibrated)
2912
+
2913
+ # Find peaks
2914
+ if not find_peaks or detector.rel_height_cutoff is None:
2915
+ use_peaks = np.ones((peak_locations.size)).astype(bool)
2916
+ else:
2917
+ # Third party modules
2918
+ from scipy.signal import find_peaks as find_peaks_scipy
2919
+
2920
+ # Local modules
2921
+ from CHAP.utils.general import index_nearest
2922
+
2923
+ peaks = find_peaks_scipy(
2924
+ mca_data_mean[index],
2925
+ height=(detector.rel_height_cutoff
2926
+ * mca_data_mean[index][mask].max()),
2927
+ width=5)
2928
+ heights = peaks[1]['peak_heights']
2929
+ widths = peaks[1]['widths']
2930
+ centers = [mca_bin_energies[v] for v in peaks[0]]
2931
+ use_peaks = np.zeros((peak_locations.size)).astype(bool)
2932
+ # RV Potentially use peak_heights/widths as initial
2933
+ # values in fit?
2934
+ # peak_heights = np.zeros((peak_locations.size))
2935
+ # peak_widths = np.zeros((peak_locations.size))
2936
+ delta = mca_bin_energies[1]-mca_bin_energies[0]
2937
+ for height, width, center in zip(heights, widths, centers):
2938
+ for n, loc in enumerate(peak_locations):
2939
+ # RV Hardwired range now, use detector.centers_range?
2940
+ if center-width*delta < loc < center+width*delta:
2941
+ use_peaks[n] = True
2942
+ # peak_heights[n] = height
2943
+ # peak_widths[n] = width*delta
2944
+ break
2945
+
2946
+ if any(use_peaks):
2947
+ self.logger.debug(
2948
+ f'Using peaks with centers at {peak_locations[use_peaks]}')
2949
+ else:
2950
+ self.logger.warning(
2951
+ 'No matching peaks with heights above the threshold, '
2952
+ f'skipping the fit for detector {detector.detector_name}')
2953
+ continue
1477
2954
 
2955
+ # Perform the fit
2956
+ self.logger.debug(f'Fitting detector {detector.detector_name} ...')
1478
2957
  (uniform_fit_centers, uniform_fit_centers_errors,
1479
2958
  uniform_fit_amplitudes, uniform_fit_amplitudes_errors,
1480
2959
  uniform_fit_sigmas, uniform_fit_sigmas_errors,
@@ -1487,7 +2966,8 @@ class StrainAnalysisProcessor(Processor):
1487
2966
  unconstrained_redchi, unconstrained_success) = \
1488
2967
  get_spectra_fits(
1489
2968
  det_nxdata.intensity.nxdata, energies,
1490
- peak_locations, detector)
2969
+ peak_locations[use_peaks], detector)
2970
+ self.logger.debug(f'... done')
1491
2971
 
1492
2972
  # Add uniform fit results to the NeXus structure
1493
2973
  nxdetector.uniform_fit = NXcollection()
@@ -1497,9 +2977,11 @@ class StrainAnalysisProcessor(Processor):
1497
2977
  fit_nxgroup.results = NXdata()
1498
2978
  fit_nxdata = fit_nxgroup.results
1499
2979
  linkdims(
1500
- fit_nxdata, {'axes': 'energy', 'index': len(map_config.shape)})
2980
+ fit_nxdata, self._nxdata,
2981
+ [{'axis': 'energy', 'index': mca_data.ndim-1}],
2982
+ oversampling_axis=oversampling_axis)
1501
2983
  fit_nxdata.makelink(det_nxdata.energy)
1502
- fit_nxdata.best_fit= uniform_best_fit
2984
+ fit_nxdata.best_fit = uniform_best_fit
1503
2985
  fit_nxdata.residuals = uniform_residuals
1504
2986
  fit_nxdata.redchi = uniform_redchi
1505
2987
  fit_nxdata.success = uniform_success
@@ -1507,14 +2989,14 @@ class StrainAnalysisProcessor(Processor):
1507
2989
  # Peak-by-peak results
1508
2990
  # fit_nxgroup.fit_hkl_centers = NXdata()
1509
2991
  # fit_nxdata = fit_nxgroup.fit_hkl_centers
1510
- # linkdims(fit_nxdata)
2992
+ # linkdims(fit_nxdata, self._nxdata)
1511
2993
  for (hkl, center_guess, centers_fit, centers_error,
1512
- amplitudes_fit, amplitudes_error, sigmas_fit,
1513
- sigmas_error) in zip(
1514
- fit_hkls, peak_locations,
1515
- uniform_fit_centers, uniform_fit_centers_errors,
1516
- uniform_fit_amplitudes, uniform_fit_amplitudes_errors,
1517
- uniform_fit_sigmas, uniform_fit_sigmas_errors):
2994
+ amplitudes_fit, amplitudes_error, sigmas_fit,
2995
+ sigmas_error) in zip(
2996
+ hkls_fit, peak_locations,
2997
+ uniform_fit_centers, uniform_fit_centers_errors,
2998
+ uniform_fit_amplitudes, uniform_fit_amplitudes_errors,
2999
+ uniform_fit_sigmas, uniform_fit_sigmas_errors):
1518
3000
  hkl_name = '_'.join(str(hkl)[1:-1].split(' '))
1519
3001
  fit_nxgroup[hkl_name] = NXparameters()
1520
3002
  # Report initial HKL peak centers
@@ -1523,7 +3005,7 @@ class StrainAnalysisProcessor(Processor):
1523
3005
  'keV'
1524
3006
  # Report HKL peak centers
1525
3007
  fit_nxgroup[hkl_name].centers = NXdata()
1526
- linkdims(fit_nxgroup[hkl_name].centers)
3008
+ linkdims(fit_nxgroup[hkl_name].centers, self._nxdata)
1527
3009
  fit_nxgroup[hkl_name].centers.values = NXfield(
1528
3010
  value=centers_fit, attrs={'units': 'keV'})
1529
3011
  fit_nxgroup[hkl_name].centers.errors = NXfield(
@@ -1533,7 +3015,7 @@ class StrainAnalysisProcessor(Processor):
1533
3015
  # fit_nxgroup[f'{hkl_name}/centers/values'], name=hkl_name)
1534
3016
  # Report HKL peak amplitudes
1535
3017
  fit_nxgroup[hkl_name].amplitudes = NXdata()
1536
- linkdims(fit_nxgroup[hkl_name].amplitudes)
3018
+ linkdims(fit_nxgroup[hkl_name].amplitudes, self._nxdata)
1537
3019
  fit_nxgroup[hkl_name].amplitudes.values = NXfield(
1538
3020
  value=amplitudes_fit, attrs={'units': 'counts'})
1539
3021
  fit_nxgroup[hkl_name].amplitudes.errors = NXfield(
@@ -1541,59 +3023,66 @@ class StrainAnalysisProcessor(Processor):
1541
3023
  fit_nxgroup[hkl_name].amplitudes.attrs['signal'] = 'values'
1542
3024
  # Report HKL peak FWHM
1543
3025
  fit_nxgroup[hkl_name].sigmas = NXdata()
1544
- linkdims(fit_nxgroup[hkl_name].sigmas)
3026
+ linkdims(fit_nxgroup[hkl_name].sigmas, self._nxdata)
1545
3027
  fit_nxgroup[hkl_name].sigmas.values = NXfield(
1546
3028
  value=sigmas_fit, attrs={'units': 'keV'})
1547
3029
  fit_nxgroup[hkl_name].sigmas.errors = NXfield(
1548
3030
  value=sigmas_error)
1549
3031
  fit_nxgroup[hkl_name].sigmas.attrs['signal'] = 'values'
1550
3032
 
1551
- if interactive or save_figures:
3033
+ if ((self._interactive or self._save_figures)
3034
+ and not skip_animation):
1552
3035
  # Third party modules
1553
3036
  import matplotlib.animation as animation
3037
+ import matplotlib.pyplot as plt
1554
3038
 
1555
- if save_figures:
3039
+ if self._save_figures:
1556
3040
  path = os.path.join(
1557
- outputdir, f'{detector.detector_name}_strainanalysis_'
1558
- 'unconstrained_fits')
3041
+ self._outputdir,
3042
+ f'{detector.detector_name}_strainanalysis_'
3043
+ 'unconstrained_fits')
1559
3044
  if not os.path.isdir(path):
1560
3045
  os.mkdir(path)
1561
3046
 
1562
3047
  def animate(i):
1563
- map_index = np.unravel_index(i, map_config.shape)
1564
- intensity.set_ydata(
1565
- det_nxdata.intensity.nxdata[map_index]
1566
- / det_nxdata.intensity.nxdata[map_index].max())
1567
- best_fit.set_ydata(
1568
- unconstrained_best_fit[map_index]
1569
- / unconstrained_best_fit[map_index].max())
1570
- # residual.set_ydata(unconstrained_residuals[map_index])
1571
- index.set_text('\n'.join(f'{k}[{i}] = {v}'
1572
- for k, v in map_config.get_coords(map_index).items()))
1573
- if save_figures:
3048
+ norm = det_nxdata.intensity.nxdata[i].max()
3049
+ intensity.set_ydata(det_nxdata.intensity.nxdata[i] / norm)
3050
+ best_fit.set_ydata(unconstrained_best_fit[i] / norm)
3051
+ axes = self._nxdata.attrs['axes']
3052
+ if isinstance(axes, str):
3053
+ axes = [axes]
3054
+ index.set_text('\n'.join(
3055
+ [f'norm = {int(norm)}'] +
3056
+ ['relative norm = '
3057
+ f'{(norm / det_nxdata.intensity.max()):.5f}'] +
3058
+ [f'{dim}[{i}] = {self._nxdata[dim][i]}'
3059
+ for dim in axes]))
3060
+ if self._save_figures:
1574
3061
  plt.savefig(os.path.join(
1575
3062
  path, f'frame_{str(i).zfill(num_digit)}.png'))
1576
- #return intensity, best_fit, residual, index
1577
3063
  return intensity, best_fit, index
1578
3064
 
1579
3065
  fig, ax = plt.subplots()
1580
- map_index = np.unravel_index(0, map_config.shape)
1581
3066
  data_normalized = (
1582
- det_nxdata.intensity.nxdata[map_index]
1583
- / det_nxdata.intensity.nxdata[map_index].max())
3067
+ det_nxdata.intensity.nxdata[0]
3068
+ / det_nxdata.intensity.nxdata[0].max())
1584
3069
  intensity, = ax.plot(
1585
3070
  energies, data_normalized, 'b.', label='data')
1586
- fit_normalized = (unconstrained_best_fit[map_index]
1587
- / unconstrained_best_fit[map_index].max())
3071
+ if unconstrained_best_fit[0].max():
3072
+ fit_normalized = (
3073
+ unconstrained_best_fit[0]
3074
+ / unconstrained_best_fit[0].max())
3075
+ else:
3076
+ fit_normalized = unconstrained_best_fit[0]
1588
3077
  best_fit, = ax.plot(
1589
3078
  energies, fit_normalized, 'k-', label='fit')
1590
3079
  # residual, = ax.plot(
1591
- # energies, unconstrained_residuals[map_index], 'r-',
3080
+ # energies, unconstrained_residuals[0], 'r-',
1592
3081
  # label='residual')
1593
3082
  ax.set(
1594
3083
  title='Unconstrained fits',
1595
3084
  xlabel='Energy (keV)',
1596
- ylabel='Normalized Intensity (-)')
3085
+ ylabel='Normalized intensity (-)')
1597
3086
  ax.legend(loc='upper right')
1598
3087
  index = ax.text(
1599
3088
  0.05, 0.95, '', transform=ax.transAxes, va='top')
@@ -1601,7 +3090,7 @@ class StrainAnalysisProcessor(Processor):
1601
3090
  num_frame = int(det_nxdata.intensity.nxdata.size
1602
3091
  / det_nxdata.intensity.nxdata.shape[-1])
1603
3092
  num_digit = len(str(num_frame))
1604
- if not save_figures:
3093
+ if not self._save_figures:
1605
3094
  ani = animation.FuncAnimation(
1606
3095
  fig, animate,
1607
3096
  frames=int(det_nxdata.intensity.nxdata.size
@@ -1629,25 +3118,32 @@ class StrainAnalysisProcessor(Processor):
1629
3118
  plt.gcf(), frames, interval=1000, blit=True,
1630
3119
  repeat=False)
1631
3120
 
1632
- if interactive:
3121
+ if self._interactive:
1633
3122
  plt.show()
1634
3123
 
1635
- if save_figures:
3124
+ if self._save_figures:
1636
3125
  path = os.path.join(
1637
- outputdir,
3126
+ self._outputdir,
1638
3127
  f'{detector.detector_name}_strainanalysis_'
1639
- 'unconstrained_fits.gif')
3128
+ 'unconstrained_fits.gif')
1640
3129
  ani.save(path)
1641
3130
  plt.close()
1642
3131
 
1643
- tth_map = detector.get_tth_map(map_config)
3132
+ tth_map = detector.get_tth_map((mca_data.shape[0],))
1644
3133
  det_nxdata.tth.nxdata = tth_map
1645
3134
  nominal_centers = np.asarray(
1646
- [get_peak_locations(d0, tth_map) for d0 in fit_ds])
3135
+ [get_peak_locations(d0, tth_map)
3136
+ for d0, use_peak in zip(ds_fit, use_peaks) if use_peak])
3137
+ uniform_strains = np.log(
3138
+ nominal_centers / uniform_fit_centers)
3139
+ uniform_strain = np.mean(uniform_strains, axis=0)
3140
+ det_nxdata.uniform_microstrain.nxdata = uniform_strain * 1e6
3141
+
1647
3142
  unconstrained_strains = np.log(
1648
3143
  nominal_centers / unconstrained_fit_centers)
1649
3144
  unconstrained_strain = np.mean(unconstrained_strains, axis=0)
1650
- det_nxdata.microstrain.nxdata = unconstrained_strain * 1e6
3145
+ det_nxdata.unconstrained_microstrain.nxdata = \
3146
+ unconstrained_strain * 1e6
1651
3147
 
1652
3148
  # Add unconstrained fit results to the NeXus structure
1653
3149
  nxdetector.unconstrained_fit = NXcollection()
@@ -1657,7 +3153,9 @@ class StrainAnalysisProcessor(Processor):
1657
3153
  fit_nxgroup.results = NXdata()
1658
3154
  fit_nxdata = fit_nxgroup.results
1659
3155
  linkdims(
1660
- fit_nxdata, {'axes': 'energy', 'index': len(map_config.shape)})
3156
+ fit_nxdata, self._nxdata,
3157
+ [{'axis': 'energy', 'index': mca_data.ndim-1}],
3158
+ oversampling_axis=oversampling_axis)
1661
3159
  fit_nxdata.makelink(det_nxdata.energy)
1662
3160
  fit_nxdata.best_fit= unconstrained_best_fit
1663
3161
  fit_nxdata.residuals = unconstrained_residuals
@@ -1667,11 +3165,11 @@ class StrainAnalysisProcessor(Processor):
1667
3165
  # Peak-by-peak results
1668
3166
  fit_nxgroup.fit_hkl_centers = NXdata()
1669
3167
  fit_nxdata = fit_nxgroup.fit_hkl_centers
1670
- linkdims(fit_nxdata)
3168
+ linkdims(fit_nxdata, self._nxdata)
1671
3169
  for (hkl, center_guesses, centers_fit, centers_error,
1672
3170
  amplitudes_fit, amplitudes_error, sigmas_fit,
1673
3171
  sigmas_error) in zip(
1674
- fit_hkls, uniform_fit_centers,
3172
+ hkls_fit, uniform_fit_centers,
1675
3173
  unconstrained_fit_centers,
1676
3174
  unconstrained_fit_centers_errors,
1677
3175
  unconstrained_fit_amplitudes,
@@ -1681,7 +3179,7 @@ class StrainAnalysisProcessor(Processor):
1681
3179
  fit_nxgroup[hkl_name] = NXparameters()
1682
3180
  # Report initial guesses HKL peak centers
1683
3181
  fit_nxgroup[hkl_name].center_initial_guess = NXdata()
1684
- linkdims(fit_nxgroup[hkl_name].center_initial_guess)
3182
+ linkdims(fit_nxgroup[hkl_name].center_initial_guess, self._nxdata)
1685
3183
  fit_nxgroup[hkl_name].center_initial_guess.makelink(
1686
3184
  nxdetector.uniform_fit[f'{hkl_name}/centers/values'],
1687
3185
  name='values')
@@ -1689,7 +3187,7 @@ class StrainAnalysisProcessor(Processor):
1689
3187
  'values'
1690
3188
  # Report HKL peak centers
1691
3189
  fit_nxgroup[hkl_name].centers = NXdata()
1692
- linkdims(fit_nxgroup[hkl_name].centers)
3190
+ linkdims(fit_nxgroup[hkl_name].centers, self._nxdata)
1693
3191
  fit_nxgroup[hkl_name].centers.values = NXfield(
1694
3192
  value=centers_fit, attrs={'units': 'keV'})
1695
3193
  fit_nxgroup[hkl_name].centers.errors = NXfield(
@@ -1699,7 +3197,7 @@ class StrainAnalysisProcessor(Processor):
1699
3197
  fit_nxgroup[hkl_name].centers.attrs['signal'] = 'values'
1700
3198
  # Report HKL peak amplitudes
1701
3199
  fit_nxgroup[hkl_name].amplitudes = NXdata()
1702
- linkdims(fit_nxgroup[hkl_name].amplitudes)
3200
+ linkdims(fit_nxgroup[hkl_name].amplitudes, self._nxdata)
1703
3201
  fit_nxgroup[hkl_name].amplitudes.values = NXfield(
1704
3202
  value=amplitudes_fit, attrs={'units': 'counts'})
1705
3203
  fit_nxgroup[hkl_name].amplitudes.errors = NXfield(
@@ -1707,7 +3205,7 @@ class StrainAnalysisProcessor(Processor):
1707
3205
  fit_nxgroup[hkl_name].amplitudes.attrs['signal'] = 'values'
1708
3206
  # Report HKL peak sigmas
1709
3207
  fit_nxgroup[hkl_name].sigmas = NXdata()
1710
- linkdims(fit_nxgroup[hkl_name].sigmas)
3208
+ linkdims(fit_nxgroup[hkl_name].sigmas, self._nxdata)
1711
3209
  fit_nxgroup[hkl_name].sigmas.values = NXfield(
1712
3210
  value=sigmas_fit, attrs={'units': 'keV'})
1713
3211
  fit_nxgroup[hkl_name].sigmas.errors = NXfield(
@@ -1716,6 +3214,164 @@ class StrainAnalysisProcessor(Processor):
1716
3214
 
1717
3215
  return nxroot
1718
3216
 
3217
+ def _get_sum_axes_data(self, nxdata, sum_axes):
3218
+ """Get the raw MCA data collected by the scan averaged over the
3219
+ sum_axes.
3220
+ """
3221
+ # Third party modules
3222
+ from nexusformat.nexus import (
3223
+ NXdata,
3224
+ NXfield,
3225
+ )
3226
+
3227
+ mca_data = np.asarray(
3228
+ nxdata.nxsignal[:,self._detector_indices,:])
3229
+ axes = [nxdata[axis] for axis in nxdata.axes if axis not in sum_axes]
3230
+ unique_points = []
3231
+ sum_indices = []
3232
+ for i in range(mca_data.shape[0]):
3233
+ point = [float(nxdata[axis.nxname][i]) for axis in axes]
3234
+ try:
3235
+ sum_indices[unique_points.index(point)].append(i)
3236
+ except:
3237
+ unique_points.append(point)
3238
+ sum_indices.append([i])
3239
+ mean_mca_data = np.empty((len(unique_points), *mca_data.shape[1:]))
3240
+ for i in range(len(unique_points)):
3241
+ mean_mca_data[i] = np.mean(mca_data[sum_indices[i],:,:], axis=0)
3242
+ self._nxdata = NXdata(
3243
+ NXfield(mean_mca_data, 'detector_data'),
3244
+ tuple([
3245
+ NXfield(
3246
+ [p[i] for p in unique_points], axis.nxname,
3247
+ attrs=axis.attrs)
3248
+ for i, axis in enumerate(axes)]))
3249
+ return mean_mca_data
3250
+
3251
+ def _get_energy_and_masks(self):
3252
+ """Get the energy mask by blanking out data below 25 keV as
3253
+ well as that in the last bin.
3254
+ """
3255
+ energy_masks = []
3256
+ for detector in self._detectors:
3257
+ energy_mask = np.where(detector.energies >= 25.0, 1, 0)
3258
+ energy_mask[-1] = 0
3259
+ energy_masks.append(energy_mask)
3260
+ return energy_masks
3261
+
3262
+ def _get_baselines(self, mca_data_mean, energy_masks):
3263
+ """Get the detector baselines."""
3264
+ # Local modules
3265
+ from CHAP.edd.models import BaselineConfig
3266
+ from CHAP.common.processor import ConstructBaseline
3267
+
3268
+ baselines = []
3269
+ for index, detector in enumerate(self._detectors):
3270
+ if detector.baseline:
3271
+ if isinstance(detector.baseline, bool):
3272
+ detector.baseline = BaselineConfig()
3273
+ if self._save_figures:
3274
+ filename = os.path.join(
3275
+ self._outputdir,
3276
+ f'{detector.detector_name}_strainanalysis_'
3277
+ 'baseline.png')
3278
+ else:
3279
+ filename = None
3280
+ baseline, baseline_config = \
3281
+ ConstructBaseline.construct_baseline(
3282
+ mca_data_mean[index], mask=energy_masks[index],
3283
+ tol=detector.baseline.tol, lam=detector.baseline.lam,
3284
+ max_iter=detector.baseline.max_iter,
3285
+ title=
3286
+ f'Baseline for detector {detector.detector_name}',
3287
+ xlabel='Energy (keV)', ylabel='Intensity (counts)',
3288
+ interactive=self._interactive, filename=filename)
3289
+
3290
+ baselines.append(baseline)
3291
+ detector.baseline.lam = baseline_config['lambda']
3292
+ detector.baseline.attrs['num_iter'] = \
3293
+ baseline_config['num_iter']
3294
+ detector.baseline.attrs['error'] = baseline_config['error']
3295
+ return baselines
3296
+
3297
+ def _adjust_material_props(self, mca_data_mean, materials, energy_masks):
3298
+ """Adjust the material properties."""
3299
+ # Local modules
3300
+ from CHAP.edd.utils import select_material_params
3301
+
3302
+ # ASK: extend to multiple detectors?
3303
+ detector = self._detectors[0]
3304
+ tth = detector.tth_calibrated
3305
+ if self._save_figures:
3306
+ filename = os.path.join(
3307
+ self._outputdir,
3308
+ f'{detector.detector_name}_strainanalysis_'
3309
+ 'material_config.png')
3310
+ else:
3311
+ filename = None
3312
+ materials = select_material_params(
3313
+ detector.energies, mca_data_mean[0]*energy_masks[0],
3314
+ tth, label='Sum of all spectra in the map',
3315
+ preselected_materials=materials, interactive=self._interactive,
3316
+ filename=filename)
3317
+ self.logger.debug(f'materials: {materials}')
3318
+
3319
+ def _get_mask_hkls(self, mca_data, mca_data_mean, materials, energy_masks):
3320
+ """Get the mask and HKLs used in the strain analysis."""
3321
+ # Local modules
3322
+ from CHAP.edd.utils import (
3323
+ get_unique_hkls_ds,
3324
+ select_mask_and_hkls,
3325
+ )
3326
+
3327
+ for index, detector in enumerate(self._detectors):
3328
+
3329
+ # Get the unique HKLs and lattice spacings for the strain
3330
+ # analysis materials
3331
+ hkls, ds = get_unique_hkls_ds(
3332
+ materials, tth_tol=detector.hkl_tth_tol,
3333
+ tth_max=detector.tth_max)
3334
+
3335
+ # Interactively adjust the mask and HKLs used in the
3336
+ # strain analysis
3337
+ if self._save_figures:
3338
+ filename = os.path.join(
3339
+ self._outputdir,
3340
+ f'{detector.detector_name}_strainanalysis_'
3341
+ 'fit_mask_hkls.png')
3342
+ else:
3343
+ filename = None
3344
+ include_bin_ranges, hkl_indices = \
3345
+ select_mask_and_hkls(
3346
+ detector.energies,
3347
+ mca_data_mean[index]*energy_masks[index],
3348
+ hkls, ds, detector.tth_calibrated,
3349
+ preselected_bin_ranges=detector.include_bin_ranges,
3350
+ preselected_hkl_indices=detector.hkl_indices,
3351
+ detector_name=detector.detector_name,
3352
+ ref_map=mca_data[:,index,:]*energy_masks[index],
3353
+ calibration_bin_ranges=detector.calibration_bin_ranges,
3354
+ label='Sum of all spectra in the map',
3355
+ interactive=self._interactive, filename=filename)
3356
+ detector.include_energy_ranges = \
3357
+ detector.get_include_energy_ranges(include_bin_ranges)
3358
+ detector.hkl_indices = hkl_indices
3359
+ self.logger.debug(
3360
+ f'include_energy_ranges for detector {detector.detector_name}:'
3361
+ f' {detector.include_energy_ranges}')
3362
+ self.logger.debug(
3363
+ f'hkl_indices for detector {detector.detector_name}:'
3364
+ f' {detector.hkl_indices}')
3365
+ if not detector.include_energy_ranges:
3366
+ raise ValueError(
3367
+ 'No value provided for include_energy_ranges. '
3368
+ 'Provide them in the MCA Tth Calibration Configuration, '
3369
+ 'or re-run the pipeline with the --interactive flag.')
3370
+ if not detector.hkl_indices:
3371
+ raise ValueError(
3372
+ 'No value provided for hkl_indices. Provide them in '
3373
+ 'the detector\'s MCA Tth Calibration Configuration, or'
3374
+ ' re-run the pipeline with the --interactive flag.')
1719
3375
 
1720
3376
  if __name__ == '__main__':
1721
3377
  # Local modules