Source code for PAOFLOW.phonon.do_ir_raman

"""Infrared (IR) and Raman spectra of zone-centre phonons (Stages 3 & 4).

Infrared (Stage 3)
------------------
The infrared activity of a phonon mode is governed by the change in the
macroscopic dipole moment it produces. For a mode :math:`\\nu` at the zone
centre with (mass-weighted) eigenvector :math:`e_{\\nu}` the *mode effective
charge vector* is

.. math::

    \\bar{Z}_{\\nu,\\alpha} = \\sum_{k,\\beta}
        Z^{*}_{k,\\alpha\\beta}\\, \\frac{e_{\\nu,k\\beta}}{\\sqrt{M_k}},

where :math:`Z^{*}_{k}` is the Born effective charge tensor of atom :math:`k`
(units of the elementary charge), :math:`M_k` its mass and :math:`\\alpha,\\beta`
Cartesian indices. The IR oscillator strength (intensity) of the mode is

.. math::

    I_{\\nu} \\propto \\sum_{\\alpha} |\\bar{Z}_{\\nu,\\alpha}|^{2}.

The transverse-optical (TO) eigenvectors at exactly :math:`\\Gamma` are used; the
non-analytical correction (LO-TO splitting) is irrelevant to the oscillator
strengths, so only the Born charges, eigenvectors and masses are required. A
Lorentzian broadening of each mode produces a continuous spectrum
:math:`I(\\omega) = \\sum_{\\nu} I_{\\nu}\\,L(\\omega;\\omega_{\\nu},\\gamma)`.

Raman (Stage 4)
---------------
The (non-resonant, Placzek) Raman activity of a mode is governed by the
derivative of the electronic dielectric/polarisability tensor with respect to
the mode normal coordinate :math:`Q_{\\nu}`. The atomic displacement pattern of
mode :math:`\\nu` is :math:`u_{k\\beta} = e_{\\nu,k\\beta}/\\sqrt{M_k}`, so a
finite, mass-weighted displacement of amplitude :math:`\\delta` along the
eigenvector (``+``/``-``) and a central difference of the static dielectric
tensor :math:`\\varepsilon(\\pm\\delta)` give the Raman tensor

.. math::

    R^{\\nu}_{\\alpha\\beta} = \\frac{\\partial \\varepsilon_{\\alpha\\beta}}
        {\\partial Q_{\\nu}} \\approx
        \\frac{\\varepsilon_{\\alpha\\beta}(+\\delta) -
               \\varepsilon_{\\alpha\\beta}(-\\delta)}{2\\,\\delta}.

The orientationally-averaged (powder) Stokes intensity follows from the two
rotational invariants of the (symmetric) Raman tensor, the isotropic mean
:math:`a = \\tfrac{1}{3}\\mathrm{Tr}\\,R` and the anisotropy
:math:`\\gamma^2`,

.. math::

    I_{\\nu} \\propto \\frac{(\\omega_L - \\omega_{\\nu})^4}{\\omega_{\\nu}}
        \\,(n_{\\nu}+1)\\,(45\\,a^2 + 7\\,\\gamma^2),

with :math:`n_{\\nu}` the Bose-Einstein occupation at temperature :math:`T` and
:math:`\\omega_L` the laser frequency. Acoustic modes are rigid translations
and leave :math:`\\varepsilon` invariant, so they are Raman-silent by
construction.
"""

import os

import numpy as np

from .do_phonopy import THZ_TO_CM1


def _unit_scale(units):
    """Return the THz -> ``units`` conversion factor (THz or cm^-1)."""
    return THZ_TO_CM1 if str(units).lower() in ('cm-1', 'cm^-1', 'cm') else 1.0


def _lorentzian(x, x0, gamma):
    """Normalised Lorentzian of full width at half maximum ``gamma``."""
    hw = 0.5 * gamma
    return (hw / np.pi) / ((x - x0) ** 2 + hw**2)


[docs] def mode_effective_charges(born, eigvecs, masses): """Mode effective charge vectors and IR oscillator strengths. Parameters ---------- born : array_like Born effective charges ``(natom, 3, 3)`` with ``born[k, alpha, beta]``. eigvecs : array_like Mass-weighted eigenvectors ``(3*natom, nmodes)`` (phonopy convention), where column ``v`` is the eigenvector of mode ``v`` and the component index is ``3*k + beta``. masses : array_like Atomic masses ``(natom,)``. Returns ------- tuple ``(mode_charges, intensities)`` with ``mode_charges`` of shape ``(nmodes, 3)`` (complex) and ``intensities`` ``(nmodes,)`` real, where ``intensities[v] = sum_alpha |mode_charges[v, alpha]|^2``. """ born = np.asarray(born, dtype=float) eigvecs = np.asarray(eigvecs) masses = np.asarray(masses, dtype=float) natom = masses.shape[0] nmodes = eigvecs.shape[1] # Eigenvector component index i = 3*k + beta; the physical displacement is # e_i / sqrt(M_k), so weight every triplet by the inverse root mass. sqrt_m = np.repeat(np.sqrt(masses), 3) # B[alpha, 3k+beta] = Z*_{k,alpha,beta} bmat = born.transpose(1, 0, 2).reshape(3, natom * 3) mode_charges = np.zeros((nmodes, 3), dtype=complex) intensities = np.zeros(nmodes, dtype=float) for v in range(nmodes): disp = eigvecs[:, v] / sqrt_m zbar = bmat @ disp mode_charges[v] = zbar intensities[v] = float(np.real(np.vdot(zbar, zbar))) return mode_charges, intensities
def _irrep_labels(phonon, nmodes, degeneracy_tolerance=None): """Best-effort per-mode irreducible-representation labels at Gamma. Returns a list of length ``nmodes`` with the irrep symbol of each branch (e.g. ``'T1u'``), or ``None`` when phonopy cannot assign labels (missing spglib symmetry, unsupported point group, ...). """ try: irreps = phonon.run_irreps([0.0, 0.0, 0.0], degeneracy_tolerance=degeneracy_tolerance) band_indices = irreps.band_indices labels = getattr(irreps, '_ir_labels', None) if band_indices is None or labels is None: return None out = [None] * nmodes for sets, lab in zip(band_indices, labels): for b in sets: if 0 <= b < nmodes: out[b] = lab return out except Exception: return None
[docs] def compute_ir_spectrum( data_controller, born=None, dielectric=None, born_file=None, freq_min=None, freq_max=None, npoints=2000, gamma=4.0, units='cm-1', fname='phonon', write=True, intensity_tol=1.0e-4, ): """Compute the infrared spectrum from Born charges and Gamma eigenvectors. Parameters ---------- born : array_like, optional Born effective charges ``(natom_prim, 3, 3)`` in units of the elementary charge. When omitted they are taken from ``born_file`` or from a previous :func:`compute_born_and_epsilon` / :func:`attach_nac` call (``arry['born_charges']``). dielectric : array_like, optional High-frequency dielectric tensor ``(3, 3)``. Unused by the oscillator strengths; accepted for API symmetry and read alongside ``born_file``. born_file : str, optional Path to a phonopy ``BORN`` file providing the Born charges. freq_min, freq_max : float, optional Frequency-axis limits of the broadened spectrum (in ``units``). Defaults span ``0`` to slightly above the highest mode. npoints : int Number of points on the broadened-spectrum frequency grid. gamma : float Lorentzian full width at half maximum (in ``units``). units : str Frequency unit for all outputs: ``'cm-1'`` (default) or ``'THz'``. fname : str Output basename; writes ``<fname>_ir_modes.dat`` and ``<fname>_ir_spectrum.dat``. write : bool When ``True`` the two output files are written (rank 0 only). intensity_tol : float Relative threshold (fraction of the maximum mode intensity) above which a mode is flagged IR-active. Returns ------- dict ``{'frequencies', 'intensities', 'mode_charges', 'spectrum', 'irreps', 'active'}``. """ arry, attr = data_controller.data_dicts() phonon = arry['phonopy'] # --- Born effective charges ------------------------------------------- if born is None: if born_file is not None: from .io import read_born_file nac = read_born_file(data_controller, born_file) born = nac['born'] if dielectric is None: dielectric = nac['dielectric'] elif arry.get('born_charges', None) is not None: born = arry['born_charges'] else: raise ValueError( 'compute_ir_spectrum requires Born effective charges: pass ' 'born=..., born_file=..., or run born_charges() first.' ) born = np.asarray(born, dtype=float) masses = np.asarray(phonon.primitive.masses, dtype=float) natom = masses.shape[0] if born.shape != (natom, 3, 3): raise ValueError( 'Born charges have shape %r but the primitive cell has %d atoms; ' 'expected (%d, 3, 3).' % (tuple(born.shape), natom, natom) ) # --- Gamma-point (TO) frequencies and eigenvectors -------------------- qpts = phonon.run_qpoints([[0.0, 0.0, 0.0]], with_eigenvectors=True) freqs_thz = np.asarray(qpts.frequencies)[0] eigvecs = np.asarray(qpts.eigenvectors)[0] nmodes = freqs_thz.shape[0] # --- Mode effective charges and oscillator strengths ------------------ mode_charges, intensities = mode_effective_charges(born, eigvecs, masses) scale = _unit_scale(units) freqs = freqs_thz * scale imax = intensities.max() if intensities.size else 0.0 active = intensities > (intensity_tol * imax) if imax > 0 else np.zeros(nmodes, bool) irreps = _irrep_labels(phonon, nmodes) # --- Broadened spectrum ------------------------------------------------ if freq_min is None: freq_min = 0.0 if freq_max is None: fmax = float(freqs.max()) if freqs.size else 0.0 freq_max = 1.1 * max(fmax, 0.0) + 5.0 * gamma grid = np.linspace(freq_min, freq_max, int(npoints)) spectrum = np.zeros_like(grid) for v in range(nmodes): if freqs[v] <= 0.0: # Acoustic / numerically-imaginary branches do not contribute. continue spectrum += intensities[v] * _lorentzian(grid, freqs[v], gamma) arry['ir_frequencies'] = freqs arry['ir_intensities'] = intensities arry['ir_mode_charges'] = mode_charges if attr.get('verbose', False) and getattr(data_controller, 'rank', 0) == 0: print('Infrared-active modes (intensity in arbitrary units):') print(' %-6s %14s %14s %-8s' % ('mode', 'freq(%s)' % units, 'intensity', 'irrep')) for v in range(nmodes): if not active[v]: continue lab = irreps[v] if irreps is not None and irreps[v] is not None else '-' print(' %-6d %14.4f %14.6e %-8s' % (v + 1, freqs[v], intensities[v], lab)) if write: _write_ir_modes( data_controller, fname, units, freqs, intensities, mode_charges, active, irreps ) _write_ir_spectrum(data_controller, fname, units, grid, spectrum) return { 'frequencies': freqs, 'intensities': intensities, 'mode_charges': mode_charges, 'spectrum': (grid, spectrum), 'irreps': irreps, 'active': active, }
def _write_ir_modes( data_controller, fname, units, freqs, intensities, mode_charges, active, irreps ): """Write the per-mode IR table ``<fname>_ir_modes.dat`` (rank 0 only).""" _, attr = data_controller.data_dicts() if getattr(data_controller, 'rank', 0) != 0: return None imax = intensities.max() if intensities.size else 0.0 norm = intensities / imax if imax > 0 else intensities path = os.path.join(attr.get('opath', '.'), fname + '_ir_modes.dat') with open(path, 'w') as f: f.write('# mode frequency(%s) intensity intensity_norm |Zbar| active irrep\n' % units) for v in range(freqs.shape[0]): zmag = float(np.sqrt(np.real(np.vdot(mode_charges[v], mode_charges[v])))) lab = irreps[v] if irreps is not None and irreps[v] is not None else '-' f.write( '%6d % 16.8e % 16.8e % 16.8e % 16.8e %3d %s\n' % (v + 1, freqs[v], intensities[v], norm[v], zmag, int(active[v]), lab) ) return path def _write_ir_spectrum(data_controller, fname, units, grid, spectrum): """Write the broadened spectrum ``<fname>_ir_spectrum.dat`` (rank 0 only).""" _, attr = data_controller.data_dicts() if getattr(data_controller, 'rank', 0) != 0: return None path = os.path.join(attr.get('opath', '.'), fname + '_ir_spectrum.dat') with open(path, 'w') as f: f.write('# frequency(%s) intensity\n' % units) for x, y in zip(grid, spectrum): f.write('% 16.8e % 16.8e\n' % (x, y)) return path # ====================================================================== # # Raman spectrum (Stage 4) # # ====================================================================== # # Boltzmann constant in cm^-1 / K (k_B / (h c)); used for the Bose factor. _KB_CM1 = 0.6950348004
[docs] def mode_displacement_vectors(eigvecs, masses, delta): """Cartesian displacement patterns of every mode for a finite step ``delta``. The physical displacement of atom ``k`` for mode ``v`` is ``delta * e_{v,k} / sqrt(M_k)`` (mass-weighted eigenvector), so a unit step along the normal coordinate ``Q_v`` corresponds to ``delta = 1``. Parameters ---------- eigvecs : array_like Mass-weighted eigenvectors ``(3*natom, nmodes)`` (phonopy convention; column ``v`` is mode ``v``, component index ``3*k + beta``). masses : array_like Atomic masses ``(natom,)``. delta : float Normal-coordinate step amplitude. Returns ------- numpy.ndarray Displacements ``(nmodes, natom, 3)`` (same length units as the cell). """ eigvecs = np.asarray(eigvecs) masses = np.asarray(masses, dtype=float) natom = masses.shape[0] nmodes = eigvecs.shape[1] sqrt_m = np.repeat(np.sqrt(masses), 3) disp = np.zeros((nmodes, natom, 3), dtype=float) for v in range(nmodes): u = np.real(eigvecs[:, v]) * (delta / sqrt_m) disp[v] = u.reshape(natom, 3) return disp
[docs] def raman_invariants(tensor): """Rotational invariants ``(a, gamma2)`` of a Raman tensor. The Raman tensor ``R`` is characterised by its isotropic mean polarisability ``a = Tr(R)/3`` and its anisotropy ``gamma2``, .. math:: \\gamma^2 = \\tfrac{1}{2}\\big[|R_{xx}-R_{yy}|^2 + |R_{yy}-R_{zz}|^2 + |R_{zz}-R_{xx}|^2\\big] + 3\\,(|R_{xy}|^2 + |R_{xz}|^2 + |R_{yz}|^2). The squared magnitudes make the invariants valid for the **complex** Raman tensor of the resonance case as well; for a real (non-resonant) tensor they reduce to the usual real invariants. ``a`` is returned complex when the input is complex (its magnitude enters the activity), and real otherwise. Parameters ---------- tensor : array_like A ``(3, 3)`` Raman tensor (symmetrised internally). Returns ------- tuple ``(a, gamma2)`` with the (possibly complex) isotropic mean and the (real) anisotropy invariant. """ r = np.asarray(tensor) r = 0.5 * (r + r.T) a = np.trace(r) / 3.0 gamma2 = 0.5 * ( np.abs(r[0, 0] - r[1, 1]) ** 2 + np.abs(r[1, 1] - r[2, 2]) ** 2 + np.abs(r[2, 2] - r[0, 0]) ** 2 ) + 3.0 * (np.abs(r[0, 1]) ** 2 + np.abs(r[0, 2]) ** 2 + np.abs(r[1, 2]) ** 2) if np.iscomplexobj(r): return complex(a), float(gamma2) return float(a), float(gamma2)
[docs] def raman_powder_activity(tensor): """Orientationally-averaged Raman activity ``45 |a|^2 + 7 gamma2``.""" a, gamma2 = raman_invariants(tensor) return 45.0 * abs(a) ** 2 + 7.0 * gamma2
def _bose_factor(freq_cm, temperature): """Bose-Einstein occupation ``n(omega, T)`` for ``freq_cm`` in cm^-1.""" if temperature is None or temperature <= 0.0: return 0.0 x = float(freq_cm) / (_KB_CM1 * float(temperature)) if x <= 0.0: return 0.0 # Guard against overflow for high-frequency / low-T modes. if x > 700.0: return 0.0 return 1.0 / np.expm1(x)
[docs] def raman_tensors_from_epsilon(eps_plus, eps_minus, delta): """Finite-difference Raman tensors ``dε/dQ`` for every mode. Parameters ---------- eps_plus, eps_minus : array_like Dielectric tensors ``(nmodes, 3, 3)`` for the ``+delta`` and ``-delta`` displacements along each mode. Modes that were not displaced should carry identical ``+/-`` tensors (or zeros) and yield a null Raman tensor. May be real (non-resonant, static) or complex (resonance, evaluated at the laser frequency). delta : float Normal-coordinate step amplitude used to build the displacements. Returns ------- numpy.ndarray Raman tensors ``(nmodes, 3, 3)`` (symmetrised), complex when the input dielectric tensors are complex. """ eps_plus = np.asarray(eps_plus) eps_minus = np.asarray(eps_minus) r = (eps_plus - eps_minus) / (2.0 * float(delta)) return 0.5 * (r + np.transpose(r, (0, 2, 1)))
[docs] def compute_raman_spectrum( data_controller, eps_plus, eps_minus, delta, computed=None, laser_nm=None, temperature=300.0, freq_min=None, freq_max=None, npoints=2000, gamma=4.0, units='cm-1', fname='phonon', write=True, intensity_tol=1.0e-4, ): """Compute the powder Raman spectrum from finite-difference dielectric data. Parameters ---------- eps_plus, eps_minus : array_like Dielectric tensors ``(nmodes, 3, 3)`` harvested from the ``+delta`` / ``-delta`` displaced-cell ``do_epsilon`` runs. Real for the non-resonant (static) spectrum, or complex when evaluated at a laser frequency for the resonance spectrum. delta : float Normal-coordinate step amplitude used to displace the cells. computed : array_like of bool, optional Mask ``(nmodes,)`` flagging the modes that were actually displaced and for which a Raman tensor is meaningful. Defaults to all modes. laser_nm : float, optional Excitation wavelength in nm. When given, the ``(omega_L - omega_nu)^4`` resonance prefactor is applied; otherwise it is omitted (set to 1). temperature : float Temperature in K for the Bose ``(n+1)`` Stokes factor. freq_min, freq_max, npoints, gamma, units, fname, write : see :func:`compute_ir_spectrum`. intensity_tol : float Relative threshold (fraction of the maximum activity) above which a mode is flagged Raman-active. Returns ------- dict ``{'frequencies', 'activities', 'intensities', 'tensors', 'invariants', 'spectrum', 'irreps', 'active'}``. """ arry, attr = data_controller.data_dicts() phonon = arry['phonopy'] eps_plus = np.asarray(eps_plus) eps_minus = np.asarray(eps_minus) qpts = phonon.run_qpoints([[0.0, 0.0, 0.0]], with_eigenvectors=True) freqs_thz = np.asarray(qpts.frequencies)[0] nmodes = freqs_thz.shape[0] if eps_plus.shape != (nmodes, 3, 3) or eps_minus.shape != (nmodes, 3, 3): raise ValueError( 'Dielectric arrays have shapes %r / %r but %d modes are expected; ' 'each must be (%d, 3, 3).' % (tuple(eps_plus.shape), tuple(eps_minus.shape), nmodes, nmodes) ) if computed is None: computed = np.ones(nmodes, dtype=bool) else: computed = np.asarray(computed, dtype=bool) scale = _unit_scale(units) freqs = freqs_thz * scale freqs_cm = freqs_thz * THZ_TO_CM1 # invariants/thermal use cm^-1 frequencies tensors = raman_tensors_from_epsilon(eps_plus, eps_minus, delta) laser_cm = (1.0e7 / float(laser_nm)) if laser_nm else None activities = np.zeros(nmodes, dtype=float) intensities = np.zeros(nmodes, dtype=float) invariants = np.zeros((nmodes, 2), dtype=float) for v in range(nmodes): if not computed[v] or freqs_cm[v] <= 0.0: continue a, gamma2 = raman_invariants(tensors[v]) invariants[v] = (float(np.real(a)), gamma2) act = 45.0 * abs(a) ** 2 + 7.0 * gamma2 activities[v] = act # Stokes scattering cross-section prefactor. pref = _bose_factor(freqs_cm[v], temperature) + 1.0 if laser_cm is not None: pref *= (laser_cm - freqs_cm[v]) ** 4 intensities[v] = act * pref / freqs_cm[v] amax = activities.max() if activities.size else 0.0 active = activities > (intensity_tol * amax) if amax > 0 else np.zeros(nmodes, bool) irreps = _irrep_labels(phonon, nmodes) if freq_min is None: freq_min = 0.0 if freq_max is None: fmax = float(freqs.max()) if freqs.size else 0.0 freq_max = 1.1 * max(fmax, 0.0) + 5.0 * gamma grid = np.linspace(freq_min, freq_max, int(npoints)) spectrum = np.zeros_like(grid) for v in range(nmodes): if freqs[v] <= 0.0 or intensities[v] <= 0.0: continue spectrum += intensities[v] * _lorentzian(grid, freqs[v], gamma) arry['raman_frequencies'] = freqs arry['raman_activities'] = activities arry['raman_intensities'] = intensities arry['raman_tensors'] = tensors if attr.get('verbose', False) and getattr(data_controller, 'rank', 0) == 0: print('Raman-active modes (activity in arbitrary units):') print( ' %-6s %14s %14s %14s %-8s' % ('mode', 'freq(%s)' % units, 'activity', 'intensity', 'irrep') ) for v in range(nmodes): if not active[v]: continue lab = irreps[v] if irreps is not None and irreps[v] is not None else '-' print( ' %-6d %14.4f %14.6e %14.6e %-8s' % (v + 1, freqs[v], activities[v], intensities[v], lab) ) if write: _write_raman_modes( data_controller, fname, units, freqs, activities, intensities, invariants, active, irreps, ) _write_raman_spectrum(data_controller, fname, units, grid, spectrum) return { 'frequencies': freqs, 'activities': activities, 'intensities': intensities, 'tensors': tensors, 'invariants': invariants, 'spectrum': (grid, spectrum), 'irreps': irreps, 'active': active, }
def _write_raman_modes( data_controller, fname, units, freqs, activities, intensities, invariants, active, irreps ): """Write the per-mode Raman table ``<fname>_raman_modes.dat`` (rank 0).""" _, attr = data_controller.data_dicts() if getattr(data_controller, 'rank', 0) != 0: return None amax = activities.max() if activities.size else 0.0 norm = activities / amax if amax > 0 else activities path = os.path.join(attr.get('opath', '.'), fname + '_raman_modes.dat') with open(path, 'w') as f: f.write( '# mode frequency(%s) activity activity_norm intensity ' 'a_mean gamma2 active irrep\n' % units ) for v in range(freqs.shape[0]): lab = irreps[v] if irreps is not None and irreps[v] is not None else '-' f.write( '%6d % 16.8e % 16.8e % 16.8e % 16.8e % 16.8e % 16.8e %3d %s\n' % ( v + 1, freqs[v], activities[v], norm[v], intensities[v], invariants[v, 0], invariants[v, 1], int(active[v]), lab, ) ) return path def _write_raman_spectrum(data_controller, fname, units, grid, spectrum): """Write the broadened Raman spectrum ``<fname>_raman_spectrum.dat`` (rank 0).""" _, attr = data_controller.data_dicts() if getattr(data_controller, 'rank', 0) != 0: return None path = os.path.join(attr.get('opath', '.'), fname + '_raman_spectrum.dat') with open(path, 'w') as f: f.write('# frequency(%s) intensity\n' % units) for x, y in zip(grid, spectrum): f.write('% 16.8e % 16.8e\n' % (x, y)) return path