Source code for MDMC.trajectory_analysis.observables.fqt

"""
Module for Intermediate Scattering Function class.
"""
import logging
from abc import abstractmethod
from collections.abc import Callable
from contextlib import suppress
from itertools import product
from typing import Generator, Literal, Optional

import numpy as np
import periodictable

from MDMC.common import units
from MDMC.common.constants import h_bar
from MDMC.common.decorators import unit_decorator, unit_decorator_getter
from MDMC.common.mathematics import (
    UNIT_VECTOR,
    faster_autocorrelation,
    faster_correlation,
)
from MDMC.resolution import Resolution
from MDMC.trajectory_analysis.compact_trajectory import CompactTrajectory
from MDMC.trajectory_analysis.observables.concurrency_tools import (
    core_batch,
    create_executor,
)
from MDMC.trajectory_analysis.observables.obs import Observable
from MDMC.trajectory_analysis.observables.obs_factory import ObservableFactory
from MDMC.trajectory_analysis.observables.sqw import SQwMixins

# pylint: disable=c-extension-no-member
ThreeVec = tuple[float, float, float]


[docs] class AbstractFQt(SQwMixins, Observable): """ Abstract class for total, coherent, and incoherent intermediate scattering functions. Equations used for calculating this are based on Kneller *et al*. [Kneller]_. See Also -------- SQwMixins : Properties for MD frames & Q are found in the SQwMixins class. References ---------- .. [Kneller] Kneller *et al*. Comput. Phys. Commun. 91 (1995) 191-214. """ def __init__(self): super().__init__() self._trajectory = None self._independent_variables = {} self._dependent_variables = {} self._errors = None # Use FFT by default self._use_FFT = True self.reciprocal_basis = None self.n_Q_vectors = None self.Q_values = None self.weights = None self.recreated_Q = [] @property def independent_variables(self) -> dict: """ Get or set the independent variables. These are the frequency Q (in ``Ang^-1``) and time t (in ``fs``). Returns ------- dict The independent variables. """ return self._independent_variables @independent_variables.setter def independent_variables(self, value: dict) -> None: self._independent_variables = value @property def dependent_variables(self) -> dict: """ Get the dependent variables. This is FQt, the intermediate scattering function (in ``arb``). Returns ------- dict The dependent variables. """ return self._dependent_variables @property def errors(self) -> dict: """ Get or set the errors on the dependent variables. This is the intermediate scattering function error (in ``arb``). Returns ------- dict The errors on the ``dependent_variables``. """ return self._errors @errors.setter def errors(self, value: dict) -> None: self._errors = value @property def t(self) -> np.ndarray: """ Get or set the times of the intermediate scattering function (in ``fs``). Returns ------- numpy.array 1D array of times in ``fs``. """ return self.independent_variables['t'] @t.setter @unit_decorator(unit=units.TIME) def t(self, value: 'np.ndarray') -> None: self.independent_variables['t'] = value @property @unit_decorator_getter(unit=units.ARBITRARY) def FQt(self) -> Optional[list[np.ndarray]]: """ Get or set the dynamic structure factor, F(Q, t) (in ``arb``). Returns ------- list of numpy.ndarray `list` of 2D arrays of F(Q, t) `float` with arbitrary units. """ try: return self.dependent_variables['FQt'] except KeyError: return None @FQt.setter def FQt(self, value: 'list[np.ndarray]') -> None: self.dependent_variables['FQt'] = value
[docs] def calculate_from_MD(self, MD_input: CompactTrajectory, verbose: int = 0, **settings: dict) -> None: """ Calculate the intermediate scattering function from a trajectory. ``independent_variables`` can either be set previously or defined within ``**settings``. Parameters ---------- MD_input : CompactTrajectory ``CompactTrajectory`` object to compute F(Q, t) from. verbose : int, optional The level of verbosity: - Verbose level 0 gives no information. - Verbose level 1 gives final time for the whole method. - Verbose level 2 gives final time and also a progress bar. - Verbose level 3 gives final time, a progress bar, and time per step. **settings **n_Q_vectors** (`int`) The maximum number of ``Q_vectors`` for any ``Q`` value. The greater the number of ``Q_vectors``, the more accurate the calculation, but the longer it will take. **dimensions** (`list`, `tuple`, `numpy.ndarray`) A 3 element `tuple` or ``array`` of `float` specifying the dimensions of the ``Universe`` in units of ``Ang`` """ self._origin = "MD" # if Q_values are specified, set Q to them with suppress(KeyError): self.Q = np.array(settings['Q_values']) self.t = MD_input.times - MD_input.times[0] self._trajectory = MD_input self._set_weights() try: self.universe_dimensions = MD_input.dimensions except AttributeError: print("DEBUG: no universe dimensions in the CompactTrajectory") try: self.universe_dimensions = np.array(settings['dimensions']) except KeyError as error: raise AttributeError('Either trajectory requires a dimensions' ' attribute or dimensions must be passed' ' when calling calculate_from_MD') from error self.reciprocal_basis = (np.array(2. * np.pi / self.universe_dimensions) * UNIT_VECTOR) # calculate Q vectors from Q self.n_Q_vectors = settings.get('n_Q_vectors', 50) try: Q_vectors = settings['Q_vectors'] except KeyError: Q_vectors = self._calculate_Q_vectors(self.Q) # Calculate FQt for each Q value # 'Q_v' is a list of Q_vectors corresponding to a single Q FQt_array = np.array([self._calculate_FQt_single_Q(Q_v) for Q_v in Q_vectors]) # Get the positions of the recreated_q_values if (self.Q is not None) and (len(self.Q) != len(self.Q_values)): self.recreated_Q.extend(np.where(value == self.Q)[0][0] for value in self.Q_values) self.Q = [val for val in self.Q if val in self.Q_values] logging.warning(" The specified universe dimensions were not able to recreate the" " lowest q values of the experimental data and so this data has been" " trimmed accordingly.") # Remove the padded elements at the end of FQt which will be filled # with NaN's # FQt_size is the number of Q values if specified, and otherwise we # assume Q is taken from settings and use the number of lists of vectors instead FQt_size = len(self.Q_values) if self.Q_values is not None else len(settings['Q_vectors']) self.FQt = FQt_array[:FQt_size]
def _calculate_Q_vectors(self, Q_values: list) -> Generator[list, None, None]: """ Calculate a number of Q vectors that lie close to each Q value. The upper limit of the number of Q vectors for a specific Q value is determined by ``self.n_Q_vectors``, however in the case that there are less than ``self.n_Q_vectors`` close to Q then the number of Q vectors will be less than ``self.n_Q_vectors``. This means in general, the second dimension of the returned array is not well defined. Parameters ---------- Q_values : list A ``list` of ``float`` for the Q values. Yields ------ list A list of Q-vectors for each Q-value - each Q-vector is a 3-dimensional vector in reciprocal space. """ # Only valid for uniform Q_values Q_step = (Q_values[1] - Q_values[0]) / 2. updated_Q_values = [] for Q in Q_values: # For each ``Q``, define a shell in momentum space bounded by # ``Q_min`` and ``Q_max`` in which to search for vectors Q_min = Q - Q_step Q_max = Q + Q_step vectors = self._calculate_vectors_single_Q(Q_min, Q_max) if len(vectors) > 0: updated_Q_values.append(Q) yield vectors self.Q_values = updated_Q_values def _calculate_vectors_single_Q(self, Q_min: float, Q_max: float) -> list: """ Calculate a number of Q vectors with a magnitude between ``Q_min`` and ``Q_max``. The upper limit of the number of Q vectors is determined by ``self.n_Q_vectors``. Parameters ---------- Q_min : float The minimum Q value for which a Q vector can be calculated. Q_max : float The maximum Q value for which a Q vector can be calculated. Returns ------- list A list of Q vectors which lie within the range defined by ``Q_min`` and ``Q_max``. """ # define a cube in reciprocal space x_max, y_max, z_max = (int(Q_max / np.linalg.norm(r_b)) for r_b in self.reciprocal_basis) # get the point group of the universe; we can use Wyckoff symmetries # to generate vectors more quickly point_group = get_point_group(self.universe_dimensions) # create components of the Q vector for each axis on each lattice point in the cube # note we are only defining vector components for one octant of the cube - # Wyckoff symmetries will reflect them into other octants for us vector_x = (np.array(list(range(0, x_max + 1))).reshape(-1, 1) * self.reciprocal_basis[0]) vector_y = (np.array(list(range(0, y_max + 1))).reshape(-1, 1) * self.reciprocal_basis[1]) vector_z = (np.array(list(range(0, z_max + 1))).reshape(-1, 1) * self.reciprocal_basis[2]) Q_vectors: list = [] # combine to create overall vectors for each lattice point in the cube # the 'if' part of the generator comprehension ensures that we aren't generating # large numbers of duplicate vectors from multiple vectors within the same symmetry group vectors = ((x[0] + x[1] + x[2]) for x in product(vector_x, vector_y, vector_z) if not any(v in Q_vectors for v in wyckoff_symmetries((tuple(x[0] + x[1] + x[2])), point_group))) # get all vectors that fit our requirements for vector in vectors: if Q_min < np.linalg.norm(vector) <= Q_max and vector.all != 0: # add vector and all its symmetries Q_vectors.extend(wyckoff_symmetries(vector, point_group)) if len(Q_vectors) >= self.n_Q_vectors: break return Q_vectors @abstractmethod def _calculate_FQt_single_Q(self, single_Q_vectors: np.ndarray) -> np.ndarray: r""" Calculate F(Q, t) for a single value of Q. The length of the correlations is bounded by the length of the ``self.E + 1`` rather than ``self.t``, as this allows energies to be calculated from trajectories with longer timescales than is required by the energy resolution. We start by calculating the Fourier transformed number densities for the atoms :math:`j` of element :math:`\alpha`: .. math:: \rho_{\alpha, m_Q}(n_t, p) = \sum_{j \in \alpha} e^{-i \vec{q_{p}} \cdot \vec{r_j}} Where :math:`n_t` indexes time and :math:`p` indexes momentum vector. F(Q,t) is calculated from the correlation :math:`C` between these number densities, where we make use of the correlation theorem of discrete periodic functions to speed up calculation using the FFT [see E.O. Brigham, The Fast Fourier Transform, 1974]: .. math:: C_{\alpha,\beta,m_Q}(n_t, p) = \Re \left[ \frac{1}{N_E - |n_t|} \mathcal{F}_t^{-1} \left[ \tilde{\rho}'^*_{\alpha, m_Q}(n_E, p) \tilde{\rho}'_{\beta, m_Q}(n_E, p) \right] \right] Where we denote the Fourier transform of :math:`\rho` as: .. math:: \tilde{\rho}'_{\alpha, m_Q}(n_E, p) = \mathcal{F}_t \left[ \rho'_{\alpha, m_Q}(n_t, p) \right] Where :math:`\rho'` denotes that :math:`\rho` has been padded with zeros in the time domain to give it twice its orginal length. For the coherent contribution, we calculate: .. math:: F_{m_Q}^{coh}(n_t) = \sum_{\alpha} \sum_{\beta} B^{coh}_\alpha B^{coh}_\beta \sum_p C_{\alpha,\beta, m_Q}(n_t, p) For the incoherent contribution, we calculate: .. math:: F_{m_Q}^{inc}(n_t) = \sum_{\alpha} \left( B^{inc}_\alpha \right)^2 \sum_p C_{\alpha,\alpha, m_Q}(n_t, p) The ideal (not accounting for instrument resolution) scattering function is normalised by both the number of atoms that contributed and the number of Q vectors used for the single value of Q. Including both coherent and incoherent contributions gives: .. math:: F_{m_Q}^{ideal}(n_t) = \frac{ F_{m_Q}^{coh}(n_t) + F_{m_Q}^{inc}(n_t)}{N_{atoms} N_p If we were only considering the coherent/incoherent scattering function, then the other term is simply omitted from the numerator in the previous equation. Parameters ---------- single_Q_vectors : numpy.ndarray An array of Q vectors with approximately the same magnitude. Returns ------- numpy.ndarray An ``array`` with length ``len(self.E) + 1``. """ raise NotImplementedError
[docs] def calculate_rho_config(self, config: np.ndarray, single_Q_vectors: list) -> np.ndarray: """ Calculate density over an entire configuration. Parameters ---------- config : np.ndarray An array of atom positions. single_Q_vectors : list A list of Q-vectors for a single value of Q. Returns ------- np.ndarray An array of rho values for each timestep summed over the atoms in the system, corresponding to each Q. """ def helper_coherent(configs: np.ndarray, q_vector: np.ndarray) -> np.ndarray: """ Wrapper for `calculate_rho` and the summation of the resulting array. Parameters ---------- configs : numpy.ndarray Array of atom positions, size (N_timesteps, 3, N_atoms). q_vector : numpy.ndarray Q vector in array form, size (3). Returns ------- numpy.ndarray Array of rho values summed over the atoms in the system, size (N_timesteps). Notes ----- This part of the calculation is handled by numpy, and so it is easy to run in parallel. """ return next(calculate_rho(configs, [q_vector])).sum(axis=1) rho_config = np.zeros((len(config), len(single_Q_vectors)), dtype=complex) executor = create_executor() # For the np.dot product to be broadcast correctly, # the [x, y, z] atom positions have to be on axis 1. # For this reason we swap the axes, moving the # axis of atom numbers to axis 2. # Time axis is still axis 0. configs = np.swapaxes(config, 1, 2) # The single_Q_vectors array contains many q vectors # with similar values of |Q|. # The following lines split the calculation by multiplying # the trajectory by each q vector separately. futures = core_batch((executor.submit(helper_coherent, configs, vector) for vector in single_Q_vectors)) # Append to rho_config as completed, block until all futures added q_num = 0 for future_batch in futures: results = [future.result() for future in future_batch] for result in results: rho_config[:, q_num] = result q_num += 1 return rho_config
@abstractmethod def _set_weights(self) -> None: """ Calculate the neutron weighting. """ raise NotImplementedError
[docs] def calculate_SQw(self, energy: list[float], resolution: Resolution = None) -> np.ndarray: """ Calculate S(Q, w) from F(Q, t), accounting for instrument resolution. In order to obtain ``len(energy)`` values in energy, we reflect the intermediate scattering function in time to give it dimensions of ``(len(self.Q), 2 * (len(self.t)) - 2)``. This uses the fact it is even in time, and the number of time points is chosen to be 1 greater than the number of energy points [Rapaport, The Art of Molecular Dynamics Simulation (2nd Edition), 2004, page 142]. The numpy implementation of the FFT gives frequencies arranged so that the first ``len(energy)`` points in the energy dimension correspond to positive frequencies, and the remaining points have negative frequency. Parameters ---------- energy : list of floats The list of energy (E) points at which S(Q, w) will be calculated. resolution : Resolution (default None) The instrument resolution object which will be applied to FQt. Returns ------- numpy.ndarray The S(Q, w) calculated from F(Q, t). """ nE = len(energy) if self.use_FFT: # Ensure that if we recorded a longer trajectory than required by # the FFT, we crop it to match the energy points. This should # already be the case, but if the energy values and trajectories # are manually provided it may not be. self.FQt = self.FQt[:, :nE + 1] if resolution is not None: self.apply_resolution(resolution) # Reflect F(t) [except for both end points] for each Q value and append # it to F(t) to form an array of shape (n_row, 2*n_col - 2) FQt_mirror = np.append(self.FQt, self.FQt[:, -2:0:-1], axis=1) if self.use_FFT: # FFT and reduce the energy dimension to positive energies SQw_cropped = np.fft.fft(FQt_mirror)[:, :nE] else: SQw_cropped = np.zeros((len(FQt_mirror), nE), dtype='complex') for i, E in enumerate(energy): # Create 1D array of exponential factors. Dotting with F(Q,t) # sums over the time/energy dimension as required for a # discrete Fourier transform # h_bar is in units of eV s whereas system units are meV fs, so # apply a factor of 1e3 * 1e15 to convert it exp = np.exp(-1j * E * self.t[:-1] / (h_bar * 1e18)) exp_neg = np.exp( 1j * E * self.t[-1:0:-1] / (h_bar * 1e18)) exp_mirror = np.append(exp, exp_neg) SQw_cropped[:, i] = np.dot(FQt_mirror, exp_mirror) # Normalisation requires factor of dt (in ps, so convert from fs) # see Kneller et al. Comput. Phys. Commun. 91 (1995) 191-214 dt = (self.t[1] - self.t[0]) / 1000. # The factor of 0.5 accounts for transforming over the reflected F(Q,t) # By default numpy fft is unnormalized, so to have the same power as in # FQt the transform should be normalized to the length of the spectra return 0.5 * dt * np.real(SQw_cropped) / len(FQt_mirror)
[docs] def apply_resolution(self, resolution: Resolution) -> "FQt": # type: ignore """ Apply instrument resolution to an FQt object. Parameters ---------- resolution : Resolution The Resolution object to apply to FQt. Returns ------- FQt The FQt object with resolution applied. """ self.FQt = resolution.apply(self.FQt, self.t, self.Q) return self.FQt
@property def dependent_variables_structure(self) -> dict[str, list]: """ The order of indexing of 'FQt' dependent variables in terms of 'Q' and 't'. The purpose of this method is to ensure consistency between different readers/methods which create ``FQt`` objects. Returns ------- dict[str, list] The shape of the SQw dependent variable. Notes ----- Explicitly: we have that self.FQt[Q_index, t_index] is the data point for given indices of self.Q and self.t. It also means that: np.shape(self.FQt)=(np.size(self.Q), np.size(self.t)). """ return {'FQt': ['Q', 't']} @property def uniformity_requirements(self) -> dict[str, dict[str, bool]]: """ Get restrictions required for computing E & Q. Capture the current limitations on the time 't' and reciprocal lattice points 'Q' within the intermediate scattering function ``Observables``. If using FFT, then 't' must be uniform and start at zero, otherwise it has no restrictions. 'Q' must be uniform but does not need to start at zero. Returns ------- dict[str, dict[str, bool]] Dictionary of uniformity restrictions for 't' and 'Q'. """ if self.use_FFT: t_requirements = {'uniform': True, 'zeroed': True} else: t_requirements = {'uniform': False, 'zeroed': False} return {'t': t_requirements, 'Q': {'uniform': True, 'zeroed': False}}
[docs] @ObservableFactory.register(('IntermediateScatteringFunction', 'FQt')) class FQt(AbstractFQt): """ Class to process the intermediate scattering function for the total dynamic structure factor. """ def _set_weights(self) -> None: """ Calculate the neutron weighting for coherent and incoherent scattering. See Also -------- periodictable.nsf : Source for neutron scattering factors. """ elem_getter = periodictable.elements.symbol self.weights = {element: {'coh': (elem_getter(element).neutron.b_c if elem_getter(element).neutron.b_c is not None else 0), 'incoh': (elem_getter(element).neutron.b_c_i if elem_getter(element).neutron.b_c_i is not None else calc_incoherent_scatt_length(element))} for element in self._trajectory.element_set} def _calculate_FQt_single_Q(self, single_Q_vectors: list) -> np.ndarray: # Inherit docstring of abstract method n_t = len(self.t) elements = self._trajectory.element_set FQt_single_Q = np.zeros(n_t) rho_element = {} executor = create_executor() for element in elements: # Get the positions of all atoms (the configuration) of each # element over time such that ``element_configs`` has time as its # first dimension and each atom of ``element`` as its second indexes = np.where(np.array(self._trajectory.element_list) == element) element_configs = self._trajectory.position[:, indexes[0], :] rho_element[element] = self.calculate_rho_config(element_configs, single_Q_vectors) # Incoherent contribution incoh_weights = self.weights[element]['incoh'] # rearrange the axes so that calculate_rho broadcasts correctly element_configs = np.swapaxes(element_configs, 1, 2) element_configs = np.swapaxes(element_configs, 0, 2) rho_all = calculate_rho(element_configs, single_Q_vectors) futures = core_batch((executor.submit(faster_autocorrelation, rho.T, weights=incoh_weights**2) for rho in rho_all)) for future_batch in futures: results = [future.result() for future in future_batch] for result in results: FQt_single_Q += result[:n_t] # Calculates the coherent contribution to SQw for element1 in elements: for element2 in elements: # A sum over the Q vectors is performed within ``correlation``. FQt_single_Q += self.weights[element1]['coh'] \ * self.weights[element2]['coh'] \ * faster_correlation(rho_element[element1], rho_element[element2])[:n_t] # Normalise to the number of orthogonal vectors # default to 1 if there are no vectors to avoid division by 0 norm = len(single_Q_vectors) or 1 return FQt_single_Q / (self._trajectory.n_atoms * norm)
[docs] def calculate_rho(positions: np.ndarray, Q_vectors: list) -> Generator[np.ndarray, None, None]: """ Calculate ``t`` dependent number density in reciprocal space for all Q vectors. As rho is the sum of the contributions for all of the specified Q vectors, these Q vectors should have the same Q value. Parameters ---------- positions : numpy.ndarray An ``array`` of atomic positions for which the reciprocal space number density should be calculated. Q_vectors : list A list of one or more Q vectors with the same Q value. Yields ------ np.ndarray Reciprocal space number density for each Q-vector. """ for Q_vector in Q_vectors: yield np.exp(-1j * np.dot(Q_vector, positions))
[docs] def get_point_group(dimensions: np.ndarray) -> str: """ Get the Hermann-Mauguin point group for the universe. .. note:: Currently, MDMC can only create universes with mutually orthogonal sides, so this method can only produce cubic, tetragonal, or orthorhombic universe point groups. Parameters ---------- dimensions : numpy.ndarray An array of length 3, containing the dimensions of the universe. Returns ------- str The point group symbol for the universe. Notes ----- For tetragonal universes, an additional identifier (``x``), (``y``), or (``z``) is added to identify which of the sides is unique. """ # we use a sum of bools to determine group; # if x == y, x == z, y == z # if all sides equal, all are true; if two sides equal, one is true; # if no sides equal, zero are true; equal_sides = sum([dimensions[0] == dimensions[1], dimensions[0] == dimensions[2], dimensions[1] == dimensions[2]]) if equal_sides == 3: # cubic return 'm-3m' if equal_sides == 1: # tetragonal # False == 0 in duck typing, so this only keeps # the side equal to True unique_side = ((dimensions[0] == dimensions[1]) * ' (z)' + (dimensions[0] == dimensions[2]) * ' (y)' + (dimensions[1] == dimensions[2]) * ' (x)') return '4/mmm' + unique_side return 'mmm' # orthorhombic
[docs] def wyckoff_symmetries(point: ThreeVec, point_group: str) -> set[ThreeVec]: """ Get the Wyckoff symmetries for a point based on its point group. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. point_group : str The point group of the universe in Hermann-Mauguin notation. Currently accepted groups are: - 'm-3m' (cubic) - '4/mmm' (tetragonal) - 'mmm' (orthorhombic) Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ def cubic(point: ThreeVec) -> set[ThreeVec]: """ The symmetries of a point in a cubic group. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ x, y, z = point # it's ugly, but an order of magnitude faster if we just list all the # symmetries out, calculate and return them return ({(x, y, z), (-x, -y, z), (-x, y, -z), (x, -y, -z), (z, x, y), (z, -x, -y), (-z, -x, y), (-z, x, -y), (y, z, x), (-y, z, -x), (y, -z, -x), (-y, -z, x), (y, x, -z), (-y, -x, -z), (y, -x, z), (-y, x, z), (x, z, -y), (-x, z, y), (-x, -z, -y), (x, -z, y), (z, y, -x), (z, -y, x), (-z, y, x), (-z, -y, -x), (-x, -y, -z), (x, y, -z), (x, -y, z), (-x, y, z), (-z, -x, -y), (-z, x, y), (z, x, -y), (z, -x, y), (-y, -z, -x), (y, -z, x), (-y, z, x), (y, z, -x), (-y, -x, z), (y, x, z), (-y, x, -z), (y, -x, -z), (-x, -z, y), (x, -z, -y), (x, z, y), (-x, z, -y), (-z, -y, x), (-z, y, -x), (z, -y, -x), (z, y, x)}) def tetragonal( unique_side: Literal['x', 'y', 'z'], ) -> Callable[[ThreeVec], set[ThreeVec]]: """ The symmetries of a point in a tetragonal group. Parameters ---------- unique_side : {'x', 'y', 'z'} Unique side of tetragon determining returned function. Returns ------- Callable A function for computing the set of the symmetries for the point. """ # slightly more complicated as we don't know what axis is unpermutable def tetragonal_z(point: ThreeVec) -> set[ThreeVec]: """ Tetragonal symmetries for unpermutable z-axis. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ x, y, z = point return ({(x, y, z), (-x, -y, z), (-y, x, z), (y, -x, z), (-x, y, -z), (x, -y, -z), (y, x, -z), (-y, -x, -z), (-x, -y, -z), (x, y, -z), (y, -x, -z), (-y, x, -z), (x, -y, z), (-x, y, z), (-y, -x, z), (y, x, z)}) def tetragonal_y(point: ThreeVec) -> set[ThreeVec]: """ Tetragonal symmetries for unpermutable y-axis. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ x, y, z = point return ({(x, y, z), (x, -y, z), (x, y, -z), (x, -y, -z), (-x, y, z), (-x, -y, z), (-x, y, -z), (-x, -y, -z), (z, y, x), (z, -y, x), (z, y, -x), (z, -y, -x), (-z, y, x), (-z, -y, x), (-z, y, -x), (-z, -y, -x)}) def tetragonal_x(point: ThreeVec) -> set[ThreeVec]: """ Tetragonal symmetries for unpermutable x-axis. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ x, y, z = point return ({(x, y, z), (-x, y, z), (x, y, -z), (-x, y, -z), (x, -y, z), (-x, -y, z), (x, -y, -z), (-x, -y, -z), (x, z, y), (-x, z, y), (x, z, -y), (-x, z, -y), (x, -z, y), (-x, -z, y), (x, -z, -y), (-x, -z, -y)}) # get the correct function and return it as the group function side = {'x': tetragonal_x, 'y': tetragonal_y, 'z': tetragonal_z} return side[unique_side] def orthorhombic(point: ThreeVec) -> set[ThreeVec]: """ The symmetries of a point in an orthorhombic group. Parameters ---------- point : tuple[float, float, float] A tuple of length 3 which corresponds to the coordinates (x, y, z) of a point. Returns ------- set[tuple[float, float, float]] A calculated set of the symmetries for the point. """ x, y, z = point return ({(x, y, z), (-x, -y, z), (-x, y, -z), (x, -y, -z), (-x, -y, -z), (x, y, -z), (x, -y, z), (-x, y, z)}) groups = {'m-3m': cubic, '4/mmm (x)': tetragonal('x'), '4/mmm (y)': tetragonal('y'), '4/mmm (z)': tetragonal('z'), 'mmm': orthorhombic} return groups[point_group](point)
[docs] def calc_incoherent_scatt_length(element): """ Calculate incoherent scattering length from incoherent scattering cross section. Parameters ---------- element : str A string representing the chemical symbol of the element, e.g 'He' for Helium. Returns ------- float Incoherent scattering length of chemical symbol passed in. """ xs_incoh = periodictable.elements.symbol(element).neutron.incoherent b_incoh = periodictable.elements.symbol(element).neutron.b_c_i if xs_incoh is not None and not b_incoh: b_incoh = float(np.sqrt(100 * xs_incoh / (4 * np.pi))) return float(b_incoh)