ChessAnalysisPipeline 0.0.13__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,43 +137,391 @@ 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_bin_ranges: List of MCA channel index ranges whose
42
- data should be included after applying a mask (the bounds are
43
- inclusive), defaults to `[]`
44
- :type include_bin_ranges: list[[int, int]], optional
45
140
  """
46
141
  detector_name: constr(strip_whitespace=True, min_length=1) = 'mca1'
47
142
  num_bins: Optional[conint(gt=0)]
48
- include_bin_ranges: conlist(
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)]
194
+ include_energy_ranges: conlist(
49
195
  min_items=1,
50
196
  item_type=conlist(
51
- item_type=conint(ge=0),
197
+ item_type=confloat(ge=25),
52
198
  min_items=2,
53
- max_items=2)) = []
199
+ max_items=2)) = [[50, 150]]
200
+
201
+ _hkl_indices: list = PrivateAttr()
54
202
 
55
- @validator('include_bin_ranges', each_item=True)
56
- def validate_include_bin_range(cls, value, values):
57
- """Ensure that no bin ranges are outside the boundary of the
203
+ @validator('include_energy_ranges', each_item=True)
204
+ def validate_include_energy_range(cls, value, values):
205
+ """Ensure that no energy ranges are outside the boundary of the
58
206
  detector.
59
207
 
60
- :param value: Field value to validate (`include_bin_ranges`).
208
+ :param value: Field value to validate (`include_energy_ranges`).
61
209
  :type values: dict
62
210
  :param values: Dictionary of previously validated field values.
63
211
  :type values: dict
64
- :return: The validated value of `include_bin_ranges`.
212
+ :return: The validated value of `include_energy_ranges`.
65
213
  :rtype: dict
66
214
  """
67
- num_bins = values.get('num_bins')
68
- if num_bins is not None:
69
- value[1] = min(value[1], num_bins-1)
70
- if value[0] >= value[1]:
71
- raise ValueError('Invalid bin range in include_bin_ranges '
72
- f'({value})')
215
+ value.sort()
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
73
228
  return value
74
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
+
490
+ @property
491
+ def include_bin_ranges(self):
492
+ """Return the value of `include_energy_ranges` represented in
493
+ terms of channel indices instead of channel energies.
494
+ """
495
+ from CHAP.utils.general import (
496
+ index_nearest_down,
497
+ index_nearest_up,
498
+ )
499
+
500
+ include_bin_ranges = []
501
+ energies = self.energies
502
+ for e_min, e_max in self.include_energy_ranges:
503
+ include_bin_ranges.append(
504
+ [index_nearest_down(energies, e_min),
505
+ index_nearest_up(energies, e_max)])
506
+ return include_bin_ranges
507
+
508
+ def get_include_energy_ranges(self, include_bin_ranges):
509
+ """Given a list of channel index ranges, return the
510
+ corresponding list of channel energy ranges.
511
+
512
+ :param include_bin_ranges: A list of channel bin ranges to convert to
513
+ energy ranges.
514
+ :type include_bin_ranges: list[list[int]]
515
+ :returns: Energy ranges
516
+ :rtype: list[list[float]]
517
+ """
518
+ energies = self.energies
519
+ return [[float(energies[i]) for i in range_]
520
+ for range_ in include_bin_ranges]
521
+
75
522
  def mca_mask(self):
76
523
  """Get a boolean mask array to use on this MCA element's data.
77
- Note that the bounds of self.include_bin_ranges are inclusive.
524
+ Note that the bounds of self.include_energy_ranges are inclusive.
78
525
 
79
526
  :return: Boolean mask array.
80
527
  :rtype: numpy.ndarray
@@ -86,6 +533,38 @@ class MCAElementConfig(BaseModel):
86
533
  mask, np.logical_and(bin_indices >= min_, bin_indices <= max_))
87
534
  return mask
88
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
+
89
568
  def dict(self, *args, **kwargs):
90
569
  """Return a representation of this configuration in a
91
570
  dictionary that is suitable for dumping to a YAML file.
@@ -94,12 +573,16 @@ class MCAElementConfig(BaseModel):
94
573
  :rtype: dict
95
574
  """
96
575
  d = super().dict(*args, **kwargs)
97
- d['include_bin_ranges'] = [
98
- list(d['include_bin_ranges'][i]) \
99
- for i in range(len(d['include_bin_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()
100
581
  return d
101
582
 
102
583
 
584
+ # Processor configuration classes
585
+
103
586
  class MCAScanDataConfig(BaseModel):
104
587
  """Class representing metadata required to locate raw MCA data for
105
588
  a single scan and construct a mask for it.
@@ -172,7 +655,7 @@ class MCAScanDataConfig(BaseModel):
172
655
  def validate_detectors(cls, values):
173
656
  """Fill in values for _scanparser / _parfile (if applicable).
174
657
  Fill in each detector's num_bins field, if needed.
175
- Check each detector's include_bin_ranges field against the
658
+ Check each detector's include_energy_ranges field against the
176
659
  flux file, if available.
177
660
 
178
661
  :param values: Dictionary of previously validated field values.
@@ -196,262 +679,96 @@ class MCAScanDataConfig(BaseModel):
196
679
  values['_parfile'].good_scan_numbers()[0])
197
680
  for detector in detectors:
198
681
  if detector.num_bins is None:
199
- try:
200
- detector.num_bins = values['_scanparser']\
201
- .get_detector_num_bins(detector.detector_name)
202
- except Exception as e:
203
- raise ValueError('No value found for num_bins') from e
204
- if flux_file is not None:
205
- # System modules
206
- from copy import deepcopy
207
-
208
- # Local modules
209
- from CHAP.utils.general import (
210
- index_nearest_down,
211
- index_nearest_upp,
212
- )
213
- flux = np.loadtxt(flux_file)
214
- flux_file_energies = flux[:,0]/1.e3
215
- energy_range = (flux_file_energies.min(), flux_file_energies.max())
216
- for detector in detectors:
217
- mca_bin_energies = np.linspace(
218
- 0, detector.max_energy_kev, detector.num_bins)
219
- e_min = index_nearest_upp(mca_bin_energies, energy_range[0])
220
- e_max = index_nearest_down(mca_bin_energies, energy_range[1])
221
- for i, (min_, max_) in enumerate(
222
- deepcopy(detector.include_bin_ranges)):
223
- if min_ < e_min or max_ > e_max:
224
- bin_range = [max(min_, e_min), min(max_, e_max)]
225
- print(f'WARNING: include_bin_ranges[{i}] out of range '
226
- f'({detector.include_bin_ranges[i]}): adjusted '
227
- f'to {bin_range}')
228
- detector.include_bin_ranges[i] = bin_range
229
-
230
- return values
231
-
232
- @property
233
- def scanparser(self):
234
- """Return the scanparser."""
235
- try:
236
- scanparser = self._scanparser
237
- except:
238
- scanparser = ScanParser(self.spec_file, self.scan_number)
239
- self._scanparser = scanparser
240
- return scanparser
241
-
242
- def mca_data(self, detector_config, scan_step_index=None):
243
- """Get the array of MCA data collected by the scan.
244
-
245
- :param detector_config: Detector for which data is returned.
246
- :type detector_config: MCAElementConfig
247
- :param scan_step_index: Only return the MCA spectrum for the
248
- given scan step index, defaults to `None`, which returns
249
- all the available MCA spectra.
250
- :type scan_step_index: int, optional
251
- :return: The current detectors's MCA data.
252
- :rtype: np.ndarray
253
- """
254
- detector_name = detector_config.detector_name
255
- if self._parfile is not None:
256
- if scan_step_index is None:
257
- data = np.asarray(
258
- [ScanParser(self._parfile.spec_file, scan_number)\
259
- .get_all_detector_data(detector_name)[0] \
260
- for scan_number in self._parfile.good_scan_numbers()])
261
- else:
262
- data = ScanParser(
263
- self._parfile.spec_file,
264
- self._parfile.good_scan_numbers()[scan_step_index])\
265
- .get_all_detector_data(detector_name)
266
- else:
267
- if scan_step_index is None:
268
- data = self.scanparser.get_all_detector_data(
269
- detector_name)
270
- else:
271
- data = self.scanparser.get_detector_data(
272
- detector_config.detector_name, self.scan_step_index)
273
- return data
274
-
275
- def dict(self, *args, **kwargs):
276
- """Return a representation of this configuration in a
277
- dictionary that is suitable for dumping to a YAML file.
278
-
279
- :return: Dictionary representation of the configuration.
280
- :rtype: dict
281
- """
282
- d = super().dict(*args, **kwargs)
283
- for k,v in d.items():
284
- if isinstance(v, PosixPath):
285
- d[k] = str(v)
286
- if d.get('_parfile') is None:
287
- del d['par_file']
288
- del d['scan_column']
289
- else:
290
- del d['spec_file']
291
- del d['scan_number']
292
- for k in ('_scanparser', '_parfile', 'inputdir'):
293
- if k in d:
294
- del d[k]
295
- return d
296
-
297
-
298
- class MaterialConfig(BaseModel):
299
- """Model for parameters to characterize a sample material.
300
-
301
- :ivar material_name: Sample material name.
302
- :type material_name: str, optional
303
- :ivar lattice_parameters: Lattice spacing(s) in angstroms.
304
- :type lattice_parameters: float, list[float], optional
305
- :ivar sgnum: Space group of the material.
306
- :type sgnum: int, optional
307
- """
308
- material_name: Optional[constr(strip_whitespace=True, min_length=1)]
309
- lattice_parameters: Optional[Union[
310
- confloat(gt=0),
311
- conlist(item_type=confloat(gt=0), min_items=1, max_items=6)]]
312
- sgnum: Optional[conint(ge=0)]
313
-
314
- _material: Optional[Material]
315
-
316
- class Config:
317
- underscore_attrs_are_private = False
318
-
319
- @root_validator
320
- def validate_material(cls, values):
321
- """Create and validate the private attribute _material.
322
-
323
- :param values: Dictionary of previously validated field values.
324
- :type values: dict
325
- :return: The validated list of `values`.
326
- :rtype: dict
327
- """
328
- # Local modules
329
- from CHAP.edd.utils import make_material
330
-
331
- values['_material'] = make_material(values.get('material_name'),
332
- values.get('sgnum'),
333
- values.get('lattice_parameters'))
334
- return values
335
-
336
- def unique_hkls_ds(self, tth_tol=0.15, tth_max=90.0):
337
- """Get a list of unique HKLs and their lattice spacings.
338
-
339
- :param tth_tol: Minimum resolvable difference in 2&theta
340
- between two unique HKL peaks, defaults to `0.15`.
341
- :type tth_tol: float, optional
342
- :param tth_max: Detector rotation about hutch x axis,
343
- defaults to `90.0`.
344
- :type tth_max: float, optional
345
- :return: Unique HKLs and their lattice spacings in angstroms.
346
- :rtype: np.ndarray, np.ndarray
347
- """
348
- # Local modules
349
- from CHAP.edd.utils import get_unique_hkls_ds
350
-
351
- return get_unique_hkls_ds([self._material])
352
-
353
- def dict(self, *args, **kwargs):
354
- """Return a representation of this configuration in a
355
- dictionary that is suitable for dumping to a YAML file.
356
-
357
- :return: Dictionary representation of the configuration.
358
- :rtype: dict
359
- """
360
- d = super().dict(*args, **kwargs)
361
- for k,v in d.items():
362
- if isinstance(v, PosixPath):
363
- d[k] = str(v)
364
- if '_material' in d:
365
- del d['_material']
366
- return d
367
-
368
-
369
- class MCAElementCalibrationConfig(MCAElementConfig):
370
- """Class representing metadata required to calibrate a single MCA
371
- detector element.
682
+ try:
683
+ detector.num_bins = values['_scanparser']\
684
+ .get_detector_num_bins(detector.detector_name)
685
+ except Exception as e:
686
+ raise ValueError('No value found for num_bins') from e
687
+ if flux_file is not None:
688
+ # System modules
689
+ from copy import deepcopy
372
690
 
373
- :ivar max_energy_kev: Maximum channel energy of the MCA in keV.
374
- :type max_energy_kev: float
375
- :ivar tth_max: Detector rotation about lab frame x axis,
376
- defaults to `90`.
377
- :type tth_max: float, optional
378
- :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
379
- two unique HKL peaks, defaults to `0.15`.
380
- :type hkl_tth_tol: float, optional
381
- :ivar hkl_indices: List of unique HKL indices to fit peaks for in
382
- the calibration routine, defaults to `[]`.
383
- :type hkl_indices: list[int], optional
384
- :ivar tth_initial_guess: Initial guess for 2&theta,
385
- defaults to `5.0`.
386
- :type tth_initial_guess: float, optional
387
- :ivar slope_initial_guess: Initial guess for the detector channel
388
- energy correction linear slope, defaults to `1.0`.
389
- :type slope_initial_guess: float, optional
390
- :ivar intercept_initial_guess: Initial guess for detector channel
391
- energy correction y-intercept, defaults to `0.0`.
392
- :type intercept_initial_guess: float, optional
393
- :ivar tth_calibrated: Calibrated value for 2&theta.
394
- :type tth_calibrated: float, optional
395
- :ivar slope_calibrated: Calibrated value for detector channel
396
- energy correction linear slope.
397
- :type slope_calibrated: float, optional
398
- :ivar intercept_calibrated: Calibrated value for detector channel
399
- energy correction y-intercept.
400
- :type intercept_calibrated: float, optional
401
- """
402
- max_energy_kev: confloat(gt=0)
403
- tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
404
- hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
405
- hkl_indices: Optional[conlist(item_type=conint(ge=0), min_items=1)] = []
406
- tth_initial_guess: confloat(gt=0, le=tth_max, allow_inf_nan=False) = 5.0
407
- slope_initial_guess: float = 1.0
408
- intercept_initial_guess: float = 0.0
409
- tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
410
- slope_calibrated: Optional[confloat(allow_inf_nan=False)]
411
- intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
691
+ flux = np.loadtxt(flux_file)
692
+ flux_file_energies = flux[:,0]/1.e3
693
+ flux_e_min = flux_file_energies.min()
694
+ flux_e_max = flux_file_energies.max()
695
+ for detector in detectors:
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
412
706
 
413
- @validator('hkl_indices', pre=True)
414
- def validate_hkl_indices(cls, hkl_indices):
415
- if isinstance(hkl_indices, str):
416
- # Local modules
417
- from CHAP.utils.general import string_to_list
707
+ return values
418
708
 
419
- hkl_indices = string_to_list(hkl_indices)
420
- 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
421
718
 
422
- class MCAElementDiffractionVolumeLengthConfig(MCAElementConfig):
423
- """Class representing metadata required to perform a diffraction
424
- 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.
425
721
 
426
- :ivar measurement_mode: Placeholder for recording whether the
427
- measured DVL value was obtained through the automated
428
- calculation or a manual selection, defaults to `'auto'`.
429
- :type measurement_mode: Literal['manual', 'auto'], optional
430
- :ivar sigma_to_dvl_factor: The DVL is obtained by fitting a reduced
431
- form of the MCA detector data. `sigma_to_dvl_factor` is a
432
- scalar value that converts the standard deviation of the
433
- gaussian fit to the measured DVL, defaults to `3.5`.
434
- :type sigma_to_dvl_factor: Literal[3.5, 2.0, 4.0], optional
435
- :ivar dvl_measured: Placeholder for the measured diffraction
436
- volume length before writing the data to file.
437
- :type dvl_measured: float, optional
438
- """
439
- measurement_mode: Optional[Literal['manual', 'auto']] = 'auto'
440
- sigma_to_dvl_factor: Optional[Literal[3.5, 2.0, 4.0]] = 3.5
441
- dvl_measured: Optional[confloat(gt=0)] = 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
442
751
 
443
752
  def dict(self, *args, **kwargs):
444
753
  """Return a representation of this configuration in a
445
754
  dictionary that is suitable for dumping to a YAML file.
446
- Exclude `sigma_to_dvl_factor` from the dict representation if
447
- `measurement_mode` is `'manual'`.
448
755
 
449
756
  :return: Dictionary representation of the configuration.
450
757
  :rtype: dict
451
758
  """
452
759
  d = super().dict(*args, **kwargs)
453
- if self.measurement_mode == 'manual':
454
- del d['sigma_to_dvl_factor']
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]
455
772
  return d
456
773
 
457
774
 
@@ -460,10 +777,15 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
460
777
  volume length calculation for an EDD setup using a steel-foil
461
778
  raster scan.
462
779
 
780
+ :ivar sample_thickness: Thickness of scanned foil sample. Quantity
781
+ must be provided in the same units as the values of the
782
+ scanning motor.
783
+ :type sample_thickness: float
463
784
  :ivar detectors: Individual detector element DVL
464
785
  measurement configurations
465
786
  :type detectors: list[MCAElementDiffractionVolumeLengthConfig]
466
787
  """
788
+ sample_thickness: float
467
789
  detectors: conlist(min_items=1,
468
790
  item_type=MCAElementDiffractionVolumeLengthConfig)
469
791
 
@@ -481,70 +803,55 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
481
803
  scan_numbers=self._parfile.good_scan_numbers())
482
804
  return self.scanparser.spec_scan_motor_vals[0]
483
805
 
484
- @property
485
- def scanned_dim_lbl(self):
486
- """Return a label for plot axes corresponding to the scanned
487
- dimension.
488
-
489
- :return: Name of scanned motor.
490
- :rtype: str
491
- """
492
- if self._parfile is not None:
493
- return self.scan_column
494
- return self.scanparser.spec_scan_motor_mnes[0]
495
-
496
-
497
- class CeriaConfig(MaterialConfig):
498
- """Model for the sample material used in calibrations.
499
-
500
- :ivar material_name: Calibration material name,
501
- defaults to `'CeO2'`.
502
- :type material_name: str, optional
503
- :ivar lattice_parameters: Lattice spacing(s) for the calibration
504
- material in angstroms, defaults to `5.41153`.
505
- :type lattice_parameters: float, list[float], optional
506
- :ivar sgnum: Space group of the calibration material,
507
- defaults to `225`.
508
- :type sgnum: int, optional
509
- """
510
- #RV Name suggests it's always Ceria, why have material_name?
511
- material_name: constr(strip_whitespace=True, min_length=1) = 'CeO2'
512
- lattice_parameters: confloat(gt=0) = 5.41153
513
- sgnum: Optional[conint(ge=0)] = 225
514
-
515
806
 
516
- class MCACeriaCalibrationConfig(MCAScanDataConfig):
807
+ class MCAEnergyCalibrationConfig(MCAScanDataConfig):
517
808
  """
518
- Class representing metadata required to perform a Ceria calibration
519
- for an MCA detector.
809
+ Class representing metadata required to perform an energy
810
+ calibration for an MCA detector.
520
811
 
521
- :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
522
813
  calibration. If not specified, the calibration will be
523
814
  performed on the average of all MCA spectra for the scan.
524
- :type scan_step_index: int, optional
525
- :ivar flux_file: File name of the csv flux file containing station
526
- beam energy in eV (column 0) versus flux (column 1).
527
- :type flux_file: str
528
- :ivar material: Material configuration for Ceria.
529
- :type material: CeriaConfig
815
+ :type scan_step_indices: list[int], optional
530
816
  :ivar detectors: List of individual MCA detector element
531
817
  calibration configurations.
532
818
  :type detectors: list[MCAElementCalibrationConfig]
533
- :ivar max_iter: Maximum number of iterations of the calibration
534
- routine, defaults to `10`.
535
- :type detectors: int, optional
536
- :ivar tune_tth_tol: Cutoff error for tuning 2&theta. Stop iterating
537
- the calibration routine after an iteration produces a change in
538
- the tuned value of 2&theta that is smaller than this cutoff,
539
- defaults to `1e-8`.
540
- :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
+
541
840
  """
542
- scan_step_index: Optional[conint(ge=0)]
543
- material: CeriaConfig = CeriaConfig()
841
+ scan_step_indices: Optional[conlist(min_items=1, item_type=conint(ge=0))]
544
842
  detectors: conlist(min_items=1, item_type=MCAElementCalibrationConfig)
545
- flux_file: FilePath
546
- max_iter: conint(gt=0) = 10
547
- 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))]
548
855
 
549
856
  @root_validator(pre=True)
550
857
  def validate_config(cls, values):
@@ -559,18 +866,61 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
559
866
  inputdir = values.get('inputdir')
560
867
  if inputdir is not None:
561
868
  flux_file = values.get('flux_file')
562
- if not os.path.isabs(flux_file):
869
+ if flux_file is not None and not os.path.isabs(flux_file):
563
870
  values['flux_file'] = os.path.join(inputdir, flux_file)
564
871
 
565
872
  return values
566
873
 
567
- @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
+
568
916
  def flux_file_energy_range(self):
569
917
  """Get the energy range in the flux corection file.
570
918
 
571
919
  :return: The energy range in the flux corection file.
572
920
  :rtype: tuple(float, float)
573
921
  """
922
+ if self.flux_file is None:
923
+ return None
574
924
  flux = np.loadtxt(self.flux_file)
575
925
  energies = flux[:,0]/1.e3
576
926
  return energies.min(), energies.max()
@@ -583,15 +933,21 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
583
933
  :return: The current detectors's MCA data.
584
934
  :rtype: np.ndarray
585
935
  """
586
- if self.scan_step_index is None:
936
+ if self.scan_step_indices is None:
587
937
  data = super().mca_data(detector_config)
588
938
  if self.scanparser.spec_scan_npts > 1:
589
- data = np.average(data, axis=1)
939
+ data = np.average(data, axis=0)
590
940
  else:
591
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])
592
945
  else:
593
- data = super().mca_data(detector_config,
594
- 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)
595
951
  return data
596
952
 
597
953
  def flux_correction_interpolation_function(self):
@@ -602,7 +958,8 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
602
958
  :return: Energy flux correction interpolation function.
603
959
  :rtype: scipy.interpolate._polyint._Interpolator1D
604
960
  """
605
-
961
+ if self.flux_file is None:
962
+ return None
606
963
  flux = np.loadtxt(self.flux_file)
607
964
  energies = flux[:,0]/1.e3
608
965
  relative_intensities = flux[:,1]/np.max(flux[:,1])
@@ -610,121 +967,43 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
610
967
  return interpolation_function
611
968
 
612
969
 
613
- class MCAElementStrainAnalysisConfig(MCAElementConfig):
614
- """Class representing metadata required to perform a strain
615
- analysis fitting for a single MCA detector element.
616
-
617
- :ivar max_energy_kev: Maximum channel energy of the MCA in keV.
618
- :type max_energy_kev: float, optional
619
- :ivar num_bins: Number of MCA channels.
620
- :type num_bins: int, optional
621
- :param tth_max: Detector rotation about hutch x axis, defaults
622
- to `90.0`.
623
- :type tth_max: float, optional
624
- :ivar hkl_tth_tol: Minimum resolvable difference in 2&theta between
625
- two unique HKL peaks, defaults to `0.15`.
626
- :type hkl_tth_tol: float, optional
627
- :ivar hkl_indices: List of unique HKL indices to fit peaks for in
628
- the calibration routine, defaults to `[]`.
629
- :type hkl_indices: list[int], optional
630
- :ivar background: Background model for peak fitting.
631
- :type background: str, list[str], optional
632
- :ivar peak_models: Peak model for peak fitting,
633
- defaults to `'gaussian'`.
634
- :type peak_models: Literal['gaussian', 'lorentzian']],
635
- list[Literal['gaussian', 'lorentzian']]], optional
636
- :ivar fwhm_min: Minimum FWHM for peak fitting, defaults to `1.0`.
637
- :type fwhm_min: float, optional
638
- :ivar fwhm_max: Maximum FWHM for peak fitting, defaults to `5.0`.
639
- :type fwhm_max: float, optional
640
- :ivar rel_amplitude_cutoff: Relative peak amplitude cutoff for
641
- peak fitting (any peak with an amplitude smaller than
642
- `rel_amplitude_cutoff` times the sum of all peak amplitudes
643
- gets removed from the fit model), defaults to `None`.
644
- :type rel_amplitude_cutoff: float, optional
645
- :ivar tth_calibrated: Calibrated value for 2&theta.
646
- :type tth_calibrated: float, optional
647
- :ivar slope_calibrated: Calibrated value for detector channel.
648
- energy correction linear slope
649
- :type slope_calibrated: float, optional
650
- :ivar intercept_calibrated: Calibrated value for detector channel
651
- energy correction y-intercept.
652
- :type intercept_calibrated: float, optional
970
+ class MCATthCalibrationConfig(MCAEnergyCalibrationConfig):
653
971
  """
654
- max_energy_kev: Optional[confloat(gt=0)]
655
- num_bins: Optional[conint(gt=0)]
656
- tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
657
- hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
658
- hkl_indices: Optional[conlist(item_type=conint(ge=0), min_items=1)] = []
659
- background: Optional[str]
660
- peak_models: Union[
661
- conlist(item_type=Literal['gaussian', 'lorentzian'], min_items=1),
662
- Literal['gaussian', 'lorentzian']] = 'gaussian'
663
- fwhm_min: confloat(gt=0, allow_inf_nan=False) = 1.0
664
- fwhm_max: confloat(gt=0, allow_inf_nan=False) = 5.0
665
- rel_amplitude_cutoff: Optional[confloat(gt=0, lt=1.0, allow_inf_nan=False)]
666
-
667
- tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
668
- slope_calibrated: Optional[confloat(allow_inf_nan=False)]
669
- intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
670
- tth_file: Optional[FilePath]
671
- tth_map: Optional[np.ndarray] = None
672
-
673
- @validator('hkl_indices', pre=True)
674
- def validate_hkl_indices(cls, hkl_indices):
675
- if isinstance(hkl_indices, str):
676
- # Local modules
677
- from CHAP.utils.general import string_to_list
678
-
679
- hkl_indices = string_to_list(hkl_indices)
680
- return sorted(hkl_indices)
681
-
682
- class Config:
683
- arbitrary_types_allowed = True
684
-
685
- def add_calibration(self, calibration):
686
- """Finalize values for some fields using a completed
687
- MCAElementCalibrationConfig that corresponds to the same
688
- detector.
689
-
690
- :param calibration: Existing calibration configuration to use
691
- by MCAElementStrainAnalysisConfig.
692
- :type calibration: MCAElementCalibrationConfig
693
- :return: None
694
- """
695
- add_fields = ['tth_calibrated', 'slope_calibrated',
696
- 'intercept_calibrated', 'num_bins', 'max_energy_kev']
697
- for field in add_fields:
698
- setattr(self, field, getattr(calibration, field))
699
-
700
- def get_tth_map(self, map_config):
701
- """Return a map of 2&theta values to use -- may vary at each
702
- point in the map.
972
+ Class representing metadata required to perform a tth calibration
973
+ for an MCA detector.
703
974
 
704
- :param map_config: The map configuration with which the
705
- returned map of 2&theta values will be used.
706
- :type map_config: CHAP.common.models.map.MapConfig
707
- :return: Map of 2&theta values.
708
- :rtype: np.ndarray
709
- """
710
- if getattr(self, 'tth_map', None) is not None:
711
- return self.tth_map
712
- 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
713
995
 
714
- def dict(self, *args, **kwargs):
715
- """Return a representation of this configuration in a
716
- 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.
717
998
 
718
- :return: Dictionary representation of the configuration.
719
- :rtype: dict
999
+ :return: The energy range in the flux corection file.
1000
+ :rtype: tuple(float, float)
720
1001
  """
721
- d = super().dict(*args, **kwargs)
722
- for k,v in d.items():
723
- if isinstance(v, PosixPath):
724
- d[k] = str(v)
725
- if isinstance(v, np.ndarray):
726
- d[k] = v.tolist()
727
- 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()
728
1007
 
729
1008
 
730
1009
  class StrainAnalysisConfig(BaseModel):
@@ -739,6 +1018,8 @@ class StrainAnalysisConfig(BaseModel):
739
1018
  :type map_config: CHAP.common.models.map.MapConfig, optional
740
1019
  :ivar par_file: Path to the par file associated with the scan.
741
1020
  :type par_file: str, optional
1021
+ :ivar dataset_id: Integer ID of the SMB-style EDD dataset.
1022
+ :type dataset_id: int, optional
742
1023
  :ivar par_dims: List of independent dimensions.
743
1024
  :type par_dims: list[dict[str,str]], optional
744
1025
  :ivar other_dims: List of other column names from `par_file`.
@@ -746,17 +1027,26 @@ class StrainAnalysisConfig(BaseModel):
746
1027
  :ivar detectors: List of individual detector element strain
747
1028
  analysis configurations
748
1029
  :type detectors: list[MCAElementStrainAnalysisConfig]
749
- :ivar material_name: Sample material configurations.
750
- :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
751
1038
  """
752
1039
  inputdir: Optional[DirectoryPath]
753
1040
  map_config: Optional[MapConfig]
754
1041
  par_file: Optional[FilePath]
1042
+ dataset_id: Optional[int]
755
1043
  par_dims: Optional[list[dict[str,str]]]
756
1044
  other_dims: Optional[list[dict[str,str]]]
757
1045
  detectors: conlist(min_items=1, item_type=MCAElementStrainAnalysisConfig)
758
1046
  materials: list[MaterialConfig]
759
- flux_file: FilePath
1047
+ flux_file: Optional[FilePath]
1048
+ sum_axes: Optional[list[str]]
1049
+ oversampling: Optional[dict] = {'num': 10}
760
1050
 
761
1051
  _parfile: Optional[ParFile]
762
1052
 
@@ -774,18 +1064,25 @@ class StrainAnalysisConfig(BaseModel):
774
1064
  inputdir = values.get('inputdir')
775
1065
  flux_file = values.get('flux_file')
776
1066
  par_file = values.get('par_file')
777
- 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)):
778
1069
  values['flux_file'] = os.path.join(inputdir, flux_file)
779
1070
  if par_file is not None:
780
1071
  if inputdir is not None and not os.path.isabs(par_file):
781
1072
  values['par_file'] = os.path.join(inputdir, par_file)
782
- 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:
783
1084
  raise ValueError(
784
- 'par_dims is required when using par_file')
785
- values['_parfile'] = ParFile(values['par_file'])
786
- values['map_config'] = values['_parfile'].get_map(
787
- 'EDD', 'id1a3', values['par_dims'],
788
- other_dims=values.get('other_dims', []))
1085
+ 'dataset_id or par_dims is required when using par_file')
789
1086
  map_config = values.get('map_config')
790
1087
  if isinstance(map_config, dict):
791
1088
  for i, scans in enumerate(map_config.get('spec_scans')):
@@ -825,6 +1122,68 @@ class StrainAnalysisConfig(BaseModel):
825
1122
  + f'{detector.tth_file}') from e
826
1123
  return detector
827
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
+
828
1187
  def mca_data(self, detector=None, map_index=None):
829
1188
  """Get MCA data for a single or multiple detector elements.
830
1189
 
@@ -842,7 +1201,8 @@ class StrainAnalysisConfig(BaseModel):
842
1201
  if detector is None:
843
1202
  mca_data = []
844
1203
  for detector_config in self.detectors:
845
- mca_data.append(self.mca_data(detector_config, map_index))
1204
+ mca_data.append(
1205
+ self.mca_data(detector_config, map_index))
846
1206
  return np.asarray(mca_data)
847
1207
  else:
848
1208
  if isinstance(detector, int):
@@ -854,8 +1214,65 @@ class StrainAnalysisConfig(BaseModel):
854
1214
  if map_index is None:
855
1215
  mca_data = []
856
1216
  for map_index in np.ndindex(self.map_config.shape):
857
- mca_data.append(self.mca_data(detector_config, map_index))
858
- 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)
859
1276
  else:
860
1277
  return self.map_config.get_detector_data(
861
1278
  detector_config.detector_name, map_index)