1# coding: utf-8
2"""Objects to run and analyze the frozen phonons generated from displacements of atoms"""
3
4import numpy as np
5import scipy.optimize as optimize
6
7from monty.functools import lazy_property
8from monty.collections import dict2namedtuple
9from abipy.core.abinit_units import phfactor_ev2units, amu_emass, Bohr_Ang, eV_Ha
10from abipy.tools.plotting import add_fig_kwargs, get_ax_fig_plt
11
12
13def quadratic_fit_function(xx, aa, bb):
14    """
15    Simple quadratic function used as a default for the fitting.
16
17    Args:
18        xx: the variable
19        aa: the coefficient of the quadratic term
20        bb: the constant term
21    """
22    return aa * xx ** 2 + bb
23
24
25class FrozenPhonon(object):
26    """
27    Class defining a set of structures with displaced atoms.
28    Provides methods to generate, interpolate and plot the data.
29    """
30
31    def __init__(self, original_structure, original_displ_cart, structures, normalized_displ_cart, etas,
32                 qpt_frac_coords, scale_matrix, energies=None):
33        """
34
35        Args:
36            original_structure: the original |Structure| object without any displacement.
37            original_displ_cart: the original displacement used for generating the structures in cartesian
38                coordinates. Should be linked to original_structure and should contain the phdispl_cart obtained
39                from the phonon calculation.
40            structures: list of structures with the displaced atoms
41            normalized_displ_cart: displacement applied to the supercell to generate the list of structures.
42                The normalization defines the largest displacement of one atom at 1 Angstrom.
43            etas: list of (signed) factors that will multiply the normalized displacement to obtain the list of
44                structure.
45            qpt_frac_coords: fractional coordinates of the qpoint corresponding to the displacement.
46            scale_matrix: the scaling matrix between the original cell and the supercell.
47            energies: energies in eV corresponding to the structures.
48        """
49
50        self.original_structure = original_structure
51        self.original_displ_cart = original_displ_cart
52        self.structures = structures
53        self.normalized_displ_cart = normalized_displ_cart
54        self.etas = np.array(etas)
55        self.qpt_frac_coords = qpt_frac_coords
56        self.scale_matrix = scale_matrix
57        self._energies = energies
58
59    @property
60    def energies(self):
61        """
62        Energies in eV corresponding to the the structures.
63        """
64        return self._energies
65
66    @energies.setter
67    def energies(self, energies):
68        """
69        Set the number of energies corresponding to the structures, should have the same length as structures
70        """
71        if len(energies) != self.n_displ:
72            raise ValueError("The size of the enegies list does not match the number of internal structures.")
73        self._energies = energies
74
75    @property
76    def qpt_cart_coords(self):
77        """
78        The cartesian coordinates of the qpoint.
79        """
80        return self.original_structure.lattice.reciprocal_lattice.get_cartesian_coords(self.qpt_frac_coords)
81
82    @property
83    def n_displ(self):
84        """
85        Number of displacements.
86        """
87        return len(self.structures)
88
89    @lazy_property
90    def ieta0(self):
91        """
92        The index corresponding to the structure with no displacements.
93        """
94        try:
95            return self.etas.tolist().index(0)
96        except ValueError:
97            raise ValueError("The structure with no displacement is not present in the list.")
98
99    @classmethod
100    def from_phbands(cls, phbands, qpt_frac_coords, imode, etas, scale_matrix=None, max_supercell=None):
101        """
102        Create an instace of FrozenPhonon using the eigendisplacements from a |PhononBands|
103
104        Args:
105            phbands: a |PhononBands| instance.
106            qpt_frac_coords: q vector in reduced coordinate in reciprocal space or index of the qpoint.
107            imode: index of the mode.
108            etas: list of amplitudes of the displacement to be applied to the system. Will correspond to the
109                largest displacement of one atom in Angstrom.
110            scale_matrix: the scaling matrix of the supercell. If None a scaling matrix suitable for
111                the qpoint will be determined.
112            max_supercell: mandatory if scale_matrix is None, ignored otherwise. Defines the largest
113                supercell in the search for a scaling matrix suitable for the q point.
114
115        Returns:
116            A FrozenPhonon.
117        """
118
119        qind = phbands.qindex(qpt_frac_coords)
120        original_displ_cart = phbands.phdispl_cart[qind, imode].reshape((-1, 3))
121
122        # first extract data with the normalized displacement (eta=1)
123        normalized_fp = phbands.get_frozen_phonons(qind, imode, 1, scale_matrix, max_supercell)
124
125        structures = []
126        for n in etas:
127            structures.append(phbands.get_frozen_phonons(qind, imode, n, normalized_fp.scale_matrix).structure)
128
129        return cls(phbands.structure, original_displ_cart, structures, normalized_fp.displ, etas,
130                   phbands.qpoints[qind].frac_coords, normalized_fp.scale_matrix)
131
132    @lazy_property
133    def mass_factor(self):
134        """
135        The factor accounting for the different masses and displacement of each atom
136        """
137        # the norms of each atomic displacement of the normalized displacement
138        displ_norms = np.linalg.norm(self.normalized_displ_cart, axis=1)
139
140        mass_factor = sum(site.specie.atomic_mass * (df**2)
141                          for site, df in zip(self.structures[0], displ_norms / displ_norms.max())) / 2.
142        mass_factor *= amu_emass
143        return mass_factor
144
145    def _freq_to_quad_coeff(self, freq):
146        """
147        Helper function to convert from a frequency in eV to the quadratic coefficient corresponding to the
148        energies in eV and the etas in Angstrom. Uses the conversions to a.u. to get the correct value.
149        """
150
151        return freq ** 2 * self.mass_factor * eV_Ha / Bohr_Ang ** 2
152
153    def _quad_coeff_to_freq(self, coeff):
154        """
155        Helper function to convert from the quadratic coefficient corresponding to the fit of energies in eV and
156        the etas in Angstrom to a frequency in eV. Uses the conversions to a.u. to get the correct value.
157        """
158
159        return np.sqrt(coeff / self.mass_factor / eV_Ha) * Bohr_Ang
160
161    def fit_to_frequency(self, fit_function=None, units="eV", min_fit_eta=None, max_fit_eta=None):
162        """
163        Uses the energies and the displacements to calculate the phonon frequency corresponding to the quadratic
164        term of the fit.
165        The fit is performed with scipy.optimize.curve_fit based on the function given in input and can also be
166        limited number to a subset of the values of the displacements.
167
168        Args:
169            fit_function: a function that will be used to fit the data. The first parameter should be the coefficient
170                of the quadratic term. If None a simple quadratic fit will be used.
171            units: units of the output frequency. Possible values in ("eV", "meV", "Ha", "cm-1", "Thz").
172                Case-insensitive.
173            min_fit_eta: if not None represents minimum value allowed for the (signed) eta to be used in the fit.
174            max_fit_eta: if not None represents maximum value allowed for the (signed) eta to be used in the fit.
175
176        Returns:
177            A namedtuple with 'freq': the values of the frequency extracted from the fit,
178            'fit_params': the parameters obtained from the fit, 'cov': the estimated covariance of fit_params
179            (see scipy.optimize.curve_fit documentation for more details).
180        """
181
182        if self.energies is None:
183            raise ValueError("The energies are required to calculate the fit")
184
185        if min_fit_eta is None:
186            min_fit_eta = self.etas.min()
187        if max_fit_eta is None:
188            max_fit_eta = self.etas.max()
189
190        indices = np.where((min_fit_eta <= self.etas) & (self.etas <= max_fit_eta))
191
192        if fit_function is None:
193            fit_function = quadratic_fit_function
194
195        etas = self.etas[indices]
196        energies = np.array(self.energies)[indices]
197
198        params, cov = optimize.curve_fit(fit_function, etas, energies)
199
200        # frequency in eV.
201        freq = self._quad_coeff_to_freq(params[0])
202
203        return dict2namedtuple(freq=freq*phfactor_ev2units(units), fit_params=params, cov=cov)
204
205    @add_fig_kwargs
206    def plot_fit_energies(self, fit_function=None, min_fit_eta=None, max_fit_eta=None, freq=None,
207                          ax=None, **kwargs):
208        """
209        Fits the displacements etas to the energies. See fit_to_frequency() for more details.
210
211        Args:
212            fit_function: a function that will be used to fit the data. The first parameter should be the coefficient
213                of the quadratic term. If None a simple quadratic fit will be used.
214            min_fit_eta: if not None represents minimum value allowed for the (signed) eta to be used in the fit.
215            max_fit_eta: if not None represents maximum value allowed for the (signed) eta to be used in the fit.
216            freq: if not None the quadratic function with this frequency as a coefficient will be added to the plot.
217                freq in eV. Requires the 0 displacement to be present in the list of etas.
218            ax: |matplotlib-Axes| or None if a new figure should be created.
219
220        Returns:
221            |matplotlib-Figure|
222        """
223
224        if fit_function is None:
225            fit_function = quadratic_fit_function
226
227        fit_data = self.fit_to_frequency(fit_function, min_fit_eta=min_fit_eta, max_fit_eta=max_fit_eta)
228
229        ax, fig, plt = get_ax_fig_plt(ax=ax)
230
231        if "color" not in kwargs and "c" not in kwargs:
232            kwargs["color"] = "blue"
233
234        kwargs_points = dict(kwargs)
235        kwargs_points["linestyle"] = "None"
236        if "markersize" not in kwargs_points and "ms" not in kwargs_points:
237            kwargs_points["markersize"] = 10
238        if "marker" not in kwargs_points and "m" not in kwargs_points:
239            kwargs_points["marker"] = "o"
240
241        ax.plot(self.etas, self.energies, **kwargs_points)
242
243        if "linewidth" not in kwargs and "lw" not in kwargs:
244            kwargs["linewidth"] = 2
245        kwargs["linestyle"] = "-"
246
247        etas_plot = np.linspace(self.etas.min(), self.etas.max(), 100)
248        ax.plot(etas_plot, fit_function(etas_plot, *fit_data.fit_params), **kwargs)
249
250        if freq is not None:
251            kwargs["linestyle"] = "--"
252            e0 = self.energies[self.ieta0]
253            en_freq = quadratic_fit_function(etas_plot, self._freq_to_quad_coeff(freq), e0)
254            ax.plot(etas_plot, en_freq, **kwargs)
255
256        ax.set_xlabel("Displacements (Ang)")
257        ax.set_ylabel("Energy (eV)")
258
259        return fig
260
261    @add_fig_kwargs
262    def plot_anharmonic_contribution(self, freq, relative=False, ax=None, **kwargs):
263        """
264        Plots the the absolute relative difference between the energies extracted from the frequency as
265        quadratic coefficient and the calculated energies, giving an estimate of the anharmonic contribution
266        Requires the 0 displacement to be present in the list of etas.
267
268        Args:
269            freq: phonon frequncy in eV
270            relative: if True the plot will represent the relative difference with respect to the expected value
271                obtained from the frequency, rather than the absolute difference.
272            ax: |matplotlib-Axes| or None if a new figure should be created.
273
274        Returns:
275            |matplotlib-Figure|
276        """
277
278        if self.energies is None:
279            raise ValueError("The energies are required to calculate the fit")
280
281        self._freq_to_quad_coeff(freq)
282
283        if "color" not in kwargs and "c" not in kwargs:
284            kwargs["color"] = "blue"
285        if "linewidth" not in kwargs and "lw" not in kwargs:
286            kwargs["linewidth"] = 2
287
288        e0 = self.energies[self.ieta0]
289        en_freq = quadratic_fit_function(self.etas,self._freq_to_quad_coeff(freq), e0)
290
291        diff = np.abs(en_freq - np.array(self.energies))
292
293        if relative:
294            diff = [d/(e-e0) * 100 if e != 0 else 0 for d, e in zip(diff, en_freq)]
295
296        ax, fig, plt = get_ax_fig_plt(ax=ax)
297
298        ax.plot(self.etas, diff, **kwargs)
299
300        ax.set_xlabel("Displacements (Ang)")
301        if relative:
302            ax.set_ylabel("Relative energy difference (%)")
303        else:
304            ax.set_ylabel("Energy difference (eV)")
305
306        return fig
307