comparison CHAP/models/integration.py @ 0:cbbe42422d56 draft

planemo upload for repository https://github.com/CHESSComputing/ChessAnalysisPipeline/tree/galaxy commit 1401a7e1ae007a6bda260d147f9b879e789b73e0-dirty
author kls286
date Tue, 28 Mar 2023 15:07:30 +0000
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:cbbe42422d56
1 import copy
2 from functools import cache, lru_cache
3 import json
4 import logging
5 import os
6 from time import time
7 from typing import Literal, Optional
8
9 # from multiprocessing.pool import ThreadPool
10 # from nexusformat.nexus import (NXdata,
11 # NXdetector,
12 # NXfield,
13 # NXprocess,
14 # NXroot)
15 import numpy as np
16 from pydantic import (BaseModel,
17 validator,
18 constr,
19 conlist,
20 conint,
21 confloat,
22 FilePath)
23 #import pyFAI, pyFAI.multi_geometry, pyFAI.units
24 from pyFAI import load as pyfai_load
25 from pyFAI.multi_geometry import MultiGeometry
26 from pyFAI.units import AZIMUTHAL_UNITS, RADIAL_UNITS
27 #from pyspec.file.tiff import TiffFile
28
29 #from .map import MapConfig, SpecScans
30
31
32 class Detector(BaseModel):
33 """
34 Detector class to represent a single detector used in the experiment.
35
36 :param prefix: Prefix of the detector in the SPEC file.
37 :type prefix: str
38 :param poni_file: Path to the poni file.
39 :type poni_file: str
40 :param mask_file: Optional path to the mask file.
41 :type mask_file: str, optional
42 """
43 prefix: constr(strip_whitespace=True, min_length=1)
44 poni_file: FilePath
45 mask_file: Optional[FilePath]
46 @validator('poni_file', allow_reuse=True)
47 def validate_poni_file(cls, poni_file):
48 """
49 Validate the poni file by checking if it's a valid PONI file.
50
51 :param poni_file: Path to the poni file.
52 :type poni_file: str
53 :raises ValueError: If poni_file is not a valid PONI file.
54 :returns: Absolute path to the poni file.
55 :rtype: str
56 """
57 poni_file = os.path.abspath(poni_file)
58 try:
59 ai = azimuthal_integrator(poni_file)
60 except:
61 raise(ValueError(f'{poni_file} is not a valid PONI file'))
62 else:
63 return(poni_file)
64 @validator('mask_file', allow_reuse=True)
65 def validate_mask_file(cls, mask_file, values):
66 """
67 Validate the mask file. If a mask file is provided, it checks if it's a valid TIFF file.
68
69 :param mask_file: Path to the mask file.
70 :type mask_file: str or None
71 :param values: A dictionary of the Detector fields.
72 :type values: dict
73 :raises ValueError: If mask_file is provided and it's not a valid TIFF file.
74 :raises ValueError: If `'poni_file'` is not provided in `values`.
75 :returns: Absolute path to the mask file or None.
76 :rtype: str or None
77 """
78 if mask_file is None:
79 return(mask_file)
80 else:
81 mask_file = os.path.abspath(mask_file)
82 poni_file = values.get('poni_file')
83 if poni_file is None:
84 raise(ValueError('Cannot validate mask file without a PONI file.'))
85 else:
86 try:
87 mask_array = get_mask_array(mask_file, poni_file)
88 except BaseException as e:
89 raise(ValueError(f'Unable to open {mask_file} as a TIFF file'))
90 else:
91 return(mask_file)
92 @property
93 def azimuthal_integrator(self):
94 return(azimuthal_integrator(self.poni_file))
95 @property
96 def mask_array(self):
97 return(get_mask_array(self.mask_file, self.poni_file))
98
99 @cache
100 def azimuthal_integrator(poni_file:str):
101 if not isinstance(poni_file, str):
102 poni_file = str(poni_file)
103 return(pyfai_load(poni_file))
104 @cache
105 def get_mask_array(mask_file:str, poni_file:str):
106 if mask_file is not None:
107 if not isinstance(mask_file, str):
108 mask_file = str(mask_file)
109
110 from pyspec.file.tiff import TiffFile
111 with TiffFile(mask_file) as tiff:
112 mask_array = tiff.asarray()
113 else:
114 mask_array = np.zeros(azimuthal_integrator(poni_file).detector.shape)
115 return(mask_array)
116
117 class IntegrationConfig(BaseModel):
118 """
119 Class representing the configuration for a raw detector data integration.
120
121 :ivar tool_type: type of integration tool; always set to "integration"
122 :type tool_type: str, optional
123 :ivar title: title of the integration
124 :type title: str
125 :ivar integration_type: type of integration, one of "azimuthal", "radial", or "cake"
126 :type integration_type: str
127 :ivar detectors: list of detectors used in the integration
128 :type detectors: List[Detector]
129 :ivar radial_units: radial units for the integration, defaults to `'q_A^-1'`
130 :type radial_units: str, optional
131 :ivar radial_min: minimum radial value for the integration range
132 :type radial_min: float, optional
133 :ivar radial_max: maximum radial value for the integration range
134 :type radial_max: float, optional
135 :ivar radial_npt: number of points in the radial range for the integration
136 :type radial_npt: int, optional
137 :ivar azimuthal_units: azimuthal units for the integration
138 :type azimuthal_units: str, optional
139 :ivar azimuthal_min: minimum azimuthal value for the integration range
140 :type azimuthal_min: float, optional
141 :ivar azimuthal_max: maximum azimuthal value for the integration range
142 :type azimuthal_max: float, optional
143 :ivar azimuthal_npt: number of points in the azimuthal range for the integration
144 :type azimuthal_npt: int, optional
145 :ivar error_model: error model for the integration, one of "poisson" or "azimuthal"
146 :type error_model: str, optional
147 """
148 tool_type: Literal['integration'] = 'integration'
149 title: constr(strip_whitespace=True, min_length=1)
150 integration_type: Literal['azimuthal', 'radial', 'cake']
151 detectors: conlist(item_type=Detector, min_items=1)
152 radial_units: str = 'q_A^-1'
153 radial_min: confloat(ge=0)
154 radial_max: confloat(gt=0)
155 radial_npt: conint(gt=0) = 1800
156 azimuthal_units: str = 'chi_deg'
157 azimuthal_min: confloat(ge=-180) = -180
158 azimuthal_max: confloat(le=360) = 180
159 azimuthal_npt: conint(gt=0) = 3600
160 error_model: Optional[Literal['poisson', 'azimuthal']]
161 sequence_index: Optional[conint(gt=0)]
162 @validator('radial_units', allow_reuse=True)
163 def validate_radial_units(cls, radial_units):
164 """
165 Validate the radial units for the integration.
166
167 :param radial_units: unvalidated radial units for the integration
168 :type radial_units: str
169 :raises ValueError: if radial units are not one of the recognized radial units
170 :return: validated radial units
171 :rtype: str
172 """
173 if radial_units in RADIAL_UNITS.keys():
174 return(radial_units)
175 else:
176 raise(ValueError(f'Invalid radial units: {radial_units}. Must be one of {", ".join(RADIAL_UNITS.keys())}'))
177 @validator('azimuthal_units', allow_reuse=True)
178 def validate_azimuthal_units(cls, azimuthal_units):
179 """
180 Validate that `azimuthal_units` is one of the keys in the
181 `pyFAI.units.AZIMUTHAL_UNITS` dictionary.
182
183 :param azimuthal_units: The string representing the unit to be validated.
184 :type azimuthal_units: str
185 :raises ValueError: If `azimuthal_units` is not one of the keys in `pyFAI.units.AZIMUTHAL_UNITS`
186 :return: The original supplied value, if is one of the keys in `pyFAI.units.AZIMUTHAL_UNITS`.
187 :rtype: str
188 """
189 if azimuthal_units in AZIMUTHAL_UNITS.keys():
190 return(azimuthal_units)
191 else:
192 raise(ValueError(f'Invalid azimuthal units: {azimuthal_units}. Must be one of {", ".join(AZIMUTHAL_UNITS.keys())}'))
193 def validate_range_max(range_name:str):
194 """Validate the maximum value of an integration range.
195
196 :param range_name: The name of the integration range (e.g. radial, azimuthal).
197 :type range_name: str
198 :return: The callable that performs the validation.
199 :rtype: callable
200 """
201 def _validate_range_max(cls, range_max, values):
202 """Check if the maximum value of the integration range is greater than its minimum value.
203
204 :param range_max: The maximum value of the integration range.
205 :type range_max: float
206 :param values: The values of the other fields being validated.
207 :type values: dict
208 :raises ValueError: If the maximum value of the integration range is not greater than its minimum value.
209 :return: The validated maximum range value
210 :rtype: float
211 """
212 range_min = values.get(f'{range_name}_min')
213 if range_min < range_max:
214 return(range_max)
215 else:
216 raise(ValueError(f'Maximum value of integration range must be greater than minimum value of integration range ({range_name}_min={range_min}).'))
217 return(_validate_range_max)
218 _validate_radial_max = validator('radial_max', allow_reuse=True)(validate_range_max('radial'))
219 _validate_azimuthal_max = validator('azimuthal_max', allow_reuse=True)(validate_range_max('azimuthal'))
220 def validate_for_map_config(self, map_config:BaseModel):
221 """
222 Validate the existence of the detector data file for all scan points in `map_config`.
223
224 :param map_config: The `MapConfig` instance to validate against.
225 :type map_config: MapConfig
226 :raises RuntimeError: If a detector data file could not be found for a scan point occurring in `map_config`.
227 :return: None
228 :rtype: None
229 """
230 for detector in self.detectors:
231 for scans in map_config.spec_scans:
232 for scan_number in scans.scan_numbers:
233 scanparser = scans.get_scanparser(scan_number)
234 for scan_step_index in range(scanparser.spec_scan_npts):
235 # Make sure the detector data file exists for all scan points
236 try:
237 detector_data_file = scanparser.get_detector_data_file(detector.prefix, scan_step_index)
238 except:
239 raise(RuntimeError(f'Could not find data file for detector prefix {detector.prefix} on scan number {scan_number} in spec file {scans.spec_file}'))
240 def get_azimuthal_adjustments(self):
241 """To enable a continuous range of integration in the azimuthal direction
242 for radial and cake integration, obtain adjusted values for this
243 `IntegrationConfig`'s `azimuthal_min` and `azimuthal_max` values, the
244 angle amount by which those values were adjusted, and the proper location
245 of the discontinuity in the azimuthal direction.
246
247 :return: Adjusted chi_min, adjusted chi_max, chi_offset, chi_discontinuity
248 :rtype: tuple[float,float,float,float]
249 """
250 return(get_azimuthal_adjustments(self.azimuthal_min, self.azimuthal_max))
251 def get_azimuthal_integrators(self):
252 """Get a list of `AzimuthalIntegrator`s that correspond to the detector
253 configurations in this instance of `IntegrationConfig`.
254
255 The returned `AzimuthalIntegrator`s are (if need be) artificially rotated
256 in the azimuthal direction to achieve a continuous range of integration
257 in the azimuthal direction.
258
259 :returns: A list of `AzimuthalIntegrator`s appropriate for use by this
260 `IntegrationConfig` tool
261 :rtype: list[pyFAI.azimuthalIntegrator.AzimuthalIntegrator]
262 """
263 chi_min, chi_max, chi_offset, chi_disc = self.get_azimuthal_adjustments()
264 return(get_azimuthal_integrators(tuple([detector.poni_file for detector in self.detectors]), chi_offset=chi_offset))
265 def get_multi_geometry_integrator(self):
266 """Get a `MultiGeometry` integrator suitable for use by this instance of
267 `IntegrationConfig`.
268
269 :return: A `MultiGeometry` integrator
270 :rtype: pyFAI.multi_geometry.MultiGeometry
271 """
272 poni_files = tuple([detector.poni_file for detector in self.detectors])
273 radial_range = (self.radial_min, self.radial_max)
274 azimuthal_range = (self.azimuthal_min, self.azimuthal_max)
275 return(get_multi_geometry_integrator(poni_files, self.radial_units, radial_range, azimuthal_range))
276 def get_azimuthally_integrated_data(self, spec_scans:BaseModel, scan_number:int, scan_step_index:int):
277 """Return azimuthally-integrated data for the scan step specified.
278
279 :param spec_scans: An instance of `SpecScans` containing the scan step requested.
280 :type spec_scans: SpecScans
281 :param scan_number: The number of the scan containing the scan step requested.
282 :type scan_number: int
283 :param scan_step_index: The index of the scan step requested.
284 :type scan_step_index: int
285 :return: A 1D array of azimuthally-integrated raw detector intensities.
286 :rtype: np.ndarray
287 """
288 detector_data = spec_scans.get_detector_data(self.detectors, scan_number, scan_step_index)
289 integrator = self.get_multi_geometry_integrator()
290 lst_mask = [detector.mask_array for detector in self.detectors]
291 result = integrator.integrate1d(detector_data, lst_mask=lst_mask, npt=self.radial_npt, error_model=self.error_model)
292 if result.sigma is None:
293 return(result.intensity)
294 else:
295 return(result.intensity, result.sigma)
296 def get_radially_integrated_data(self, spec_scans:BaseModel, scan_number:int, scan_step_index:int):
297 """Return radially-integrated data for the scan step specified.
298
299 :param spec_scans: An instance of `SpecScans` containing the scan step requested.
300 :type spec_scans: SpecScans
301 :param scan_number: The number of the scan containing the scan step requested.
302 :type scan_number: int
303 :param scan_step_index: The index of the scan step requested.
304 :type scan_step_index: int
305 :return: A 1D array of radially-integrated raw detector intensities.
306 :rtype: np.ndarray
307 """
308 # Handle idiosyncracies of azimuthal ranges in pyFAI
309 # Adjust chi ranges to get a continuous range of iintegrated data
310 chi_min, chi_max, chi_offset, chi_disc = self.get_azimuthal_adjustments()
311 # Perform radial integration on a detector-by-detector basis.
312 I_each_detector = []
313 variance_each_detector = []
314 integrators = self.get_azimuthal_integrators()
315 for i,(integrator,detector) in enumerate(zip(integrators,self.detectors)):
316 detector_data = spec_scans.get_detector_data([detector], scan_number, scan_step_index)[0]
317 result = integrator.integrate_radial(detector_data, self.azimuthal_npt,
318 unit=self.azimuthal_units, azimuth_range=(chi_min,chi_max),
319 radial_unit=self.radial_units, radial_range=(self.radial_min,self.radial_max),
320 mask=detector.mask_array) #, error_model=self.error_model)
321 I_each_detector.append(result.intensity)
322 if result.sigma is not None:
323 variance_each_detector.append(result.sigma**2)
324 # Add the individual detectors' integrated intensities together
325 I = np.nansum(I_each_detector, axis=0)
326 # Ignore data at values of chi for which there was no data
327 I = np.where(I==0, np.nan, I)
328 if len(I_each_detector) != len(variance_each_detector):
329 return(I)
330 else:
331 # Get the standard deviation of the summed detectors' intensities
332 sigma = np.sqrt(np.nansum(variance_each_detector, axis=0))
333 return(I, sigma)
334 def get_cake_integrated_data(self, spec_scans:BaseModel, scan_number:int, scan_step_index:int):
335 """Return cake-integrated data for the scan step specified.
336
337 :param spec_scans: An instance of `SpecScans` containing the scan step requested.
338 :type spec_scans: SpecScans
339 :param scan_number: The number of the scan containing the scan step requested.
340 :type scan_number: int
341 :param scan_step_index: The index of the scan step requested.
342 :type scan_step_index: int
343 :return: A 2D array of cake-integrated raw detector intensities.
344 :rtype: np.ndarray
345 """
346 detector_data = spec_scans.get_detector_data(self.detectors, scan_number, scan_step_index)
347 integrator = self.get_multi_geometry_integrator()
348 lst_mask = [detector.mask_array for detector in self.detectors]
349 result = integrator.integrate2d(detector_data, lst_mask=lst_mask,
350 npt_rad=self.radial_npt, npt_azim=self.azimuthal_npt,
351 method='bbox',
352 error_model=self.error_model)
353 if result.sigma is None:
354 return(result.intensity)
355 else:
356 return(result.intensity, result.sigma)
357 def get_integrated_data(self, spec_scans:BaseModel, scan_number:int, scan_step_index:int):
358 """Return integrated data for the scan step specified.
359
360 :param spec_scans: An instance of `SpecScans` containing the scan step requested.
361 :type spec_scans: SpecScans
362 :param scan_number: The number of the scan containing the scan step requested.
363 :type scan_number: int
364 :param scan_step_index: The index of the scan step requested.
365 :type scan_step_index: int
366 :return: An array of integrated raw detector intensities.
367 :rtype: np.ndarray
368 """
369 if self.integration_type == 'azimuthal':
370 return(self.get_azimuthally_integrated_data(spec_scans, scan_number, scan_step_index))
371 elif self.integration_type == 'radial':
372 return(self.get_radially_integrated_data(spec_scans, scan_number, scan_step_index))
373 elif self.integration_type == 'cake':
374 return(self.get_cake_integrated_data(spec_scans, scan_number, scan_step_index))
375
376 @property
377 def integrated_data_coordinates(self):
378 """
379 Return a dictionary of coordinate arrays for navigating the dimension(s)
380 of the integrated data produced by this instance of `IntegrationConfig`.
381
382 :return: A dictionary with either one or two keys: 'azimuthal' and/or
383 'radial', each of which points to a 1-D `numpy` array of coordinate
384 values.
385 :rtype: dict[str,np.ndarray]
386 """
387 if self.integration_type == 'azimuthal':
388 return(get_integrated_data_coordinates(radial_range=(self.radial_min,self.radial_max),
389 radial_npt=self.radial_npt))
390 elif self.integration_type == 'radial':
391 return(get_integrated_data_coordinates(azimuthal_range=(self.azimuthal_min,self.azimuthal_max),
392 azimuthal_npt=self.azimuthal_npt))
393 elif self.integration_type == 'cake':
394 return(get_integrated_data_coordinates(radial_range=(self.radial_min,self.radial_max),
395 radial_npt=self.radial_npt,
396 azimuthal_range=(self.azimuthal_min,self.azimuthal_max),
397 azimuthal_npt=self.azimuthal_npt))
398 @property
399 def integrated_data_dims(self):
400 """Return a tuple of the coordinate labels for the integrated data
401 produced by this instance of `IntegrationConfig`.
402 """
403 directions = list(self.integrated_data_coordinates.keys())
404 dim_names = [getattr(self, f'{direction}_units') for direction in directions]
405 return(dim_names)
406 @property
407 def integrated_data_shape(self):
408 """Return a tuple representing the shape of the integrated data
409 produced by this instance of `IntegrationConfig` for a single scan step.
410 """
411 return(tuple([len(coordinate_values) for coordinate_name,coordinate_values in self.integrated_data_coordinates.items()]))
412
413 @cache
414 def get_azimuthal_adjustments(chi_min:float, chi_max:float):
415 """
416 Fix chi discontinuity at 180 degrees and return the adjusted chi range,
417 offset, and discontinuty.
418
419 If the discontinuity is crossed, obtain the offset to artificially rotate
420 detectors to achieve a continuous azimuthal integration range.
421
422 :param chi_min: The minimum value of the azimuthal range.
423 :type chi_min: float
424 :param chi_max: The maximum value of the azimuthal range.
425 :type chi_max: float
426 :return: The following four values: the adjusted minimum value of the
427 azimuthal range, the adjusted maximum value of the azimuthal range, the
428 value by which the chi angle was adjusted, the position of the chi
429 discontinuity.
430 """
431 # Fix chi discontinuity at 180 degrees for now.
432 chi_disc = 180
433 # If the discontinuity is crossed, artificially rotate the detectors to
434 # achieve a continuous azimuthal integration range
435 if chi_min < chi_disc and chi_max > chi_disc:
436 chi_offset = chi_max - chi_disc
437 else:
438 chi_offset = 0
439 return(chi_min-chi_offset, chi_max-chi_offset, chi_offset, chi_disc)
440 @cache
441 def get_azimuthal_integrators(poni_files:tuple, chi_offset=0):
442 """
443 Return a list of `AzimuthalIntegrator` objects generated from PONI files.
444
445 :param poni_files: Tuple of strings, each string being a path to a PONI file. : tuple
446 :type poni_files: tuple
447 :param chi_offset: The angle in degrees by which the `AzimuthalIntegrator` objects will be rotated, defaults to 0.
448 :type chi_offset: float, optional
449 :return: List of `AzimuthalIntegrator` objects
450 :rtype: list[pyFAI.azimuthalIntegrator.AzimuthalIntegrator]
451 """
452 ais = []
453 for poni_file in poni_files:
454 ai = copy.deepcopy(azimuthal_integrator(poni_file))
455 ai.rot3 += chi_offset * np.pi/180
456 ais.append(ai)
457 return(ais)
458 @cache
459 def get_multi_geometry_integrator(poni_files:tuple, radial_unit:str, radial_range:tuple, azimuthal_range:tuple):
460 """Return a `MultiGeometry` instance that can be used for azimuthal or cake
461 integration.
462
463 :param poni_files: Tuple of PONI files that describe the detectors to be
464 integrated.
465 :type poni_files: tuple
466 :param radial_unit: Unit to use for radial integration range.
467 :type radial_unit: str
468 :param radial_range: Tuple describing the range for radial integration.
469 :type radial_range: tuple[float,float]
470 :param azimuthal_range:Tuple describing the range for azimuthal integration.
471 :type azimuthal_range: tuple[float,float]
472 :return: `MultiGeometry` instance that can be used for azimuthal or cake
473 integration.
474 :rtype: pyFAI.multi_geometry.MultiGeometry
475 """
476 chi_min, chi_max, chi_offset, chi_disc = get_azimuthal_adjustments(*azimuthal_range)
477 ais = copy.deepcopy(get_azimuthal_integrators(poni_files, chi_offset=chi_offset))
478 multi_geometry = MultiGeometry(ais,
479 unit=radial_unit,
480 radial_range=radial_range,
481 azimuth_range=(chi_min,chi_max),
482 wavelength=sum([ai.wavelength for ai in ais])/len(ais),
483 chi_disc=chi_disc)
484 return(multi_geometry)
485 @cache
486 def get_integrated_data_coordinates(azimuthal_range:tuple=None, azimuthal_npt:int=None, radial_range:tuple=None, radial_npt:int=None):
487 """
488 Return a dictionary of coordinate arrays for the specified radial and/or
489 azimuthal integration ranges.
490
491 :param azimuthal_range: Tuple specifying the range of azimuthal angles over
492 which to generate coordinates, in the format (min, max), defaults to
493 None.
494 :type azimuthal_range: tuple[float,float], optional
495 :param azimuthal_npt: Number of azimuthal coordinate points to generate,
496 defaults to None.
497 :type azimuthal_npt: int, optional
498 :param radial_range: Tuple specifying the range of radial distances over
499 which to generate coordinates, in the format (min, max), defaults to
500 None.
501 :type radial_range: tuple[float,float], optional
502 :param radial_npt: Number of radial coordinate points to generate, defaults
503 to None.
504 :type radial_npt: int, optional
505 :return: A dictionary with either one or two keys: 'azimuthal' and/or
506 'radial', each of which points to a 1-D `numpy` array of coordinate
507 values.
508 :rtype: dict[str,np.ndarray]
509 """
510 integrated_data_coordinates = {}
511 if azimuthal_range is not None and azimuthal_npt is not None:
512 integrated_data_coordinates['azimuthal'] = np.linspace(*azimuthal_range, azimuthal_npt)
513 if radial_range is not None and radial_npt is not None:
514 integrated_data_coordinates['radial'] = np.linspace(*radial_range, radial_npt)
515 return(integrated_data_coordinates)