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

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

Potentially problematic release.


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

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