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/models.py CHANGED
@@ -12,12 +12,14 @@ import numpy as np
12
12
  from hexrd.material import Material
13
13
  from pydantic import (
14
14
  BaseModel,
15
+ DirectoryPath,
16
+ FilePath,
17
+ PrivateAttr,
18
+ StrictBool,
15
19
  confloat,
16
20
  conint,
17
21
  conlist,
18
22
  constr,
19
- DirectoryPath,
20
- FilePath,
21
23
  root_validator,
22
24
  validator,
23
25
  )
@@ -29,6 +31,103 @@ from CHAP.utils.parfile import ParFile
29
31
  from CHAP.utils.scanparsers import SMBMCAScanParser as ScanParser
30
32
 
31
33
 
34
+ # Baseline configuration class
35
+
36
+ class BaselineConfig(BaseModel):
37
+ """Baseline model configuration
38
+
39
+ :ivar tol: The convergence tolerence, defaults to `1.e-6`.
40
+ :type tol: float, optional
41
+ :ivar lam: The &lambda (smoothness) parameter (the balance
42
+ between the residual of the data and the baseline and the
43
+ smoothness of the baseline). The suggested range is between
44
+ 100 and 10^8, defaults to `10^6`.
45
+ :type lam: float, optional
46
+ :ivar max_iter: The maximum number of iterations,
47
+ defaults to `100`.
48
+ :type max_iter: int, optional
49
+ """
50
+ tol: confloat(gt=0, allow_inf_nan=False) = 1.e-6
51
+ lam: confloat(gt=0, allow_inf_nan=False) = 1.e6
52
+ max_iter: conint(gt=0) = 100
53
+ attrs: Optional[dict] = {}
54
+
55
+
56
+ # Material configuration classes
57
+
58
+ class MaterialConfig(BaseModel):
59
+ """Model for parameters to characterize a sample material.
60
+
61
+ :ivar material_name: Sample material name.
62
+ :type material_name: str, optional
63
+ :ivar lattice_parameters: Lattice spacing(s) in angstroms.
64
+ :type lattice_parameters: float, list[float], optional
65
+ :ivar sgnum: Space group of the material.
66
+ :type sgnum: int, optional
67
+ """
68
+ material_name: Optional[constr(strip_whitespace=True, min_length=1)]
69
+ lattice_parameters: Optional[Union[
70
+ confloat(gt=0),
71
+ conlist(item_type=confloat(gt=0), min_items=1, max_items=6)]]
72
+ sgnum: Optional[conint(ge=0)]
73
+
74
+ _material: Optional[Material]
75
+
76
+ class Config:
77
+ underscore_attrs_are_private = False
78
+
79
+ @root_validator
80
+ def validate_material(cls, values):
81
+ """Create and validate the private attribute _material.
82
+
83
+ :param values: Dictionary of previously validated field values.
84
+ :type values: dict
85
+ :return: The validated list of `values`.
86
+ :rtype: dict
87
+ """
88
+ # Local modules
89
+ from CHAP.edd.utils import make_material
90
+
91
+ values['_material'] = make_material(values.get('material_name'),
92
+ values.get('sgnum'),
93
+ values.get('lattice_parameters'))
94
+ return values
95
+
96
+ def unique_hkls_ds(self, tth_tol=0.15, tth_max=90.0):
97
+ """Get a list of unique HKLs and their lattice spacings.
98
+
99
+ :param tth_tol: Minimum resolvable difference in 2&theta
100
+ between two unique HKL peaks, defaults to `0.15`.
101
+ :type tth_tol: float, optional
102
+ :param tth_max: Detector rotation about hutch x axis,
103
+ defaults to `90.0`.
104
+ :type tth_max: float, optional
105
+ :return: Unique HKLs and their lattice spacings in angstroms.
106
+ :rtype: np.ndarray, np.ndarray
107
+ """
108
+ # Local modules
109
+ from CHAP.edd.utils import get_unique_hkls_ds
110
+
111
+ return get_unique_hkls_ds([self._material])
112
+
113
+ def dict(self, *args, **kwargs):
114
+ """Return a representation of this configuration in a
115
+ dictionary that is suitable for dumping to a YAML file.
116
+
117
+ :return: Dictionary representation of the configuration.
118
+ :rtype: dict
119
+ """
120
+ d = super().dict(*args, **kwargs)
121
+ for k,v in d.items():
122
+ if isinstance(v, PosixPath):
123
+ d[k] = str(v)
124
+ if '_material' in d:
125
+ del d['_material']
126
+ return d
127
+
128
+
129
+ # Detector configuration classes
130
+
32
131
  class MCAElementConfig(BaseModel):
33
132
  """Class representing metadata required to configure a single MCA
34
133
  detector element.
@@ -38,20 +137,68 @@ class MCAElementConfig(BaseModel):
38
137
  :type detector_name: str
39
138
  :ivar num_bins: Number of MCA channels.
40
139
  :type num_bins: int, optional
41
- :ivar include_energy_ranges: List of MCA channel energy ranges
42
- whose data should be included after applying a mask (the
43
- bounds are inclusive), defaults to `[[50,150]]`
44
- :type include_energy_ranges: list[[float, float]], optional
45
140
  """
46
141
  detector_name: constr(strip_whitespace=True, min_length=1) = 'mca1'
47
142
  num_bins: Optional[conint(gt=0)]
48
- max_energy_kev: confloat(gt=0) = 200
143
+
144
+ def dict(self, *args, **kwargs):
145
+ """Return a representation of this configuration in a
146
+ dictionary that is suitable for dumping to a YAML file.
147
+
148
+ :return: Dictionary representation of the configuration.
149
+ :rtype: dict
150
+ """
151
+ d = super().dict(*args, **kwargs)
152
+ return d
153
+
154
+
155
+ class MCAElementCalibrationConfig(MCAElementConfig):
156
+ """Class representing metadata required to calibrate a single MCA
157
+ detector element.
158
+
159
+ :ivar tth_max: Detector rotation about lab frame x axis,
160
+ defaults to `90`.
161
+ :type tth_max: float, optional
162
+ :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
163
+ two unique Bragg peaks, defaults to `0.15`.
164
+ :type hkl_tth_tol: float, optional
165
+ :ivar energy_calibration_coeffs: Detector channel index to energy
166
+ polynomial conversion coefficients ([a, b, c] with
167
+ E_i = a*i^2 + b*i + c), defaults to `[0, 0, 1]`.
168
+ :type energy_calibration_coeffs:
169
+ list[float, float, float], optional
170
+ :ivar background: Background model for peak fitting.
171
+ :type background: str, list[str], optional
172
+ :ivar baseline: Automated baseline subtraction configuration,
173
+ defaults to `False`.
174
+ :type baseline: Union(bool, BaselineConfig), optional
175
+ :ivar tth_initial_guess: Initial guess for 2&theta,
176
+ defaults to `5.0`.
177
+ :type tth_initial_guess: float, optional
178
+ :ivar tth_calibrated: Calibrated value for 2&theta.
179
+ :type tth_calibrated: float, optional
180
+ :ivar include_energy_ranges: List of MCA channel energy ranges
181
+ in keV whose data should be included after applying a mask
182
+ (bounds are inclusive), defaults to `[[50, 150]]`
183
+ :type include_energy_ranges: list[[float, float]], optional
184
+ """
185
+ tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
186
+ hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
187
+ energy_calibration_coeffs: conlist(
188
+ min_items=3, max_items=3,
189
+ item_type=confloat(allow_inf_nan=False)) = [0, 0, 1]
190
+ background: Optional[Union[str, list]]
191
+ baseline: Optional[Union[bool, BaselineConfig]] = False
192
+ tth_initial_guess: confloat(gt=0, le=tth_max, allow_inf_nan=False) = 5.0
193
+ tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
49
194
  include_energy_ranges: conlist(
50
195
  min_items=1,
51
196
  item_type=conlist(
52
- item_type=confloat(ge=0),
197
+ item_type=confloat(ge=25),
53
198
  min_items=2,
54
- max_items=2)) = [[50,150]]
199
+ max_items=2)) = [[50, 150]]
200
+
201
+ _hkl_indices: list = PrivateAttr()
55
202
 
56
203
  @validator('include_energy_ranges', each_item=True)
57
204
  def validate_include_energy_range(cls, value, values):
@@ -65,39 +212,312 @@ class MCAElementConfig(BaseModel):
65
212
  :return: The validated value of `include_energy_ranges`.
66
213
  :rtype: dict
67
214
  """
68
- max_energy_kev = values.get('max_energy_kev')
69
215
  value.sort()
70
- if value[1] > max_energy_kev:
71
- value[1] = max_energy_kev
216
+ n_max = values.get('num_bins')
217
+ if n_max is not None:
218
+ n_max -= 1
219
+ a, b, c = values.get('energy_calibration_coeffs')
220
+ e_max = (a*n_max + b)*n_max +c
221
+ if value[0] < c or value[1] > e_max:
222
+ newvalue = [float(max(value[0], c)),
223
+ float(min(value[1], e_max))]
224
+ print(
225
+ f'WARNING: include_energy_range out of range'
226
+ f' ({value}): adjusted to {newvalue}')
227
+ value = newvalue
72
228
  return value
73
229
 
230
+ @property
231
+ def energies(self):
232
+ """Return calibrated bin energies."""
233
+ a, b, c = self.energy_calibration_coeffs
234
+ channels = np.arange(self.num_bins)
235
+ return (a*channels + b)*channels + c
236
+
237
+ @property
238
+ def include_bin_ranges(self):
239
+ """Return the value of `include_energy_ranges` represented in
240
+ terms of channel indices instead of channel energies.
241
+ """
242
+ from CHAP.utils.general import (
243
+ index_nearest_down,
244
+ index_nearest_up,
245
+ )
246
+
247
+ include_bin_ranges = []
248
+ energies = self.energies
249
+ for e_min, e_max in self.include_energy_ranges:
250
+ include_bin_ranges.append(
251
+ [index_nearest_down(energies, e_min),
252
+ index_nearest_up(energies, e_max)])
253
+ return include_bin_ranges
254
+
255
+ @property
256
+ def hkl_indices(self):
257
+ """Return the hkl_indices consistent with the selected energy
258
+ ranges (include_energy_ranges).
259
+ """
260
+ if hasattr(self, '_hkl_indices'):
261
+ return self._hkl_indices
262
+ return []
263
+
264
+ def get_include_energy_ranges(self, include_bin_ranges):
265
+ """Given a list of channel index ranges, return the
266
+ corresponding list of channel energy ranges.
267
+
268
+ :param include_bin_ranges: A list of channel bin ranges to convert to
269
+ energy ranges.
270
+ :type include_bin_ranges: list[list[int]]
271
+ :returns: Energy ranges
272
+ :rtype: list[list[float]]
273
+ """
274
+ energies = self.energies
275
+ return [[float(energies[i]) for i in range_]
276
+ for range_ in include_bin_ranges]
277
+
278
+ def mca_mask(self):
279
+ """Get a boolean mask array to use on this MCA element's data.
280
+ Note that the bounds of self.include_energy_ranges are inclusive.
281
+
282
+ :return: Boolean mask array.
283
+ :rtype: numpy.ndarray
284
+ """
285
+ mask = np.asarray([False] * self.num_bins)
286
+ bin_indices = np.arange(self.num_bins)
287
+ for min_, max_ in self.include_bin_ranges:
288
+ mask = np.logical_or(
289
+ mask, np.logical_and(bin_indices >= min_, bin_indices <= max_))
290
+ return mask
291
+
292
+ def set_hkl_indices(self, hkl_indices):
293
+ """Set the private attribute `hkl_indices`."""
294
+ self._hkl_indices = hkl_indices
295
+
296
+ #RV need def dict?
297
+ # d['include_energy_ranges'] = [
298
+ # [float(energy) for energy in d['include_energy_ranges'][i]]
299
+ # for i in range(len(d['include_energy_ranges']))]
300
+
301
+
302
+ class MCAElementDiffractionVolumeLengthConfig(MCAElementConfig):
303
+ """Class representing metadata required to perform a diffraction
304
+ volume length measurement for a single MCA detector element.
305
+
306
+ :ivar include_bin_ranges: List of MCA channel index ranges
307
+ whose data is included in the measurement.
308
+ :type include_bin_ranges: list[[int, int]], optional
309
+ :ivar measurement_mode: Placeholder for recording whether the
310
+ measured DVL value was obtained through the automated
311
+ calculation or a manual selection, defaults to `'auto'`.
312
+ :type measurement_mode: Literal['manual', 'auto'], optional
313
+ :ivar sigma_to_dvl_factor: The DVL is obtained by fitting a reduced
314
+ form of the MCA detector data. `sigma_to_dvl_factor` is a
315
+ scalar value that converts the standard deviation of the
316
+ gaussian fit to the measured DVL, defaults to `3.5`.
317
+ :type sigma_to_dvl_factor: Literal[3.5, 2.0, 4.0], optional
318
+ :ivar dvl_measured: Placeholder for the measured diffraction
319
+ volume length before writing the data to file.
320
+ :type dvl_measured: float, optional
321
+ :ivar fit_amplitude: Placeholder for amplitude of the gaussian fit.
322
+ :type fit_amplitude: float, optional
323
+ :ivar fit_center: Placeholder for center of the gaussian fit.
324
+ :type fit_center: float, optional
325
+ :ivar fit_sigma: Placeholder for sigma of the gaussian fit.
326
+ :type fit_sigma: float, optional
327
+ """
328
+ include_bin_ranges: Optional[
329
+ conlist(
330
+ min_items=1,
331
+ item_type=conlist(
332
+ item_type=conint(ge=0),
333
+ min_items=2,
334
+ max_items=2))]
335
+ measurement_mode: Optional[Literal['manual', 'auto']] = 'auto'
336
+ sigma_to_dvl_factor: Optional[Literal[3.5, 2.0, 4.0]] = 3.5
337
+ dvl_measured: Optional[confloat(gt=0)] = None
338
+ fit_amplitude: Optional[float] = None
339
+ fit_center: Optional[float] = None
340
+ fit_sigma: Optional[float] = None
341
+
342
+ def mca_mask(self):
343
+ """Get a boolean mask array to use on this MCA element's data.
344
+ Note that the bounds of self.include_energy_ranges are inclusive.
345
+
346
+ :return: Boolean mask array.
347
+ :rtype: numpy.ndarray
348
+ """
349
+ mask = np.asarray([False] * self.num_bins)
350
+ bin_indices = np.arange(self.num_bins)
351
+ for min_, max_ in self.include_bin_ranges:
352
+ mask = np.logical_or(
353
+ mask, np.logical_and(bin_indices >= min_, bin_indices <= max_))
354
+ return mask
355
+
356
+ def dict(self, *args, **kwargs):
357
+ """Return a representation of this configuration in a
358
+ dictionary that is suitable for dumping to a YAML file.
359
+ Exclude `sigma_to_dvl_factor` from the dict representation if
360
+ `measurement_mode` is `'manual'`.
361
+
362
+ :return: Dictionary representation of the configuration.
363
+ :rtype: dict
364
+ """
365
+ d = super().dict(*args, **kwargs)
366
+ if self.measurement_mode == 'manual':
367
+ del d['sigma_to_dvl_factor']
368
+ for param in ('amplitude', 'center', 'sigma'):
369
+ d[f'fit_{param}'] = float(d[f'fit_{param}'])
370
+ return d
371
+
372
+
373
+ class MCAElementStrainAnalysisConfig(MCAElementConfig):
374
+ """Class representing metadata required to perform a strain
375
+ analysis fitting for a single MCA detector element.
376
+
377
+ :param tth_max: Detector rotation about hutch x axis, defaults
378
+ to `90.0`.
379
+ :type tth_max: float, optional
380
+ :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
381
+ two unique HKL peaks, defaults to `0.15`.
382
+ :type hkl_tth_tol: float, optional
383
+ :ivar hkl_indices: List of unique HKL indices to fit peaks for in
384
+ the calibration routine, defaults to `[]`.
385
+ :type hkl_indices: list[int], optional
386
+ :ivar background: Background model for peak fitting.
387
+ :type background: str, list[str], optional
388
+ :ivar baseline: Automated baseline subtraction configuration,
389
+ defaults to `False`.
390
+ :type baseline: Union(bool, BaselineConfig), optional
391
+ :ivar num_proc: Number of processors used for peak fitting.
392
+ :type num_proc: int, optional
393
+ :ivar peak_models: Peak model for peak fitting,
394
+ defaults to `'gaussian'`.
395
+ :type peak_models: Literal['gaussian', 'lorentzian']],
396
+ list[Literal['gaussian', 'lorentzian']]], optional
397
+ :ivar fwhm_min: Minimum FWHM for peak fitting, defaults to `0.25`.
398
+ :type fwhm_min: float, optional
399
+ :ivar fwhm_max: Maximum FWHM for peak fitting, defaults to `2.0`.
400
+ :type fwhm_max: float, optional
401
+ :ivar centers_range: Peak centers range for peak fitting.
402
+ The allowed range the peak centers will be the initial
403
+ values &pm; `centers_range`. Defaults to `2.0`.
404
+ :type centers_range: float, optional
405
+ :ivar rel_height_cutoff: Relative peak height cutoff for
406
+ peak fitting (any peak with a height smaller than
407
+ `rel_height_cutoff` times the maximum height of all peaks
408
+ gets removed from the fit model), defaults to `None`.
409
+ :type rel_height_cutoff: float, optional
410
+ :ivar tth_calibrated: Calibrated value for 2&theta.
411
+ :type tth_calibrated: float, optional
412
+ :ivar energy_calibration_coeffs: Detector channel index to energy
413
+ polynomial conversion coefficients ([a, b, c] with
414
+ E_i = a*i^2 + b*i + c), defaults to `[0, 0, 1]`.
415
+ :type energy_calibration_coeffs:
416
+ list[float, float, float], optional
417
+ :ivar calibration_bin_ranges: List of MCA channel index ranges
418
+ whose data is included in the calibration.
419
+ :type calibration_bin_ranges: list[[int, int]], optional
420
+ :ivar tth_file: Path to the file with the 2&theta map.
421
+ :type tth_file: FilePath, optional
422
+ :ivar tth_map: Map of the 2&theta values.
423
+ :type tth_map: np.ndarray, optional
424
+ :ivar include_energy_ranges: List of MCA channel energy ranges
425
+ in keV whose data should be included after applying a mask
426
+ (bounds are inclusive), defaults to `[[50, 150]]`
427
+ :type include_energy_ranges: list[[float, float]], optional
428
+ """
429
+ tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
430
+ hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
431
+ hkl_indices: Optional[conlist(item_type=conint(ge=0))] = []
432
+ background: Optional[Union[str, list]]
433
+ baseline: Optional[Union[bool, BaselineConfig]] = False
434
+ num_proc: Optional[conint(gt=0)] = os.cpu_count()
435
+ peak_models: Union[
436
+ conlist(item_type=Literal['gaussian', 'lorentzian'], min_items=1),
437
+ Literal['gaussian', 'lorentzian']] = 'gaussian'
438
+ fwhm_min: confloat(gt=0, allow_inf_nan=False) = 0.25
439
+ fwhm_max: confloat(gt=0, allow_inf_nan=False) = 2.0
440
+ centers_range: confloat(gt=0, allow_inf_nan=False) = 2.0
441
+ rel_height_cutoff: Optional[confloat(gt=0, lt=1.0, allow_inf_nan=False)]
442
+
443
+ tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
444
+ energy_calibration_coeffs: conlist(
445
+ min_items=3, max_items=3,
446
+ item_type=confloat(allow_inf_nan=False)) = [0, 0, 1]
447
+ calibration_bin_ranges: Optional[
448
+ conlist(
449
+ min_items=1,
450
+ item_type=conlist(
451
+ item_type=conint(ge=0),
452
+ min_items=2,
453
+ max_items=2))]
454
+ tth_file: Optional[FilePath]
455
+ tth_map: Optional[np.ndarray] = None
456
+ include_energy_ranges: conlist(
457
+ min_items=1,
458
+ item_type=conlist(
459
+ item_type=confloat(ge=25),
460
+ min_items=2,
461
+ max_items=2)) = [[50, 150]]
462
+
463
+ #RV lots of overlap with MCAElementCalibrationConfig (only missing
464
+ # tth_initial_guess)
465
+ # Should we derive from MCAElementCalibrationConfig in some way
466
+ # or make a MCAElementEnergyCalibrationConfig with what's shared
467
+ # and derive MCAElementCalibrationConfig from this as well with
468
+ # the unique fields tth_initial_guess added?
469
+ # Revisit when we redo the detectors
470
+
471
+ @validator('hkl_indices', pre=True)
472
+ def validate_hkl_indices(cls, hkl_indices):
473
+ if isinstance(hkl_indices, str):
474
+ # Local modules
475
+ from CHAP.utils.general import string_to_list
476
+
477
+ hkl_indices = string_to_list(hkl_indices)
478
+ return sorted(hkl_indices)
479
+
480
+ class Config:
481
+ arbitrary_types_allowed = True
482
+
483
+ @property
484
+ def energies(self):
485
+ """Return calibrated bin energies."""
486
+ a, b, c = self.energy_calibration_coeffs
487
+ channels = np.arange(self.num_bins)
488
+ return (a*channels + b)*channels + c
489
+
74
490
  @property
75
491
  def include_bin_ranges(self):
76
492
  """Return the value of `include_energy_ranges` represented in
77
493
  terms of channel indices instead of channel energies.
78
494
  """
79
- from CHAP.utils.general import index_nearest_down, index_nearest_upp
495
+ from CHAP.utils.general import (
496
+ index_nearest_down,
497
+ index_nearest_up,
498
+ )
80
499
 
81
500
  include_bin_ranges = []
82
- energies = np.linspace(0, self.max_energy_kev, self.num_bins)
501
+ energies = self.energies
83
502
  for e_min, e_max in self.include_energy_ranges:
84
503
  include_bin_ranges.append(
85
504
  [index_nearest_down(energies, e_min),
86
- index_nearest_upp(energies, e_max)])
505
+ index_nearest_up(energies, e_max)])
87
506
  return include_bin_ranges
88
507
 
89
- def get_energy_ranges(self, bin_ranges):
508
+ def get_include_energy_ranges(self, include_bin_ranges):
90
509
  """Given a list of channel index ranges, return the
91
- correspongin list of channel energy ranges.
510
+ corresponding list of channel energy ranges.
92
511
 
93
- :param bin_ranges: A list of channel bin ranges to convert to
512
+ :param include_bin_ranges: A list of channel bin ranges to convert to
94
513
  energy ranges.
95
- :type bin_ranges: list[list[int]]
514
+ :type include_bin_ranges: list[list[int]]
96
515
  :returns: Energy ranges
97
516
  :rtype: list[list[float]]
98
517
  """
99
- energies = np.linspace(0, self.max_energy_kev, self.num_bins)
100
- return [[energies[i] for i in range_] for range_ in bin_ranges]
518
+ energies = self.energies
519
+ return [[float(energies[i]) for i in range_]
520
+ for range_ in include_bin_ranges]
101
521
 
102
522
  def mca_mask(self):
103
523
  """Get a boolean mask array to use on this MCA element's data.
@@ -113,6 +533,38 @@ class MCAElementConfig(BaseModel):
113
533
  mask, np.logical_and(bin_indices >= min_, bin_indices <= max_))
114
534
  return mask
115
535
 
536
+ def add_calibration(self, calibration):
537
+ """Finalize values for some fields using a completed
538
+ MCAElementCalibrationConfig that corresponds to the same
539
+ detector.
540
+
541
+ :param calibration: Existing calibration configuration to use
542
+ by MCAElementStrainAnalysisConfig.
543
+ :type calibration: MCAElementCalibrationConfig
544
+ :return: None
545
+ """
546
+ add_fields = [
547
+ 'tth_calibrated', 'energy_calibration_coeffs', 'num_bins']
548
+ for field in add_fields:
549
+ setattr(self, field, getattr(calibration, field))
550
+ self.calibration_bin_ranges = calibration.include_bin_ranges
551
+
552
+ def get_tth_map(self, map_shape):
553
+ """Return the map of 2&theta values to use -- may vary at each
554
+ point in the map.
555
+
556
+ :param map_shape: The shape of the suplied 2&theta map.
557
+ :return: Map of 2&theta values.
558
+ :rtype: np.ndarray
559
+ """
560
+ if getattr(self, 'tth_map', None) is not None:
561
+ if self.tth_map.shape != map_shape:
562
+ raise ValueError(
563
+ 'Invalid "tth_map" field shape '
564
+ f'{self.tth_map.shape} (expected {map_shape})')
565
+ return self.tth_map
566
+ return np.full(map_shape, self.tth_calibrated)
567
+
116
568
  def dict(self, *args, **kwargs):
117
569
  """Return a representation of this configuration in a
118
570
  dictionary that is suitable for dumping to a YAML file.
@@ -121,12 +573,16 @@ class MCAElementConfig(BaseModel):
121
573
  :rtype: dict
122
574
  """
123
575
  d = super().dict(*args, **kwargs)
124
- d['include_energy_ranges'] = [
125
- [float(energy) for energy in d['include_energy_ranges'][i]]
126
- for i in range(len(d['include_energy_ranges']))]
576
+ for k,v in d.items():
577
+ if isinstance(v, PosixPath):
578
+ d[k] = str(v)
579
+ if isinstance(v, np.ndarray):
580
+ d[k] = v.tolist()
127
581
  return d
128
582
 
129
583
 
584
+ # Processor configuration classes
585
+
130
586
  class MCAScanDataConfig(BaseModel):
131
587
  """Class representing metadata required to locate raw MCA data for
132
588
  a single scan and construct a mask for it.
@@ -232,268 +688,87 @@ class MCAScanDataConfig(BaseModel):
232
688
  # System modules
233
689
  from copy import deepcopy
234
690
 
235
- # Local modules
236
- from CHAP.utils.general import (
237
- index_nearest_down,
238
- index_nearest_upp,
239
- )
240
691
  flux = np.loadtxt(flux_file)
241
692
  flux_file_energies = flux[:,0]/1.e3
242
693
  flux_e_min = flux_file_energies.min()
243
694
  flux_e_max = flux_file_energies.max()
244
695
  for detector in detectors:
245
- mca_bin_energies = np.linspace(
246
- 0, detector.max_energy_kev, detector.num_bins)
247
- for i, (det_e_min, det_e_max) in enumerate(
248
- deepcopy(detector.include_energy_ranges)):
249
- if det_e_min < flux_e_min or det_e_max > flux_e_max:
250
- energy_range = [min(det_e_min, flux_e_min),
251
- max(det_e_max, flux_e_max)]
252
- print(
253
- f'WARNING: include_energy_ranges[{i}] out of range'
254
- f' ({detector.include_energy_ranges[i]}): adjusted'
255
- f' to {energy_range}')
256
- detector.include_energy_ranges[i] = energy_range
257
-
258
- return values
259
-
260
- @property
261
- def scanparser(self):
262
- """Return the scanparser."""
263
- try:
264
- scanparser = self._scanparser
265
- except:
266
- scanparser = ScanParser(self.spec_file, self.scan_number)
267
- self._scanparser = scanparser
268
- return scanparser
269
-
270
- def mca_data(self, detector_config, scan_step_index=None):
271
- """Get the array of MCA data collected by the scan.
272
-
273
- :param detector_config: Detector for which data is returned.
274
- :type detector_config: MCAElementConfig
275
- :param scan_step_index: Only return the MCA spectrum for the
276
- given scan step index, defaults to `None`, which returns
277
- all the available MCA spectra.
278
- :type scan_step_index: int, optional
279
- :return: The current detectors's MCA data.
280
- :rtype: np.ndarray
281
- """
282
- detector_name = detector_config.detector_name
283
- if self._parfile is not None:
284
- if scan_step_index is None:
285
- data = np.asarray(
286
- [ScanParser(self._parfile.spec_file, scan_number)\
287
- .get_all_detector_data(detector_name)[0] \
288
- for scan_number in self._parfile.good_scan_numbers()])
289
- else:
290
- data = ScanParser(
291
- self._parfile.spec_file,
292
- self._parfile.good_scan_numbers()[scan_step_index])\
293
- .get_all_detector_data(detector_name)
294
- else:
295
- if scan_step_index is None:
296
- data = self.scanparser.get_all_detector_data(
297
- detector_name)
298
- else:
299
- data = self.scanparser.get_detector_data(
300
- detector_config.detector_name, self.scan_step_index)
301
- return data
302
-
303
- def dict(self, *args, **kwargs):
304
- """Return a representation of this configuration in a
305
- dictionary that is suitable for dumping to a YAML file.
306
-
307
- :return: Dictionary representation of the configuration.
308
- :rtype: dict
309
- """
310
- d = super().dict(*args, **kwargs)
311
- for k,v in d.items():
312
- if isinstance(v, PosixPath):
313
- d[k] = str(v)
314
- if d.get('_parfile') is None:
315
- del d['par_file']
316
- del d['scan_column']
317
- else:
318
- del d['spec_file']
319
- del d['scan_number']
320
- for k in ('_scanparser', '_parfile', 'inputdir'):
321
- if k in d:
322
- del d[k]
323
- return d
324
-
325
-
326
- class MaterialConfig(BaseModel):
327
- """Model for parameters to characterize a sample material.
328
-
329
- :ivar material_name: Sample material name.
330
- :type material_name: str, optional
331
- :ivar lattice_parameters: Lattice spacing(s) in angstroms.
332
- :type lattice_parameters: float, list[float], optional
333
- :ivar sgnum: Space group of the material.
334
- :type sgnum: int, optional
335
- """
336
- material_name: Optional[constr(strip_whitespace=True, min_length=1)]
337
- lattice_parameters: Optional[Union[
338
- confloat(gt=0),
339
- conlist(item_type=confloat(gt=0), min_items=1, max_items=6)]]
340
- sgnum: Optional[conint(ge=0)]
341
-
342
- _material: Optional[Material]
343
-
344
- class Config:
345
- underscore_attrs_are_private = False
346
-
347
- @root_validator
348
- def validate_material(cls, values):
349
- """Create and validate the private attribute _material.
350
-
351
- :param values: Dictionary of previously validated field values.
352
- :type values: dict
353
- :return: The validated list of `values`.
354
- :rtype: dict
355
- """
356
- # Local modules
357
- from CHAP.edd.utils import make_material
358
-
359
- values['_material'] = make_material(values.get('material_name'),
360
- values.get('sgnum'),
361
- values.get('lattice_parameters'))
362
- return values
363
-
364
- def unique_hkls_ds(self, tth_tol=0.15, tth_max=90.0):
365
- """Get a list of unique HKLs and their lattice spacings.
366
-
367
- :param tth_tol: Minimum resolvable difference in 2&theta
368
- between two unique HKL peaks, defaults to `0.15`.
369
- :type tth_tol: float, optional
370
- :param tth_max: Detector rotation about hutch x axis,
371
- defaults to `90.0`.
372
- :type tth_max: float, optional
373
- :return: Unique HKLs and their lattice spacings in angstroms.
374
- :rtype: np.ndarray, np.ndarray
375
- """
376
- # Local modules
377
- from CHAP.edd.utils import get_unique_hkls_ds
378
-
379
- return get_unique_hkls_ds([self._material])
380
-
381
- def dict(self, *args, **kwargs):
382
- """Return a representation of this configuration in a
383
- dictionary that is suitable for dumping to a YAML file.
384
-
385
- :return: Dictionary representation of the configuration.
386
- :rtype: dict
387
- """
388
- d = super().dict(*args, **kwargs)
389
- for k,v in d.items():
390
- if isinstance(v, PosixPath):
391
- d[k] = str(v)
392
- if '_material' in d:
393
- del d['_material']
394
- return d
395
-
396
-
397
- class MCAElementCalibrationConfig(MCAElementConfig):
398
- """Class representing metadata required to calibrate a single MCA
399
- detector element.
400
-
401
- :ivar max_energy_kev: Maximum channel energy of the MCA in keV.
402
- :type max_energy_kev: float
403
- :ivar tth_max: Detector rotation about lab frame x axis,
404
- defaults to `90`.
405
- :type tth_max: float, optional
406
- :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
407
- two unique HKL peaks, defaults to `0.15`.
408
- :type hkl_tth_tol: float, optional
409
- :ivar hkl_indices: List of unique HKL indices to fit peaks for in
410
- the calibration routine, defaults to `[]`.
411
- :type hkl_indices: list[int], optional
412
- :ivar background: Background model for peak fitting.
413
- :type background: str, list[str], optional
414
- :ivar tth_initial_guess: Initial guess for 2&theta,
415
- defaults to `5.0`.
416
- :type tth_initial_guess: float, optional
417
- :ivar slope_initial_guess: Initial guess for the detector channel
418
- energy correction linear slope, defaults to `1.0`.
419
- :type slope_initial_guess: float, optional
420
- :ivar intercept_initial_guess: Initial guess for detector channel
421
- energy correction y-intercept, defaults to `0.0`.
422
- :type intercept_initial_guess: float, optional
423
- :ivar tth_calibrated: Calibrated value for 2&theta.
424
- :type tth_calibrated: float, optional
425
- :ivar slope_calibrated: Calibrated value for detector channel
426
- energy correction linear slope.
427
- :type slope_calibrated: float, optional
428
- :ivar intercept_calibrated: Calibrated value for detector channel
429
- energy correction y-intercept.
430
- :type intercept_calibrated: float, optional
431
- """
432
- max_energy_kev: confloat(gt=0)
433
- tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
434
- hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
435
- hkl_indices: Optional[conlist(item_type=conint(ge=0), min_items=1)] = []
436
- background: Optional[Union[str, list]]
437
- tth_initial_guess: confloat(gt=0, le=tth_max, allow_inf_nan=False) = 5.0
438
- slope_initial_guess: float = 1.0
439
- intercept_initial_guess: float = 0.0
440
- tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
441
- slope_calibrated: Optional[confloat(allow_inf_nan=False)]
442
- intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
696
+ for i, (det_e_min, det_e_max) in enumerate(
697
+ deepcopy(detector.include_energy_ranges)):
698
+ if det_e_min < flux_e_min or det_e_max > flux_e_max:
699
+ energy_range = [float(max(det_e_min, flux_e_min)),
700
+ float(min(det_e_max, flux_e_max))]
701
+ print(
702
+ f'WARNING: include_energy_ranges[{i}] out of range'
703
+ f' ({detector.include_energy_ranges[i]}): adjusted'
704
+ f' to {energy_range}')
705
+ detector.include_energy_ranges[i] = energy_range
443
706
 
444
- @validator('hkl_indices', pre=True)
445
- def validate_hkl_indices(cls, hkl_indices):
446
- if isinstance(hkl_indices, str):
447
- # Local modules
448
- from CHAP.utils.general import string_to_list
707
+ return values
449
708
 
450
- hkl_indices = string_to_list(hkl_indices)
451
- return sorted(hkl_indices)
709
+ @property
710
+ def scanparser(self):
711
+ """Return the scanparser."""
712
+ try:
713
+ scanparser = self._scanparser
714
+ except:
715
+ scanparser = ScanParser(self.spec_file, self.scan_number)
716
+ self._scanparser = scanparser
717
+ return scanparser
452
718
 
453
- class MCAElementDiffractionVolumeLengthConfig(MCAElementConfig):
454
- """Class representing metadata required to perform a diffraction
455
- volume length measurement for a single MCA detector element.
719
+ def mca_data(self, detector_config, scan_step_index=None):
720
+ """Get the array of MCA data collected by the scan.
456
721
 
457
- :ivar measurement_mode: Placeholder for recording whether the
458
- measured DVL value was obtained through the automated
459
- calculation or a manual selection, defaults to `'auto'`.
460
- :type measurement_mode: Literal['manual', 'auto'], optional
461
- :ivar sigma_to_dvl_factor: The DVL is obtained by fitting a reduced
462
- form of the MCA detector data. `sigma_to_dvl_factor` is a
463
- scalar value that converts the standard deviation of the
464
- gaussian fit to the measured DVL, defaults to `3.5`.
465
- :type sigma_to_dvl_factor: Literal[3.5, 2.0, 4.0], optional
466
- :ivar dvl_measured: Placeholder for the measured diffraction
467
- volume length before writing the data to file.
468
- :type dvl_measured: float, optional
469
- :ivar fit_amplitude: Placeholder for amplitude of the gaussian fit.
470
- :type fit_amplitude: float, optional
471
- :ivar fit_center: Placeholder for center of the gaussian fit.
472
- :type fit_center: float, optional
473
- :ivar fit_sigma: Placeholder for sigma of the gaussian fit.
474
- :type fit_sigma: float, optional
475
- """
476
- measurement_mode: Optional[Literal['manual', 'auto']] = 'auto'
477
- sigma_to_dvl_factor: Optional[Literal[3.5, 2.0, 4.0]] = 3.5
478
- dvl_measured: Optional[confloat(gt=0)] = None
479
- fit_amplitude: Optional[float] = None
480
- fit_center: Optional[float] = None
481
- fit_sigma: Optional[float] = None
722
+ :param detector_config: Detector for which data is returned.
723
+ :type detector_config: MCAElementConfig
724
+ :param scan_step_index: Only return the MCA spectrum for the
725
+ given scan step index, defaults to `None`, which returns
726
+ all the available MCA spectra.
727
+ :type scan_step_index: int, optional
728
+ :return: The current detectors's MCA data.
729
+ :rtype: np.ndarray
730
+ """
731
+ detector_name = detector_config.detector_name
732
+ if self._parfile is not None:
733
+ if scan_step_index is None:
734
+ data = np.asarray(
735
+ [ScanParser(self._parfile.spec_file, scan_number)\
736
+ .get_all_detector_data(detector_name)[0] \
737
+ for scan_number in self._parfile.good_scan_numbers()])
738
+ else:
739
+ data = ScanParser(
740
+ self._parfile.spec_file,
741
+ self._parfile.good_scan_numbers()[scan_step_index])\
742
+ .get_all_detector_data(detector_name)
743
+ else:
744
+ if scan_step_index is None:
745
+ data = self.scanparser.get_all_detector_data(
746
+ detector_name)
747
+ else:
748
+ data = self.scanparser.get_detector_data(
749
+ detector_config.detector_name, scan_step_index)
750
+ return data
482
751
 
483
752
  def dict(self, *args, **kwargs):
484
753
  """Return a representation of this configuration in a
485
754
  dictionary that is suitable for dumping to a YAML file.
486
- Exclude `sigma_to_dvl_factor` from the dict representation if
487
- `measurement_mode` is `'manual'`.
488
755
 
489
756
  :return: Dictionary representation of the configuration.
490
757
  :rtype: dict
491
758
  """
492
759
  d = super().dict(*args, **kwargs)
493
- if self.measurement_mode == 'manual':
494
- del d['sigma_to_dvl_factor']
495
- for param in ('amplitude', 'center', 'sigma'):
496
- d[f'fit_{param}'] = float(d[f'fit_{param}'])
760
+ for k,v in d.items():
761
+ if isinstance(v, PosixPath):
762
+ d[k] = str(v)
763
+ if d.get('_parfile') is None:
764
+ del d['par_file']
765
+ del d['scan_column']
766
+ else:
767
+ del d['spec_file']
768
+ del d['scan_number']
769
+ for k in ('_scanparser', '_parfile', 'inputdir'):
770
+ if k in d:
771
+ del d[k]
497
772
  return d
498
773
 
499
774
 
@@ -529,57 +804,54 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
529
804
  return self.scanparser.spec_scan_motor_vals[0]
530
805
 
531
806
 
532
- class CeriaConfig(MaterialConfig):
533
- """Model for the sample material used in calibrations.
534
-
535
- :ivar material_name: Calibration material name,
536
- defaults to `'CeO2'`.
537
- :type material_name: str, optional
538
- :ivar lattice_parameters: Lattice spacing(s) for the calibration
539
- material in angstroms, defaults to `5.41153`.
540
- :type lattice_parameters: float, list[float], optional
541
- :ivar sgnum: Space group of the calibration material,
542
- defaults to `225`.
543
- :type sgnum: int, optional
544
- """
545
- #RV Name suggests it's always Ceria, why have material_name?
546
- material_name: constr(strip_whitespace=True, min_length=1) = 'CeO2'
547
- lattice_parameters: confloat(gt=0) = 5.41153
548
- sgnum: Optional[conint(ge=0)] = 225
549
-
550
-
551
- class MCACeriaCalibrationConfig(MCAScanDataConfig):
807
+ class MCAEnergyCalibrationConfig(MCAScanDataConfig):
552
808
  """
553
- Class representing metadata required to perform a Ceria calibration
554
- for an MCA detector.
809
+ Class representing metadata required to perform an energy
810
+ calibration for an MCA detector.
555
811
 
556
- :ivar scan_step_index: Optional scan step index to use for the
812
+ :ivar scan_step_indices: Optional scan step indices to use for the
557
813
  calibration. If not specified, the calibration will be
558
814
  performed on the average of all MCA spectra for the scan.
559
- :type scan_step_index: int, optional
560
- :ivar flux_file: File name of the csv flux file containing station
561
- beam energy in eV (column 0) versus flux (column 1).
562
- :type flux_file: str
563
- :ivar material: Material configuration for Ceria.
564
- :type material: CeriaConfig
815
+ :type scan_step_indices: list[int], optional
565
816
  :ivar detectors: List of individual MCA detector element
566
817
  calibration configurations.
567
818
  :type detectors: list[MCAElementCalibrationConfig]
568
- :ivar max_iter: Maximum number of iterations of the calibration
569
- routine, defaults to `10`.
570
- :type detectors: int, optional
571
- :ivar tune_tth_tol: Cutoff error for tuning 2&theta. Stop iterating
572
- the calibration routine after an iteration produces a change in
573
- the tuned value of 2&theta that is smaller than this cutoff,
574
- defaults to `1e-8`.
575
- :ivar tune_tth_tol: float, optional
819
+ :ivar flux_file: File name of the csv flux file containing station
820
+ beam energy in eV (column 0) versus flux (column 1).
821
+ :type flux_file: str, optional
822
+ :ivar material: Material configuration for the calibration,
823
+ defaults to `Ceria`.
824
+ :type material: MaterialConfig, optional
825
+ :ivar peak_energies: Theoretical locations of peaks in keV to use
826
+ for calibrating the MCA channel energies. It is _strongly_
827
+ recommended to use fluorescence peaks for the energy
828
+ calibration.
829
+ :type peak_energies: list[float]
830
+ :ivar max_peak_index: Index of the peak in `peak_energies`
831
+ with the highest amplitude.
832
+ :type max_peak_index: int
833
+ :ivar fit_index_ranges: Explicit ranges of uncalibrated MCA
834
+ channel index ranges to include during energy calibration
835
+ when the given peaks are fitted to the provied MCA spectrum.
836
+ Use this parameter or select it interactively by running a
837
+ pipeline with `config.interactive: True`.
838
+ :type fit_index_ranges: list[[int, int]], optional
839
+
576
840
  """
577
- scan_step_index: Optional[conint(ge=0)]
578
- material: CeriaConfig = CeriaConfig()
841
+ scan_step_indices: Optional[conlist(min_items=1, item_type=conint(ge=0))]
579
842
  detectors: conlist(min_items=1, item_type=MCAElementCalibrationConfig)
580
- flux_file: FilePath
581
- max_iter: conint(gt=0) = 10
582
- tune_tth_tol: confloat(ge=0) = 1e-8
843
+ flux_file: Optional[FilePath]
844
+ material: Optional[MaterialConfig] = MaterialConfig(
845
+ material_name='CeO2', lattice_parameters=5.41153, sgnum=225)
846
+ peak_energies: conlist(item_type=confloat(gt=0), min_items=2)
847
+ max_peak_index: conint(ge=0)
848
+ fit_index_ranges: Optional[
849
+ conlist(
850
+ min_items=1,
851
+ item_type=conlist(
852
+ item_type=conint(ge=0),
853
+ min_items=2,
854
+ max_items=2))]
583
855
 
584
856
  @root_validator(pre=True)
585
857
  def validate_config(cls, values):
@@ -594,18 +866,61 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
594
866
  inputdir = values.get('inputdir')
595
867
  if inputdir is not None:
596
868
  flux_file = values.get('flux_file')
597
- if not os.path.isabs(flux_file):
869
+ if flux_file is not None and not os.path.isabs(flux_file):
598
870
  values['flux_file'] = os.path.join(inputdir, flux_file)
599
871
 
600
872
  return values
601
873
 
602
- @property
874
+ @validator('scan_step_indices', pre=True, always=True)
875
+ def validate_scan_step_indices(cls, scan_step_indices, values):
876
+ """Validate the specified list of scan numbers.
877
+
878
+ :ivar scan_step_indices: Optional scan step indices to use for the
879
+ calibration. If not specified, the calibration will be
880
+ performed on the average of all MCA spectra for the scan.
881
+ :type scan_step_indices: list[int], optional
882
+ :param values: Dictionary of validated class field values.
883
+ :type values: dict
884
+ :raises ValueError: If a specified scan number is not found in
885
+ the SPEC file.
886
+ :return: List of step indices.
887
+ :rtype: list of int
888
+ """
889
+ if isinstance(scan_step_indices, str):
890
+ # Local modules
891
+ from CHAP.utils.general import string_to_list
892
+
893
+ scan_step_indices = string_to_list(
894
+ scan_step_indices, raise_error=True)
895
+ return scan_step_indices
896
+
897
+ @validator('max_peak_index')
898
+ def validate_max_peak_index(cls, max_peak_index, values):
899
+ """Validate the specified index of the XRF peak with the
900
+ highest amplitude.
901
+
902
+ :ivar max_peak_index: The index of the XRF peak with the
903
+ highest amplitude.
904
+ :type max_peak_index: int
905
+ :param values: Dictionary of validated class field values.
906
+ :type values: dict
907
+ :raises ValueError: Invalid max_peak_index.
908
+ :return: The validated value of `max_peak_index`.
909
+ :rtype: int
910
+ """
911
+ peak_energies = values.get('peak_energies')
912
+ if not 0 <= max_peak_index < len(peak_energies):
913
+ raise ValueError('max_peak_index out of bounds')
914
+ return max_peak_index
915
+
603
916
  def flux_file_energy_range(self):
604
917
  """Get the energy range in the flux corection file.
605
918
 
606
919
  :return: The energy range in the flux corection file.
607
920
  :rtype: tuple(float, float)
608
921
  """
922
+ if self.flux_file is None:
923
+ return None
609
924
  flux = np.loadtxt(self.flux_file)
610
925
  energies = flux[:,0]/1.e3
611
926
  return energies.min(), energies.max()
@@ -618,15 +933,21 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
618
933
  :return: The current detectors's MCA data.
619
934
  :rtype: np.ndarray
620
935
  """
621
- if self.scan_step_index is None:
936
+ if self.scan_step_indices is None:
622
937
  data = super().mca_data(detector_config)
623
938
  if self.scanparser.spec_scan_npts > 1:
624
- data = np.average(data, axis=1)
939
+ data = np.average(data, axis=0)
625
940
  else:
626
941
  data = data[0]
942
+ elif len(self.scan_step_indices) == 1:
943
+ data = super().mca_data(
944
+ detector_config, scan_step_index=self.scan_step_indices[0])
627
945
  else:
628
- data = super().mca_data(detector_config,
629
- scan_step_index=self.scan_step_index)
946
+ data = []
947
+ for scan_step_index in self.scan_step_indices:
948
+ data.append(super().mca_data(
949
+ detector_config, scan_step_index=scan_step_index))
950
+ data = np.average(data, axis=0)
630
951
  return data
631
952
 
632
953
  def flux_correction_interpolation_function(self):
@@ -637,7 +958,8 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
637
958
  :return: Energy flux correction interpolation function.
638
959
  :rtype: scipy.interpolate._polyint._Interpolator1D
639
960
  """
640
-
961
+ if self.flux_file is None:
962
+ return None
641
963
  flux = np.loadtxt(self.flux_file)
642
964
  energies = flux[:,0]/1.e3
643
965
  relative_intensities = flux[:,1]/np.max(flux[:,1])
@@ -645,132 +967,43 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
645
967
  return interpolation_function
646
968
 
647
969
 
648
- class MCAElementStrainAnalysisConfig(MCAElementConfig):
649
- """Class representing metadata required to perform a strain
650
- analysis fitting for a single MCA detector element.
651
-
652
- :ivar max_energy_kev: Maximum channel energy of the MCA in keV.
653
- :type max_energy_kev: float, optional
654
- :ivar num_bins: Number of MCA channels.
655
- :type num_bins: int, optional
656
- :param tth_max: Detector rotation about hutch x axis, defaults
657
- to `90.0`.
658
- :type tth_max: float, optional
659
- :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
660
- two unique HKL peaks, defaults to `0.15`.
661
- :type hkl_tth_tol: float, optional
662
- :ivar hkl_indices: List of unique HKL indices to fit peaks for in
663
- the calibration routine, defaults to `[]`.
664
- :type hkl_indices: list[int], optional
665
- :ivar background: Background model for peak fitting.
666
- :type background: str, list[str], optional
667
- :ivar num_proc: Number of processors used for peak fitting.
668
- :type num_proc: int, optional
669
- :ivar peak_models: Peak model for peak fitting,
670
- defaults to `'gaussian'`.
671
- :type peak_models: Literal['gaussian', 'lorentzian']],
672
- list[Literal['gaussian', 'lorentzian']]], optional
673
- :ivar fwhm_min: Minimum FWHM for peak fitting, defaults to `1.0`.
674
- :type fwhm_min: float, optional
675
- :ivar fwhm_max: Maximum FWHM for peak fitting, defaults to `5.0`.
676
- :type fwhm_max: float, optional
677
- :ivar rel_amplitude_cutoff: Relative peak amplitude cutoff for
678
- peak fitting (any peak with an amplitude smaller than
679
- `rel_amplitude_cutoff` times the sum of all peak amplitudes
680
- gets removed from the fit model), defaults to `None`.
681
- :type rel_amplitude_cutoff: float, optional
682
- :ivar tth_calibrated: Calibrated value for 2&theta.
683
- :type tth_calibrated: float, optional
684
- :ivar slope_calibrated: Calibrated value for detector channel.
685
- energy correction linear slope
686
- :type slope_calibrated: float, optional
687
- :ivar intercept_calibrated: Calibrated value for detector channel
688
- energy correction y-intercept.
689
- :type intercept_calibrated: float, optional
970
+ class MCATthCalibrationConfig(MCAEnergyCalibrationConfig):
690
971
  """
691
- max_energy_kev: Optional[confloat(gt=0)]
692
- num_bins: Optional[conint(gt=0)]
693
- tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
694
- hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
695
- hkl_indices: Optional[conlist(item_type=conint(ge=0), min_items=1)] = []
696
- background: Optional[Union[str, list]]
697
- num_proc: Optional[conint(gt=0)] = os.cpu_count()
698
- peak_models: Union[
699
- conlist(item_type=Literal['gaussian', 'lorentzian'], min_items=1),
700
- Literal['gaussian', 'lorentzian']] = 'gaussian'
701
- fwhm_min: confloat(gt=0, allow_inf_nan=False) = 1.0
702
- fwhm_max: confloat(gt=0, allow_inf_nan=False) = 5.0
703
- rel_amplitude_cutoff: Optional[confloat(gt=0, lt=1.0, allow_inf_nan=False)]
704
-
705
- tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
706
- slope_calibrated: Optional[confloat(allow_inf_nan=False)]
707
- intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
708
- calibration_bin_ranges: Optional[
709
- conlist(
710
- min_items=1,
711
- item_type=conlist(
712
- item_type=conint(ge=0),
713
- min_items=2,
714
- max_items=2))]
715
- tth_file: Optional[FilePath]
716
- tth_map: Optional[np.ndarray] = None
717
-
718
- @validator('hkl_indices', pre=True)
719
- def validate_hkl_indices(cls, hkl_indices):
720
- if isinstance(hkl_indices, str):
721
- # Local modules
722
- from CHAP.utils.general import string_to_list
723
-
724
- hkl_indices = string_to_list(hkl_indices)
725
- return sorted(hkl_indices)
726
-
727
- class Config:
728
- arbitrary_types_allowed = True
729
-
730
- def add_calibration(self, calibration):
731
- """Finalize values for some fields using a completed
732
- MCAElementCalibrationConfig that corresponds to the same
733
- detector.
734
-
735
- :param calibration: Existing calibration configuration to use
736
- by MCAElementStrainAnalysisConfig.
737
- :type calibration: MCAElementCalibrationConfig
738
- :return: None
739
- """
740
- add_fields = ['tth_calibrated', 'slope_calibrated',
741
- 'intercept_calibrated', 'num_bins', 'max_energy_kev']
742
- for field in add_fields:
743
- setattr(self, field, getattr(calibration, field))
744
- self.calibration_bin_ranges = calibration.include_bin_ranges
745
-
746
- def get_tth_map(self, map_config):
747
- """Return a map of 2&theta values to use -- may vary at each
748
- point in the map.
972
+ Class representing metadata required to perform a tth calibration
973
+ for an MCA detector.
749
974
 
750
- :param map_config: The map configuration with which the
751
- returned map of 2&theta values will be used.
752
- :type map_config: CHAP.common.models.map.MapConfig
753
- :return: Map of 2&theta values.
754
- :rtype: np.ndarray
755
- """
756
- if getattr(self, 'tth_map', None) is not None:
757
- return self.tth_map
758
- return np.full(map_config.shape, self.tth_calibrated)
975
+ :ivar calibration_method: Type of calibration method,
976
+ defaults to `'direct_fit_residual'`.
977
+ :type calibration_method:
978
+ Literal['direct_fit_residual', 'iterate_tth'], optional
979
+ :ivar max_iter: Maximum number of iterations of the calibration
980
+ routine (only used for `'iterate_tth'`), defaults to `10`.
981
+ :type max_iter: int, optional
982
+ :ivar tune_tth_tol: Cutoff error for tuning 2&theta (only used for
983
+ `'iterate_tth'`). Stop iterating the calibration routine after
984
+ an iteration produces a change in the tuned value of 2&theta
985
+ that is smaller than this cutoff, defaults to `1e-8`.
986
+ :ivar tune_tth_tol: float, optional
987
+ """
988
+ calibration_method: Optional[Literal[
989
+ 'direct_fit_residual',
990
+ 'direct_fit_peak_energies',
991
+ 'direct_fit_combined',
992
+ 'iterate_tth']] = 'iterate_tth'
993
+ max_iter: conint(gt=0) = 10
994
+ tune_tth_tol: confloat(ge=0) = 1e-8
759
995
 
760
- def dict(self, *args, **kwargs):
761
- """Return a representation of this configuration in a
762
- dictionary that is suitable for dumping to a YAML file.
996
+ def flux_file_energy_range(self):
997
+ """Get the energy range in the flux corection file.
763
998
 
764
- :return: Dictionary representation of the configuration.
765
- :rtype: dict
999
+ :return: The energy range in the flux corection file.
1000
+ :rtype: tuple(float, float)
766
1001
  """
767
- d = super().dict(*args, **kwargs)
768
- for k,v in d.items():
769
- if isinstance(v, PosixPath):
770
- d[k] = str(v)
771
- if isinstance(v, np.ndarray):
772
- d[k] = v.tolist()
773
- return d
1002
+ if self.flux_file is None:
1003
+ return None
1004
+ flux = np.loadtxt(self.flux_file)
1005
+ energies = flux[:,0]/1.e3
1006
+ return energies.min(), energies.max()
774
1007
 
775
1008
 
776
1009
  class StrainAnalysisConfig(BaseModel):
@@ -785,6 +1018,8 @@ class StrainAnalysisConfig(BaseModel):
785
1018
  :type map_config: CHAP.common.models.map.MapConfig, optional
786
1019
  :ivar par_file: Path to the par file associated with the scan.
787
1020
  :type par_file: str, optional
1021
+ :ivar dataset_id: Integer ID of the SMB-style EDD dataset.
1022
+ :type dataset_id: int, optional
788
1023
  :ivar par_dims: List of independent dimensions.
789
1024
  :type par_dims: list[dict[str,str]], optional
790
1025
  :ivar other_dims: List of other column names from `par_file`.
@@ -792,17 +1027,26 @@ class StrainAnalysisConfig(BaseModel):
792
1027
  :ivar detectors: List of individual detector element strain
793
1028
  analysis configurations
794
1029
  :type detectors: list[MCAElementStrainAnalysisConfig]
795
- :ivar material_name: Sample material configurations.
796
- :type material_name: list[MaterialConfig]
1030
+ :ivar materials: Sample material configurations.
1031
+ :type materials: list[MaterialConfig]
1032
+ :ivar flux_file: File name of the csv flux file containing station
1033
+ beam energy in eV (column 0) versus flux (column 1).
1034
+ :type flux_file: str, optional
1035
+ :ivar sum_axes: Whether to sum over the fly axis or not
1036
+ for EDD scan types not 0, defaults to `True`.
1037
+ :type sum_axes: bool, optional
797
1038
  """
798
1039
  inputdir: Optional[DirectoryPath]
799
1040
  map_config: Optional[MapConfig]
800
1041
  par_file: Optional[FilePath]
1042
+ dataset_id: Optional[int]
801
1043
  par_dims: Optional[list[dict[str,str]]]
802
1044
  other_dims: Optional[list[dict[str,str]]]
803
1045
  detectors: conlist(min_items=1, item_type=MCAElementStrainAnalysisConfig)
804
1046
  materials: list[MaterialConfig]
805
- flux_file: FilePath
1047
+ flux_file: Optional[FilePath]
1048
+ sum_axes: Optional[list[str]]
1049
+ oversampling: Optional[dict] = {'num': 10}
806
1050
 
807
1051
  _parfile: Optional[ParFile]
808
1052
 
@@ -820,18 +1064,25 @@ class StrainAnalysisConfig(BaseModel):
820
1064
  inputdir = values.get('inputdir')
821
1065
  flux_file = values.get('flux_file')
822
1066
  par_file = values.get('par_file')
823
- if inputdir is not None and not os.path.isabs(flux_file):
1067
+ if (inputdir is not None and flux_file is not None
1068
+ and not os.path.isabs(flux_file)):
824
1069
  values['flux_file'] = os.path.join(inputdir, flux_file)
825
1070
  if par_file is not None:
826
1071
  if inputdir is not None and not os.path.isabs(par_file):
827
1072
  values['par_file'] = os.path.join(inputdir, par_file)
828
- if 'par_dims' not in values:
1073
+ if 'dataset_id' in values:
1074
+ from CHAP.edd import EddMapReader
1075
+ values['_parfile'] = ParFile(values['par_file'])
1076
+ values['map_config'] = EddMapReader().read(
1077
+ values['par_file'], values['dataset_id'])
1078
+ elif 'par_dims' in values:
1079
+ values['_parfile'] = ParFile(values['par_file'])
1080
+ values['map_config'] = values['_parfile'].get_map(
1081
+ 'EDD', 'id1a3', values['par_dims'],
1082
+ other_dims=values.get('other_dims', []))
1083
+ else:
829
1084
  raise ValueError(
830
- 'par_dims is required when using par_file')
831
- values['_parfile'] = ParFile(values['par_file'])
832
- values['map_config'] = values['_parfile'].get_map(
833
- 'EDD', 'id1a3', values['par_dims'],
834
- other_dims=values.get('other_dims', []))
1085
+ 'dataset_id or par_dims is required when using par_file')
835
1086
  map_config = values.get('map_config')
836
1087
  if isinstance(map_config, dict):
837
1088
  for i, scans in enumerate(map_config.get('spec_scans')):
@@ -871,6 +1122,68 @@ class StrainAnalysisConfig(BaseModel):
871
1122
  + f'{detector.tth_file}') from e
872
1123
  return detector
873
1124
 
1125
+ @validator('sum_axes', always=True)
1126
+ def validate_sum_axes(cls, value, values):
1127
+ """Validate the sum_axes field.
1128
+
1129
+ :param value: Field value to validate (`sum_axes`).
1130
+ :type value: bool
1131
+ :param values: Dictionary of validated class field values.
1132
+ :type values: dict
1133
+ :return: The validated value for sum_axes.
1134
+ :rtype: bool
1135
+ """
1136
+ if value is None:
1137
+ map_config = values.get('map_config')
1138
+ if map_config is not None:
1139
+ if map_config.attrs['scan_type'] < 3:
1140
+ value = value
1141
+ else:
1142
+ value = map_config.attrs.get('fly_axis_labels', [])
1143
+ return value
1144
+
1145
+ @validator('oversampling', always=True)
1146
+ def validate_oversampling(cls, value, values):
1147
+ """Validate the oversampling field.
1148
+
1149
+ :param value: Field value to validate (`oversampling`).
1150
+ :type value: bool
1151
+ :param values: Dictionary of validated class field values.
1152
+ :type values: dict
1153
+ :return: The validated value for oversampling.
1154
+ :rtype: bool
1155
+ """
1156
+ # Local modules
1157
+ from CHAP.utils.general import is_int
1158
+
1159
+ map_config = values.get('map_config')
1160
+ if map_config is None or map_config.attrs['scan_type'] < 3:
1161
+ return None
1162
+ if value is None:
1163
+ return {'num': 10}
1164
+ if 'start' in value and not is_int(value['start'], ge=0):
1165
+ raise ValueError('Invalid "start" parameter in "oversampling" '
1166
+ f'field ({value["start"]})')
1167
+ if 'end' in value and not is_int(value['end'], gt=0):
1168
+ raise ValueError('Invalid "end" parameter in "oversampling" '
1169
+ f'field ({value["end"]})')
1170
+ if 'width' in value and not is_int(value['width'], gt=0):
1171
+ raise ValueError('Invalid "width" parameter in "oversampling" '
1172
+ f'field ({value["width"]})')
1173
+ if 'stride' in value and not is_int(value['stride'], gt=0):
1174
+ raise ValueError('Invalid "stride" parameter in "oversampling" '
1175
+ f'field ({value["stride"]})')
1176
+ if 'num' in value and not is_int(value['num'], gt=0):
1177
+ raise ValueError('Invalid "num" parameter in "oversampling" '
1178
+ f'field ({value["num"]})')
1179
+ if 'mode' in value and 'mode' not in ('valid', 'full'):
1180
+ raise ValueError('Invalid "mode" parameter in "oversampling" '
1181
+ f'field ({value["mode"]})')
1182
+ if not ('width' in value or 'stride' in value or 'num' in value):
1183
+ raise ValueError('Invalid input parameters, specify at least one '
1184
+ 'of "width", "stride" or "num"')
1185
+ return value
1186
+
874
1187
  def mca_data(self, detector=None, map_index=None):
875
1188
  """Get MCA data for a single or multiple detector elements.
876
1189
 
@@ -888,7 +1201,8 @@ class StrainAnalysisConfig(BaseModel):
888
1201
  if detector is None:
889
1202
  mca_data = []
890
1203
  for detector_config in self.detectors:
891
- mca_data.append(self.mca_data(detector_config, map_index))
1204
+ mca_data.append(
1205
+ self.mca_data(detector_config, map_index))
892
1206
  return np.asarray(mca_data)
893
1207
  else:
894
1208
  if isinstance(detector, int):
@@ -900,8 +1214,65 @@ class StrainAnalysisConfig(BaseModel):
900
1214
  if map_index is None:
901
1215
  mca_data = []
902
1216
  for map_index in np.ndindex(self.map_config.shape):
903
- mca_data.append(self.mca_data(detector_config, map_index))
904
- return np.asarray(mca_data)
1217
+ mca_data.append(self.mca_data(
1218
+ detector_config, map_index))
1219
+ mca_data = np.reshape(
1220
+ mca_data, (*self.map_config.shape, len(mca_data[0])))
1221
+ if self.sum_axes:
1222
+ scan_type = self.map_config.attrs['scan_type']
1223
+ if self.map_config.map_type == 'structured':
1224
+ sum_axis_indices = []
1225
+ for axis in self.sum_axes:
1226
+ sum_axis_indices.append(
1227
+ self.map_config.dims.index(axis))
1228
+ mca_data = np.sum(
1229
+ mca_data, tuple(sorted(sum_axis_indices)))
1230
+ if scan_type == 4:
1231
+ raise NotImplementedError(
1232
+ 'Oversampling scan types not tested yet.')
1233
+ from CHAP.edd.utils import get_rolling_sum_spectra
1234
+ mca_data = get_rolling_sum_spectra(
1235
+ mca_data,
1236
+ self.map_config.dims.index(fly_axis_labels[0]),
1237
+ self.oversampling.get('start', 0),
1238
+ self.oversampling.get('end'),
1239
+ self.oversampling.get('width'),
1240
+ self.oversampling.get('stride'),
1241
+ self.oversampling.get('num'),
1242
+ self.oversampling.get('mode', 'valid'))
1243
+ elif scan_type not in (0, 1, 2, 3, 5):
1244
+ raise ValueError(
1245
+ f'scan_type {scan_type} not implemented yet '
1246
+ 'in StrainAnalysisConfig.mca_data()')
1247
+ else:
1248
+ # Perform summing along axes of an unstructured map
1249
+ map_dims = self.map_config.dims
1250
+ map_coords = self.map_config.coords
1251
+ map_length = len(map_coords[map_dims[0]])
1252
+ for sum_axis in self.sum_axes:
1253
+ axis_index = map_dims.index(sum_axis)
1254
+ sum_map_indices = {}
1255
+ for i in range(map_length):
1256
+ coord = tuple(
1257
+ v[i] for k, v in map_coords.items() \
1258
+ if k != sum_axis)
1259
+ if coord not in sum_map_indices:
1260
+ sum_map_indices[coord] = []
1261
+ sum_map_indices[coord].append(i)
1262
+ map_dims = (*map_dims[:axis_index],
1263
+ *map_dims[axis_index + 1:])
1264
+ sum_indices_list = sum_map_indices.values()
1265
+ map_coords = {
1266
+ dim: [map_coords[dim][sum_indices[0]] \
1267
+ for sum_indices in sum_indices_list] \
1268
+ for dim in map_dims}
1269
+ map_length = len(map_coords[map_dims[0]])
1270
+ mca_data = np.asarray(
1271
+ [np.sum(mca_data[sum_indices], axis=0) \
1272
+ for sum_indices in sum_indices_list])
1273
+ return mca_data
1274
+ else:
1275
+ return np.asarray(mca_data)
905
1276
  else:
906
1277
  return self.map_config.get_detector_data(
907
1278
  detector_config.detector_name, map_index)