from abc import abstractmethod
import copy
import os
from typing import Optional
from hexrd.instrument.constants import (
COATING_DEFAULT, FILTER_DEFAULTS, PHOSPHOR_DEFAULT
)
from hexrd.instrument.physics_package import AbstractPhysicsPackage
import numba
import numpy as np
from hexrd import constants as ct
from hexrd import distortion as distortion_pkg
from hexrd import matrixutil as mutil
from hexrd import xrdutil
from hexrd.rotations import mapAngle
from hexrd.material import crystallography
from hexrd.material.crystallography import PlaneData
from hexrd.transforms.xfcapi import (
xy_to_gvec,
gvec_to_xy,
make_beam_rmat,
make_rmat_of_expmap,
oscill_angles_of_hkls,
angles_to_dvec,
)
from hexrd.utils.decorators import memoize
from hexrd.gridutil import cellIndices
from hexrd.instrument import detector_coatings
from hexrd.material.utils import (
calculate_linear_absorption_length,
calculate_incoherent_scattering)
distortion_registry = distortion_pkg.Registry()
max_workers_DFLT = max(1, os.cpu_count() - 1)
beam_energy_DFLT = 65.351
# Memoize these, so each detector can avoid re-computing if nothing
# has changed.
_lorentz_factor = memoize(crystallography.lorentz_factor)
_polarization_factor = memoize(crystallography.polarization_factor)
[docs]class Detector:
"""
Base class for 2D detectors with functions and properties
common to planar and cylindrical detectors. This class
will be inherited by both those classes.
"""
__pixelPitchUnit = 'mm'
# Abstract methods that must be redefined in derived classes
@property
@abstractmethod
def detector_type(self):
raise NotImplementedError
[docs] @abstractmethod
def cart_to_angles(
self,
xy_data,
rmat_s=None,
tvec_s=None,
tvec_c=None,
apply_distortion=False,
):
"""
Transform cartesian coordinates to angular.
Parameters
----------
xy_data : TYPE
The (n, 2) array of n (x, y) coordinates to be transformed in
either the raw or ideal cartesian plane (see `apply_distortion`
kwarg below).
rmat_s : array_like, optional
The (3, 3) COB matrix for the sample frame. The default is None.
tvec_s : array_like, optional
The (3, ) translation vector for the sample frame.
The default is None.
tvec_c : array_like, optional
The (3, ) translation vector for the crystal frame.
The default is None.
apply_distortion : bool, optional
If True, apply distortion to the inpout cartesian coordinates.
The default is False.
Returns
-------
tth_eta : TYPE
DESCRIPTION.
g_vec : TYPE
DESCRIPTION.
"""
raise NotImplementedError
[docs] @abstractmethod
def angles_to_cart(
self,
tth_eta,
rmat_s=None,
tvec_s=None,
rmat_c=None,
tvec_c=None,
apply_distortion=False,
):
"""
Transform angular coordinates to cartesian.
Parameters
----------
tth_eta : array_like
The (n, 2) array of n (tth, eta) coordinates to be transformed.
rmat_s : array_like, optional
The (3, 3) COB matrix for the sample frame. The default is None.
tvec_s : array_like, optional
The (3, ) translation vector for the sample frame.
The default is None.
rmat_c : array_like, optional
(3, 3) COB matrix for the crystal frame.
The default is None.
tvec_c : array_like, optional
The (3, ) translation vector for the crystal frame.
The default is None.
apply_distortion : bool, optional
If True, apply distortion to take cartesian coordinates to the
"warped" configuration. The default is False.
Returns
-------
xy_det : array_like
The (n, 2) array on the n input coordinates in the .
"""
raise NotImplementedError
[docs] @abstractmethod
def cart_to_dvecs(self, xy_data):
"""Convert cartesian coordinates to dvectors"""
raise NotImplementedError
[docs] @abstractmethod
def pixel_angles(self, origin=ct.zeros_3):
raise NotImplementedError
[docs] @abstractmethod
def pixel_tth_gradient(self, origin=ct.zeros_3):
raise NotImplementedError
[docs] @abstractmethod
def pixel_eta_gradient(self, origin=ct.zeros_3):
raise NotImplementedError
[docs] @abstractmethod
def calc_filter_coating_transmission(self, energy):
pass
@property
@abstractmethod
def beam_position(self):
"""
returns the coordinates of the beam in the cartesian detector
frame {Xd, Yd, Zd}. NaNs if no intersection.
"""
raise NotImplementedError
@property
def extra_config_kwargs(self):
return {}
# End of abstract methods
def __init__(
self,
rows=2048,
cols=2048,
pixel_size=(0.2, 0.2),
tvec=np.r_[0.0, 0.0, -1000.0],
tilt=ct.zeros_3,
name='default',
bvec=ct.beam_vec,
xrs_dist=None,
evec=ct.eta_vec,
saturation_level=None,
panel_buffer=None,
tth_distortion=None,
roi=None,
group=None,
distortion=None,
max_workers=max_workers_DFLT,
detector_filter: Optional[detector_coatings.Filter] = None,
detector_coating: Optional[detector_coatings.Coating] = None,
phosphor: Optional[detector_coatings.Phosphor] = None,
):
"""
Instantiate a PlanarDetector object.
Parameters
----------
rows : TYPE, optional
DESCRIPTION. The default is 2048.
cols : TYPE, optional
DESCRIPTION. The default is 2048.
pixel_size : TYPE, optional
DESCRIPTION. The default is (0.2, 0.2).
tvec : TYPE, optional
DESCRIPTION. The default is np.r_[0., 0., -1000.].
tilt : TYPE, optional
DESCRIPTION. The default is ct.zeros_3.
name : TYPE, optional
DESCRIPTION. The default is 'default'.
bvec : TYPE, optional
DESCRIPTION. The default is ct.beam_vec.
evec : TYPE, optional
DESCRIPTION. The default is ct.eta_vec.
saturation_level : TYPE, optional
DESCRIPTION. The default is None.
panel_buffer : TYPE, optional
If a scalar or len(2) array_like, the interpretation is a border
in mm. If an array with shape (nrows, ncols), interpretation is a
boolean with True marking valid pixels. The default is None.
roi : TYPE, optional
DESCRIPTION. The default is None.
group : TYPE, optional
DESCRIPTION. The default is None.
distortion : TYPE, optional
DESCRIPTION. The default is None.
detector_filter : detector_coatings.Filter, optional
filter specifications including material type,
density and thickness. Used for absorption correction
calculations.
detector_coating : detector_coatings.Coating, optional
coating specifications including material type,
density and thickness. Used for absorption correction
calculations.
phosphor : detector_coatings.Phosphor, optional
phosphor specifications including material type,
density and thickness. Used for absorption correction
calculations.
Returns
-------
None.
"""
self._name = name
self._rows = rows
self._cols = cols
self._pixel_size_row = pixel_size[0]
self._pixel_size_col = pixel_size[1]
self._saturation_level = saturation_level
self._panel_buffer = panel_buffer
self._tth_distortion = tth_distortion
if roi is None:
self._roi = roi
else:
assert len(roi) == 2, "roi is set via (start_row, start_col)"
self._roi = (
(roi[0], roi[0] + self._rows),
(roi[1], roi[1] + self._cols),
)
self._tvec = np.array(tvec).flatten()
self._tilt = np.array(tilt).flatten()
self._bvec = np.array(bvec).flatten()
self._xrs_dist = xrs_dist
self._evec = np.array(evec).flatten()
self._distortion = distortion
self.max_workers = max_workers
self.group = group
if detector_filter is None:
detector_filter = detector_coatings.Filter(
**FILTER_DEFAULTS.TARDIS)
self.filter = detector_filter
if detector_coating is None:
detector_coating = detector_coatings.Coating(**COATING_DEFAULT)
self.coating = detector_coating
if phosphor is None:
phosphor = detector_coatings.Phosphor(**PHOSPHOR_DEFAULT)
self.phosphor = phosphor
# detector ID
@property
def name(self):
return self._name
@name.setter
def name(self, s):
assert isinstance(s, str), "requires string input"
self._name = s
@property
def lmfit_name(self):
# lmfit requires underscores instead of dashes
return self.name.replace('-', '_')
# properties for physical size of rectangular detector
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, x):
assert isinstance(x, int)
self._rows = x
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, x):
assert isinstance(x, int)
self._cols = x
@property
def pixel_size_row(self):
return self._pixel_size_row
@pixel_size_row.setter
def pixel_size_row(self, x):
self._pixel_size_row = float(x)
@property
def pixel_size_col(self):
return self._pixel_size_col
@pixel_size_col.setter
def pixel_size_col(self, x):
self._pixel_size_col = float(x)
@property
def pixel_area(self):
return self.pixel_size_row * self.pixel_size_col
@property
def saturation_level(self):
return self._saturation_level
@saturation_level.setter
def saturation_level(self, x):
if x is not None:
assert np.isreal(x)
self._saturation_level = x
@property
def panel_buffer(self):
return self._panel_buffer
@panel_buffer.setter
def panel_buffer(self, x):
"""if not None, a buffer in mm (x, y)"""
if x is not None:
assert len(x) == 2 or x.ndim == 2
self._panel_buffer = x
@property
def tth_distortion(self):
return self._tth_distortion
@tth_distortion.setter
def tth_distortion(self, x):
"""if not None, a buffer in mm (x, y)"""
if x is not None:
assert x.ndim == 2 and x.shape == self.shape
self._tth_distortion = x
@property
def roi(self):
return self._roi
@roi.setter
def roi(self, vertex_array):
"""
!!! vertex array must be (r0, c0)
"""
if vertex_array is not None:
assert (
len(vertex_array) == 2
), "roi is set via (start_row, start_col)"
self._roi = (
(vertex_array[0], vertex_array[0] + self.rows),
(vertex_array[1], vertex_array[1] + self.cols),
)
@property
def row_dim(self):
return self.rows * self.pixel_size_row
@property
def col_dim(self):
return self.cols * self.pixel_size_col
@property
def row_pixel_vec(self):
return self.pixel_size_row * (
0.5 * (self.rows - 1) - np.arange(self.rows)
)
@property
def row_edge_vec(self):
return _row_edge_vec(self.rows, self.pixel_size_row)
@property
def col_pixel_vec(self):
return self.pixel_size_col * (
np.arange(self.cols) - 0.5 * (self.cols - 1)
)
@property
def col_edge_vec(self):
return _col_edge_vec(self.cols, self.pixel_size_col)
@property
def corner_ul(self):
return np.r_[-0.5 * self.col_dim, 0.5 * self.row_dim]
@property
def corner_ll(self):
return np.r_[-0.5 * self.col_dim, -0.5 * self.row_dim]
@property
def corner_lr(self):
return np.r_[0.5 * self.col_dim, -0.5 * self.row_dim]
@property
def corner_ur(self):
return np.r_[0.5 * self.col_dim, 0.5 * self.row_dim]
@property
def shape(self):
return (self.rows, self.cols)
@property
def tvec(self):
return self._tvec
@tvec.setter
def tvec(self, x):
x = np.array(x).flatten()
assert len(x) == 3, 'input must have length = 3'
self._tvec = x
@property
def tilt(self):
return self._tilt
@tilt.setter
def tilt(self, x):
assert len(x) == 3, 'input must have length = 3'
self._tilt = np.array(x).squeeze()
@property
def bvec(self):
return self._bvec
@bvec.setter
def bvec(self, x):
x = np.array(x).flatten()
assert (
len(x) == 3 and sum(x * x) > 1 - ct.sqrt_epsf
), 'input must have length = 3 and have unit magnitude'
self._bvec = x
@property
def xrs_dist(self):
return self._xrs_dist
@xrs_dist.setter
def xrs_dist(self, x):
assert x is None or np.isscalar(
x
), f"'source_distance' must be None or scalar; you input '{x}'"
self._xrs_dist = x
@property
def evec(self):
return self._evec
@evec.setter
def evec(self, x):
x = np.array(x).flatten()
assert (
len(x) == 3 and sum(x * x) > 1 - ct.sqrt_epsf
), 'input must have length = 3 and have unit magnitude'
self._evec = x
@property
def distortion(self):
return self._distortion
@distortion.setter
def distortion(self, x):
if x is not None:
registry = distortion_registry.distortion_registry
check_arg = np.zeros(len(registry), dtype=bool)
for i, dcls in enumerate(registry.values()):
check_arg[i] = isinstance(x, dcls)
assert np.any(check_arg), 'input distortion is not in registry!'
self._distortion = x
@property
def rmat(self):
return make_rmat_of_expmap(self.tilt)
@property
def normal(self):
return self.rmat[:, 2]
# ...memoize???
@property
def pixel_coords(self):
pix_i, pix_j = np.meshgrid(
self.row_pixel_vec, self.col_pixel_vec, indexing='ij'
)
return pix_i, pix_j
@property
def pixel_solid_angles(self) -> np.ndarray:
y, x = self.pixel_coords
xy = np.vstack((x.flatten(), y.flatten())).T
dvecs = self.cart_to_dvecs(xy)
mod_r = np.linalg.norm(dvecs, axis=1)
return (
(self.pixel_area / mod_r**3) *
np.abs(np.sum(
self.pixel_normal * dvecs,
axis=1,
))
).reshape(self.shape)
# =========================================================================
# METHODS
# =========================================================================
[docs] def pixel_Q(self, energy: np.floating,
origin: np.ndarray = ct.zeros_3) -> np.ndarray:
'''get the equivalent momentum transfer
for the angles.
Parameters
----------
energy: float
incident photon energy in keV
origin: np.ndarray
origin of diffraction volume
Returns
-------
np.ndarray
pixel wise Q in A^-1
'''
lam = ct.keVToAngstrom(energy)
tth, _ = self.pixel_angles(origin=origin)
return 4.*np.pi*np.sin(tth*0.5)/lam
[docs] def pixel_compton_energy_loss(
self,
energy: np.floating,
origin: np.ndarray = ct.zeros_3,
) -> np.ndarray:
'''inelastic compton scattering leads
to energy loss of the incident photons.
compute the final energy of the photons
for each pixel.
Parameters
----------
energy: float
incident photon energy in keV
origin: np.ndarray
origin of diffraction volume
Returns
-------
np.ndarray
pixel wise energy of inelastically
scatterd photons in keV
'''
energy = np.asarray(energy)
tth, _ = self.pixel_angles()
ang_fact = (1 - np.cos(tth))
beta = energy/ct.cRestmasskeV
return energy/(1 + beta*ang_fact)
[docs] def pixel_compton_attenuation_length(
self,
energy: np.floating,
density: np.floating,
formula: str,
origin: np.ndarray = ct.zeros_3,
) -> np.ndarray:
'''each pixel intercepts inelastically
scattered photons of different energy.
the attenuation length and the transmission
for these photons are different. this function
calculate attenuatin length for each pixel
on the detector.
Parameters
----------
energy: float
incident photon energy in keV
density: float
density of material in g/cc
formula: str
formula of the material scattering
origin: np.ndarray
origin of diffraction volume
Returns
-------
np.ndarray
pixel wise attentuation length of compton
scattered photons
'''
pixel_energy = self.pixel_compton_energy_loss(energy)
pixel_attenuation_length = calculate_linear_absorption_length(
density,
formula,
pixel_energy.flatten(),
)
return pixel_attenuation_length.reshape(self.shape)
[docs] def compute_compton_scattering_intensity(
self,
energy: np.floating,
rMat_s: np.array,
physics_package: AbstractPhysicsPackage,
origin: np.array = ct.zeros_3,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
''' compute the theoretical compton scattering
signal on the detector. this value is corrected
for the transmission of compton scattered photons
and normlaized before getting subtracting from the
raw intensity
Parameters
-----------
energy: float
energy of incident photon
rMat_s: np.ndarray
rotation matrix of sample orientation
physics_package: AbstractPhysicsPackage
physics package information
Returns
-------
compton_intensity: np.ndarray
transmission corrected compton scattering
intensity
'''
q = self.pixel_Q(energy)
inc_s = calculate_incoherent_scattering(
physics_package.sample_material,
q.flatten()).reshape(self.shape)
inc_w = calculate_incoherent_scattering(
physics_package.window_material,
q.flatten()).reshape(self.shape)
t_s = self.calc_compton_physics_package_transmission(
energy, rMat_s, physics_package)
t_w = self.calc_compton_window_transmission(
energy, rMat_s, physics_package)
return inc_s * t_s + inc_w * t_w, t_s, t_w
[docs] def polarization_factor(self, f_hor, f_vert, unpolarized=False):
"""
Calculated the polarization factor for every pixel.
Parameters
----------
f_hor : float
the fraction of horizontal polarization. for XFELs
this is close to 1.
f_vert : TYPE
the fraction of vertical polarization, which is ~0 for XFELs.
Raises
------
RuntimeError
DESCRIPTION.
Returns
-------
TYPE
DESCRIPTION.
"""
s = f_hor + f_vert
if np.abs(s - 1) > ct.sqrt_epsf:
msg = (
"sum of fraction of "
"horizontal and vertical polarizations "
"must be equal to 1."
)
raise RuntimeError(msg)
if f_hor < 0 or f_vert < 0:
msg = (
"fraction of polarization in horizontal "
"or vertical directions can't be negative."
)
raise RuntimeError(msg)
tth, eta = self.pixel_angles()
kwargs = {
'tth': tth,
'eta': eta,
'f_hor': f_hor,
'f_vert': f_vert,
'unpolarized': unpolarized,
}
return _polarization_factor(**kwargs)
[docs] def lorentz_factor(self):
"""
calculate the lorentz factor for every pixel
Parameters
----------
None
Raises
------
None
Returns
-------
numpy.ndarray
returns an array the same size as the detector panel
with each element containg the lorentz factor of the
corresponding pixel
"""
tth, eta = self.pixel_angles()
return _lorentz_factor(tth)
[docs] def config_dict(
self,
chi=0,
tvec=ct.zeros_3,
beam_energy=beam_energy_DFLT,
beam_vector=ct.beam_vec,
sat_level=None,
panel_buffer=None,
style='yaml',
):
"""
Return a dictionary of detector parameters.
Optional instrument level parameters. This is a convenience function
to work with the APIs in several functions in xrdutil.
Parameters
----------
chi : float, optional
DESCRIPTION. The default is 0.
tvec : array_like (3,), optional
DESCRIPTION. The default is ct.zeros_3.
beam_energy : float, optional
DESCRIPTION. The default is beam_energy_DFLT.
beam_vector : aray_like (3,), optional
DESCRIPTION. The default is ct.beam_vec.
sat_level : scalar, optional
DESCRIPTION. The default is None.
panel_buffer : scalar, array_like (2,), optional
DESCRIPTION. The default is None.
Returns
-------
config_dict : dict
DESCRIPTION.
"""
assert style.lower() in ['yaml', 'hdf5'], (
"style must be either 'yaml', or 'hdf5'; you gave '%s'" % style
)
config_dict = {}
# =====================================================================
# DETECTOR PARAMETERS
# =====================================================================
# transform and pixels
#
# assign local vars; listify if necessary
tilt = self.tilt
translation = self.tvec
roi = (
None
if self.roi is None
else np.array([self.roi[0][0], self.roi[1][0]]).flatten()
)
if style.lower() == 'yaml':
tilt = tilt.tolist()
translation = translation.tolist()
tvec = tvec.tolist()
roi = None if roi is None else roi.tolist()
det_dict = dict(
detector_type=self.detector_type,
transform=dict(
tilt=tilt,
translation=translation,
),
pixels=dict(
rows=int(self.rows),
columns=int(self.cols),
size=[float(self.pixel_size_row), float(self.pixel_size_col)],
),
)
if roi is not None:
# Only add roi if it is not None
det_dict['pixels']['roi'] = roi
if self.group is not None:
# Only add group if it is not None
det_dict['group'] = self.group
# distortion
if self.distortion is not None:
dparams = self.distortion.params
if style.lower() == 'yaml':
dparams = dparams.tolist()
dist_d = dict(
function_name=self.distortion.maptype, parameters=dparams
)
det_dict['distortion'] = dist_d
# saturation level
if sat_level is None:
sat_level = self.saturation_level
det_dict['saturation_level'] = float(sat_level)
# panel buffer
if panel_buffer is None:
# could be none, a 2-element list, or a 2-d array (rows, cols)
panel_buffer = copy.deepcopy(self.panel_buffer)
# !!! now we have to do some style-dependent munging of panel_buffer
if isinstance(panel_buffer, np.ndarray):
if panel_buffer.ndim == 1:
assert len(panel_buffer) == 2, "length of 1-d buffer must be 2"
# if here is a 2-element array
if style.lower() == 'yaml':
panel_buffer = panel_buffer.tolist()
elif panel_buffer.ndim == 2:
if style.lower() == 'yaml':
# !!! can't practically write array-like buffers to YAML
# so forced to clobber
print("clobbering panel buffer array in yaml-ready output")
panel_buffer = [0.0, 0.0]
else:
raise RuntimeError(
"panel buffer ndim must be 1 or 2; you specified %d"
% panel_buffer.ndmin
)
elif panel_buffer is None:
# still None on self
# !!! this gets handled by unwrap_dict_to_h5 now
# if style.lower() == 'hdf5':
# # !!! can't write None to hdf5; substitute with zeros
# panel_buffer = np.r_[0., 0.]
pass
det_dict['buffer'] = panel_buffer
det_dict.update(self.extra_config_kwargs)
# =====================================================================
# SAMPLE STAGE PARAMETERS
# =====================================================================
stage_dict = dict(chi=chi, translation=tvec)
# =====================================================================
# BEAM PARAMETERS
# =====================================================================
# !!! make_reflection_patches is still using the vector
# azim, pola = calc_angles_from_beam_vec(beam_vector)
# beam_dict = dict(
# energy=beam_energy,
# vector=dict(
# azimuth=azim,
# polar_angle=pola
# )
# )
beam_dict = dict(energy=beam_energy, vector=beam_vector)
config_dict['detector'] = det_dict
config_dict['oscillation_stage'] = stage_dict
config_dict['beam'] = beam_dict
return config_dict
[docs] def cartToPixel(self, xy_det, pixels=False, apply_distortion=False):
"""
Coverts cartesian coordinates to pixel coordinates
Parameters
----------
xy_det : array_like
The (n, 2) vstacked array of (x, y) pairs in the reference
cartesian frame (possibly subject to distortion).
pixels : bool, optional
If True, return discrete pixel indices; otherwise fractional pixel
coordinates are returned. The default is False.
apply_distortion : bool, optional
If True, apply self.distortion to the input (if applicable).
The default is False.
Returns
-------
ij_det : array_like
The (n, 2) array of vstacked (i, j) coordinates in the pixel
reference frame where i is the (slow) row dimension and j is the
(fast) column dimension.
"""
xy_det = np.atleast_2d(xy_det)
if apply_distortion and self.distortion is not None:
xy_det = self.distortion.apply(xy_det)
npts = len(xy_det)
tmp_ji = xy_det - np.tile(self.corner_ul, (npts, 1))
i_pix = -tmp_ji[:, 1] / self.pixel_size_row - 0.5
j_pix = tmp_ji[:, 0] / self.pixel_size_col - 0.5
ij_det = np.vstack([i_pix, j_pix]).T
if pixels:
# Hide any runtime warnings in this conversion. Their output values
# will certainly be off the detector, which is fine.
with np.errstate(invalid='ignore'):
ij_det = np.array(np.round(ij_det), dtype=int)
return ij_det
[docs] def pixelToCart(self, ij_det):
"""
Convert vstacked array or list of [i,j] pixel indices
(or UL corner-based points) and convert to (x,y) in the
cartesian frame {Xd, Yd, Zd}
"""
ij_det = np.atleast_2d(ij_det)
x = (ij_det[:, 1] + 0.5) * self.pixel_size_col + self.corner_ll[0]
y = (
self.rows - ij_det[:, 0] - 0.5
) * self.pixel_size_row + self.corner_ll[1]
return np.vstack([x, y]).T
[docs] def angularPixelSize(self, xy, rMat_s=None, tVec_s=None, tVec_c=None):
"""
Notes
-----
!!! assumes xy are in raw (distorted) frame, if applicable
"""
# munge kwargs
if rMat_s is None:
rMat_s = ct.identity_3x3
if tVec_s is None:
tVec_s = ct.zeros_3x1
if tVec_c is None:
tVec_c = ct.zeros_3x1
# FIXME: perhaps not necessary, but safe...
xy = np.atleast_2d(xy)
'''
# ---------------------------------------------------------------------
# TODO: needs testing and memoized gradient arrays!
# ---------------------------------------------------------------------
# need origin arg
origin = np.dot(rMat_s, tVec_c).flatten() + tVec_s.flatten()
# get pixel indices
i_crds = cellIndices(self.row_edge_vec, xy[:, 1])
j_crds = cellIndices(self.col_edge_vec, xy[:, 0])
ptth_grad = self.pixel_tth_gradient(origin=origin)[i_crds, j_crds]
peta_grad = self.pixel_eta_gradient(origin=origin)[i_crds, j_crds]
return np.vstack([ptth_grad, peta_grad]).T
'''
# call xrdutil function
ang_ps = xrdutil.angularPixelSize(
xy,
(self.pixel_size_row, self.pixel_size_col),
self.rmat,
rMat_s,
self.tvec,
tVec_s,
tVec_c,
distortion=self.distortion,
beamVec=self.bvec,
etaVec=self.evec,
)
return ang_ps
[docs] def clip_to_panel(self, xy, buffer_edges=True):
"""
if self.roi is not None, uses it by default
TODO: check if need shape kwarg
TODO: optimize ROI search better than list comprehension below
TODO: panel_buffer can be a 2-d boolean mask, but needs testing
"""
xy = np.atleast_2d(xy)
'''
# !!! THIS LOGIC IS OBSOLETE
if self.roi is not None:
ij_crds = self.cartToPixel(xy, pixels=True)
ii, jj = polygon(self.roi[:, 0], self.roi[:, 1],
shape=(self.rows, self.cols))
on_panel_rows = [i in ii for i in ij_crds[:, 0]]
on_panel_cols = [j in jj for j in ij_crds[:, 1]]
on_panel = np.logical_and(on_panel_rows, on_panel_cols)
else:
'''
xlim = 0.5 * self.col_dim
ylim = 0.5 * self.row_dim
if buffer_edges and self.panel_buffer is not None:
if self.panel_buffer.ndim == 2:
pix = self.cartToPixel(xy, pixels=True)
roff = np.logical_or(pix[:, 0] < 0, pix[:, 0] >= self.rows)
coff = np.logical_or(pix[:, 1] < 0, pix[:, 1] >= self.cols)
idx = np.logical_or(roff, coff)
on_panel = np.full(pix.shape[0], False)
valid_pix = pix[~idx, :]
on_panel[~idx] = self.panel_buffer[
valid_pix[:, 0], valid_pix[:, 1]
]
else:
xlim -= self.panel_buffer[0]
ylim -= self.panel_buffer[1]
on_panel_x = np.logical_and(
xy[:, 0] >= -xlim, xy[:, 0] <= xlim
)
on_panel_y = np.logical_and(
xy[:, 1] >= -ylim, xy[:, 1] <= ylim
)
on_panel = np.logical_and(on_panel_x, on_panel_y)
elif not buffer_edges or self.panel_buffer is None:
on_panel_x = np.logical_and(xy[:, 0] >= -xlim, xy[:, 0] <= xlim)
on_panel_y = np.logical_and(xy[:, 1] >= -ylim, xy[:, 1] <= ylim)
on_panel = np.logical_and(on_panel_x, on_panel_y)
return xy[on_panel, :], on_panel
[docs] def interpolate_nearest(self, xy, img, pad_with_nans=True):
"""
TODO: revisit normalization in here?
"""
is_2d = img.ndim == 2
right_shape = img.shape[0] == self.rows and img.shape[1] == self.cols
assert (
is_2d and right_shape
), "input image must be 2-d with shape (%d, %d)" % (
self.rows,
self.cols,
)
# initialize output with nans
if pad_with_nans:
int_xy = np.nan * np.ones(len(xy))
else:
int_xy = np.zeros(len(xy))
# clip away points too close to or off the edges of the detector
xy_clip, on_panel = self.clip_to_panel(xy, buffer_edges=True)
# get pixel indices of clipped points
i_src = cellIndices(self.row_pixel_vec, xy_clip[:, 1])
j_src = cellIndices(self.col_pixel_vec, xy_clip[:, 0])
# next interpolate across cols
int_vals = img[i_src, j_src]
int_xy[on_panel] = int_vals
return int_xy
[docs] def interpolate_bilinear(
self,
xy: np.ndarray,
img: np.ndarray,
pad_with_nans: bool = True,
):
"""
Interpolate an image array at the specified cartesian points.
Parameters
----------
xy : array_like, (n, 2)
Array of cartesian coordinates in the image plane at which
to evaluate intensity.
img : array_like
2-dimensional image array. The shape must match (rows, cols).
pad_with_nans : bool, optional
Toggle for assigning NaN to points that fall off the detector.
The default is True.
Returns
-------
int_xy : array_like, (n,)
The array of interpolated intensities at each of the n input
coordinates.
Notes
-----
TODO: revisit normalization in here?
"""
fill_value = np.nan if pad_with_nans else 0
int_xy = np.full(len(xy), fill_value, dtype=float)
# clip away points too close to or off the detector edges
xy_clip, on_panel = self.clip_to_panel(xy, buffer_edges=True)
# Generate the interpolation dict
interp_dict = self._generate_bilinear_interp_dict(xy_clip)
# Set the output and return
int_xy[on_panel] = _interpolate_bilinear(img, **interp_dict)
return int_xy
def _generate_bilinear_interp_dict(
self,
xy_clip: np.ndarray,
) -> dict[str, np.ndarray]:
"""Compute bilinear interpolation multipliers and indices for the panel
If you are going to be using the same panel settings and performing
interpolation on multiple images, it is advised to run this beforehand
to precompute the interpolation parameters, so you can use them
repeatedly.
"""
# grab fractional pixel indices of clipped points
ij_frac = self.cartToPixel(xy_clip)
# get floors/ceils from array of pixel _centers_
# and fix indices running off the pixel centers
# !!! notice we already clipped points to the panel!
i_floor = cellIndices(self.row_pixel_vec, xy_clip[:, 1])
i_floor_img = _fix_indices(i_floor, 0, self.rows - 1)
j_floor = cellIndices(self.col_pixel_vec, xy_clip[:, 0])
j_floor_img = _fix_indices(j_floor, 0, self.cols - 1)
# ceilings from floors
i_ceil = i_floor + 1
i_ceil_img = _fix_indices(i_ceil, 0, self.rows - 1)
j_ceil = j_floor + 1
j_ceil_img = _fix_indices(j_ceil, 0, self.cols - 1)
# Compute differences between raw coordinates to use for interpolation
j_ceil_sub = j_ceil - ij_frac[:, 1]
j_floor_sub = ij_frac[:, 1] - j_floor
i_ceil_sub = i_ceil - ij_frac[:, 0]
i_floor_sub = ij_frac[:, 0] - i_floor
return {
# Compute interpolation multipliers for every pixel
'cc': j_ceil_sub * i_ceil_sub,
'fc': j_floor_sub * i_ceil_sub,
'cf': j_ceil_sub * i_floor_sub,
'ff': j_floor_sub * i_floor_sub,
# Store needed pixel indices
'i_floor_img': i_floor_img,
'j_floor_img': j_floor_img,
'i_ceil_img': i_ceil_img,
'j_ceil_img': j_ceil_img,
}
[docs] def make_powder_rings(
self,
pd,
merge_hkls=False,
delta_tth=None,
delta_eta=10.0,
eta_period=None,
eta_list=None,
rmat_s=ct.identity_3x3,
tvec_s=ct.zeros_3,
tvec_c=ct.zeros_3,
full_output=False,
tth_distortion=None,
):
"""
Generate points on Debye_Scherrer rings over the detector.
!!! it is assuming that rmat_s is built from (chi, ome) as it the case
for HEDM!
Parameters
----------
pd : TYPE
DESCRIPTION.
merge_hkls : TYPE, optional
DESCRIPTION. The default is False.
delta_tth : TYPE, optional
DESCRIPTION. The default is None.
delta_eta : TYPE, optional
DESCRIPTION. The default is 10..
eta_period : TYPE, optional
DESCRIPTION. The default is None.
eta_list : TYPE, optional
DESCRIPTION. The default is None.
rmat_s : TYPE, optional
DESCRIPTION. The default is ct.identity_3x3.
tvec_s : TYPE, optional
DESCRIPTION. The default is ct.zeros_3.
tvec_c : TYPE, optional
DESCRIPTION. The default is ct.zeros_3.
full_output : TYPE, optional
DESCRIPTION. The default is False.
tth_distortion : special class, optional
Special distortion class. The default is None.
Raises
------
RuntimeError
DESCRIPTION.
Returns
-------
TYPE
DESCRIPTION.
"""
if tth_distortion is not None:
tnorms = mutil.rowNorm(np.vstack([tvec_s, tvec_c]))
assert (
np.all(tnorms) < ct.sqrt_epsf
), "If using distrotion function, translations must be zero"
# in case you want to give it tth angles directly
if isinstance(pd, PlaneData):
pd = PlaneData(None, pd)
if delta_tth is not None:
pd.tThWidth = np.radians(delta_tth)
else:
delta_tth = np.degrees(pd.tThWidth)
# !!! conversions, meh...
del_eta = np.radians(delta_eta)
# do merging if asked
if merge_hkls:
_, tth_ranges = pd.getMergedRanges(cullDupl=True)
tth = np.average(tth_ranges, axis=1)
else:
tth_ranges = pd.getTThRanges()
tth = pd.getTTh()
tth_pm = tth_ranges - np.tile(tth, (2, 1)).T
sector_vertices = np.vstack(
[
[
i[0],
-del_eta,
i[0],
del_eta,
i[1],
del_eta,
i[1],
-del_eta,
0.0,
0.0,
]
for i in tth_pm
]
)
else:
# Okay, we have a array-like tth specification
tth = np.array(pd).flatten()
if delta_tth is None:
raise RuntimeError(
"If supplying a 2theta list as first arg, "
+ "must supply a delta_tth"
)
tth_pm = 0.5 * delta_tth * np.r_[-1.0, 1.0]
tth_ranges = np.radians([i + tth_pm for i in tth]) # !!! units
sector_vertices = np.tile(
0.5
* np.radians(
[
-delta_tth,
-delta_eta,
-delta_tth,
delta_eta,
delta_tth,
delta_eta,
delta_tth,
-delta_eta,
0.0,
0.0,
]
),
(len(tth), 1),
)
# !! conversions, meh...
tth = np.radians(tth)
del_eta = np.radians(delta_eta)
# for generating rings, make eta vector in correct period
if eta_period is None:
eta_period = (-np.pi, np.pi)
if eta_list is None:
neta = int(360.0 / float(delta_eta))
# this is the vector of ETA EDGES
eta_edges = mapAngle(
np.radians(delta_eta * np.linspace(0.0, neta, num=neta + 1))
+ eta_period[0],
eta_period,
)
# get eta bin centers from edges
"""
# !!! this way is probably overkill, since we have delta eta
eta_centers = np.average(
np.vstack([eta[:-1], eta[1:]),
axis=0)
"""
# !!! should be safe as eta_edges are monotonic
eta_centers = eta_edges[:-1] + 0.5 * del_eta
else:
eta_centers = np.radians(eta_list).flatten()
neta = len(eta_centers)
eta_edges = (
np.tile(eta_centers, (2, 1))
+ np.tile(0.5 * del_eta * np.r_[-1, 1], (neta, 1)).T
).T.flatten()
# get chi and ome from rmat_s
# !!! API ambiguity
# !!! this assumes rmat_s was made from the composition
# !!! rmat_s = R(Xl, chi) * R(Yl, ome)
ome = np.arctan2(rmat_s[0, 2], rmat_s[0, 0])
# make list of angle tuples
angs = [
np.vstack([i * np.ones(neta), eta_centers, ome * np.ones(neta)])
for i in tth
]
# need xy coords and pixel sizes
valid_ang = []
valid_xy = []
map_indices = []
npp = 5 # [ll, ul, ur, lr, center]
for i_ring in range(len(angs)):
# expand angles to patch vertices
these_angs = angs[i_ring].T
# push to vertices to see who falls off
# FIXME: clipping is not checking if masked regions are on the
# patch interior
patch_vertices = (
np.tile(these_angs[:, :2], (1, npp))
+ np.tile(sector_vertices[i_ring], (neta, 1))
).reshape(npp * neta, 2)
# find vertices that all fall on the panel
# !!! not API ambiguity regarding rmat_s above
all_xy = self.angles_to_cart(
patch_vertices,
rmat_s=rmat_s,
tvec_s=tvec_s,
rmat_c=None,
tvec_c=tvec_c,
apply_distortion=True,
)
_, on_panel = self.clip_to_panel(all_xy)
# all vertices must be on...
patch_is_on = np.all(on_panel.reshape(neta, npp), axis=1)
patch_xys = all_xy.reshape(neta, 5, 2)[patch_is_on]
# !!! Have to apply after clipping, distortion can get wonky near
# the edeg of the panel, and it is assumed to be <~1 deg
# !!! The tth_ranges are NOT correct!
if tth_distortion is not None:
patch_valid_angs = tth_distortion.apply(
self.angles_to_cart(these_angs[patch_is_on, :2]),
return_nominal=True,
)
patch_valid_xys = self.angles_to_cart(
patch_valid_angs, apply_distortion=True
)
else:
patch_valid_angs = these_angs[patch_is_on, :2]
patch_valid_xys = patch_xys[:, -1, :].squeeze()
# form output arrays
valid_ang.append(patch_valid_angs)
valid_xy.append(patch_valid_xys)
map_indices.append(patch_is_on)
# ??? is this option necessary?
if full_output:
return valid_ang, valid_xy, tth_ranges, map_indices, eta_edges
else:
return valid_ang, valid_xy, tth_ranges
[docs] def map_to_plane(self, pts, rmat, tvec):
"""
Map detctor points to specified plane.
Parameters
----------
pts : TYPE
DESCRIPTION.
rmat : TYPE
DESCRIPTION.
tvec : TYPE
DESCRIPTION.
Returns
-------
TYPE
DESCRIPTION.
Notes
-----
by convention:
n * (u*pts_l - tvec) = 0
[pts]_l = rmat*[pts]_m + tvec
"""
# arg munging
pts = np.atleast_2d(pts)
npts = len(pts)
# map plane normal & translation vector, LAB FRAME
nvec_map_lab = rmat[:, 2].reshape(3, 1)
tvec_map_lab = np.atleast_2d(tvec).reshape(3, 1)
tvec_d_lab = np.atleast_2d(self.tvec).reshape(3, 1)
# put pts as 3-d in panel CS and transform to 3-d lab coords
pts_det = np.hstack([pts, np.zeros((npts, 1))])
pts_lab = np.dot(self.rmat, pts_det.T) + tvec_d_lab
# scaling along pts vectors to hit map plane
u = np.dot(nvec_map_lab.T, tvec_map_lab) / np.dot(
nvec_map_lab.T, pts_lab
)
# pts on map plane, in LAB FRAME
pts_map_lab = np.tile(u, (3, 1)) * pts_lab
return np.dot(rmat.T, pts_map_lab - tvec_map_lab)[:2, :].T
[docs] def simulate_rotation_series(
self,
plane_data,
grain_param_list,
eta_ranges=[
(-np.pi, np.pi),
],
ome_ranges=[
(-np.pi, np.pi),
],
ome_period=(-np.pi, np.pi),
chi=0.0,
tVec_s=ct.zeros_3,
wavelength=None,
energy_correction=None,
):
"""
Simulate a monochromatic rotation series for a list of grains.
Parameters
----------
plane_data : TYPE
DESCRIPTION.
grain_param_list : TYPE
DESCRIPTION.
eta_ranges : TYPE, optional
DESCRIPTION. The default is [(-np.pi, np.pi), ].
ome_ranges : TYPE, optional
DESCRIPTION. The default is [(-np.pi, np.pi), ].
ome_period : TYPE, optional
DESCRIPTION. The default is (-np.pi, np.pi).
chi : TYPE, optional
DESCRIPTION. The default is 0..
tVec_s : TYPE, optional
DESCRIPTION. The default is ct.zeros_3.
wavelength : TYPE, optional
DESCRIPTION. The default is None.
Returns
-------
valid_ids : TYPE
DESCRIPTION.
valid_hkls : TYPE
DESCRIPTION.
valid_angs : TYPE
DESCRIPTION.
valid_xys : TYPE
DESCRIPTION.
ang_pixel_size : TYPE
DESCRIPTION.
"""
# grab B-matrix from plane data
bMat = plane_data.latVecOps['B']
# reconcile wavelength
# * added sanity check on exclusions here; possible to
# * make some reflections invalid (NaN)
if wavelength is None:
wavelength = plane_data.wavelength
else:
if plane_data.wavelength != wavelength:
plane_data.wavelength = ct.keVToAngstrom(wavelength)
assert not np.any(
np.isnan(plane_data.getTTh())
), "plane data exclusions incompatible with wavelength"
# vstacked G-vector id, h, k, l
full_hkls = xrdutil._fetch_hkls_from_planedata(plane_data)
""" LOOP OVER GRAINS """
valid_ids = []
valid_hkls = []
valid_angs = []
valid_xys = []
ang_pixel_size = []
for gparm in grain_param_list:
# make useful parameters
rMat_c = make_rmat_of_expmap(gparm[:3])
tVec_c = gparm[3:6]
vInv_s = gparm[6:]
# Apply an energy correction according to grain position
corrected_wavelength = xrdutil.apply_correction_to_wavelength(
wavelength,
energy_correction,
tVec_s,
tVec_c,
)
# All possible bragg conditions as vstacked [tth, eta, ome]
# for each omega solution
angList = np.vstack(
oscill_angles_of_hkls(
full_hkls[:, 1:],
chi,
rMat_c,
bMat,
corrected_wavelength,
v_inv=vInv_s,
beam_vec=self.bvec,
)
)
# filter by eta and omega ranges
# ??? get eta range from detector?
allAngs, allHKLs = xrdutil._filter_hkls_eta_ome(
full_hkls, angList, eta_ranges, ome_ranges
)
allAngs[:, 2] = mapAngle(allAngs[:, 2], ome_period)
# find points that fall on the panel
det_xy, rMat_s, on_plane = xrdutil._project_on_detector_plane(
allAngs,
self.rmat,
rMat_c,
chi,
self.tvec,
tVec_c,
tVec_s,
self.distortion,
self.bvec,
)
xys_p, on_panel = self.clip_to_panel(det_xy)
valid_xys.append(xys_p)
# filter angs and hkls that are on the detector plane
# !!! check this -- seems unnecessary but the results of
# _project_on_detector_plane() can have len < the input?
# the output of _project_on_detector_plane has been modified to
# hand back the index array to remedy this JVB 2020-05-27
if np.any(~on_plane):
allAngs = np.atleast_2d(allAngs[on_plane, :])
allHKLs = np.atleast_2d(allHKLs[on_plane, :])
# grab hkls and gvec ids for this panel
valid_hkls.append(allHKLs[on_panel, 1:])
valid_ids.append(allHKLs[on_panel, 0])
# reflection angles (voxel centers) and pixel size in (tth, eta)
valid_angs.append(allAngs[on_panel, :])
ang_pixel_size.append(self.angularPixelSize(xys_p))
return valid_ids, valid_hkls, valid_angs, valid_xys, ang_pixel_size
[docs] def simulate_laue_pattern(
self,
crystal_data,
minEnergy=5.0,
maxEnergy=35.0,
rmat_s=None,
tvec_s=None,
grain_params=None,
beam_vec=None,
):
""" """
if isinstance(crystal_data, PlaneData):
plane_data = crystal_data
# grab the expanded list of hkls from plane_data
hkls = np.hstack(plane_data.getSymHKLs())
# and the unit plane normals (G-vectors) in CRYSTAL FRAME
gvec_c = np.dot(plane_data.latVecOps['B'], hkls)
# Filter out g-vectors going in the wrong direction. `gvec_to_xy()` used
# to do this, but not anymore.
to_keep = np.dot(gvec_c.T, self.bvec) <= 0
hkls = hkls[:, to_keep]
gvec_c = gvec_c[:, to_keep]
elif len(crystal_data) == 2:
# !!! should clean this up
hkls = np.array(crystal_data[0])
bmat = crystal_data[1]
gvec_c = np.dot(bmat, hkls)
else:
raise RuntimeError(
f'argument list not understood: {crystal_data=}'
)
nhkls_tot = hkls.shape[1]
# parse energy ranges
# TODO: allow for spectrum parsing
multipleEnergyRanges = False
if hasattr(maxEnergy, '__len__'):
assert len(maxEnergy) == len(
minEnergy
), 'energy cutoff ranges must have the same length'
multipleEnergyRanges = True
lmin = []
lmax = []
for i in range(len(maxEnergy)):
lmin.append(ct.keVToAngstrom(maxEnergy[i]))
lmax.append(ct.keVToAngstrom(minEnergy[i]))
else:
lmin = ct.keVToAngstrom(maxEnergy)
lmax = ct.keVToAngstrom(minEnergy)
# parse grain parameters kwarg
if grain_params is None:
grain_params = np.atleast_2d(
np.hstack([np.zeros(6), ct.identity_6x1])
)
n_grains = len(grain_params)
# sample rotation
if rmat_s is None:
rmat_s = ct.identity_3x3
# dummy translation vector... make input
if tvec_s is None:
tvec_s = ct.zeros_3
# beam vector
if beam_vec is None:
beam_vec = ct.beam_vec
# =========================================================================
# LOOP OVER GRAINS
# =========================================================================
# pre-allocate output arrays
xy_det = np.nan * np.ones((n_grains, nhkls_tot, 2))
hkls_in = np.nan * np.ones((n_grains, 3, nhkls_tot))
angles = np.nan * np.ones((n_grains, nhkls_tot, 2))
dspacing = np.nan * np.ones((n_grains, nhkls_tot))
energy = np.nan * np.ones((n_grains, nhkls_tot))
for iG, gp in enumerate(grain_params):
rmat_c = make_rmat_of_expmap(gp[:3])
tvec_c = gp[3:6].reshape(3, 1)
vInv_s = mutil.vecMVToSymm(gp[6:].reshape(6, 1))
# stretch them: V^(-1) * R * Gc
gvec_s_str = np.dot(vInv_s, np.dot(rmat_c, gvec_c))
ghat_c_str = mutil.unitVector(np.dot(rmat_c.T, gvec_s_str))
# project
dpts = gvec_to_xy(
ghat_c_str.T,
self.rmat,
rmat_s,
rmat_c,
self.tvec,
tvec_s,
tvec_c,
beam_vec=beam_vec,
)
# check intersections with detector plane
canIntersect = ~np.isnan(dpts[:, 0])
npts_in = sum(canIntersect)
if np.any(canIntersect):
dpts = dpts[canIntersect, :].reshape(npts_in, 2)
dhkl = hkls[:, canIntersect].reshape(3, npts_in)
rmat_b = make_beam_rmat(beam_vec, ct.eta_vec)
# back to angles
tth_eta, gvec_l = xy_to_gvec(
dpts,
self.rmat,
rmat_s,
self.tvec,
tvec_s,
tvec_c,
rmat_b=rmat_b,
)
tth_eta = np.vstack(tth_eta).T
# warp measured points
if self.distortion is not None:
dpts = self.distortion.apply_inverse(dpts)
# plane spacings and energies
dsp = 1.0 / mutil.rowNorm(gvec_s_str[:, canIntersect].T)
wlen = 2 * dsp * np.sin(0.5 * tth_eta[:, 0])
# clip to detector panel
_, on_panel = self.clip_to_panel(dpts, buffer_edges=True)
if multipleEnergyRanges:
validEnergy = np.zeros(len(wlen), dtype=bool)
for i in range(len(lmin)):
in_energy_range = np.logical_and(
wlen >= lmin[i], wlen <= lmax[i]
)
validEnergy = validEnergy | in_energy_range
else:
validEnergy = np.logical_and(wlen >= lmin, wlen <= lmax)
# index for valid reflections
keepers = np.where(np.logical_and(on_panel, validEnergy))[0]
# assign output arrays
xy_det[iG][keepers, :] = dpts[keepers, :]
hkls_in[iG][:, keepers] = dhkl[:, keepers]
angles[iG][keepers, :] = tth_eta[keepers, :]
dspacing[iG, keepers] = dsp[keepers]
energy[iG, keepers] = ct.keVToAngstrom(wlen[keepers])
return xy_det, hkls_in, angles, dspacing, energy
[docs] @staticmethod
def update_memoization_sizes(all_panels):
funcs = [
_polarization_factor,
_lorentz_factor,
]
min_size = len(all_panels)
return Detector.increase_memoization_sizes(funcs, min_size)
[docs] @staticmethod
def increase_memoization_sizes(funcs, min_size):
for f in funcs:
cache_info = f.cache_info()
if cache_info['maxsize'] < min_size:
f.set_cache_maxsize(min_size)
[docs] def calc_physics_package_transmission(self, energy: np.floating,
rMat_s: np.array,
physics_package: AbstractPhysicsPackage) -> np.float64:
"""get the transmission from the physics package
need to consider HED and HEDM samples separately
"""
bvec = self.bvec
sample_normal = np.dot(rMat_s, [0., 0., np.sign(bvec[2])])
seca = 1./np.dot(bvec, sample_normal)
tth, eta = self.pixel_angles()
angs = np.vstack((tth.flatten(), eta.flatten(),
np.zeros(tth.flatten().shape))).T
dvecs = angles_to_dvec(angs, beam_vec=bvec)
cosb = np.dot(dvecs, sample_normal)
'''angles for which secb <= 0 or close are diffracted beams
almost parallel to the sample surface or backscattered, we
can mask out these values by setting secb to nan
'''
mask = np.logical_or(
cosb < 0,
np.isclose(
cosb,
0.,
atol=5E-2,
)
)
cosb[mask] = np.nan
secb = 1./cosb.reshape(self.shape)
T_sample = self.calc_transmission_sample(
seca, secb, energy, physics_package)
T_window = self.calc_transmission_window(
secb, energy, physics_package)
transmission_physics_package = T_sample * T_window
return transmission_physics_package
[docs] def calc_compton_physics_package_transmission(
self,
energy: np.floating,
rMat_s: np.array,
physics_package: AbstractPhysicsPackage,
) -> np.ndarray:
'''calculate the attenuation of inelastically
scattered photons. since these photons lose energy,
the attenuation length is angle dependent ergo a separate
routine than elastically scattered absorption.
'''
bvec = self.bvec
sample_normal = np.dot(rMat_s, [0., 0., np.sign(bvec[2])])
seca = 1./np.dot(bvec, sample_normal)
tth, eta = self.pixel_angles()
angs = np.vstack((tth.flatten(), eta.flatten(),
np.zeros(tth.flatten().shape))).T
dvecs = angles_to_dvec(angs, beam_vec=bvec)
cosb = np.dot(dvecs, sample_normal)
'''angles for which secb <= 0 or close are diffracted beams
almost parallel to the sample surface or backscattered, we
can mask out these values by setting secb to nan
'''
mask = np.logical_or(
cosb < 0,
np.isclose(
cosb,
0.,
atol=5E-2,
)
)
cosb[mask] = np.nan
secb = 1./cosb.reshape(self.shape)
T_sample = self.calc_compton_transmission(
seca, secb, energy,
physics_package, 'sample')
T_window = self.calc_compton_transmission_window(
secb, energy, physics_package)
return T_sample * T_window
[docs] def calc_compton_window_transmission(
self,
energy: np.floating,
rMat_s: np.ndarray,
physics_package: AbstractPhysicsPackage,
) -> np.ndarray:
'''calculate the attenuation of inelastically
scattered photons just fropm the window.
since these photons lose energy, the attenuation length
is angle dependent ergo a separate routine than
elastically scattered absorption.
'''
bvec = self.bvec
sample_normal = np.dot(rMat_s, [0., 0., np.sign(bvec[2])])
seca = 1./np.dot(bvec, sample_normal)
tth, eta = self.pixel_angles()
angs = np.vstack((tth.flatten(), eta.flatten(),
np.zeros(tth.flatten().shape))).T
dvecs = angles_to_dvec(angs, beam_vec=bvec)
cosb = np.dot(dvecs, sample_normal)
'''angles for which secb <= 0 or close are diffracted beams
almost parallel to the sample surface or backscattered, we
can mask out these values by setting secb to nan
'''
mask = np.logical_or(
cosb < 0,
np.isclose(
cosb,
0.,
atol=5E-2,
)
)
cosb[mask] = np.nan
secb = 1./cosb.reshape(self.shape)
T_window = self.calc_compton_transmission(
seca, secb, energy,
physics_package, 'window')
T_sample = self.calc_compton_transmission_sample(
seca, energy, physics_package)
return T_sample * T_window
[docs] def calc_transmission_sample(self, seca: np.array,
secb: np.array, energy: np.floating,
physics_package: AbstractPhysicsPackage) -> np.array:
thickness_s = physics_package.sample_thickness # in microns
if np.isclose(thickness_s, 0):
return np.ones(self.shape)
# in microns^-1
mu_s = 1./physics_package.sample_absorption_length(energy)
x = (mu_s*thickness_s)
pre = 1./x/(secb - seca)
num = np.exp(-x*seca) - np.exp(-x*secb)
return pre * num
[docs] def calc_transmission_window(self, secb: np.array, energy: np.floating,
physics_package: AbstractPhysicsPackage) -> np.array:
material_w = physics_package.window_material
thickness_w = physics_package.window_thickness # in microns
if material_w is None or np.isclose(thickness_w, 0):
return np.ones(self.shape)
# in microns^-1
mu_w = 1./physics_package.window_absorption_length(energy)
return np.exp(-thickness_w*mu_w*secb)
[docs] def calc_compton_transmission(
self,
seca: np.ndarray,
secb: np.ndarray,
energy: np.floating,
physics_package: AbstractPhysicsPackage,
pp_layer: str,
) -> np.ndarray:
if pp_layer == 'sample':
formula = physics_package.sample_material
density = physics_package.sample_density
thickness = physics_package.sample_thickness
mu = 1./physics_package.sample_absorption_length(energy)
mu_prime = 1. / self.pixel_compton_attenuation_length(
energy, density, formula,
)
elif pp_layer == 'window':
formula = physics_package.window_material
if formula is None:
return np.ones(self.shape)
density = physics_package.window_density
thickness = physics_package.window_thickness
mu = 1./physics_package.sample_absorption_length(energy)
mu_prime = 1./self.pixel_compton_attenuation_length(
energy, density, formula)
if thickness <= 0:
return np.ones(self.shape)
x1 = mu*thickness*seca
x2 = mu_prime*thickness*secb
num = (np.exp(-x1) - np.exp(-x2))
return -num/(x1 - x2)
[docs] def calc_compton_transmission_sample(
self,
seca: np.ndarray,
energy: np.floating,
physics_package: AbstractPhysicsPackage,
) -> np.ndarray:
thickness_s = physics_package.sample_thickness # in microns
mu_s = 1./physics_package.sample_absorption_length(
energy)
return np.exp(-mu_s*thickness_s*seca)
[docs] def calc_compton_transmission_window(
self,
secb: np.ndarray,
energy: np.floating,
physics_package: AbstractPhysicsPackage,
) -> np.ndarray:
formula = physics_package.window_material
if formula is None:
return np.ones(self.shape)
density = physics_package.window_density # in g/cc
thickness_w = physics_package.window_thickness # in microns
mu_w_prime = 1./self.pixel_compton_attenuation_length(
energy, density, formula)
return np.exp(-mu_w_prime*thickness_w*secb)
[docs] def calc_effective_pinhole_area(self, physics_package: AbstractPhysicsPackage) -> np.array:
'''get the effective pinhole area correction
@SS 04/01/25 a modification was made based on the
CeO2 data recorded on NIF. An extra factor of sec(beta)
was included as compared to RSI 91, 043902 (2020).
'''
hod = (
physics_package.pinhole_thickness /
physics_package.pinhole_diameter
)
'''we compute the beta angle using existing
functions by just changing beam vector
to be the z-axis with the right sign.
'''
bvec = np.array([0., 0., np.sign(self.bvec[2])])
beta, eta = self.pixel_angles(bvec=bvec)
tb = np.tan(beta)
jb = hod*tb
jb[jb > 1] = np.nan
jb2 = jb**2
mask = np.isclose(jb2, 0.)
f1 = np.zeros_like(jb)
f3 = 1/jb2[~mask] - 1
f3[f3<0.] = np.nan
f1[~mask] = np.arctan(np.sqrt(f3))
f1[mask] = np.pi/2
f3 = 1 - jb2
f3[f3<0.] = np.nan
f2 = jb*np.sqrt(f3)
return 0.5*(f1 - f2)
[docs] def calc_transmission_generic(self,
secb: np.array,
thickness: np.floating,
absorption_length: np.floating) -> np.array:
if np.isclose(thickness, 0):
return np.ones(self.shape)
mu = 1./absorption_length # in microns^-1
return np.exp(-thickness*mu*secb)
[docs] def calc_transmission_phosphor(self,
secb: np.array,
thickness: np.floating,
readout_length: np.floating,
absorption_length: np.floating,
energy: np.floating,
pre_U0: np.floating) -> np.array:
if np.isclose(thickness, 0):
return np.ones(self.shape)
f1 = absorption_length*thickness
f2 = absorption_length*readout_length
arg = (secb + 1/f2)
return pre_U0 * energy*((1.0 - np.exp(-f1*arg))/arg)
# =============================================================================
# UTILITY METHODS
# =============================================================================
def _fix_indices(idx, lo, hi):
nidx = np.array(idx)
off_lo = nidx < lo
off_hi = nidx > hi
nidx[off_lo] = lo
nidx[off_hi] = hi
return nidx
def _row_edge_vec(rows, pixel_size_row):
return pixel_size_row * (0.5 * rows - np.arange(rows + 1))
def _col_edge_vec(cols, pixel_size_col):
return pixel_size_col * (np.arange(cols + 1) - 0.5 * cols)
@numba.njit(nogil=True, cache=True)
def _interpolate_bilinear(
img: np.ndarray,
cc: np.ndarray,
fc: np.ndarray,
cf: np.ndarray,
ff: np.ndarray,
i_floor_img: np.ndarray,
j_floor_img: np.ndarray,
i_ceil_img: np.ndarray,
j_ceil_img: np.ndarray,
) -> np.ndarray:
# The math is faster and uses the GIL less (which is more
# multi-threading friendly) when we run this code in numba.
result = np.zeros(i_floor_img.shape[0], dtype=img.dtype)
on_panel_idx = np.arange(i_floor_img.shape[0])
_interpolate_bilinear_in_place(
img,
cc,
fc,
cf,
ff,
i_floor_img,
j_floor_img,
i_ceil_img,
j_ceil_img,
on_panel_idx,
result,
)
return result
@numba.njit(nogil=True, cache=True)
def _interpolate_bilinear_in_place(
img: np.ndarray,
cc: np.ndarray,
fc: np.ndarray,
cf: np.ndarray,
ff: np.ndarray,
i_floor_img: np.ndarray,
j_floor_img: np.ndarray,
i_ceil_img: np.ndarray,
j_ceil_img: np.ndarray,
on_panel_idx: np.ndarray,
output_img: np.ndarray,
):
# The math is faster and uses the GIL less (which is more
# multi-threading friendly) when we run this code in numba.
# Running in-place eliminates some intermediary arrays for
# even faster performance.
for i in range(on_panel_idx.shape[0]):
idx = on_panel_idx[i]
output_img[idx] += (
cc[i] * img[i_floor_img[i], j_floor_img[i]] +
fc[i] * img[i_floor_img[i], j_ceil_img[i]] +
cf[i] * img[i_ceil_img[i], j_floor_img[i]] +
ff[i] * img[i_ceil_img[i], j_ceil_img[i]]
)