ChessAnalysisPipeline 0.0.11__py3-none-any.whl → 0.0.13__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
@@ -1,22 +1,30 @@
1
- # third party modules
2
- import numpy as np
1
+ # System modules
3
2
  import os
4
3
  from pathlib import PosixPath
5
- from pydantic import (BaseModel,
6
- confloat,
7
- conint,
8
- conlist,
9
- constr,
10
- DirectoryPath,
11
- FilePath,
12
- root_validator,
13
- validator)
4
+ from typing import (
5
+ Literal,
6
+ Optional,
7
+ Union,
8
+ )
9
+
10
+ # Third party modules
11
+ import numpy as np
12
+ from hexrd.material import Material
13
+ from pydantic import (
14
+ BaseModel,
15
+ confloat,
16
+ conint,
17
+ conlist,
18
+ constr,
19
+ DirectoryPath,
20
+ FilePath,
21
+ root_validator,
22
+ validator,
23
+ )
14
24
  from scipy.interpolate import interp1d
15
- from typing import Literal, Optional, Union
16
25
 
17
- # local modules
26
+ # Local modules
18
27
  from CHAP.common.models.map import MapConfig
19
- from CHAP.utils.material import Material
20
28
  from CHAP.utils.parfile import ParFile
21
29
  from CHAP.utils.scanparsers import SMBMCAScanParser as ScanParser
22
30
 
@@ -25,50 +33,64 @@ class MCAElementConfig(BaseModel):
25
33
  """Class representing metadata required to configure a single MCA
26
34
  detector element.
27
35
 
28
- :ivar detector_name: name of the MCA used with the scan
36
+ :ivar detector_name: Name of the MCA detector element in the scan,
37
+ defaults to `'mca1'`.
29
38
  :type detector_name: str
30
- :ivar num_bins: number of channels on the MCA
31
- :type num_bins: int
32
- :ivar include_bin_ranges: list of MCA channel index ranges whose
33
- data should be included after applying a mask
34
- :type include_bin_ranges: list[list[int]]
39
+ :ivar num_bins: Number of MCA channels.
40
+ :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
35
45
  """
36
46
  detector_name: constr(strip_whitespace=True, min_length=1) = 'mca1'
37
47
  num_bins: Optional[conint(gt=0)]
38
- include_bin_ranges: Optional[
39
- conlist(
40
- min_items=1,
41
- item_type=conlist(
42
- item_type=conint(ge=0),
43
- min_items=2,
44
- max_items=2))] = None
48
+ include_bin_ranges: conlist(
49
+ min_items=1,
50
+ item_type=conlist(
51
+ item_type=conint(ge=0),
52
+ min_items=2,
53
+ max_items=2)) = []
45
54
 
46
55
  @validator('include_bin_ranges', each_item=True)
47
56
  def validate_include_bin_range(cls, value, values):
48
- """Ensure no bin ranges are outside the boundary of the detector"""
57
+ """Ensure that no bin ranges are outside the boundary of the
58
+ detector.
59
+
60
+ :param value: Field value to validate (`include_bin_ranges`).
61
+ :type values: dict
62
+ :param values: Dictionary of previously validated field values.
63
+ :type values: dict
64
+ :return: The validated value of `include_bin_ranges`.
65
+ :rtype: dict
66
+ """
49
67
  num_bins = values.get('num_bins')
50
68
  if num_bins is not None:
51
- value[1] = min(value[1], num_bins)
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})')
52
73
  return value
53
74
 
54
75
  def mca_mask(self):
55
76
  """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.
56
78
 
57
- :return: boolean mask array
79
+ :return: Boolean mask array.
58
80
  :rtype: numpy.ndarray
59
81
  """
60
82
  mask = np.asarray([False] * self.num_bins)
61
83
  bin_indices = np.arange(self.num_bins)
62
84
  for min_, max_ in self.include_bin_ranges:
63
- _mask = np.logical_and(bin_indices > min_, bin_indices < max_)
64
- mask = np.logical_or(mask, _mask)
85
+ mask = np.logical_or(
86
+ mask, np.logical_and(bin_indices >= min_, bin_indices <= max_))
65
87
  return mask
66
88
 
67
89
  def dict(self, *args, **kwargs):
68
90
  """Return a representation of this configuration in a
69
91
  dictionary that is suitable for dumping to a YAML file.
70
92
 
71
- :return: dictionary representation of the configuration.
93
+ :return: Dictionary representation of the configuration.
72
94
  :rtype: dict
73
95
  """
74
96
  d = super().dict(*args, **kwargs)
@@ -82,22 +104,26 @@ class MCAScanDataConfig(BaseModel):
82
104
  """Class representing metadata required to locate raw MCA data for
83
105
  a single scan and construct a mask for it.
84
106
 
85
- :ivar spec_file: Path to the SPEC file containing the scan
86
- :ivar scan_number: Number of the scan in `spec_file`
87
- :ivar detectors: list of detector element metadata configurations
88
-
89
- :ivar detector_name: name of the MCA used with the scan
90
- :ivar num_bins: number of channels on the MCA
91
-
92
- :ivar include_bin_ranges: list of MCA channel index ranges whose
93
- data should be included after applying a mask
107
+ :ivar inputdir: Input directory, used only if any file in the
108
+ configuration is not an absolute path.
109
+ :type inputdir: str, optional
110
+ :ivar spec_file: Path to the SPEC file containing the scan.
111
+ :type spec_file: str, optional
112
+ :ivar scan_number: Number of the scan in `spec_file`.
113
+ :type scan_number: int, optional
114
+ :ivar par_file: Path to the par file associated with the scan.
115
+ :type par_file: str, optional
116
+ :ivar scan_column: Required column name in `par_file`.
117
+ :type scan_column: str, optional
118
+ :ivar detectors: List of MCA detector element metadata
119
+ configurations.
120
+ :type detectors: list[MCAElementConfig]
94
121
  """
95
122
  inputdir: Optional[DirectoryPath]
96
123
  spec_file: Optional[FilePath]
97
124
  scan_number: Optional[conint(gt=0)]
98
125
  par_file: Optional[FilePath]
99
- scan_column: Optional[Union[conint(ge=0), str]]
100
-
126
+ scan_column: Optional[str]
101
127
  detectors: conlist(min_items=1, item_type=MCAElementConfig)
102
128
 
103
129
  _parfile: Optional[ParFile]
@@ -108,35 +134,30 @@ class MCAScanDataConfig(BaseModel):
108
134
 
109
135
  @root_validator(pre=True)
110
136
  def validate_scan(cls, values):
111
- """Finalize file paths for spec_file/par_file and flux_file.
137
+ """Finalize file paths for spec_file and par_file.
112
138
 
113
- :param values: dictionary of field values to validate
139
+ :param values: Dictionary of class field values.
114
140
  :type values: dict
115
- :return: the validated form of `values`
141
+ :raises ValueError: Invalid SPEC or par file.
142
+ :return: The validated list of `values`.
116
143
  :rtype: dict
117
144
  """
118
145
  inputdir = values.get('inputdir')
119
146
  spec_file = values.get('spec_file')
120
147
  par_file = values.get('par_file')
121
- flux_file = values.get('flux_file')
122
- if flux_file:
123
- if not os.path.isabs(flux_file):
124
- values['flux_file'] = os.path.join(inputdir, flux_file)
125
- if spec_file and par_file:
148
+ if spec_file is not None and par_file is not None:
126
149
  raise ValueError('Use either spec_file or par_file, not both')
127
- elif spec_file:
128
- if inputdir is not None:
129
- if not os.path.isabs(spec_file):
130
- values['spec_file'] = os.path.join(inputdir, spec_file)
131
- elif par_file:
132
- if inputdir is not None:
133
- if not os.path.isabs(par_file):
134
- values['par_file'] = os.path.join(inputdir, par_file)
150
+ elif spec_file is not None:
151
+ if inputdir is not None and not os.path.isabs(spec_file):
152
+ values['spec_file'] = os.path.join(inputdir, spec_file)
153
+ elif par_file is not None:
154
+ if inputdir is not None and not os.path.isabs(par_file):
155
+ values['par_file'] = os.path.join(inputdir, par_file)
135
156
  if 'scan_column' not in values:
136
157
  raise ValueError(
137
- 'When par_file is used, scan_column must be used, too')
158
+ 'scan_column is required when par_file is used')
138
159
  if isinstance(values['scan_column'], str):
139
- parfile = ParFile(values.get('par_file'))
160
+ parfile = ParFile(par_file)
140
161
  if values['scan_column'] not in parfile.column_names:
141
162
  raise ValueError(
142
163
  f'No column named {values["scan_column"]} in '
@@ -149,31 +170,68 @@ class MCAScanDataConfig(BaseModel):
149
170
 
150
171
  @root_validator
151
172
  def validate_detectors(cls, values):
152
- """Fill in values for _scanparser / _parfile (if
153
- applicable). Fill in values for each detector's num_bins
154
- field, if needed.
173
+ """Fill in values for _scanparser / _parfile (if applicable).
174
+ Fill in each detector's num_bins field, if needed.
175
+ Check each detector's include_bin_ranges field against the
176
+ flux file, if available.
177
+
178
+ :param values: Dictionary of previously validated field values.
179
+ :type values: dict
180
+ :raises ValueError: Unable to obtain a value for num_bins.
181
+ :return: The validated list of `values`.
182
+ :rtype: dict
155
183
  """
156
- if values.get('spec_file', False):
157
- values['_scanparser'] = ScanParser(values.get('spec_file'),
158
- values.get('scan_number'))
184
+ spec_file = values.get('spec_file')
185
+ par_file = values.get('par_file')
186
+ detectors = values.get('detectors')
187
+ flux_file = values.get('flux_file')
188
+ if spec_file is not None:
189
+ values['_scanparser'] = ScanParser(
190
+ spec_file, values.get('scan_number'))
159
191
  values['_parfile'] = None
160
- elif values.get('par_file', False):
161
- values['_parfile'] = ParFile(values.get('par_file'))
192
+ elif par_file is not None:
193
+ values['_parfile'] = ParFile(par_file)
162
194
  values['_scanparser'] = ScanParser(
163
195
  values['_parfile'].spec_file,
164
196
  values['_parfile'].good_scan_numbers()[0])
165
- for detector in values.get('detectors'):
197
+ for detector in detectors:
166
198
  if detector.num_bins is None:
167
199
  try:
168
200
  detector.num_bins = values['_scanparser']\
169
201
  .get_detector_num_bins(detector.detector_name)
170
- except Exception as exc:
171
- raise ValueError('No value found for num_bins') from exc
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
172
229
 
173
230
  return values
174
231
 
175
232
  @property
176
233
  def scanparser(self):
234
+ """Return the scanparser."""
177
235
  try:
178
236
  scanparser = self._scanparser
179
237
  except:
@@ -184,15 +242,18 @@ class MCAScanDataConfig(BaseModel):
184
242
  def mca_data(self, detector_config, scan_step_index=None):
185
243
  """Get the array of MCA data collected by the scan.
186
244
 
187
- :param detector_config: detector for which data will be returned
245
+ :param detector_config: Detector for which data is returned.
188
246
  :type detector_config: MCAElementConfig
189
- :return: MCA data
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.
190
252
  :rtype: np.ndarray
191
253
  """
192
254
  detector_name = detector_config.detector_name
193
255
  if self._parfile is not None:
194
256
  if scan_step_index is None:
195
- import numpy as np
196
257
  data = np.asarray(
197
258
  [ScanParser(self._parfile.spec_file, scan_number)\
198
259
  .get_all_detector_data(detector_name)[0] \
@@ -215,7 +276,7 @@ class MCAScanDataConfig(BaseModel):
215
276
  """Return a representation of this configuration in a
216
277
  dictionary that is suitable for dumping to a YAML file.
217
278
 
218
- :return: dictionary representation of the configuration.
279
+ :return: Dictionary representation of the configuration.
219
280
  :rtype: dict
220
281
  """
221
282
  d = super().dict(*args, **kwargs)
@@ -235,14 +296,14 @@ class MCAScanDataConfig(BaseModel):
235
296
 
236
297
 
237
298
  class MaterialConfig(BaseModel):
238
- """Model for parameters to characterize a sample material
239
-
240
- :ivar hexrd_h5_material_file: path to a HEXRD materials.h5 file containing
241
- an entry for the material properties.
242
- :ivar hexrd_h5_material_name: Name of the material entry in
243
- `hexrd_h5_material_file`.
244
- :ivar lattice_parameter_angstrom: lattice spacing in angstrom to use for
245
- a cubic crystal.
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
246
307
  """
247
308
  material_name: Optional[constr(strip_whitespace=True, min_length=1)]
248
309
  lattice_parameters: Optional[Union[
@@ -257,32 +318,43 @@ class MaterialConfig(BaseModel):
257
318
 
258
319
  @root_validator
259
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
260
329
  from CHAP.edd.utils import make_material
330
+
261
331
  values['_material'] = make_material(values.get('material_name'),
262
332
  values.get('sgnum'),
263
333
  values.get('lattice_parameters'))
264
334
  return values
265
335
 
266
- def unique_ds(self, tth_tol=0.15, tth_max=90.0):
267
- """Get a list of unique HKLs and their lattice spacings
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.
268
338
 
269
- :param tth_tol: minimum resolvable difference in 2&theta
339
+ :param tth_tol: Minimum resolvable difference in 2&theta
270
340
  between two unique HKL peaks, defaults to `0.15`.
271
341
  :type tth_tol: float, optional
272
- :param tth_max: detector rotation about hutch x axis, defaults
273
- to `90.0`.
342
+ :param tth_max: Detector rotation about hutch x axis,
343
+ defaults to `90.0`.
274
344
  :type tth_max: float, optional
275
- :return: unique HKLs and their lattice spacings in angstroms
345
+ :return: Unique HKLs and their lattice spacings in angstroms.
276
346
  :rtype: np.ndarray, np.ndarray
277
347
  """
348
+ # Local modules
278
349
  from CHAP.edd.utils import get_unique_hkls_ds
350
+
279
351
  return get_unique_hkls_ds([self._material])
280
352
 
281
353
  def dict(self, *args, **kwargs):
282
354
  """Return a representation of this configuration in a
283
355
  dictionary that is suitable for dumping to a YAML file.
284
356
 
285
- :return: dictionary representation of the configuration.
357
+ :return: Dictionary representation of the configuration.
286
358
  :rtype: dict
287
359
  """
288
360
  d = super().dict(*args, **kwargs)
@@ -295,79 +367,87 @@ class MaterialConfig(BaseModel):
295
367
 
296
368
 
297
369
  class MCAElementCalibrationConfig(MCAElementConfig):
298
- """Class representing metadata & parameters required for
299
- calibrating a single MCA detector element.
300
-
301
- :ivar max_energy_kev: maximum channel energy of the MCA in keV
302
- :ivar tth_max: detector rotation about hutch x axis, defaults to `90`.
303
- :ivar hkl_tth_tol: minimum resolvable difference in 2&theta between two
304
- unique HKL peaks, defaults to `0.15`.
305
- :ivar fit_hkls: list of unique HKL indices to fit peaks for in the
306
- calibration routine
307
- :ivar tth_initial_guess: initial guess for 2&theta
308
- :ivar slope_initial_guess: initial guess for detector channel energy
309
- correction linear slope, defaults to `1.0`.
310
- :ivar intercept_initial_guess: initial guess for detector channel energy
311
- correction y-intercept, defaults to `0.0`.
312
- :ivar tth_calibrated: calibrated value for 2&theta, defaults to None
313
- :ivar slope_calibrated: calibrated value for detector channel energy
314
- correction linear slope, defaults to `None`
315
- :ivar intercept_calibrated: calibrated value for detector channel energy
316
- correction y-intercept, defaluts to None
370
+ """Class representing metadata required to calibrate a single MCA
371
+ detector element.
372
+
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
317
401
  """
318
402
  max_energy_kev: confloat(gt=0)
319
403
  tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
320
404
  hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
321
- fit_hkls: Optional[conlist(item_type=conint(ge=0), min_items=1)] = None
322
- tth_initial_guess: confloat(gt=0, le=tth_max, allow_inf_nan=False)
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
323
407
  slope_initial_guess: float = 1.0
324
408
  intercept_initial_guess: float = 0.0
325
409
  tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
326
410
  slope_calibrated: Optional[confloat(allow_inf_nan=False)]
327
411
  intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
328
412
 
329
- def fit_ds(self, materials):
330
- """Get a list of HKLs and their lattice spacings that will be
331
- fit in the calibration routine
332
-
333
- :return: HKLs to fit and their lattice spacings in angstroms
334
- :rtype: np.ndarray, np.ndarray
335
- """
336
- if not isinstance(materials, list):
337
- materials = [materials]
338
- from CHAP.edd.utils import get_unique_hkls_ds
339
- unique_hkls, unique_ds = get_unique_hkls_ds(materials)
340
-
341
- fit_hkls = np.array([unique_hkls[i] for i in self.fit_hkls])
342
- fit_ds = np.array([unique_ds[i] for i in self.fit_hkls])
343
-
344
- return fit_hkls, fit_ds
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
345
418
 
419
+ hkl_indices = string_to_list(hkl_indices)
420
+ return sorted(hkl_indices)
346
421
 
347
422
  class MCAElementDiffractionVolumeLengthConfig(MCAElementConfig):
348
- """Class representing input parameters required to perform a
349
- diffraction volume length measurement for a single MCA detector
350
- element.
423
+ """Class representing metadata required to perform a diffraction
424
+ volume length measurement for a single MCA detector element.
351
425
 
352
- :ivar measurement_mode: placeholder for recording whether the
426
+ :ivar measurement_mode: Placeholder for recording whether the
353
427
  measured DVL value was obtained through the automated
354
- calculation or a manual selection.
355
- :type measurement_mode: Literal['manual', 'auto']
356
- :ivar sigma_to_dvl_factor: to measure the DVL, a gaussian is fit
357
- to a reduced from of the raster scan MCA data. This variable
358
- is a scalar that converts the standard deviation of the
359
- gaussian fit to the measured DVL.
360
- :type sigma_to_dvl_factor: Optional[Literal[1.75, 1., 2.]]
361
- :ivar dvl_measured: placeholder for the measured diffraction
362
- volume length before writing data to file.
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
363
438
  """
364
439
  measurement_mode: Optional[Literal['manual', 'auto']] = 'auto'
365
- sigma_to_dvl_factor: Optional[Literal[3.5, 2., 4.]] = 3.5
440
+ sigma_to_dvl_factor: Optional[Literal[3.5, 2.0, 4.0]] = 3.5
366
441
  dvl_measured: Optional[confloat(gt=0)] = None
367
442
 
368
443
  def dict(self, *args, **kwargs):
369
- """If measurement_mode is 'manual', exclude
370
- sigma_to_dvl_factor from the dict representation.
444
+ """Return a representation of this configuration in a
445
+ 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
+
449
+ :return: Dictionary representation of the configuration.
450
+ :rtype: dict
371
451
  """
372
452
  d = super().dict(*args, **kwargs)
373
453
  if self.measurement_mode == 'manual':
@@ -380,7 +460,7 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
380
460
  volume length calculation for an EDD setup using a steel-foil
381
461
  raster scan.
382
462
 
383
- :ivar detectors: list of individual detector elmeent DVL
463
+ :ivar detectors: Individual detector element DVL
384
464
  measurement configurations
385
465
  :type detectors: list[MCAElementDiffractionVolumeLengthConfig]
386
466
  """
@@ -392,7 +472,7 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
392
472
  """Return the list of values visited by the scanning motor
393
473
  over the course of the raster scan.
394
474
 
395
- :return: list of scanned motor values
475
+ :return: List of scanned motor values
396
476
  :rtype: np.ndarray
397
477
  """
398
478
  if self._parfile is not None:
@@ -404,69 +484,103 @@ class DiffractionVolumeLengthConfig(MCAScanDataConfig):
404
484
  @property
405
485
  def scanned_dim_lbl(self):
406
486
  """Return a label for plot axes corresponding to the scanned
407
- dimension
487
+ dimension.
408
488
 
489
+ :return: Name of scanned motor.
409
490
  :rtype: str
410
491
  """
411
492
  if self._parfile is not None:
412
493
  return self.scan_column
413
494
  return self.scanparser.spec_scan_motor_mnes[0]
414
495
 
415
- class CeriaConfig(MaterialConfig):
416
- """Model for a Material representing CeO2 used in calibrations.
417
496
 
418
- :ivar hexrd_h5_material_name: Name of the material entry in
419
- `hexrd_h5_material_file`, defaults to `'CeO2'`.
420
- :ivar lattice_parameter_angstrom: lattice spacing in angstrom to use for
421
- the cubic CeO2 crystal, defaults to `5.41153`.
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
422
509
  """
510
+ #RV Name suggests it's always Ceria, why have material_name?
423
511
  material_name: constr(strip_whitespace=True, min_length=1) = 'CeO2'
424
- sgnum: Optional[conint(ge=0)] = 225
425
512
  lattice_parameters: confloat(gt=0) = 5.41153
513
+ sgnum: Optional[conint(ge=0)] = 225
426
514
 
427
515
 
428
516
  class MCACeriaCalibrationConfig(MCAScanDataConfig):
429
517
  """
430
- Class representing metadata required to perform a Ceria calibration for an
431
- MCA detector.
432
-
433
- :ivar scan_step_index: Index of the scan step to use for calibration,
434
- optional. If not specified, the calibration routine will be performed
435
- on the average of all MCA spectra for the scan.
436
-
437
- :ivar flux_file: csv file containing station beam energy in eV (column 0)
438
- and flux (column 1)
439
-
440
- :ivar material: material configuration for Ceria
518
+ Class representing metadata required to perform a Ceria calibration
519
+ for an MCA detector.
520
+
521
+ :ivar scan_step_index: Optional scan step index to use for the
522
+ calibration. If not specified, the calibration will be
523
+ 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.
441
529
  :type material: CeriaConfig
442
-
443
- :ivar detectors: list of individual detector element calibration
444
- configurations
530
+ :ivar detectors: List of individual MCA detector element
531
+ calibration configurations.
445
532
  :type detectors: list[MCAElementCalibrationConfig]
446
-
447
- :ivar max_iter: maximum number of iterations of the calibration routine,
448
- defaults to `10`.
449
- :ivar tune_tth_tol: stop iteratively tuning 2&theta when an iteration
450
- produces a change in the tuned value of 2&theta that is smaller than
451
- this value, defaults to `1e-8`.
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
452
541
  """
453
542
  scan_step_index: Optional[conint(ge=0)]
454
-
455
- flux_file: FilePath
456
-
457
543
  material: CeriaConfig = CeriaConfig()
458
-
459
544
  detectors: conlist(min_items=1, item_type=MCAElementCalibrationConfig)
460
-
545
+ flux_file: FilePath
461
546
  max_iter: conint(gt=0) = 10
462
547
  tune_tth_tol: confloat(ge=0) = 1e-8
463
548
 
549
+ @root_validator(pre=True)
550
+ def validate_config(cls, values):
551
+ """Ensure that a valid configuration was provided and finalize
552
+ flux_file filepath.
553
+
554
+ :param values: Dictionary of class field values.
555
+ :type values: dict
556
+ :return: The validated list of `values`.
557
+ :rtype: dict
558
+ """
559
+ inputdir = values.get('inputdir')
560
+ if inputdir is not None:
561
+ flux_file = values.get('flux_file')
562
+ if not os.path.isabs(flux_file):
563
+ values['flux_file'] = os.path.join(inputdir, flux_file)
564
+
565
+ return values
566
+
567
+ @property
568
+ def flux_file_energy_range(self):
569
+ """Get the energy range in the flux corection file.
570
+
571
+ :return: The energy range in the flux corection file.
572
+ :rtype: tuple(float, float)
573
+ """
574
+ flux = np.loadtxt(self.flux_file)
575
+ energies = flux[:,0]/1.e3
576
+ return energies.min(), energies.max()
577
+
464
578
  def mca_data(self, detector_config):
465
- """Get the 1D array of MCA data to use for calibration.
579
+ """Get the array of MCA data to use for calibration.
466
580
 
467
- :param detector_config: detector for which data will be returned
581
+ :param detector_config: Detector for which data is returned.
468
582
  :type detector_config: MCAElementConfig
469
- :return: MCA data
583
+ :return: The current detectors's MCA data.
470
584
  :rtype: np.ndarray
471
585
  """
472
586
  if self.scan_step_index is None:
@@ -482,10 +596,10 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
482
596
 
483
597
  def flux_correction_interpolation_function(self):
484
598
  """
485
- Get an interpolation function to correct MCA data for relative energy
486
- flux of the incident beam.
599
+ Get an interpolation function to correct MCA data for the
600
+ relative energy flux of the incident beam.
487
601
 
488
- :return: energy flux correction interpolation function
602
+ :return: Energy flux correction interpolation function.
489
603
  :rtype: scipy.interpolate._polyint._Interpolator1D
490
604
  """
491
605
 
@@ -497,32 +611,85 @@ class MCACeriaCalibrationConfig(MCAScanDataConfig):
497
611
 
498
612
 
499
613
  class MCAElementStrainAnalysisConfig(MCAElementConfig):
500
- """Model for parameters need to perform strain analysis fitting
501
- for one MCA element.
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
502
653
  """
654
+ max_energy_kev: Optional[confloat(gt=0)]
655
+ num_bins: Optional[conint(gt=0)]
503
656
  tth_max: confloat(gt=0, allow_inf_nan=False) = 90.0
504
657
  hkl_tth_tol: confloat(gt=0, allow_inf_nan=False) = 0.15
505
- fit_hkls: Optional[conlist(item_type=conint(ge=0), min_items=1)] = None
658
+ hkl_indices: Optional[conlist(item_type=conint(ge=0), min_items=1)] = []
506
659
  background: Optional[str]
507
660
  peak_models: Union[
508
661
  conlist(item_type=Literal['gaussian', 'lorentzian'], min_items=1),
509
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)]
510
666
 
511
- tth_file: Optional[FilePath]
512
667
  tth_calibrated: Optional[confloat(gt=0, allow_inf_nan=False)]
513
668
  slope_calibrated: Optional[confloat(allow_inf_nan=False)]
514
669
  intercept_calibrated: Optional[confloat(allow_inf_nan=False)]
515
- max_energy_kev: Optional[confloat(gt=0)]
516
- num_bins: Optional[conint(gt=0)]
670
+ tth_file: Optional[FilePath]
671
+ tth_map: Optional[np.ndarray] = None
517
672
 
518
- _tth_map: Optional[np.ndarray]
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
519
684
 
520
685
  def add_calibration(self, calibration):
521
686
  """Finalize values for some fields using a completed
522
687
  MCAElementCalibrationConfig that corresponds to the same
523
688
  detector.
524
689
 
525
- :param calibration: MCAElementCalibrationConfig
690
+ :param calibration: Existing calibration configuration to use
691
+ by MCAElementStrainAnalysisConfig.
692
+ :type calibration: MCAElementCalibrationConfig
526
693
  :return: None
527
694
  """
528
695
  add_fields = ['tth_calibrated', 'slope_calibrated',
@@ -530,71 +697,91 @@ class MCAElementStrainAnalysisConfig(MCAElementConfig):
530
697
  for field in add_fields:
531
698
  setattr(self, field, getattr(calibration, field))
532
699
 
533
- def fit_ds(self, materials):
534
- """Get a list of HKLs and their lattice spacings that will be
535
- fit in the strain analysis routine
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.
536
703
 
537
- :return: HKLs to fit and their lattice spacings in angstroms
538
- :rtype: np.ndarray, np.ndarray
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
539
709
  """
540
- if not isinstance(materials, list):
541
- materials = [materials]
542
- from CHAP.edd.utils import get_unique_hkls_ds
543
- unique_hkls, unique_ds = get_unique_hkls_ds(materials)
544
-
545
- # unique_hkls, unique_ds = material.unique_ds(
546
- # tth_tol=self.hkl_tth_tol, tth_max=self.tth_max)
547
-
548
- fit_hkls = np.array([unique_hkls[i] for i in self.fit_hkls])
549
- fit_ds = np.array([unique_ds[i] for i in self.fit_hkls])
550
-
551
- return fit_hkls, fit_ds
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)
552
713
 
553
- def tth_map(self, map_config):
554
- """Return a map of tth values to use -- may vary at each point
555
- in the map.
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.
556
717
 
557
- :param map_config: the map configuration with which the
558
- returned map of tth values will be used.
559
- :type map_config: MapConfig
560
- :return: map of thh values
561
- :rtype: np.ndarray
718
+ :return: Dictionary representation of the configuration.
719
+ :rtype: dict
562
720
  """
563
- if self._tth_map is not None:
564
- return self._tth_map
565
- return np.full(map_config.shape, self.tth_calibrated)
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
566
728
 
567
729
 
568
730
  class StrainAnalysisConfig(BaseModel):
569
- """Model for inputs to CHAP.edd.StrainAnalysisProcessor"""
731
+ """Class representing input parameters required to perform a
732
+ strain analysis.
733
+
734
+ :ivar inputdir: Input directory, used only if any file in the
735
+ configuration is not an absolute path.
736
+ :type inputdir: str, optional
737
+ :ivar map_config: The map configuration for the MCA data on which
738
+ the strain analysis is performed.
739
+ :type map_config: CHAP.common.models.map.MapConfig, optional
740
+ :ivar par_file: Path to the par file associated with the scan.
741
+ :type par_file: str, optional
742
+ :ivar par_dims: List of independent dimensions.
743
+ :type par_dims: list[dict[str,str]], optional
744
+ :ivar other_dims: List of other column names from `par_file`.
745
+ :type other_dims: list[dict[str,str]], optional
746
+ :ivar detectors: List of individual detector element strain
747
+ analysis configurations
748
+ :type detectors: list[MCAElementStrainAnalysisConfig]
749
+ :ivar material_name: Sample material configurations.
750
+ :type material_name: list[MaterialConfig]
751
+ """
570
752
  inputdir: Optional[DirectoryPath]
571
753
  map_config: Optional[MapConfig]
572
754
  par_file: Optional[FilePath]
573
755
  par_dims: Optional[list[dict[str,str]]]
574
756
  other_dims: Optional[list[dict[str,str]]]
575
- flux_file: FilePath
576
757
  detectors: conlist(min_items=1, item_type=MCAElementStrainAnalysisConfig)
577
758
  materials: list[MaterialConfig]
759
+ flux_file: FilePath
578
760
 
579
761
  _parfile: Optional[ParFile]
580
762
 
581
763
  @root_validator(pre=True)
582
- def validate_map(cls, values):
583
- """Ensure exactly one valid map configuration was provided,
584
- and finalize input filepaths
764
+ def validate_config(cls, values):
765
+ """Ensure that a valid configuration was provided and finalize
766
+ input filepaths.
767
+
768
+ :param values: Dictionary of class field values.
769
+ :type values: dict
770
+ :raises ValueError: Missing par_dims value.
771
+ :return: The validated list of `values`.
772
+ :rtype: dict
585
773
  """
586
774
  inputdir = values.get('inputdir')
587
775
  flux_file = values.get('flux_file')
588
776
  par_file = values.get('par_file')
589
- if flux_file:
590
- if not os.path.isabs(flux_file):
591
- values['flux_file'] = os.path.join(inputdir, flux_file)
592
- if par_file:
593
- if not os.path.isabs(par_file):
777
+ if inputdir is not None and not os.path.isabs(flux_file):
778
+ values['flux_file'] = os.path.join(inputdir, flux_file)
779
+ if par_file is not None:
780
+ if inputdir is not None and not os.path.isabs(par_file):
594
781
  values['par_file'] = os.path.join(inputdir, par_file)
595
782
  if 'par_dims' not in values:
596
783
  raise ValueError(
597
- 'If using par_file, must also use par_dims')
784
+ 'par_dims is required when using par_file')
598
785
  values['_parfile'] = ParFile(values['par_file'])
599
786
  values['map_config'] = values['_parfile'].get_map(
600
787
  'EDD', 'id1a3', values['par_dims'],
@@ -603,7 +790,7 @@ class StrainAnalysisConfig(BaseModel):
603
790
  if isinstance(map_config, dict):
604
791
  for i, scans in enumerate(map_config.get('spec_scans')):
605
792
  spec_file = scans.get('spec_file')
606
- if not os.path.isabs(spec_file):
793
+ if inputdir is not None and not os.path.isabs(spec_file):
607
794
  values['map_config']['spec_scans'][i]['spec_file'] = \
608
795
  os.path.join(inputdir, spec_file)
609
796
  return values
@@ -630,7 +817,7 @@ class StrainAnalysisConfig(BaseModel):
630
817
  + 'StrainAnalysisConfig that uses par_file.')
631
818
  else:
632
819
  try:
633
- detector._tth_map = ParFile(values['par_file']).map_values(
820
+ detector.tth_map = ParFile(values['par_file']).map_values(
634
821
  values['map_config'], np.loadtxt(detector.tth_file))
635
822
  except Exception as e:
636
823
  raise ValueError(
@@ -638,11 +825,46 @@ class StrainAnalysisConfig(BaseModel):
638
825
  + f'{detector.tth_file}') from e
639
826
  return detector
640
827
 
828
+ def mca_data(self, detector=None, map_index=None):
829
+ """Get MCA data for a single or multiple detector elements.
830
+
831
+ :param detector: Detector(s) for which data is returned,
832
+ defaults to `None`, which return MCA data for all
833
+ detector elements.
834
+ :type detector: Union[int, MCAElementStrainAnalysisConfig],
835
+ optional
836
+ :param map_index: Index of a single point in the map, defaults
837
+ to `None`, which returns MCA data for each point in the map.
838
+ :type map_index: tuple, optional
839
+ :return: A single MCA spectrum.
840
+ :rtype: np.ndarray
841
+ """
842
+ if detector is None:
843
+ mca_data = []
844
+ for detector_config in self.detectors:
845
+ mca_data.append(self.mca_data(detector_config, map_index))
846
+ return np.asarray(mca_data)
847
+ else:
848
+ if isinstance(detector, int):
849
+ detector_config = self.detectors[detector]
850
+ else:
851
+ if not isinstance(detector, MCAElementStrainAnalysisConfig):
852
+ raise ValueError('Invalid parameter detector ({detector})')
853
+ detector_config = detector
854
+ if map_index is None:
855
+ mca_data = []
856
+ 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)
859
+ else:
860
+ return self.map_config.get_detector_data(
861
+ detector_config.detector_name, map_index)
862
+
641
863
  def dict(self, *args, **kwargs):
642
864
  """Return a representation of this configuration in a
643
865
  dictionary that is suitable for dumping to a YAML file.
644
866
 
645
- :return: dictionary representation of the configuration.
867
+ :return: Dictionary representation of the configuration.
646
868
  :rtype: dict
647
869
  """
648
870
  d = super().dict(*args, **kwargs)
@@ -652,29 +874,3 @@ class StrainAnalysisConfig(BaseModel):
652
874
  if '_scanparser' in d:
653
875
  del d['_scanparser']
654
876
  return d
655
-
656
- def mca_data(self, detector_config, map_index):
657
- """Get MCA data for a single detector element.
658
-
659
- :param detector_config: the detector to get data for
660
- :type detector_config: MCAElementStrainAnalysisConfig
661
- :param map_index: index of a single point in the map
662
- :type map_index: tuple
663
- :return: one spectrum of MCA data
664
- :rtype: np.ndarray
665
- """
666
- map_coords = {dim: self.map_config.coords[dim][i]
667
- for dim,i in zip(self.map_config.dims, map_index)}
668
- for scans in self.map_config.spec_scans:
669
- for scan_number in scans.scan_numbers:
670
- scanparser = scans.get_scanparser(scan_number)
671
- for scan_step_index in range(scanparser.spec_scan_npts):
672
- _coords = {
673
- dim.label:dim.get_value(
674
- scans, scan_number, scan_step_index)
675
- for dim in self.map_config.independent_dimensions}
676
- if _coords == map_coords:
677
- break
678
- scanparser = scans.get_scanparser(scan_number)
679
- return scanparser.get_detector_data(detector_config.detector_name,
680
- scan_step_index)