Source code for corrct.physics.materials

#!/usr/bin/env python3
"""
Materials support functions and classes.

@author: Nicola VIGANĂ’, ESRF - The European Synchrotron, Grenoble, France,
and CEA-IRIG, Grenoble, France
"""

import copy as cp
from collections.abc import Sequence
from typing import Union

import numpy as np
from numpy.typing import DTypeLike, NDArray

from corrct.physics import xrf  # noqa: F401, F402
from corrct.physics.xraylib_helper import get_compound_cross_section
from corrct.physics.xraylib_helper import get_element_number
from corrct.physics.xraylib_helper import xraylib


[docs] class VolumeMaterial: """ VolumeMaterial class, that can be used for predicting fluorescence and Compton productions, attenuation, etc. Parameters ---------- material_fractions : Sequence Concentration fractions of each material for each voxel. material_compounds : Sequence Compound description of each material. voxel_size_cm : float Voxel size in cm. dtype : DTypeLike, optional Data type of the produced data. The default is None. Raises ------ ValueError Raised in case of incorrect parameters. """ def __init__( self, materials_fractions: Sequence[NDArray], materials_composition: Sequence, voxel_size_cm: float, dtype: DTypeLike = None, verbose: bool = False, ): if len(materials_fractions) != len(materials_composition): raise ValueError( f"Materials fractions (# {len(materials_fractions)}) and " f"materials composition (# {len(materials_composition)}) arrays should have the same length" ) if len(materials_fractions) == 0: raise ValueError("Phase list is empty") self.materials_fractions = list(materials_fractions) self.shape = np.array(self.materials_fractions[0].shape) for ii, ph in enumerate(self.materials_fractions): if len(self.shape) != len(ph.shape): raise ValueError("All material fraction volumes should have the same number of dimensions") if not np.all(self.shape == ph.shape): raise ValueError("Materials fraction volumes should all have the same shape") if ph.dtype == bool: self.materials_fractions[ii] = ph.astype(np.float32) if dtype is None: dtype = self.materials_fractions[0].dtype self.dtype = dtype for ii, ph in enumerate(self.materials_fractions): self.materials_fractions[ii] = ph.astype(self.dtype) self.materials_compositions = [ xraylib.GetCompoundDataNISTByName(cmp) if isinstance(cmp, str) else cmp for cmp in materials_composition ] self.voxel_size_cm = voxel_size_cm self.verbose = verbose
[docs] def get_attenuation(self, energy_keV: float) -> NDArray: """ Compute the local attenuation for each voxel. Parameters ---------- energy_keV : float The X-ray energy. Returns ------- NDArray The computed local attenuation volume. """ ph_lin_att = np.zeros(self.shape, self.dtype) for ph, cmp in zip(self.materials_fractions, self.materials_compositions): cmp_cs = get_compound_cross_section(cmp, energy_keV) if self.verbose: print(f"Attenuation ({cmp['name']} at {energy_keV}):") print( f" - cross-section * mass fraction = {cmp_cs}, density = {cmp['density']}, pixel-size {self.voxel_size_cm}" ) print(f" - total {cmp['density'] * cmp_cs * self.voxel_size_cm} (assuming material mass fraction = 1)") ph_lin_att += ph * cmp["density"] * cmp_cs return ph_lin_att * self.voxel_size_cm
[docs] def get_element_mass_fraction(self, element: Union[str, int]) -> NDArray: """Compute the local element mass fraction through out all the materials. Parameters ---------- element : str | int The element to look for. Returns ------- mass_fraction : NDArray The local mass fraction in each voxel. """ el_num = get_element_number(element) mass_fraction = np.zeros(self.shape, self.dtype) for ph, cmp in zip(self.materials_fractions, self.materials_compositions): if el_num in cmp["Elements"]: el_ind = np.where(np.array(cmp["Elements"]) == el_num)[0][0] mass_fraction += ph * cmp["density"] * cmp["massFractions"][el_ind] return mass_fraction
def _check_parallax_detector(self, detector: xrf.DetectorXRF, tolerance: float = 1e-2) -> bool: half_sample_size = np.max(self.voxel_size_cm * self.shape) / 2 if isinstance(detector.distance_mm, float) or ( isinstance(detector.distance_mm, np.ndarray) and detector.distance_mm.size == 1 ): dets = cp.deepcopy(detector) dets.distance_mm = dets.distance_mm + np.array([-half_sample_size, half_sample_size]) else: dets = detector solid_angles = dets.solid_angle_sr max_parallax = np.max(solid_angles) - np.min(solid_angles) return max_parallax < tolerance
[docs] def get_compton_scattering( self, energy_in_keV: float, angle_rad: Union[float, None] = None, detector: Union[xrf.DetectorXRF, None] = None ) -> tuple[float, NDArray]: """Compute the local Compton scattering. Parameters ---------- energy_in_keV : float Incoming beam energy. angle_rad : float, optional The detector angle, with respect to incoming beam direction. The default is None. detector : DetectorXRF, optional The detector object. The default is None. Raises ------ ValueError In case neither of `angle_rad` nor `detector` have been passed. Returns ------- energy_out_keV : float The energy of the Compton radiation received by the detector. cmptn_prod : NDArray Local production of Compton radiation. Either `angle_rad` or `detector` need to be supplied. """ if detector is None: if angle_rad is None: raise ValueError("Either 'angle_rad' or 'detector' should be passed.") else: if not self._check_parallax_detector(detector): print("WARNING - detector parallax is above 1e-2") if angle_rad is None: angle_rad = detector.angle_rad cmptn_prod = np.zeros(self.shape, self.dtype) for ph, cmp in zip(self.materials_fractions, self.materials_compositions): try: cmp_cs = xraylib.DCS_Compt_CP(cmp["name"], energy_in_keV, angle_rad) except ValueError: cmp_cs = np.sum( [ xraylib.DCS_Compt(el, energy_in_keV, angle_rad) * cmp["massFractions"][ii] for ii, el in enumerate(cmp["Elements"]) ], axis=0, ) if self.verbose: print( f"Compton - {cmp['name']} at incoming energy {energy_in_keV} (keV)," + f" outgoing angle {np.rad2deg(angle_rad)} (deg):\n" + f" - cross-section * mass fraction = {cmp_cs}, density = {cmp['density']}" + ", pixel-size {self.voxel_size_cm}" + f" - total {cmp['density'] * cmp_cs * self.voxel_size_cm} (assuming material mass fraction = 1)" ) cmptn_prod += ph * cmp["density"] * cmp_cs cmptn_prod *= self.voxel_size_cm if detector: cmptn_prod *= detector.solid_angle_sr energy_out_keV = xraylib.ComptonEnergy(energy_in_keV, angle_rad) return (energy_out_keV, cmptn_prod)
[docs] def get_fluo_production( self, element: Union[str, int], energy_in_keV: float, fluo_lines: Union[str, xrf.FluoLine, Sequence[xrf.FluoLine]], detector: Union[xrf.DetectorXRF, None] = None, ) -> tuple[float, NDArray]: """Compute the local fluorescence production, for the given line of the given element. Using Eq. (1) from: - T. Schoonjans et al., "The xraylib library for X-ray-matter interactions. Recent developments," Spectrochim. Acta Part B At. Spectrosc., vol. 66, no. 11-12, pp. 776-784, Nov. 2011, doi: 10.1016/j.sab.2011.09.011. Parameters ---------- element : str | int The element to consider. energy_in_keV : float The incoming X-ray beam energy. fluo_lines : str | FluoLine | Sequence[FluoLine] The fluorescence line to consider. detector : DetectorXRF, optional The detector geometry. The default is None. Returns ------- energy_out_keV : float The emitted fluorescence energy. el_prod : NDArray The local fluorescence production in each voxel. """ if detector: if not self._check_parallax_detector(detector): print("WARNING - detector parallax is above 1e-2") if isinstance(fluo_lines, xrf.FluoLine): fluo_lines = [fluo_lines] elif isinstance(fluo_lines, str): fluo_lines = xrf.LinesSiegbahn.get_lines(fluo_lines) el_num = get_element_number(element) el_yield = np.empty((len(fluo_lines),), self.dtype) for ii, line in enumerate(fluo_lines): try: el_yield[ii] = xraylib.CS_FluorLine_Kissel(el_num, line.indx, energy_in_keV) # fluo production for cm2/g except ValueError as exc: el_sym = xraylib.AtomicNumberToSymbol(el_num) if self.verbose: print(f"Energy {exc}: el_num={el_num} ({el_sym}) line={line}") el_yield[ii] = 0 el_prod = self.get_element_mass_fraction(el_num) * np.sum(el_yield) * self.voxel_size_cm if detector: el_prod *= detector.solid_angle_sr energy_out_keV = xrf.get_energy(el_num, fluo_lines, compute_average=True) return float(energy_out_keV), el_prod