madman.helpers.ase.workflows.eps

Dielectric response.

  1r"""Dielectric response."""
  2
  3from __future__ import annotations
  4import os
  5from argparse import ArgumentParser
  6from collections.abc import Mapping, Sequence
  7from functools import cache
  8from itertools import product
  9from math import ceil
 10from typing import Any, Literal, Optional, Union
 11
 12import numpy as np
 13import seaborn as sns
 14import xarray as xr
 15import yaml
 16from ase.parallel import paropen, parprint
 17from gpaw.bztools import find_high_symmetry_monkhorst_pack
 18from gpaw.calculator import GPAW
 19from gpaw.response.df import DielectricFunction
 20from matplotlib import pyplot as plt
 21from pydantic import BaseModel, Field, confloat, conint, constr, field_validator
 22from scipy.interpolate import Akima1DInterpolator
 23
 24from madman.helpers.ase.workflows.converge import MultivariateConvergence
 25from madman.utilities import gen_regular_grid, redirect_to
 26
 27
 28sns.set_theme(
 29    context="talk",
 30    style="white",
 31    rc={"figure.titlesize": "medium", "axes.formatter.useoffset": False},
 32)
 33
 34
 35class Config(BaseModel):
 36    r"""Configuration."""
 37
 38    ground_path: constr(pattern=r".*\.gpw$") = Field(frozen=True)
 39    r"""Path to electronic ground state."""
 40
 41    kptden: Union[
 42        ConvergenceParameterSettingsReal,
 43        Mapping,
 44        confloat(gt=0.0, allow_inf_nan=False),
 45    ] = Field(10.0, frozen=True)
 46    r"""Convergence settings for $\mathbf{k}$-point density [$\mathrm{Å}$]."""
 47
 48    nbands_empty: Union[
 49        ConvergenceParameterSettingsIntegral,
 50        Mapping,
 51        conint(ge=0),
 52    ] = Field(10, frozen=True)
 53    r"""Convergence settings for number of empty bands."""
 54
 55    domega0: Union[
 56        ConvergenceParameterSettingsReal,
 57        Mapping,
 58        confloat(gt=0.0, allow_inf_nan=False),
 59    ] = Field(0.05, frozen=True)
 60    r"""Convergence settings for $\Delta \omega_0$ [$\mathrm{eV}$]."""
 61
 62    omega2: Union[
 63        ConvergenceParameterSettingsReal,
 64        Mapping,
 65        confloat(gt=0.0, allow_inf_nan=False),
 66    ] = Field(10.0, frozen=True)
 67    r"""Convergence settings for $\omega_2$ [$\mathrm{eV}$]."""
 68
 69    ecut: Union[
 70        ConvergenceParameterSettingsReal,
 71        Mapping,
 72        confloat(gt=0.0, allow_inf_nan=False),
 73    ] = Field(50.0, frozen=True)
 74    r"""Convergence settings for energy cutoff [$\mathrm{eV}$]."""
 75
 76    energy_min: confloat(ge=0.0, allow_inf_nan=False) = Field(0.0, frozen=True)
 77    r"""Photon energy minimum [$\mathrm{eV}$]."""
 78
 79    energy_max: confloat(gt=0.0, allow_inf_nan=False) = Field(5.0, frozen=True)
 80    r"""Photon energy maximum [$\mathrm{eV}$]."""
 81
 82    energy_spc: confloat(gt=0.0, allow_inf_nan=False) = Field(0.01, frozen=True)
 83    r"""Photon energy spacing [$\mathrm{eV}$]."""
 84
 85    directions: Sequence[Literal["x", "y", "z"], ...] = Field(
 86        {"x", "y", "z"}, frozen=True
 87    )
 88    r"""Dielectric function directions."""
 89
 90    strict: bool = Field(False, frozen=True)
 91    r"""If True, max. slope is used for convergence; otherwise, avg. is used."""
 92
 93    niter_max: conint(gt=0) = Field(1, frozen=True)
 94    r"""Maximum number of iterations for convergence."""
 95
 96    ks_calc_prefix: Optional[str] = Field(None, frozen=True)
 97    r"""Prefix to save Kohn-Sham orbitals calculator."""
 98
 99    eps_save_prefix: Optional[str] = Field(None, frozen=True)
100    r"""Prefix to save dielectric function."""
101
102    eps_plot_prefix: Optional[str] = Field(None, frozen=True)
103    r"""Prefix to save dielectric function plot."""
104
105    conv_plot_root: Optional[str] = Field(None, frozen=True)
106    r"""Root to save convergence plots."""
107
108    log_prefix: Optional[str] = Field(None, frozen=True)
109    r"""Prefix to save GPAW log."""
110
111    cache_root: str = Field("./tmpdata/", frozen=True)
112    r"""Root to cache electron densitysity of states."""
113
114    @field_validator("ground_path")
115    def validate_ground_path(cls, value):
116        r"""Validate path to electronic ground state."""
117        if not os.path.isfile(value):
118            raise FileNotFoundError
119        return value
120
121    @field_validator("kptden", "domega0", "omega2", "ecut")
122    def parse_conv_param_cfg_real(cls, value):
123        r"""Parse convergence parameter settings (real-valued)."""
124        if isinstance(value, Mapping):
125            value = ConvergenceParameterSettingsReal(**value)
126        return value
127
128    @field_validator("nbands_empty")
129    def parse_conv_param_cfg_int(cls, value):
130        r"""Parse convergence parameter settings (integral-valued)."""
131        if isinstance(value, Mapping):
132            value = ConvergenceParameterSettingsIntegral(**value)
133        return value
134
135    @field_validator("directions", mode="before")
136    def parse_directions(cls, value):
137        if isinstance(value, str):
138            value = [c for c in value]
139        return sorted(set(value))
140
141    @field_validator(
142        "ks_calc_prefix",
143        "eps_save_prefix",
144        "eps_plot_prefix",
145        "conv_plot_root",
146        "log_prefix",
147        "cache_root",
148    )
149    def create_tree(cls, value):
150        r"""Create tree for prefix or path."""
151        if value:
152            tree = os.path.dirname(value)
153            if tree:
154                os.makedirs(tree, exist_ok=True)
155        return value
156
157
158class ConvergenceParameterSettings(BaseModel):
159    r"""Convergence parameter settings."""
160
161    threshold: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
162        None, frozen=True
163    )
164    r"""Convergence threshold."""
165
166    stability: Optional[conint(gt=0)] = Field(None, frozen=True)
167    r"""Convergence stability."""
168
169
170class ConvergenceParameterSettingsReal(ConvergenceParameterSettings):
171    r"""Convergence parameter settings (real-valued)."""
172
173    value_min: confloat(gt=0.0, allow_inf_nan=False) = Field(frozen=True)
174    r"""Parameter minimum value."""
175
176    value_max: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
177        None, frozen=True
178    )
179    r"""Parameter maximum value."""
180
181    value_spc: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
182        None, frozen=True
183    )
184    r"""Paramenter value spacing."""
185
186
187class ConvergenceParameterSettingsIntegral(ConvergenceParameterSettings):
188    r"""Convergence parameter settings (integral-valued)."""
189
190    value_min: conint(gt=0) = Field(frozen=True)
191    r"""Parameter minimum value."""
192
193    value_max: Optional[conint(gt=0)] = Field(None, frozen=True)
194    r"""Parameter maximum value."""
195
196    value_spc: Optional[conint(gt=0)] = Field(None, frozen=True)
197    r"""Paramenter value spacing."""
198
199
200def ret_params(config: Config) -> dict:
201    r"""Return convergence parameters.
202
203    Args:
204        config: Configuration.
205
206    Returns:
207        Convergence parameters.
208
209    Raises:
210        TypeError: If configuration is not a Config instance.
211    """
212    if not isinstance(config, Config):
213        raise TypeError("Not a Config instance!")
214
215    kptden_d = config.kptden
216    if isinstance(kptden_d, float):
217        kptden_d = {"value_min": kptden_d}
218    else:
219        kptden_d = kptden_d.dict()
220    kptden_d.update(
221        {
222            "direction": 1,
223            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
224            "symb": r"\lambda_{\mathbf{k}}",
225            "unit": r"Å",
226        }
227    )
228    nbands_empty_d = config.nbands_empty
229    if isinstance(nbands_empty_d, int):
230        nbands_empty_d = {"value_min": nbands_empty_d}
231    else:
232        nbands_empty_d = nbands_empty_d.dict()
233    nbands_empty_d.update(
234        {
235            "direction": 1,
236            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
237            "symb": r"N^{*}",
238            "unit": r"1",
239        }
240    )
241    domega0_d = config.domega0
242    if isinstance(domega0_d, float):
243        domega0_d = {"value_min": domega0_d}
244    else:
245        domega0_d = domega0_d.dict()
246    domega0_d.update(
247        {
248            "direction": -1,
249            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
250            "symb": r"\Delta{\omega_{0}}",
251            "unit": r"eV",
252        }
253    )
254    omega2_d = config.omega2
255    if isinstance(omega2_d, float):
256        omega2_d = {"value_min": omega2_d}
257    else:
258        omega2_d = omega2_d.dict()
259    omega2_d.update(
260        {
261            "direction": 1,
262            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
263            "symb": r"\omega_{2}",
264            "unit": r"eV",
265        }
266    )
267    ecut_d = config.ecut
268    if isinstance(ecut_d, float):
269        ecut_d = {"value_min": ecut_d}
270    else:
271        ecut_d = ecut_d.dict()
272    ecut_d.update(
273        {
274            "direction": 1,
275            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
276            "symb": r"\epsilon_{\text{cut}}",
277            "unit": r"eV",
278        }
279    )
280    params = {
281        "kptden": kptden_d,
282        "nbands_empty": nbands_empty_d,
283        "domega0": domega0_d,
284        "omega2": omega2_d,
285        "ecut": ecut_d,
286    }
287    return params
288
289
290def calc_eps(config: Config) -> None:
291    r"""Calculate dielectric function.
292
293    Args:
294        config: Configuration.
295
296    Raises:
297        TypeError: If configuration is not a Config instance.
298    """
299    if not isinstance(config, Config):
300        raise TypeError("Not a Config instance!")
301
302    with redirect_to(config.log_prefix, mode="w"):
303        pass
304    parprint("GPAW log initialized...")
305
306    cache_ks = {}
307    ks_iter_i = 0
308    parprint("Cache initialized...")
309
310    ground = GPAW(config.ground_path)
311    parprint("Electronic ground state imported...")
312
313    nelectrons = ground.get_number_of_electrons()
314    nbands_occupied = ceil(0.5 * nelectrons)
315    parprint(f"Number of occupied bands: {nbands_occupied}...")
316
317    params = ret_params(config)
318    parprint("Dielectric function convergence parameters set up...")
319
320    energy_grid = gen_regular_grid(
321        config.energy_min, config.energy_max, config.energy_spc
322    )
323    parprint("Photon energy grid set up...")
324
325    @cache
326    def objective(kptden, nbands_empty, domega0, omega2, ecut):
327        nonlocal ks_iter_i
328        nbands_empty = int(nbands_empty) # FIXME: Should be fixed in `convergence.py` module
329
330        key_ks = tuple(sorted([("kptden", kptden), ("nbands_empty", nbands_empty)]))
331        if key_ks not in cache_ks:
332            path_ks = os.path.join(config.cache_root, f"ks{ks_iter_i}.gpw")
333            nbands = nbands_occupied + nbands_empty
334            with redirect_to(config.log_prefix, mode="a"):
335                calc_ks = ground.fixed_density(kpts={"density": kptden})
336                calc_ks.write(path_ks, "all")
337            cache_ks.update({key_ks: path_ks})
338            ks_iter_i += 1
339        path_ks = cache_ks[key_ks]
340        freqs = {"type": "nonlinear", "domega0": domega0, "omega2": omega2}
341        with redirect_to(config.log_prefix, mode="a"):
342            calc_eps = DielectricFunction(
343                calc=path_ks,
344                frequencies=freqs,
345                ecut=ecut,
346                rate="eta",
347            )
348        # The dielectric function rows are sorted according to:
349        # - direction (x, y, z)
350        # - local field correction (0: off; 1: on)
351        # - real / imaginary part
352        eps_arr = []
353        for direction in config.directions:
354            with redirect_to(config.log_prefix, mode="a"):
355                eps = calc_eps.get_dielectric_function(
356                    direction=direction,
357                    filename=os.path.join(config.cache_root, "eps.csv"),
358                )
359            for has_loc_field_corr in [0, 1]:
360                eps_re = Akima1DInterpolator(
361                    x=calc_eps.get_frequencies(),
362                    y=eps[has_loc_field_corr].real,
363                    method="akima",
364                    extrapolate=False,
365                )(energy_grid)
366                eps_arr.append(eps_re)
367                eps_im = Akima1DInterpolator(
368                    x=calc_eps.get_frequencies(),
369                    y=eps[has_loc_field_corr].imag,
370                    method="akima",
371                    extrapolate=False,
372                )(energy_grid)
373                eps_arr.append(eps_im)
374        return np.stack(eps_arr, dtype=float)
375
376    parprint("Dielectric function objective function set up...")
377
378    obj_symb = [
379        f"{part}\\left[\\epsilon_{{{direction}}}^{{\\text{{{has_loc_field_corr}}}}}\\right]"
380        for direction in config.directions
381        for has_loc_field_corr in ["", "LFC"]
382        for part in ["\\Re", "\\Im"]
383    ]
384    convergence = MultivariateConvergence(
385        objective,
386        params,
387        crop=True,
388        req_sc=True,
389        niter_max=config.niter_max,
390        obj_symb=obj_symb,
391        obj_unit=r"1",
392    )
393    parprint("Dielectric function convergence set up...")
394
395    parprint("Dielectric function convergence started...")
396    convergence.run()
397    if convergence.converged is False:
398        parprint("Dielectric function convergence failed!")
399    else:
400        parprint("Dielectric function converged...")
401
402    params_opt = convergence.values_opt
403    eps_arr = objective(**params_opt)
404    parprint("Dielectric function calculated...")
405
406    if config.eps_save_prefix is not None:
407        eps_da = xr.DataArray(
408            eps_arr.reshape(len(config.directions), 2, 2, -1),
409            coords=[config.directions, [0, 1], ["re", "im"], energy_grid],
410            dims=["direction", "has_loc_field_corr", "part", "energy"],
411        )
412        eps_da.to_netcdf(f"{config.eps_save_prefix}.nc")
413        parprint("Dielectric function saved...")
414
415    if config.eps_plot_prefix is not None:
416        fig, ax = plt.subplots(tight_layout=True)
417        ax.set_xlabel(r"$\hbar \, \omega$ / $\mathrm{eV}$")
418        ax.set_ylabel(r"$\epsilon$")
419        for eps, label in zip(eps_arr, obj_symb):
420            ax.plot(energy_grid, eps, label=f"${label}$")
421        ax.legend(
422            loc="upper center",
423            bbox_to_anchor=[1.0, 1.0],
424            frameon=True,
425            ncols=len(config.directions),
426            edgecolor="k",
427            fontsize="xx-small",
428        )
429        fig.savefig(f"{config.eps_plot_prefix}.svg")
430        parprint("Dielectric function plot saved...")
431
432    if config.conv_plot_root is not None:
433        value_plots_map = convergence.plot("obj-value")
434        slope_plots_map = convergence.plot("obj-slope")
435
436        ndigits = len(str(config.niter_max))
437
438        for qnty, plots_map in zip(
439            ["eps", "meps"], [value_plots_map, slope_plots_map]
440        ):
441            for param, plots_seq in plots_map.items():
442                for i, plots in enumerate(plots_seq):
443                    for (direction, has_loc_field_corr, part), plot in zip(
444                        product(config.directions, [0, 1], ["re", "im"]), plots
445                    ):
446                        figdir = os.path.join(
447                            config.conv_plot_root,
448                            f"{qnty}-vs-{param}",
449                            f"{direction}-{has_loc_field_corr}-{part}",
450                        )
451                        os.makedirs(figdir, exist_ok=True)
452                        figpath = os.path.join(figdir, f"{i:0{ndigits}.0f}.svg")
453                        fig = plot.get_figure()
454                        fig.savefig(figpath)
455                        plt.close(fig)
456        parprint("Converge plots saved...")
457
458    return None
459
460def calc_eps_cli() -> None:
461    r"""Calculate electron density of states - CLI interface."""
462    parser = ArgumentParser(description="Calculate electron density of states")
463    parser.add_argument(
464        "config",
465        nargs="?",
466        default="./config.yml",
467        help="configuration file",
468    )
469    args = parser.parse_args()
470    with paropen(args.config, "r") as stream:
471        config = yaml.safe_load(stream)
472    config = Config(**config)
473    calc_eps(config)
class Config(pydantic.main.BaseModel):
 36class Config(BaseModel):
 37    r"""Configuration."""
 38
 39    ground_path: constr(pattern=r".*\.gpw$") = Field(frozen=True)
 40    r"""Path to electronic ground state."""
 41
 42    kptden: Union[
 43        ConvergenceParameterSettingsReal,
 44        Mapping,
 45        confloat(gt=0.0, allow_inf_nan=False),
 46    ] = Field(10.0, frozen=True)
 47    r"""Convergence settings for $\mathbf{k}$-point density [$\mathrm{Å}$]."""
 48
 49    nbands_empty: Union[
 50        ConvergenceParameterSettingsIntegral,
 51        Mapping,
 52        conint(ge=0),
 53    ] = Field(10, frozen=True)
 54    r"""Convergence settings for number of empty bands."""
 55
 56    domega0: Union[
 57        ConvergenceParameterSettingsReal,
 58        Mapping,
 59        confloat(gt=0.0, allow_inf_nan=False),
 60    ] = Field(0.05, frozen=True)
 61    r"""Convergence settings for $\Delta \omega_0$ [$\mathrm{eV}$]."""
 62
 63    omega2: Union[
 64        ConvergenceParameterSettingsReal,
 65        Mapping,
 66        confloat(gt=0.0, allow_inf_nan=False),
 67    ] = Field(10.0, frozen=True)
 68    r"""Convergence settings for $\omega_2$ [$\mathrm{eV}$]."""
 69
 70    ecut: Union[
 71        ConvergenceParameterSettingsReal,
 72        Mapping,
 73        confloat(gt=0.0, allow_inf_nan=False),
 74    ] = Field(50.0, frozen=True)
 75    r"""Convergence settings for energy cutoff [$\mathrm{eV}$]."""
 76
 77    energy_min: confloat(ge=0.0, allow_inf_nan=False) = Field(0.0, frozen=True)
 78    r"""Photon energy minimum [$\mathrm{eV}$]."""
 79
 80    energy_max: confloat(gt=0.0, allow_inf_nan=False) = Field(5.0, frozen=True)
 81    r"""Photon energy maximum [$\mathrm{eV}$]."""
 82
 83    energy_spc: confloat(gt=0.0, allow_inf_nan=False) = Field(0.01, frozen=True)
 84    r"""Photon energy spacing [$\mathrm{eV}$]."""
 85
 86    directions: Sequence[Literal["x", "y", "z"], ...] = Field(
 87        {"x", "y", "z"}, frozen=True
 88    )
 89    r"""Dielectric function directions."""
 90
 91    strict: bool = Field(False, frozen=True)
 92    r"""If True, max. slope is used for convergence; otherwise, avg. is used."""
 93
 94    niter_max: conint(gt=0) = Field(1, frozen=True)
 95    r"""Maximum number of iterations for convergence."""
 96
 97    ks_calc_prefix: Optional[str] = Field(None, frozen=True)
 98    r"""Prefix to save Kohn-Sham orbitals calculator."""
 99
100    eps_save_prefix: Optional[str] = Field(None, frozen=True)
101    r"""Prefix to save dielectric function."""
102
103    eps_plot_prefix: Optional[str] = Field(None, frozen=True)
104    r"""Prefix to save dielectric function plot."""
105
106    conv_plot_root: Optional[str] = Field(None, frozen=True)
107    r"""Root to save convergence plots."""
108
109    log_prefix: Optional[str] = Field(None, frozen=True)
110    r"""Prefix to save GPAW log."""
111
112    cache_root: str = Field("./tmpdata/", frozen=True)
113    r"""Root to cache electron densitysity of states."""
114
115    @field_validator("ground_path")
116    def validate_ground_path(cls, value):
117        r"""Validate path to electronic ground state."""
118        if not os.path.isfile(value):
119            raise FileNotFoundError
120        return value
121
122    @field_validator("kptden", "domega0", "omega2", "ecut")
123    def parse_conv_param_cfg_real(cls, value):
124        r"""Parse convergence parameter settings (real-valued)."""
125        if isinstance(value, Mapping):
126            value = ConvergenceParameterSettingsReal(**value)
127        return value
128
129    @field_validator("nbands_empty")
130    def parse_conv_param_cfg_int(cls, value):
131        r"""Parse convergence parameter settings (integral-valued)."""
132        if isinstance(value, Mapping):
133            value = ConvergenceParameterSettingsIntegral(**value)
134        return value
135
136    @field_validator("directions", mode="before")
137    def parse_directions(cls, value):
138        if isinstance(value, str):
139            value = [c for c in value]
140        return sorted(set(value))
141
142    @field_validator(
143        "ks_calc_prefix",
144        "eps_save_prefix",
145        "eps_plot_prefix",
146        "conv_plot_root",
147        "log_prefix",
148        "cache_root",
149    )
150    def create_tree(cls, value):
151        r"""Create tree for prefix or path."""
152        if value:
153            tree = os.path.dirname(value)
154            if tree:
155                os.makedirs(tree, exist_ok=True)
156        return value

Configuration.

ground_path: Annotated[str, StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=None, max_length=None, pattern='.*\\.gpw$')]

Path to electronic ground state.

kptden: Union[ConvergenceParameterSettingsReal, collections.abc.Mapping, Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Convergence settings for $\mathbf{k}$-point density [$\mathrm{Å}$].

nbands_empty: Union[ConvergenceParameterSettingsIntegral, collections.abc.Mapping, Annotated[int, None, Interval(gt=None, ge=0, lt=None, le=None), None]]

Convergence settings for number of empty bands.

domega0: Union[ConvergenceParameterSettingsReal, collections.abc.Mapping, Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Convergence settings for $\Delta \omega_0$ [$\mathrm{eV}$].

omega2: Union[ConvergenceParameterSettingsReal, collections.abc.Mapping, Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Convergence settings for $\omega_2$ [$\mathrm{eV}$].

ecut: Union[ConvergenceParameterSettingsReal, collections.abc.Mapping, Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Convergence settings for energy cutoff [$\mathrm{eV}$].

energy_min: Annotated[float, None, Interval(gt=None, ge=0.0, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]

Photon energy minimum [$\mathrm{eV}$].

energy_max: Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]

Photon energy maximum [$\mathrm{eV}$].

energy_spc: Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]

Photon energy spacing [$\mathrm{eV}$].

directions: collections.abc.Sequence[typing.Literal['x', 'y', 'z'], ...]

Dielectric function directions.

strict: bool

If True, max. slope is used for convergence; otherwise, avg. is used.

niter_max: Annotated[int, None, Interval(gt=0, ge=None, lt=None, le=None), None]

Maximum number of iterations for convergence.

ks_calc_prefix: Optional[str]

Prefix to save Kohn-Sham orbitals calculator.

eps_save_prefix: Optional[str]

Prefix to save dielectric function.

eps_plot_prefix: Optional[str]

Prefix to save dielectric function plot.

conv_plot_root: Optional[str]

Root to save convergence plots.

log_prefix: Optional[str]

Prefix to save GPAW log.

cache_root: str

Root to cache electron densitysity of states.

@field_validator('ground_path')
def validate_ground_path(cls, value):
115    @field_validator("ground_path")
116    def validate_ground_path(cls, value):
117        r"""Validate path to electronic ground state."""
118        if not os.path.isfile(value):
119            raise FileNotFoundError
120        return value

Validate path to electronic ground state.

@field_validator('kptden', 'domega0', 'omega2', 'ecut')
def parse_conv_param_cfg_real(cls, value):
122    @field_validator("kptden", "domega0", "omega2", "ecut")
123    def parse_conv_param_cfg_real(cls, value):
124        r"""Parse convergence parameter settings (real-valued)."""
125        if isinstance(value, Mapping):
126            value = ConvergenceParameterSettingsReal(**value)
127        return value

Parse convergence parameter settings (real-valued).

@field_validator('nbands_empty')
def parse_conv_param_cfg_int(cls, value):
129    @field_validator("nbands_empty")
130    def parse_conv_param_cfg_int(cls, value):
131        r"""Parse convergence parameter settings (integral-valued)."""
132        if isinstance(value, Mapping):
133            value = ConvergenceParameterSettingsIntegral(**value)
134        return value

Parse convergence parameter settings (integral-valued).

@field_validator('directions', mode='before')
def parse_directions(cls, value):
136    @field_validator("directions", mode="before")
137    def parse_directions(cls, value):
138        if isinstance(value, str):
139            value = [c for c in value]
140        return sorted(set(value))
@field_validator('ks_calc_prefix', 'eps_save_prefix', 'eps_plot_prefix', 'conv_plot_root', 'log_prefix', 'cache_root')
def create_tree(cls, value):
142    @field_validator(
143        "ks_calc_prefix",
144        "eps_save_prefix",
145        "eps_plot_prefix",
146        "conv_plot_root",
147        "log_prefix",
148        "cache_root",
149    )
150    def create_tree(cls, value):
151        r"""Create tree for prefix or path."""
152        if value:
153            tree = os.path.dirname(value)
154            if tree:
155                os.makedirs(tree, exist_ok=True)
156        return value

Create tree for prefix or path.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

model_fields: ClassVar[Dict[str, pydantic.fields.FieldInfo]] = {'ground_path': FieldInfo(annotation=str, required=True, frozen=True, metadata=[StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=None, max_length=None, pattern='.*\\.gpw$')]), 'kptden': FieldInfo(annotation=ForwardRef('Union[ConvergenceParameterSettingsReal, Mapping, confloat(gt=0.0, allow_inf_nan=False)]'), required=False, default=10.0, frozen=True), 'nbands_empty': FieldInfo(annotation=ForwardRef('Union[ConvergenceParameterSettingsIntegral, Mapping, conint(ge=0)]'), required=False, default=10, frozen=True), 'domega0': FieldInfo(annotation=ForwardRef('Union[ConvergenceParameterSettingsReal, Mapping, confloat(gt=0.0, allow_inf_nan=False)]'), required=False, default=0.05, frozen=True), 'omega2': FieldInfo(annotation=ForwardRef('Union[ConvergenceParameterSettingsReal, Mapping, confloat(gt=0.0, allow_inf_nan=False)]'), required=False, default=10.0, frozen=True), 'ecut': FieldInfo(annotation=ForwardRef('Union[ConvergenceParameterSettingsReal, Mapping, confloat(gt=0.0, allow_inf_nan=False)]'), required=False, default=50.0, frozen=True), 'energy_min': FieldInfo(annotation=float, required=False, default=0.0, frozen=True, metadata=[None, Interval(gt=None, ge=0.0, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]), 'energy_max': FieldInfo(annotation=float, required=False, default=5.0, frozen=True, metadata=[None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]), 'energy_spc': FieldInfo(annotation=float, required=False, default=0.01, frozen=True, metadata=[None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]), 'directions': FieldInfo(annotation=Sequence[Literal['x', 'y', 'z'], ...], required=False, default={'z', 'x', 'y'}, frozen=True), 'strict': FieldInfo(annotation=bool, required=False, default=False, frozen=True), 'niter_max': FieldInfo(annotation=int, required=False, default=1, frozen=True, metadata=[None, Interval(gt=0, ge=None, lt=None, le=None), None]), 'ks_calc_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'eps_save_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'eps_plot_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'conv_plot_root': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'log_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'cache_root': FieldInfo(annotation=str, required=False, default='./tmpdata/', frozen=True)}

Metadata about the fields defined on the model, mapping of field names to [FieldInfo][pydantic.fields.FieldInfo] objects.

This replaces Model.__fields__ from Pydantic V1.

model_computed_fields: ClassVar[Dict[str, pydantic.fields.ComputedFieldInfo]] = {}

A dictionary of computed field names and their corresponding ComputedFieldInfo objects.

Inherited Members
pydantic.main.BaseModel
BaseModel
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
dict
json
parse_obj
parse_raw
parse_file
from_orm
construct
copy
schema
schema_json
validate
update_forward_refs
class ConvergenceParameterSettings(pydantic.main.BaseModel):
159class ConvergenceParameterSettings(BaseModel):
160    r"""Convergence parameter settings."""
161
162    threshold: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
163        None, frozen=True
164    )
165    r"""Convergence threshold."""
166
167    stability: Optional[conint(gt=0)] = Field(None, frozen=True)
168    r"""Convergence stability."""

Convergence parameter settings.

threshold: Optional[Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Convergence threshold.

stability: Optional[Annotated[int, None, Interval(gt=0, ge=None, lt=None, le=None), None]]

Convergence stability.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

model_fields: ClassVar[Dict[str, pydantic.fields.FieldInfo]] = {'threshold': FieldInfo(annotation=Union[Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True), 'stability': FieldInfo(annotation=Union[Annotated[int, NoneType, Interval, NoneType], NoneType], required=False, default=None, frozen=True)}

Metadata about the fields defined on the model, mapping of field names to [FieldInfo][pydantic.fields.FieldInfo] objects.

This replaces Model.__fields__ from Pydantic V1.

model_computed_fields: ClassVar[Dict[str, pydantic.fields.ComputedFieldInfo]] = {}

A dictionary of computed field names and their corresponding ComputedFieldInfo objects.

Inherited Members
pydantic.main.BaseModel
BaseModel
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
dict
json
parse_obj
parse_raw
parse_file
from_orm
construct
copy
schema
schema_json
validate
update_forward_refs
class ConvergenceParameterSettingsReal(ConvergenceParameterSettings):
171class ConvergenceParameterSettingsReal(ConvergenceParameterSettings):
172    r"""Convergence parameter settings (real-valued)."""
173
174    value_min: confloat(gt=0.0, allow_inf_nan=False) = Field(frozen=True)
175    r"""Parameter minimum value."""
176
177    value_max: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
178        None, frozen=True
179    )
180    r"""Parameter maximum value."""
181
182    value_spc: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
183        None, frozen=True
184    )
185    r"""Paramenter value spacing."""

Convergence parameter settings (real-valued).

value_min: Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]

Parameter minimum value.

value_max: Optional[Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Parameter maximum value.

value_spc: Optional[Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]]

Paramenter value spacing.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

model_fields: ClassVar[Dict[str, pydantic.fields.FieldInfo]] = {'threshold': FieldInfo(annotation=Union[Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True), 'stability': FieldInfo(annotation=Union[Annotated[int, NoneType, Interval, NoneType], NoneType], required=False, default=None, frozen=True), 'value_min': FieldInfo(annotation=float, required=True, frozen=True, metadata=[None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)]), 'value_max': FieldInfo(annotation=Union[Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True), 'value_spc': FieldInfo(annotation=Union[Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True)}

Metadata about the fields defined on the model, mapping of field names to [FieldInfo][pydantic.fields.FieldInfo] objects.

This replaces Model.__fields__ from Pydantic V1.

model_computed_fields: ClassVar[Dict[str, pydantic.fields.ComputedFieldInfo]] = {}

A dictionary of computed field names and their corresponding ComputedFieldInfo objects.

Inherited Members
pydantic.main.BaseModel
BaseModel
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
dict
json
parse_obj
parse_raw
parse_file
from_orm
construct
copy
schema
schema_json
validate
update_forward_refs
ConvergenceParameterSettings
threshold
stability
class ConvergenceParameterSettingsIntegral(ConvergenceParameterSettings):
188class ConvergenceParameterSettingsIntegral(ConvergenceParameterSettings):
189    r"""Convergence parameter settings (integral-valued)."""
190
191    value_min: conint(gt=0) = Field(frozen=True)
192    r"""Parameter minimum value."""
193
194    value_max: Optional[conint(gt=0)] = Field(None, frozen=True)
195    r"""Parameter maximum value."""
196
197    value_spc: Optional[conint(gt=0)] = Field(None, frozen=True)
198    r"""Paramenter value spacing."""

Convergence parameter settings (integral-valued).

value_min: Annotated[int, None, Interval(gt=0, ge=None, lt=None, le=None), None]

Parameter minimum value.

value_max: Optional[Annotated[int, None, Interval(gt=0, ge=None, lt=None, le=None), None]]

Parameter maximum value.

value_spc: Optional[Annotated[int, None, Interval(gt=0, ge=None, lt=None, le=None), None]]

Paramenter value spacing.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

model_fields: ClassVar[Dict[str, pydantic.fields.FieldInfo]] = {'threshold': FieldInfo(annotation=Union[Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True), 'stability': FieldInfo(annotation=Union[Annotated[int, NoneType, Interval, NoneType], NoneType], required=False, default=None, frozen=True), 'value_min': FieldInfo(annotation=int, required=True, frozen=True, metadata=[None, Interval(gt=0, ge=None, lt=None, le=None), None]), 'value_max': FieldInfo(annotation=Union[Annotated[int, NoneType, Interval, NoneType], NoneType], required=False, default=None, frozen=True), 'value_spc': FieldInfo(annotation=Union[Annotated[int, NoneType, Interval, NoneType], NoneType], required=False, default=None, frozen=True)}

Metadata about the fields defined on the model, mapping of field names to [FieldInfo][pydantic.fields.FieldInfo] objects.

This replaces Model.__fields__ from Pydantic V1.

model_computed_fields: ClassVar[Dict[str, pydantic.fields.ComputedFieldInfo]] = {}

A dictionary of computed field names and their corresponding ComputedFieldInfo objects.

Inherited Members
pydantic.main.BaseModel
BaseModel
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
dict
json
parse_obj
parse_raw
parse_file
from_orm
construct
copy
schema
schema_json
validate
update_forward_refs
ConvergenceParameterSettings
threshold
stability
def ret_params(config: Config) -> dict:
201def ret_params(config: Config) -> dict:
202    r"""Return convergence parameters.
203
204    Args:
205        config: Configuration.
206
207    Returns:
208        Convergence parameters.
209
210    Raises:
211        TypeError: If configuration is not a Config instance.
212    """
213    if not isinstance(config, Config):
214        raise TypeError("Not a Config instance!")
215
216    kptden_d = config.kptden
217    if isinstance(kptden_d, float):
218        kptden_d = {"value_min": kptden_d}
219    else:
220        kptden_d = kptden_d.dict()
221    kptden_d.update(
222        {
223            "direction": 1,
224            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
225            "symb": r"\lambda_{\mathbf{k}}",
226            "unit": r"Å",
227        }
228    )
229    nbands_empty_d = config.nbands_empty
230    if isinstance(nbands_empty_d, int):
231        nbands_empty_d = {"value_min": nbands_empty_d}
232    else:
233        nbands_empty_d = nbands_empty_d.dict()
234    nbands_empty_d.update(
235        {
236            "direction": 1,
237            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
238            "symb": r"N^{*}",
239            "unit": r"1",
240        }
241    )
242    domega0_d = config.domega0
243    if isinstance(domega0_d, float):
244        domega0_d = {"value_min": domega0_d}
245    else:
246        domega0_d = domega0_d.dict()
247    domega0_d.update(
248        {
249            "direction": -1,
250            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
251            "symb": r"\Delta{\omega_{0}}",
252            "unit": r"eV",
253        }
254    )
255    omega2_d = config.omega2
256    if isinstance(omega2_d, float):
257        omega2_d = {"value_min": omega2_d}
258    else:
259        omega2_d = omega2_d.dict()
260    omega2_d.update(
261        {
262            "direction": 1,
263            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
264            "symb": r"\omega_{2}",
265            "unit": r"eV",
266        }
267    )
268    ecut_d = config.ecut
269    if isinstance(ecut_d, float):
270        ecut_d = {"value_min": ecut_d}
271    else:
272        ecut_d = ecut_d.dict()
273    ecut_d.update(
274        {
275            "direction": 1,
276            "criterion": f"slope-abs-{"max" if config.strict else "avg"}",
277            "symb": r"\epsilon_{\text{cut}}",
278            "unit": r"eV",
279        }
280    )
281    params = {
282        "kptden": kptden_d,
283        "nbands_empty": nbands_empty_d,
284        "domega0": domega0_d,
285        "omega2": omega2_d,
286        "ecut": ecut_d,
287    }
288    return params

Return convergence parameters.

Arguments:
  • config: Configuration.
Returns:

Convergence parameters.

Raises:
  • TypeError: If configuration is not a Config instance.
def calc_eps(config: Config) -> None:
291def calc_eps(config: Config) -> None:
292    r"""Calculate dielectric function.
293
294    Args:
295        config: Configuration.
296
297    Raises:
298        TypeError: If configuration is not a Config instance.
299    """
300    if not isinstance(config, Config):
301        raise TypeError("Not a Config instance!")
302
303    with redirect_to(config.log_prefix, mode="w"):
304        pass
305    parprint("GPAW log initialized...")
306
307    cache_ks = {}
308    ks_iter_i = 0
309    parprint("Cache initialized...")
310
311    ground = GPAW(config.ground_path)
312    parprint("Electronic ground state imported...")
313
314    nelectrons = ground.get_number_of_electrons()
315    nbands_occupied = ceil(0.5 * nelectrons)
316    parprint(f"Number of occupied bands: {nbands_occupied}...")
317
318    params = ret_params(config)
319    parprint("Dielectric function convergence parameters set up...")
320
321    energy_grid = gen_regular_grid(
322        config.energy_min, config.energy_max, config.energy_spc
323    )
324    parprint("Photon energy grid set up...")
325
326    @cache
327    def objective(kptden, nbands_empty, domega0, omega2, ecut):
328        nonlocal ks_iter_i
329        nbands_empty = int(nbands_empty) # FIXME: Should be fixed in `convergence.py` module
330
331        key_ks = tuple(sorted([("kptden", kptden), ("nbands_empty", nbands_empty)]))
332        if key_ks not in cache_ks:
333            path_ks = os.path.join(config.cache_root, f"ks{ks_iter_i}.gpw")
334            nbands = nbands_occupied + nbands_empty
335            with redirect_to(config.log_prefix, mode="a"):
336                calc_ks = ground.fixed_density(kpts={"density": kptden})
337                calc_ks.write(path_ks, "all")
338            cache_ks.update({key_ks: path_ks})
339            ks_iter_i += 1
340        path_ks = cache_ks[key_ks]
341        freqs = {"type": "nonlinear", "domega0": domega0, "omega2": omega2}
342        with redirect_to(config.log_prefix, mode="a"):
343            calc_eps = DielectricFunction(
344                calc=path_ks,
345                frequencies=freqs,
346                ecut=ecut,
347                rate="eta",
348            )
349        # The dielectric function rows are sorted according to:
350        # - direction (x, y, z)
351        # - local field correction (0: off; 1: on)
352        # - real / imaginary part
353        eps_arr = []
354        for direction in config.directions:
355            with redirect_to(config.log_prefix, mode="a"):
356                eps = calc_eps.get_dielectric_function(
357                    direction=direction,
358                    filename=os.path.join(config.cache_root, "eps.csv"),
359                )
360            for has_loc_field_corr in [0, 1]:
361                eps_re = Akima1DInterpolator(
362                    x=calc_eps.get_frequencies(),
363                    y=eps[has_loc_field_corr].real,
364                    method="akima",
365                    extrapolate=False,
366                )(energy_grid)
367                eps_arr.append(eps_re)
368                eps_im = Akima1DInterpolator(
369                    x=calc_eps.get_frequencies(),
370                    y=eps[has_loc_field_corr].imag,
371                    method="akima",
372                    extrapolate=False,
373                )(energy_grid)
374                eps_arr.append(eps_im)
375        return np.stack(eps_arr, dtype=float)
376
377    parprint("Dielectric function objective function set up...")
378
379    obj_symb = [
380        f"{part}\\left[\\epsilon_{{{direction}}}^{{\\text{{{has_loc_field_corr}}}}}\\right]"
381        for direction in config.directions
382        for has_loc_field_corr in ["", "LFC"]
383        for part in ["\\Re", "\\Im"]
384    ]
385    convergence = MultivariateConvergence(
386        objective,
387        params,
388        crop=True,
389        req_sc=True,
390        niter_max=config.niter_max,
391        obj_symb=obj_symb,
392        obj_unit=r"1",
393    )
394    parprint("Dielectric function convergence set up...")
395
396    parprint("Dielectric function convergence started...")
397    convergence.run()
398    if convergence.converged is False:
399        parprint("Dielectric function convergence failed!")
400    else:
401        parprint("Dielectric function converged...")
402
403    params_opt = convergence.values_opt
404    eps_arr = objective(**params_opt)
405    parprint("Dielectric function calculated...")
406
407    if config.eps_save_prefix is not None:
408        eps_da = xr.DataArray(
409            eps_arr.reshape(len(config.directions), 2, 2, -1),
410            coords=[config.directions, [0, 1], ["re", "im"], energy_grid],
411            dims=["direction", "has_loc_field_corr", "part", "energy"],
412        )
413        eps_da.to_netcdf(f"{config.eps_save_prefix}.nc")
414        parprint("Dielectric function saved...")
415
416    if config.eps_plot_prefix is not None:
417        fig, ax = plt.subplots(tight_layout=True)
418        ax.set_xlabel(r"$\hbar \, \omega$ / $\mathrm{eV}$")
419        ax.set_ylabel(r"$\epsilon$")
420        for eps, label in zip(eps_arr, obj_symb):
421            ax.plot(energy_grid, eps, label=f"${label}$")
422        ax.legend(
423            loc="upper center",
424            bbox_to_anchor=[1.0, 1.0],
425            frameon=True,
426            ncols=len(config.directions),
427            edgecolor="k",
428            fontsize="xx-small",
429        )
430        fig.savefig(f"{config.eps_plot_prefix}.svg")
431        parprint("Dielectric function plot saved...")
432
433    if config.conv_plot_root is not None:
434        value_plots_map = convergence.plot("obj-value")
435        slope_plots_map = convergence.plot("obj-slope")
436
437        ndigits = len(str(config.niter_max))
438
439        for qnty, plots_map in zip(
440            ["eps", "meps"], [value_plots_map, slope_plots_map]
441        ):
442            for param, plots_seq in plots_map.items():
443                for i, plots in enumerate(plots_seq):
444                    for (direction, has_loc_field_corr, part), plot in zip(
445                        product(config.directions, [0, 1], ["re", "im"]), plots
446                    ):
447                        figdir = os.path.join(
448                            config.conv_plot_root,
449                            f"{qnty}-vs-{param}",
450                            f"{direction}-{has_loc_field_corr}-{part}",
451                        )
452                        os.makedirs(figdir, exist_ok=True)
453                        figpath = os.path.join(figdir, f"{i:0{ndigits}.0f}.svg")
454                        fig = plot.get_figure()
455                        fig.savefig(figpath)
456                        plt.close(fig)
457        parprint("Converge plots saved...")
458
459    return None

Calculate dielectric function.

Arguments:
  • config: Configuration.
Raises:
  • TypeError: If configuration is not a Config instance.
def calc_eps_cli() -> None:
461def calc_eps_cli() -> None:
462    r"""Calculate electron density of states - CLI interface."""
463    parser = ArgumentParser(description="Calculate electron density of states")
464    parser.add_argument(
465        "config",
466        nargs="?",
467        default="./config.yml",
468        help="configuration file",
469    )
470    args = parser.parse_args()
471    with paropen(args.config, "r") as stream:
472        config = yaml.safe_load(stream)
473    config = Config(**config)
474    calc_eps(config)

Calculate electron density of states - CLI interface.