ChessAnalysisPipeline 0.0.14__py3-none-any.whl → 0.0.15__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.

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