def __init__(self, crystal: Crystal, qpts: np.ndarray, frequencies: Quantity, structure_factors: Quantity, weights: Optional[np.ndarray] = None, temperature: Optional[Quantity] = None) -> None: """ Parameters ---------- crystal Lattice and atom information qpts Shape (n_qpts, 3) float ndarray. Q-point coordinates, in fractional coordinates of the reciprocal lattice frequencies Shape (n_qpts, 3*crystal.n_atoms) float Quantity. Phonon frequencies per q-point and mode structure_factors Shape (n_qpts, 3*crystal.n_atoms) float Quantity. Structure factor per q-point and mode weights Shape (n_qpts,) float ndarray. The weight for each q-point. If None, equal weights are assumed temperature Scalar float Quantity. The temperature used to calculate any temperature-dependent parts of the structure factor (e.g. Debye-Waller, Bose population factor). None if no temperature-dependent effects have been applied """ super().__init__(crystal, qpts, frequencies, weights) n_at = crystal.n_atoms n_qpts = len(qpts) # Check freqs axis 1 shape here - QpointFrequencies doesn't # enforce that the number of modes = 3*(number of atoms) _check_constructor_inputs( [frequencies, structure_factors, temperature], [Quantity, Quantity, [Quantity, type(None)]], [(n_qpts, 3 * n_at), (n_qpts, 3 * n_at), ()], ['frequencies', 'structure_factors', 'temperature']) self._structure_factors = structure_factors.to(ureg.bohr**2).magnitude self.structure_factors_unit = str(structure_factors.units) if temperature is not None: self._temperature = temperature.to(ureg.K).magnitude self.temperature_unit = str(temperature.units) else: self._temperature = None self.temperature_unit = str(ureg.K)
def from_cell_vectors(cls: CR, cell_vectors: Quantity) -> CR: """ Create a Crystal object from just cell vectors, containing no detailed structure information (atomic positions, species, masses) Parameters ---------- cell_vectors Shape (3, 3) float Quantity in length units. Cartesian unit cell vectors. cell_vectors[0] = a, cell_vectors[:, 0] = x etc. Returns ------- crystal """ return cls(cell_vectors, np.array([]), np.array([]), Quantity(np.array([], dtype=np.float64), 'amu'))
def broaden(self: S1D, x_width: Quantity, shape: str = 'gauss') -> S1D: """ Broaden y_data and return a new broadened spectrum object Parameters ---------- x_width Scalar float Quantity. The broadening FWHM shape One of {'gauss', 'lorentz'}. The broadening shape Returns ------- broadened_spectrum A new Spectrum1D object with broadened y_data Raises ------ ValueError If shape is not one of the allowed strings """ if shape == 'gauss': xsigma = self._gfwhm_to_sigma(x_width, self.get_bin_centres()) y_broadened = gaussian_filter1d( self.y_data.magnitude, xsigma, mode='constant') * ureg( self.y_data_unit) elif shape == 'lorentz': broadening = _distribution_1d(self.get_bin_centres().magnitude, x_width.to( self.x_data_unit).magnitude, shape=shape) y_broadened = correlate1d(self.y_data.magnitude, broadening, mode='constant') * ureg(self.y_data_unit) else: raise ValueError(f"Distribution shape '{shape}' not recognised") return Spectrum1D( np.copy(self.x_data.magnitude) * ureg(self.x_data_unit), y_broadened, deepcopy( (self.x_tick_labels)), deepcopy(self.metadata))
def _qpts_cart_to_frac(qpts: Quantity, crystal: Crystal) -> np.ndarray: """Convert set of q-points from Cartesian to fractional coordinates Parameters ---------- qpts Array of q-points in Cartesian coordinates. crystal Crystal structure determining reciprocal lattice Returns ------- np.ndarray Dimensionless array of q-points in fractional coordinates """ lattice = crystal.reciprocal_cell() return np.linalg.solve(lattice.to(ureg('1/bohr')).magnitude.T, qpts.to(ureg('1/bohr')).magnitude.T ).T
def __init__(self, crystal: Crystal, qpts: np.ndarray, frequencies: Quantity, weights: Optional[np.ndarray] = None) -> None: """ Parameters ---------- crystal Lattice and atom information qpts Shape (n_qpts, 3) float ndarray. Q-point coordinates frequencies Shape (n_qpts, n_branches) float Quantity. Frequencies per q-point and mode weights Shape (n_qpts,) float ndarray. The weight for each q-point. If None, equal weights are assumed """ _check_constructor_inputs( [crystal, qpts], [Crystal, np.ndarray], [(), (-1, 3)], ['crystal', 'qpts']) n_qpts = len(qpts) # Unlike QpointPhononModes and StructureFactor, don't test the # frequencies shape against number of atoms in the crystal, as # we may only have the cell vectors _check_constructor_inputs( [frequencies, weights], [Quantity, [np.ndarray, type(None)]], [(n_qpts, -1), (n_qpts,)], ['frequencies', 'weights']) self.crystal = crystal self.qpts = qpts self.n_qpts = n_qpts self._frequencies = frequencies.to(ureg.hartree).magnitude self.frequencies_unit = str(frequencies.units) if weights is not None: self.weights = weights else: self.weights = np.full(self.n_qpts, 1/self.n_qpts)
def mode_gradients_to_widths(mode_gradients: Quantity, cell_vectors: Quantity) -> Quantity: """ Converts mode gradients (units energy/(length^-1)) to an estimate of the mode widths (units energy) by using the cell volume and number of q-points to estimate the q-spacing. Note that the number of q-points is determined by the size of mode_gradients, so is not likely to give accurate widths if the q-points have been symmetry reduced. Parameters ---------- mode_gradients Shape (n_qpts, n_modes) float Quantity. The mode gradients. cell_vectors Shape (3, 3) float Quantity. The cell vectors """ cell_volume = Crystal.from_cell_vectors(cell_vectors)._cell_volume() modg = mode_gradients.to('hartree*bohr').magnitude q_spacing = 2 / (np.cbrt(len(mode_gradients) * cell_volume)) mode_widths = q_spacing * modg return mode_widths * ureg('hartree').to( mode_gradients.units / cell_vectors.units)
def temperature(self): # See https://pint.readthedocs.io/en/latest/nonmult.html return Quantity(self._temperature, ureg('K')).to(self.temperature_unit)
def calculate_debye_waller(self, temperature: Quantity, frequency_min: Quantity = Quantity(0.01, 'meV'), symmetrise: bool = True) -> DebyeWaller: """ Calculate the 3 x 3 Debye-Waller exponent for each atom over the q-points contained in this object Parameters ---------- temperature Scalar float Quantity. The temperature to use in the Debye-Waller calculation frequency_min Scalar float Quantity in energy units. Excludes frequencies below this limit from the calculation, as the calculation contains a 1/frequency factor which would result in infinite values. This also allows negative frequencies to be excluded symmetrise Whether to symmetrise the Debye-Waller factor based on the crystal symmetry operations. Note that if the Debye-Waller exponent is not symmetrised, the results may not be the same for unfolded and symmetry-reduced q-point grids Returns ------- dw An object containing the 3x3 Debye-Waller exponent for each atom Notes ----- As part of the structure factor calculation, the anisotropic Debye-Waller factor is defined as: .. math:: e^{-W} = e^{-\\sum_{\\alpha\\beta}{W^{\\kappa}_{\\alpha\\beta}Q_{\\alpha}Q_{\\beta}}} The Debye-Waller exponent is defined as :math:`W^{\\kappa}_{\\alpha\\beta}` and is independent of Q, so for efficiency can be precalculated to be used in the structure factor calculation. The Debye-Waller exponent is calculated by [2]_ .. math:: W^{\\kappa}_{\\alpha\\beta} = \\frac{1}{4M_{\\kappa}\\sum_{q}{weight_q}} \\sum_{q\\nu}weight_q\\frac{\\epsilon_{q\\nu\\kappa\\alpha}\\epsilon^{*}_{q\\nu\\kappa\\beta}} {\\omega_{q\\nu}} coth(\\frac{\\omega_{q\\nu}}{2k_BT}) Where :math:`\\nu` runs over phonon modes, :math:`\\kappa` runs over atoms, :math:`\\alpha,\\beta` run over the Cartesian directions, :math:`M_{\\kappa}` is the atom mass, :math:`\\epsilon_{q\\nu\\kappa\\alpha}` are the eigenvectors, :math:`\\omega_{q\\nu}` are the frequencies, and :math:`weight_q` is the per q-point weight. The q-points should be distributed over the 1st Brillouin Zone. .. [2] G.L. Squires, Introduction to the Theory of Thermal Neutron Scattering, Dover Publications, New York, 1996, 34-37 """ # Convert units kB = (1 * ureg.k).to('hartree/K').magnitude n_atoms = self.crystal.n_atoms atom_mass = self.crystal._atom_mass freqs = self._frequencies freq_min = frequency_min.to('hartree').magnitude qpts = self.qpts evecs = self.eigenvectors weights = self.weights temp = temperature.to('K').magnitude mass_term = 1 / (4 * atom_mass) # Mask out frequencies below frequency_min freq_mask = np.ones(freqs.shape) freq_mask[freqs < freq_min] = 0 if temp > 0: x = freqs / (2 * kB * temp) freq_term = 1 / (freqs * np.tanh(x)) else: freq_term = 1 / (freqs) dw = np.zeros((n_atoms, 3, 3)) # Calculating the e.e* term is expensive, do in chunks chunk = 1000 for i in range(int((len(qpts) - 1) / chunk) + 1): qi = i * chunk qf = min((i + 1) * chunk, len(qpts)) evec_term = np.real( np.einsum('ijkl,ijkm->ijklm', evecs[qi:qf], np.conj(evecs[qi:qf]))) dw += (np.einsum('i,k,ij,ij,ijklm->klm', weights[qi:qf], mass_term, freq_term[qi:qf], freq_mask[qi:qf], evec_term)) dw = dw / np.sum(weights) if symmetrise: dw_tmp = np.zeros(dw.shape) (rot, trans, eq_atoms) = self.crystal.get_symmetry_equivalent_atoms() cell_vec = self.crystal._cell_vectors recip_vec = self.crystal.reciprocal_cell().to('1/bohr').magnitude rot_cart = np.einsum('ijk,jl,km->ilm', rot, cell_vec, recip_vec) / (2 * np.pi) for s in range(len(rot)): dw_tmp[eq_atoms[s]] += np.einsum('ij,kjl,ml->kim', rot_cart[s], dw, rot_cart[s]) dw = dw_tmp / len(rot) dw = dw * ureg('bohr**2').to(self.crystal.cell_vectors_unit + '**2') return DebyeWaller(self.crystal, dw, temperature)
def get_symmetry_equivalent_atoms( self, tol: Quantity = Quantity(1e-5, 'angstrom') ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Returns the rotational and translational symmetry operations as obtained by spglib.get_symmetry, and also the equivalent atoms that each atom gets mapped onto for each symmetry operation Parameters ---------- tol Scalar float Quantity in length units. The distance tolerance, if the distance between atoms is less than this, they are considered to be equivalent. This is also passed to spglib.get_symmetry as symprec Returns ------- rotations Shape (n_symmetry_ops, 3, 3) integer np.ndarray. The rotational symmetry matrices as returned by spglib.get_symmetry translations Shape (n_symmetry_ops, 3) float np.ndarray. The rotational symmetry matrices as returned by spglib.get_symmetry equivalent_atoms Shape (n_symmetry_ops, n_atoms) integer np.ndarray. The equivalent atoms for each symmetry operation. e.g. equivalent_atoms[s, i] = j means symmetry operation s maps atom i to atom j """ tol_calc = tol.to('bohr').magnitude symprec = tol.to(self.cell_vectors_unit).magnitude # Sometimes if symprec is very low, even the identity # symmetry op won't be found, and None will be returned # For some reason this can't always be reproduced symm = get_symmetry(self.to_spglib_cell(), symprec=symprec) if symm is None: raise RuntimeError(f'spglib.get_symmetry returned None with ' f'symprec={symprec}. Try increasing tol') n_ops = len(symm['rotations']) equiv_atoms = np.full((n_ops, self.n_atoms), -1, dtype=np.int32) atom_r_symm = ( np.einsum('ijk,lk->ilj', symm['rotations'], self.atom_r) + symm['translations'][:, np.newaxis, :]) atom_r_symm -= np.floor(atom_r_symm + 0.5) species_idx = self.get_species_idx() for spec, idx in species_idx.items(): for i in idx: atom_r_symm_i = atom_r_symm[:, i, :] # Difference between symmetry-transformed atom i and all # other atoms of that species for each symmetry operation diff_frac = (atom_r_symm_i[:, np.newaxis, :] - self.atom_r[np.newaxis, idx, :]) diff_frac -= np.floor(diff_frac + 0.5) diff_cart = np.einsum('ijk,kl->ijl', diff_frac, self._cell_vectors) diff_r = np.linalg.norm(diff_cart, axis=2) equiv_idx = np.where(diff_r < tol_calc) # There should be one matching atom per symm op if not np.array_equal(equiv_idx[0], np.arange(n_ops)): for op_idx, diff_r_op in enumerate(diff_r): equiv_idx_op = np.where(diff_r_op < tol_calc)[0] err_info = (f'for {spec} atom at {self.atom_r[i]} for ' f'symmetry op {op_idx}. Rotation ' f'{symm["rotations"][op_idx]} translation ' f'{symm["translations"][op_idx]}') if len(equiv_idx_op) == 0: raise RuntimeError( f'No equivalent atom found {err_info}') elif len(equiv_idx_op) > 1: raise RuntimeError( f'Multiple equivalent atoms found {err_info}') equiv_atoms[:, i] = idx[equiv_idx[1]] return symm['rotations'], symm['translations'], equiv_atoms
class TestCrystalCreation: @pytest.fixture(params=[ get_expected_crystal('quartz'), get_expected_crystal('quartz_cv_only'), get_expected_crystal('LZO') ]) def create_from_constructor(self, request): expected_crystal = request.param crystal = Crystal(*expected_crystal.to_constructor_args()) return crystal, expected_crystal @pytest.fixture(params=[ get_expected_crystal('quartz'), get_expected_crystal('quartz_cv_only'), get_expected_crystal('LZO') ]) def create_from_dict(self, request): expected_crystal = request.param d = expected_crystal.to_dict() crystal = Crystal.from_dict(d) return crystal, expected_crystal @pytest.fixture(params=[ (get_json_file('quartz'), get_expected_crystal('quartz')), (get_json_file('quartz_cv_only'), get_expected_crystal('quartz_cv_only')), (get_json_file('LZO'), get_expected_crystal('LZO')) ]) def create_from_json_file(self, request): filename, expected_crystal = request.param return get_crystal_from_json_file(filename), expected_crystal @pytest.fixture(params=[(Quantity( np.array([[2.426176, -4.20226, 0.000000], [2.426176, 4.20226, 0.000000], [0.000000, 0.00000, 5.350304]]), 'angstrom'), get_expected_crystal('quartz_cv_only'))]) def create_from_cell_vectors(self, request): cell_vectors, expected_crystal = request.param return Crystal.from_cell_vectors(cell_vectors), expected_crystal @pytest.mark.parametrize('crystal_creator', [ pytest.lazy_fixture('create_from_constructor'), pytest.lazy_fixture('create_from_json_file'), pytest.lazy_fixture('create_from_dict'), pytest.lazy_fixture('create_from_cell_vectors') ]) def test_create(self, crystal_creator): crystal, expected_crystal = crystal_creator check_crystal(crystal, expected_crystal) faulty_elements = [ ('cell_vectors', np.array([[1.23, 2.45, 0.0], [3.45, 5.66, 7.22]]) * ureg('angstrom'), ValueError), ('cell_vectors', get_expected_crystal('quartz').cell_vectors.magnitude * ureg('kg'), DimensionalityError), ('cell_vectors', get_expected_crystal('quartz').cell_vectors.magnitude * ureg(''), DimensionalityError), ('atom_r', np.array([[0.125, 0.125, 0.125], [0.875, 0.875, 0.875]]), ValueError), ('atom_mass', np.array([15.999399987607514, 15.999399987607514, 91.2239999293416]) * ureg('amu'), ValueError), ('atom_mass', get_expected_crystal('quartz').atom_mass.magnitude * ureg('angstrom'), DimensionalityError), ('atom_mass', get_expected_crystal('quartz').atom_mass.magnitude * ureg(''), DimensionalityError), ('atom_type', np.array(['O', 'Zr', 'La']), ValueError), ] @pytest.fixture(params=faulty_elements) def inject_faulty_elements(self, request): faulty_arg, faulty_value, expected_exception = request.param crystal = get_expected_crystal('quartz') # Inject the faulty value and get a tuple of constructor arguments args = crystal.to_constructor_args(**{faulty_arg: faulty_value}) return args, expected_exception def test_faulty_creation(self, inject_faulty_elements): faulty_args, expected_exception = inject_faulty_elements with pytest.raises(expected_exception): Crystal(*faulty_args)
def _get_q_range(data: Quantity) -> float: dimensionless_data = data.to(x_unit).magnitude return dimensionless_data[-1] - dimensionless_data[0]