madman.helpers.ase.workflows.ground

Electronic ground state.

  1r"""Electronic ground state."""
  2
  3import os
  4from argparse import ArgumentParser
  5from collections.abc import Mapping
  6from functools import cache
  7from numbers import Real
  8from typing import Any, Literal, Optional, Union
  9
 10import numpy as np
 11import seaborn as sns
 12import yaml
 13from ase import Atoms
 14from ase.filters import FrechetCellFilter
 15from ase.io import Trajectory, read
 16from ase.optimize import BFGS
 17from ase.parallel import paropen, parprint, world
 18from gpaw.calculator import GPAW
 19from gpaw.occupations import FermiDirac
 20from gpaw.wavefunctions.pw import PW
 21from matplotlib import pyplot as plt
 22from pydantic import BaseModel, Field, confloat, conint, validator
 23
 24from madman.helpers.ase.workflows.converge import MultivariateConvergence
 25from madman.utilities import 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 ConvergenceParameterSettings(BaseModel):
 36    r"""Total energy convergence parameter settings."""
 37
 38    value_min: confloat(ge=0.0, allow_inf_nan=False) = Field(frozen=True)
 39    r"""Parameter minimum value."""
 40
 41    value_max: Optional[confloat(ge=0.0, allow_inf_nan=False)] = Field(
 42        None, frozen=True
 43    )
 44    r"""Parameter maximum value."""
 45
 46    value_spc: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
 47        None, frozen=True
 48    )
 49    r"""Pamater value spacing."""
 50
 51    threshold: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
 52        None, frozen=True
 53    )
 54    r"""Convergence threshold."""
 55
 56    stability: Optional[conint(gt=0)] = Field(None, frozen=True)
 57    r"""Convergence stability."""
 58
 59
 60class ConvergenceSettings(BaseModel):
 61    r"""Total energy convergence settings."""
 62
 63    basis: Literal["dzp"] = Field("dzp", frozen=True)
 64    r"""LCAO basis set."""
 65
 66    ecut: Optional[
 67        Union[
 68            ConvergenceParameterSettings, confloat(gt=0.0, allow_inf_nan=False)
 69        ]
 70    ] = Field(None, frozen=True)
 71    r"""Energy cutoff [$\mathrm{eV}$]."""
 72
 73    kptden: Union[
 74        ConvergenceParameterSettings, confloat(gt=0.0, allow_inf_nan=False)
 75    ] = Field(1.0, frozen=True)
 76    r"""$\mathbf{k}$-point density [$\mathrm{Å}$]."""
 77
 78    smear: Union[
 79        ConvergenceParameterSettings,
 80        confloat(ge=0.0, allow_inf_nan=False),
 81    ] = Field(0.0, frozen=True)
 82    r"""Smearing [$\mathrm{eV}$]."""
 83
 84    niter_max: conint(gt=0) = Field(1, frozen=True)
 85    r"""Maximum number of iterations."""
 86
 87    fig_root: Optional[str] = Field(None, frozen=True)
 88    r"""Root to save convergence plots."""
 89
 90    cache_root: Optional[str] = Field(None, frozen=True)
 91    r"""Root to cache electronic ground states."""
 92
 93    @validator("ecut", "kptden", "smear", pre=True)
 94    def parse_param_map(cls, value: Any) -> Any:
 95        r"""Parse convergence parameter map."""
 96        if isinstance(value, Mapping):
 97            value = ConvergenceParameterSettings(**value)
 98        return value
 99
100    @validator("fig_root", "cache_root")
101    def create_root(cls, root: Any) -> Any:
102        r"""Create root, if needed."""
103        if isinstance(root, str) and root != "":
104            os.makedirs(root, exist_ok=True)
105        return root
106
107
108class RelaxationSettings(BaseModel):
109    r"""Atomic structure relaxation settings."""
110
111    algorithm: Optional[Literal["single-point", "iterative"]] = Field(
112        None, frozen=True
113    )
114    r"""Relaxation algorithm."""
115
116    force_max: confloat(gt=0.0, allow_inf_nan=False) = Field(0.01, frozen=True)
117    r"""Maximum force for equilibrium [$\mathrm{eV \, Å^{-1}}$]."""
118
119    niter_max: conint(gt=0) = Field(1, frozen=True)
120    r"""Maximum number of iterations."""
121
122    rlx_prefix: Optional[str] = Field(None, frozen=True)
123    r"""Prefix to save relaxed atomic structure."""
124
125    hst_prefix: Optional[str] = Field(None, frozen=True)
126    r"""Prefix to save relaxation history."""
127
128    fig_prefix: Optional[str] = Field(None, frozen=True)
129    r"""Prefix to save relaxation plot."""
130
131    @validator("rlx_prefix", "hst_prefix", "fig_prefix")
132    def create_tree(cls, prefix: Any) -> Any:
133        r"""Create prefix tree, if needed."""
134        if isinstance(prefix, str):
135            prefix_dir = os.path.dirname(prefix)
136            if prefix_dir != "":
137                os.makedirs(prefix_dir, exist_ok=True)
138        return prefix
139
140
141class ElectronicGroundState:
142    r"""Electronic ground state."""
143
144    def __init__(
145        self,
146        atm_str: Atoms,
147        *,
148        charge: Real = 0.0,
149        xc: Literal["LDA", "PBE"] = "PBE",
150        conv_cfg: Union[ConvergenceSettings, Mapping],
151        relax_cfg: Union[RelaxationSettings, Mapping],
152        parallel: Optional[Mapping] = None,
153        egs_prefix: Optional[str] = None,
154        log_prefix: Optional[str] = None,
155    ) -> None:
156        r"""Initialize ElectronicGroundState object.
157
158        Args:
159            atm_str: Atomic structure.
160            charge: Charge [$q$].
161            xc: Exchange-correlation functional.
162            conv_cfg: Total energy convergence settings.
163            relax_cfg: Atomic structure relaxation settings.
164            parallel: GPAW parallelization options.
165            egs_prefix: Prefix to save electronic ground state.
166            log_prefix: Prefix to save GPAW log.
167
168        Raises:
169            TypeError: If atomic structure is not a Atoms object.
170            TypeError: If charge is not real.
171            ValueError: If exchange-correlation functional is invalid.
172        """
173        if not isinstance(atm_str, Atoms):
174            raise TypeError("Invalid atomic structure!")
175        self._atm_str = atm_str
176
177        if not isinstance(charge, Real):
178            raise TypeError("Invalid charge!")
179        self._charge = float(charge)
180
181        if xc not in {"LDA", "PBE"}:
182            raise ValueError("Invalid exchange-correlation functional!")
183        self._xc = xc
184
185        if isinstance(conv_cfg, Mapping):
186            conv_cfg = ConvergenceSettings(**conv_cfg)
187        if not isinstance(conv_cfg, ConvergenceSettings):
188            raise TypeError("Invalid total energy convergence settings!")
189        self._conv_cfg = conv_cfg
190
191        if isinstance(relax_cfg, Mapping):
192            relax_cfg = RelaxationSettings(**relax_cfg)
193        if not isinstance(relax_cfg, RelaxationSettings):
194            raise TypeError("Invalid relaxation settings!")
195        self._relax_cfg = relax_cfg
196
197        if parallel is not None and not isinstance(parallel, Mapping):
198            raise TypeError("Invalid GPAW parallelization options!")
199        self._parallel = None if parallel is None else dict(parallel)
200
201        if egs_prefix is not None and not isinstance(egs_prefix, str):
202            raise TypeError("Invalid prefix to save electronic ground state!")
203        self._egs_prefix = egs_prefix
204
205        if log_prefix is not None and not isinstance(log_prefix, str):
206            raise TypeError("Invalid prefix to save GPAW log!")
207        self._log_prefix = log_prefix
208
209        for prefix in [self._egs_prefix, self._log_prefix]:
210            if isinstance(prefix, str):
211                prefix_dir = os.path.dirname(prefix)
212                if prefix_dir != "":
213                    os.makedirs(prefix_dir, exist_ok=True)
214        with redirect_to(self._log_prefix, mode="w"):
215            pass
216
217        self._cache = {}
218        self._history = {}
219        self._etot_iter_i = 0
220        self._relax_iter_i = 0
221
222        kptden_d = self._conv_cfg.kptden
223        if isinstance(kptden_d, float):
224            kptden_d = {"value_min": kptden_d}
225        else:
226            kptden_d = kptden_d.dict()
227        kptden_d.update(
228            {
229                "direction": 1,
230                "criterion": "slope-abs",
231                "symb": r"\lambda_{\mathbf{k}}",
232                "unit": r"Å",
233            }
234        )
235        smear_d = self._conv_cfg.smear
236        if isinstance(smear_d, float):
237            smear_d = {"value_min": smear_d}
238        else:
239            smear_d = smear_d.dict()
240        smear_d.update(
241            {
242                "direction": 1,
243                "criterion": "1st-min",
244                "symb": r"\epsilon_{\text{smear}}",
245                "unit": r"eV",
246            }
247        )
248        ecut_d = self._conv_cfg.ecut
249
250        if ecut_d is not None:
251            if isinstance(ecut_d, float):
252                ecut_d = {"value_min": ecut_d}
253            else:
254                ecut_d = ecut_d.dict()
255            ecut_d.update(
256                {
257                    "direction": 1,
258                    "criterion": "slope-neg",
259                    "symb": r"\epsilon_{\text{cut}}",
260                    "unit": r"eV",
261                }
262            )
263            params = {"ecut": ecut_d, "kptden": kptden_d, "smear": smear_d}
264
265            @cache
266            def objective(ecut, kptden, smear):
267                atm_str = self._atm_str.copy()
268                with redirect_to(self._log_prefix, mode="a"):
269                    etot = type(self).calc_etot(
270                        atm_str,
271                        charge=self._charge,
272                        xc=self._xc,
273                        ecut=ecut,
274                        kptden=kptden,
275                        smear=smear,
276                        parallel=self._parallel,
277                    )
278                self._etot_iter_i += 1
279                key = [("ecut", ecut), ("kptden", kptden), ("smear", smear)]
280                if self._conv_cfg.cache_root is None:
281                    self._cache.update({tuple(sorted(key)): atm_str.calc})
282                else:
283                    egs_path = os.path.join(
284                        self._conv_cfg.cache_root,
285                        f"{self._etot_iter_i}.gpw",
286                    )
287                    atm_str.calc.write(egs_path)
288                    self._cache.update({tuple(sorted(key)): egs_path})
289                return etot
290
291        else:
292            params = {"kptden": kptden_d, "smear": smear_d}
293
294            @cache
295            def objective(kptden, smear):
296                atm_str = self._atm_str.copy()
297                with redirect_to(self._log_prefix, mode="a"):
298                    etot = type(self).calc_etot(
299                        atm_str,
300                        charge=self._charge,
301                        xc=self._xc,
302                        basis=self._conv_cfg.basis,
303                        kptden=kptden,
304                        smear=smear,
305                        parallel=self._parallel,
306                    )
307                self._etot_iter_i += 1
308                key = [("kptden", kptden), ("smear", smear)]
309                if self._conv_cfg.cache_root is None:
310                    self._cache.update({tuple(sorted(key)): atm_str.calc})
311                else:
312                    egs_path = os.path.join(
313                        self._conv_cfg.cache_root,
314                        f"{self._etot_iter_i}.gpw",
315                    )
316                    atm_str.calc.write(egs_path)
317                    self._cache.update({tuple(sorted(key)): egs_path})
318                return etot
319
320        self._convergence = MultivariateConvergence(
321            objective,
322            params,
323            crop=False,
324            req_sc=True,
325            niter_max=self._conv_cfg.niter_max,
326            obj_symb=r"E_{\text{tot}}",
327            obj_unit=r"eV",
328        )
329        self._relaxation = None
330
331    @property
332    def atm_str(self) -> Atoms:
333        r"""Atomic structure."""
334        return self._atm_str
335
336    @property
337    def charge(self) -> float:
338        r"""Change [$q$]."""
339        return self._charge
340
341    @property
342    def xc(self) -> str:
343        r"""Exchange-correlation functional."""
344        return self._xc
345
346    @property
347    def conv_cfg(self) -> ConvergenceSettings:
348        r"""Total energy convergence settings."""
349        return self._conv_cfg
350
351    @property
352    def relax_cfg(self) -> RelaxationSettings:
353        r"""Atomic structure relaxation settings."""
354        return self._relax_cfg
355
356    @property
357    def parallel(self) -> dict:
358        r"""GPAW parallelization options."""
359        return self._parallel
360
361    @property
362    def egs_prefix(self) -> Optional[str]:
363        r"""Prefix to save electronic ground state."""
364        return self._egs_prefix
365
366    @property
367    def log_prefix(self) -> Optional[str]:
368        r"""Prefix to save GPAW logs."""
369        return self._log_prefix
370
371    @property
372    def cache(self) -> dict[tuple[tuple[str, float], ...], Union[GPAW, str]]:
373        r"""Electronic groud state cache."""
374        return self._cache
375
376    @property
377    def history(self) -> Optional[dict[int, list[FrechetCellFilter, ...]]]:
378        r"""Relaxation history."""
379        return self._history
380
381    @property
382    def etot_iter_i(self) -> int:
383        r"""Total energy iteration index."""
384        return self._etot_iter_i
385
386    @property
387    def relax_iter_i(self) -> int:
388        r"""Relaxation iteration index."""
389        return self._relax_iter_i
390
391    @property
392    def convergence(self) -> MultivariateConvergence:
393        r"""Total energy convergence."""
394        return self._convergence
395
396    @property
397    def relaxation(self) -> Optional[BFGS]:
398        r"""Atomic structure relaxation."""
399        return self._relaxation
400
401    def converge(self) -> None:
402        r"""Converge total energy.
403
404        Returns:
405            True if total energy convergence succeeded.
406        """
407        self._convergence.run()
408        params_opt = self._convergence.values_opt
409
410        key = tuple(sorted(params_opt.items()))
411        if key not in self._cache:
412            self._convergence.objective(**params_opt)
413        if self._conv_cfg.cache_root is None:
414            self._atm_str.calc = self._cache[key]
415        else:
416            self._atm_str.calc = GPAW(self._cache[key])
417        self.save_ground()
418
419        if self._convergence.converged is False:
420            parprint("Total energy convergence failed!")
421        else:
422            parprint("Total energy converged...")
423        self._relaxation = BFGS(
424            atoms=FrechetCellFilter(self._atm_str),
425            logfile="-",
426            trajectory=(
427                None
428                if self.relax_cfg.hst_prefix is None
429                else f"{self.relax_cfg.hst_prefix}.traj"
430            ),
431        )
432        return None
433
434
435    def relax(self) -> None:
436        r"""Relax atomic structure.
437
438        Returns:
439            True if atomic structure relaxation succeeded.
440        """
441        if self._relaxation is None:
442            parprint("Please, converge first! Skipping...")
443            return None
444
445        with redirect_to(self._log_prefix, mode="a"):
446            parprint("Relaxing atomic structure...")
447            relaxed = self._relaxation.run(fmax=self._relax_cfg.force_max)
448
449        self._atm_str = self._relaxation.atoms.atoms
450        self.save_relaxed()
451        self.save_ground()
452
453        if self._conv_cfg.cache_root is not None:
454            if world.rank == 0:
455                for egs_path in self._cache.values():
456                    os.remove(egs_path)
457
458        self._cache = {}
459        self._convergence.objective.cache_clear()
460
461        if isinstance(self._history, dict):
462            self._history.update({self._relax_iter_i: []})
463            for atm_str in Trajectory(f"{self.relax_cfg.hst_prefix}.traj"):
464                atm_str = FrechetCellFilter(atm_str)
465                self._history[self._relax_iter_i].append(atm_str)
466
467        if relaxed is False:
468            parprint("Atomic structure relaxation failed!")
469        else:
470            parprint("Atomic structure relaxed...")
471        return None
472
473    def calculate(self) -> None:
474        r"""Calculate electronic ground state.
475
476        Raises:
477            ValueError: If algorithm is invalid.
478        """
479        if self._relax_cfg.algorithm is None:
480            _ = self.converge()
481            self.plot_convergence()
482            return None
483
484        if self._relax_cfg.algorithm == "single-point":
485            self.converge()
486            self.plot_convergence()
487            self.relax()
488            self.plot_relaxation()
489            return None
490
491        if self._relax_cfg.algorithm == "iterative":
492            prev_values_opt = None
493            while self._relax_iter_i < self._relax_cfg.niter_max:
494                self.converge()
495                self.plot_convergence()
496                if self._convergence.values_opt == prev_values_opt:
497                    parprint("Self-consistency achieved...")
498                    self.plot_relaxation()
499                    return None
500                self.relax()
501                self.plot_relaxation()
502                prev_values_opt = self._convergence.values_opt.copy()
503                self._relax_iter_i += 1
504            parprint("Self-consistency not achived within iterations limit!")
505            return None
506
507        raise ValueError("Unknown algorithm!")
508
509    def save_ground(self) -> None:
510        r"""Save electronic ground state."""
511        if self._egs_prefix is None:
512            parprint(
513                "Prefix to save electronic ground state not specified!"
514                + " Not saving..."
515            )
516            return None
517
518        self._atm_str.calc.write(f"{self._egs_prefix}.gpw")
519        parprint("Electronic ground state saved...")
520        return None
521
522    def save_relaxed(self) -> None:
523        r"""Save relaxed atomic structure."""
524        if self._relax_cfg.rlx_prefix is None:
525            parprint(
526                "Prefix to save relaxed atomic structure not specified!"
527                + " Not saving..."
528            )
529            return None
530
531        for ext in [".traj", ".cif"]:
532            self._atm_str.write(f"{self._relax_cfg.rlx_prefix}{ext}")
533        parprint("Relaxed atomic structure saved...")
534        return None
535
536    def plot_convergence(self) -> None:
537        r"""Plot convergence at current relaxation step."""
538        if world.rank == 0:
539            value_plots_map = self._convergence.plot("obj-value")
540            slope_plots_map = self._convergence.plot("obj-slope")
541
542            if self._conv_cfg.fig_root is not None:
543                rnd = len(str(self._relax_cfg.niter_max))
544                cnd = len(str(self._conv_cfg.niter_max))
545
546                for qnty, plots_map in zip(
547                    ["etot", "metot"], [value_plots_map, slope_plots_map]
548                ):
549                    for param, plots_seq in plots_map.items():
550                        figdir = os.path.join(
551                            self._conv_cfg.fig_root, f"{qnty}-vs-{param}"
552                        )
553                        os.makedirs(figdir, exist_ok=True)
554                        for i, plots in enumerate(plots_seq):
555                            (plot,) = tuple(plots)
556                            fn = f"{self._relax_iter_i:0{rnd}.0f}_{i:0{cnd}.0f}"
557                            figpath = os.path.join(figdir, f"{fn}.svg")
558                            fig = plot.get_figure()
559                            fig.savefig(figpath)
560                            plt.close(fig)
561
562    def plot_relaxation(self) -> Optional[plt.Axes]:
563        r"""Plot relaxation.
564
565        Returns:
566            Plot figure.
567        """
568        if world.rank == 0:
569            if self._history is None:
570                parprint("Relaxation history not saved! Skipping...")
571                return None
572
573            fig, ax = plt.subplots(tight_layout=True)
574            ax.set_xlabel(r"#")
575            ax.set_ylabel(r"$F_{\text{max}}$ / $\mathrm{eV \, Å^{-1}}$")
576            ax.set_yscale("log")
577
578            f_max_vs_step = []
579            relax_iter_start_step = 0
580            for _, atm_str_vs_step in sorted(self._history.items()):
581                if relax_iter_start_step > 0:
582                    ax.axvline(relax_iter_start_step - 0.5, c="k", alpha=0.5)
583                for atm_str in atm_str_vs_step:
584                    forces = atm_str.get_forces()
585                    f_max = np.sqrt((forces**2).sum(axis=1).max())
586                    f_max_vs_step.append(f_max)
587                relax_iter_start_step += len(atm_str_vs_step)
588            ax.plot(f_max_vs_step, "o-", color="k")
589            ax.axhline(self._relax_cfg.force_max, ls="--", c="k")
590
591            if self._relax_cfg.fig_prefix is not None:
592                fig.savefig(f"{self._relax_cfg.fig_prefix}.svg")
593            return None
594
595    @staticmethod
596    def calc_etot(
597        atm_str: Atoms,
598        *,
599        charge: Real = 0.0,
600        xc: str = "PBE",
601        basis: Mapping | str = "dzp",
602        ecut: Optional[Real] = None,
603        kptden: Real = 1.0,
604        smear: Real = 0.0,
605        parallel: Optional[Mapping] = None,
606    ) -> float:
607        r"""Calculate total energy.
608
609        Args:
610            atm_str: Atomic structure.
611            charge: Charge [$q$].
612            xc: Exchange-correlation functional.
613            basis: LCAO basis set.
614            ecut: Energy cutoff [$\mathrm{eV}$].
615            kptden: $\mathbf{k}$-point density [$\mathrm{Å}$].
616            smear: Smearing [$\mathrm{eV}$].
617            parallel: GPAW parallel options.
618
619        Returns:
620            Total energy [eV].
621        """
622        gpaw_kwargs = {
623            "charge": charge,
624            "xc": xc,
625            "kpts": {"density": kptden, "even": True},
626            "occupations": FermiDirac(smear),
627            "parallel": parallel,
628        }
629        if ecut is not None:
630            gpaw_kwargs.update({"mode": PW(ecut)})
631        else:
632            gpaw_kwargs.update({"mode": "lcao", "basis": basis})
633        atm_str.calc = GPAW(**gpaw_kwargs)
634        etot = atm_str.get_potential_energy()
635        return etot
636
637
638def calc_ground_cli() -> None:
639    r"""Calculate electronic ground state - CLI interface."""
640    parser = ArgumentParser(description="Calculate electronic ground state")
641    parser.add_argument(
642        "config",
643        nargs="?",
644        default="./config.yml",
645        help="configuration file",
646    )
647    args = parser.parse_args()
648
649    with paropen(args.config, "r") as stream:
650        config = yaml.safe_load(stream)
651
652    atm_str_path = config.pop("atm_str_path")
653    atm_str = read(atm_str_path)
654
655    ground = ElectronicGroundState(atm_str, **config)
656    ground.calculate()
class ConvergenceParameterSettings(pydantic.main.BaseModel):
36class ConvergenceParameterSettings(BaseModel):
37    r"""Total energy convergence parameter settings."""
38
39    value_min: confloat(ge=0.0, allow_inf_nan=False) = Field(frozen=True)
40    r"""Parameter minimum value."""
41
42    value_max: Optional[confloat(ge=0.0, allow_inf_nan=False)] = Field(
43        None, frozen=True
44    )
45    r"""Parameter maximum value."""
46
47    value_spc: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
48        None, frozen=True
49    )
50    r"""Pamater value spacing."""
51
52    threshold: Optional[confloat(gt=0.0, allow_inf_nan=False)] = Field(
53        None, frozen=True
54    )
55    r"""Convergence threshold."""
56
57    stability: Optional[conint(gt=0)] = Field(None, frozen=True)
58    r"""Convergence stability."""

Total energy convergence parameter settings.

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

Parameter minimum value.

value_max: Optional[Annotated[float, None, Interval(gt=None, ge=0.0, 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)]]

Pamater value spacing.

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]] = {'value_min': FieldInfo(annotation=float, required=True, frozen=True, metadata=[None, Interval(gt=None, ge=0.0, 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), '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 ConvergenceSettings(pydantic.main.BaseModel):
 61class ConvergenceSettings(BaseModel):
 62    r"""Total energy convergence settings."""
 63
 64    basis: Literal["dzp"] = Field("dzp", frozen=True)
 65    r"""LCAO basis set."""
 66
 67    ecut: Optional[
 68        Union[
 69            ConvergenceParameterSettings, confloat(gt=0.0, allow_inf_nan=False)
 70        ]
 71    ] = Field(None, frozen=True)
 72    r"""Energy cutoff [$\mathrm{eV}$]."""
 73
 74    kptden: Union[
 75        ConvergenceParameterSettings, confloat(gt=0.0, allow_inf_nan=False)
 76    ] = Field(1.0, frozen=True)
 77    r"""$\mathbf{k}$-point density [$\mathrm{Å}$]."""
 78
 79    smear: Union[
 80        ConvergenceParameterSettings,
 81        confloat(ge=0.0, allow_inf_nan=False),
 82    ] = Field(0.0, frozen=True)
 83    r"""Smearing [$\mathrm{eV}$]."""
 84
 85    niter_max: conint(gt=0) = Field(1, frozen=True)
 86    r"""Maximum number of iterations."""
 87
 88    fig_root: Optional[str] = Field(None, frozen=True)
 89    r"""Root to save convergence plots."""
 90
 91    cache_root: Optional[str] = Field(None, frozen=True)
 92    r"""Root to cache electronic ground states."""
 93
 94    @validator("ecut", "kptden", "smear", pre=True)
 95    def parse_param_map(cls, value: Any) -> Any:
 96        r"""Parse convergence parameter map."""
 97        if isinstance(value, Mapping):
 98            value = ConvergenceParameterSettings(**value)
 99        return value
100
101    @validator("fig_root", "cache_root")
102    def create_root(cls, root: Any) -> Any:
103        r"""Create root, if needed."""
104        if isinstance(root, str) and root != "":
105            os.makedirs(root, exist_ok=True)
106        return root

Total energy convergence settings.

basis: Literal['dzp']

LCAO basis set.

ecut: Union[ConvergenceParameterSettings, Annotated[float, None, Interval(gt=0.0, ge=None, lt=None, le=None), None, AllowInfNan(allow_inf_nan=False)], NoneType]

Energy cutoff [$\mathrm{eV}$].

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

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

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

Smearing [$\mathrm{eV}$].

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

Maximum number of iterations.

fig_root: Optional[str]

Root to save convergence plots.

cache_root: Optional[str]

Root to cache electronic ground states.

@validator('ecut', 'kptden', 'smear', pre=True)
def parse_param_map(cls, value: Any) -> Any:
94    @validator("ecut", "kptden", "smear", pre=True)
95    def parse_param_map(cls, value: Any) -> Any:
96        r"""Parse convergence parameter map."""
97        if isinstance(value, Mapping):
98            value = ConvergenceParameterSettings(**value)
99        return value

Parse convergence parameter map.

@validator('fig_root', 'cache_root')
def create_root(cls, root: Any) -> Any:
101    @validator("fig_root", "cache_root")
102    def create_root(cls, root: Any) -> Any:
103        r"""Create root, if needed."""
104        if isinstance(root, str) and root != "":
105            os.makedirs(root, exist_ok=True)
106        return root

Create root, if needed.

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]] = {'basis': FieldInfo(annotation=Literal['dzp'], required=False, default='dzp', frozen=True), 'ecut': FieldInfo(annotation=Union[ConvergenceParameterSettings, Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)], NoneType], required=False, default=None, frozen=True), 'kptden': FieldInfo(annotation=Union[ConvergenceParameterSettings, Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)]], required=False, default=1.0, frozen=True), 'smear': FieldInfo(annotation=Union[ConvergenceParameterSettings, Annotated[float, NoneType, Interval, NoneType, AllowInfNan(allow_inf_nan=False)]], required=False, default=0.0, 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]), 'fig_root': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'cache_root': FieldInfo(annotation=Union[str, 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 RelaxationSettings(pydantic.main.BaseModel):
109class RelaxationSettings(BaseModel):
110    r"""Atomic structure relaxation settings."""
111
112    algorithm: Optional[Literal["single-point", "iterative"]] = Field(
113        None, frozen=True
114    )
115    r"""Relaxation algorithm."""
116
117    force_max: confloat(gt=0.0, allow_inf_nan=False) = Field(0.01, frozen=True)
118    r"""Maximum force for equilibrium [$\mathrm{eV \, Å^{-1}}$]."""
119
120    niter_max: conint(gt=0) = Field(1, frozen=True)
121    r"""Maximum number of iterations."""
122
123    rlx_prefix: Optional[str] = Field(None, frozen=True)
124    r"""Prefix to save relaxed atomic structure."""
125
126    hst_prefix: Optional[str] = Field(None, frozen=True)
127    r"""Prefix to save relaxation history."""
128
129    fig_prefix: Optional[str] = Field(None, frozen=True)
130    r"""Prefix to save relaxation plot."""
131
132    @validator("rlx_prefix", "hst_prefix", "fig_prefix")
133    def create_tree(cls, prefix: Any) -> Any:
134        r"""Create prefix tree, if needed."""
135        if isinstance(prefix, str):
136            prefix_dir = os.path.dirname(prefix)
137            if prefix_dir != "":
138                os.makedirs(prefix_dir, exist_ok=True)
139        return prefix

Atomic structure relaxation settings.

algorithm: Optional[Literal['single-point', 'iterative']]

Relaxation algorithm.

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

Maximum force for equilibrium [$\mathrm{eV \, Å^{-1}}$].

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

Maximum number of iterations.

rlx_prefix: Optional[str]

Prefix to save relaxed atomic structure.

hst_prefix: Optional[str]

Prefix to save relaxation history.

fig_prefix: Optional[str]

Prefix to save relaxation plot.

@validator('rlx_prefix', 'hst_prefix', 'fig_prefix')
def create_tree(cls, prefix: Any) -> Any:
132    @validator("rlx_prefix", "hst_prefix", "fig_prefix")
133    def create_tree(cls, prefix: Any) -> Any:
134        r"""Create prefix tree, if needed."""
135        if isinstance(prefix, str):
136            prefix_dir = os.path.dirname(prefix)
137            if prefix_dir != "":
138                os.makedirs(prefix_dir, exist_ok=True)
139        return prefix

Create prefix tree, if needed.

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]] = {'algorithm': FieldInfo(annotation=Union[Literal['single-point', 'iterative'], NoneType], required=False, default=None, frozen=True), 'force_max': 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)]), 'niter_max': FieldInfo(annotation=int, required=False, default=1, frozen=True, metadata=[None, Interval(gt=0, ge=None, lt=None, le=None), None]), 'rlx_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'hst_prefix': FieldInfo(annotation=Union[str, NoneType], required=False, default=None, frozen=True), 'fig_prefix': FieldInfo(annotation=Union[str, 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 ElectronicGroundState:
142class ElectronicGroundState:
143    r"""Electronic ground state."""
144
145    def __init__(
146        self,
147        atm_str: Atoms,
148        *,
149        charge: Real = 0.0,
150        xc: Literal["LDA", "PBE"] = "PBE",
151        conv_cfg: Union[ConvergenceSettings, Mapping],
152        relax_cfg: Union[RelaxationSettings, Mapping],
153        parallel: Optional[Mapping] = None,
154        egs_prefix: Optional[str] = None,
155        log_prefix: Optional[str] = None,
156    ) -> None:
157        r"""Initialize ElectronicGroundState object.
158
159        Args:
160            atm_str: Atomic structure.
161            charge: Charge [$q$].
162            xc: Exchange-correlation functional.
163            conv_cfg: Total energy convergence settings.
164            relax_cfg: Atomic structure relaxation settings.
165            parallel: GPAW parallelization options.
166            egs_prefix: Prefix to save electronic ground state.
167            log_prefix: Prefix to save GPAW log.
168
169        Raises:
170            TypeError: If atomic structure is not a Atoms object.
171            TypeError: If charge is not real.
172            ValueError: If exchange-correlation functional is invalid.
173        """
174        if not isinstance(atm_str, Atoms):
175            raise TypeError("Invalid atomic structure!")
176        self._atm_str = atm_str
177
178        if not isinstance(charge, Real):
179            raise TypeError("Invalid charge!")
180        self._charge = float(charge)
181
182        if xc not in {"LDA", "PBE"}:
183            raise ValueError("Invalid exchange-correlation functional!")
184        self._xc = xc
185
186        if isinstance(conv_cfg, Mapping):
187            conv_cfg = ConvergenceSettings(**conv_cfg)
188        if not isinstance(conv_cfg, ConvergenceSettings):
189            raise TypeError("Invalid total energy convergence settings!")
190        self._conv_cfg = conv_cfg
191
192        if isinstance(relax_cfg, Mapping):
193            relax_cfg = RelaxationSettings(**relax_cfg)
194        if not isinstance(relax_cfg, RelaxationSettings):
195            raise TypeError("Invalid relaxation settings!")
196        self._relax_cfg = relax_cfg
197
198        if parallel is not None and not isinstance(parallel, Mapping):
199            raise TypeError("Invalid GPAW parallelization options!")
200        self._parallel = None if parallel is None else dict(parallel)
201
202        if egs_prefix is not None and not isinstance(egs_prefix, str):
203            raise TypeError("Invalid prefix to save electronic ground state!")
204        self._egs_prefix = egs_prefix
205
206        if log_prefix is not None and not isinstance(log_prefix, str):
207            raise TypeError("Invalid prefix to save GPAW log!")
208        self._log_prefix = log_prefix
209
210        for prefix in [self._egs_prefix, self._log_prefix]:
211            if isinstance(prefix, str):
212                prefix_dir = os.path.dirname(prefix)
213                if prefix_dir != "":
214                    os.makedirs(prefix_dir, exist_ok=True)
215        with redirect_to(self._log_prefix, mode="w"):
216            pass
217
218        self._cache = {}
219        self._history = {}
220        self._etot_iter_i = 0
221        self._relax_iter_i = 0
222
223        kptden_d = self._conv_cfg.kptden
224        if isinstance(kptden_d, float):
225            kptden_d = {"value_min": kptden_d}
226        else:
227            kptden_d = kptden_d.dict()
228        kptden_d.update(
229            {
230                "direction": 1,
231                "criterion": "slope-abs",
232                "symb": r"\lambda_{\mathbf{k}}",
233                "unit": r"Å",
234            }
235        )
236        smear_d = self._conv_cfg.smear
237        if isinstance(smear_d, float):
238            smear_d = {"value_min": smear_d}
239        else:
240            smear_d = smear_d.dict()
241        smear_d.update(
242            {
243                "direction": 1,
244                "criterion": "1st-min",
245                "symb": r"\epsilon_{\text{smear}}",
246                "unit": r"eV",
247            }
248        )
249        ecut_d = self._conv_cfg.ecut
250
251        if ecut_d is not None:
252            if isinstance(ecut_d, float):
253                ecut_d = {"value_min": ecut_d}
254            else:
255                ecut_d = ecut_d.dict()
256            ecut_d.update(
257                {
258                    "direction": 1,
259                    "criterion": "slope-neg",
260                    "symb": r"\epsilon_{\text{cut}}",
261                    "unit": r"eV",
262                }
263            )
264            params = {"ecut": ecut_d, "kptden": kptden_d, "smear": smear_d}
265
266            @cache
267            def objective(ecut, kptden, smear):
268                atm_str = self._atm_str.copy()
269                with redirect_to(self._log_prefix, mode="a"):
270                    etot = type(self).calc_etot(
271                        atm_str,
272                        charge=self._charge,
273                        xc=self._xc,
274                        ecut=ecut,
275                        kptden=kptden,
276                        smear=smear,
277                        parallel=self._parallel,
278                    )
279                self._etot_iter_i += 1
280                key = [("ecut", ecut), ("kptden", kptden), ("smear", smear)]
281                if self._conv_cfg.cache_root is None:
282                    self._cache.update({tuple(sorted(key)): atm_str.calc})
283                else:
284                    egs_path = os.path.join(
285                        self._conv_cfg.cache_root,
286                        f"{self._etot_iter_i}.gpw",
287                    )
288                    atm_str.calc.write(egs_path)
289                    self._cache.update({tuple(sorted(key)): egs_path})
290                return etot
291
292        else:
293            params = {"kptden": kptden_d, "smear": smear_d}
294
295            @cache
296            def objective(kptden, smear):
297                atm_str = self._atm_str.copy()
298                with redirect_to(self._log_prefix, mode="a"):
299                    etot = type(self).calc_etot(
300                        atm_str,
301                        charge=self._charge,
302                        xc=self._xc,
303                        basis=self._conv_cfg.basis,
304                        kptden=kptden,
305                        smear=smear,
306                        parallel=self._parallel,
307                    )
308                self._etot_iter_i += 1
309                key = [("kptden", kptden), ("smear", smear)]
310                if self._conv_cfg.cache_root is None:
311                    self._cache.update({tuple(sorted(key)): atm_str.calc})
312                else:
313                    egs_path = os.path.join(
314                        self._conv_cfg.cache_root,
315                        f"{self._etot_iter_i}.gpw",
316                    )
317                    atm_str.calc.write(egs_path)
318                    self._cache.update({tuple(sorted(key)): egs_path})
319                return etot
320
321        self._convergence = MultivariateConvergence(
322            objective,
323            params,
324            crop=False,
325            req_sc=True,
326            niter_max=self._conv_cfg.niter_max,
327            obj_symb=r"E_{\text{tot}}",
328            obj_unit=r"eV",
329        )
330        self._relaxation = None
331
332    @property
333    def atm_str(self) -> Atoms:
334        r"""Atomic structure."""
335        return self._atm_str
336
337    @property
338    def charge(self) -> float:
339        r"""Change [$q$]."""
340        return self._charge
341
342    @property
343    def xc(self) -> str:
344        r"""Exchange-correlation functional."""
345        return self._xc
346
347    @property
348    def conv_cfg(self) -> ConvergenceSettings:
349        r"""Total energy convergence settings."""
350        return self._conv_cfg
351
352    @property
353    def relax_cfg(self) -> RelaxationSettings:
354        r"""Atomic structure relaxation settings."""
355        return self._relax_cfg
356
357    @property
358    def parallel(self) -> dict:
359        r"""GPAW parallelization options."""
360        return self._parallel
361
362    @property
363    def egs_prefix(self) -> Optional[str]:
364        r"""Prefix to save electronic ground state."""
365        return self._egs_prefix
366
367    @property
368    def log_prefix(self) -> Optional[str]:
369        r"""Prefix to save GPAW logs."""
370        return self._log_prefix
371
372    @property
373    def cache(self) -> dict[tuple[tuple[str, float], ...], Union[GPAW, str]]:
374        r"""Electronic groud state cache."""
375        return self._cache
376
377    @property
378    def history(self) -> Optional[dict[int, list[FrechetCellFilter, ...]]]:
379        r"""Relaxation history."""
380        return self._history
381
382    @property
383    def etot_iter_i(self) -> int:
384        r"""Total energy iteration index."""
385        return self._etot_iter_i
386
387    @property
388    def relax_iter_i(self) -> int:
389        r"""Relaxation iteration index."""
390        return self._relax_iter_i
391
392    @property
393    def convergence(self) -> MultivariateConvergence:
394        r"""Total energy convergence."""
395        return self._convergence
396
397    @property
398    def relaxation(self) -> Optional[BFGS]:
399        r"""Atomic structure relaxation."""
400        return self._relaxation
401
402    def converge(self) -> None:
403        r"""Converge total energy.
404
405        Returns:
406            True if total energy convergence succeeded.
407        """
408        self._convergence.run()
409        params_opt = self._convergence.values_opt
410
411        key = tuple(sorted(params_opt.items()))
412        if key not in self._cache:
413            self._convergence.objective(**params_opt)
414        if self._conv_cfg.cache_root is None:
415            self._atm_str.calc = self._cache[key]
416        else:
417            self._atm_str.calc = GPAW(self._cache[key])
418        self.save_ground()
419
420        if self._convergence.converged is False:
421            parprint("Total energy convergence failed!")
422        else:
423            parprint("Total energy converged...")
424        self._relaxation = BFGS(
425            atoms=FrechetCellFilter(self._atm_str),
426            logfile="-",
427            trajectory=(
428                None
429                if self.relax_cfg.hst_prefix is None
430                else f"{self.relax_cfg.hst_prefix}.traj"
431            ),
432        )
433        return None
434
435
436    def relax(self) -> None:
437        r"""Relax atomic structure.
438
439        Returns:
440            True if atomic structure relaxation succeeded.
441        """
442        if self._relaxation is None:
443            parprint("Please, converge first! Skipping...")
444            return None
445
446        with redirect_to(self._log_prefix, mode="a"):
447            parprint("Relaxing atomic structure...")
448            relaxed = self._relaxation.run(fmax=self._relax_cfg.force_max)
449
450        self._atm_str = self._relaxation.atoms.atoms
451        self.save_relaxed()
452        self.save_ground()
453
454        if self._conv_cfg.cache_root is not None:
455            if world.rank == 0:
456                for egs_path in self._cache.values():
457                    os.remove(egs_path)
458
459        self._cache = {}
460        self._convergence.objective.cache_clear()
461
462        if isinstance(self._history, dict):
463            self._history.update({self._relax_iter_i: []})
464            for atm_str in Trajectory(f"{self.relax_cfg.hst_prefix}.traj"):
465                atm_str = FrechetCellFilter(atm_str)
466                self._history[self._relax_iter_i].append(atm_str)
467
468        if relaxed is False:
469            parprint("Atomic structure relaxation failed!")
470        else:
471            parprint("Atomic structure relaxed...")
472        return None
473
474    def calculate(self) -> None:
475        r"""Calculate electronic ground state.
476
477        Raises:
478            ValueError: If algorithm is invalid.
479        """
480        if self._relax_cfg.algorithm is None:
481            _ = self.converge()
482            self.plot_convergence()
483            return None
484
485        if self._relax_cfg.algorithm == "single-point":
486            self.converge()
487            self.plot_convergence()
488            self.relax()
489            self.plot_relaxation()
490            return None
491
492        if self._relax_cfg.algorithm == "iterative":
493            prev_values_opt = None
494            while self._relax_iter_i < self._relax_cfg.niter_max:
495                self.converge()
496                self.plot_convergence()
497                if self._convergence.values_opt == prev_values_opt:
498                    parprint("Self-consistency achieved...")
499                    self.plot_relaxation()
500                    return None
501                self.relax()
502                self.plot_relaxation()
503                prev_values_opt = self._convergence.values_opt.copy()
504                self._relax_iter_i += 1
505            parprint("Self-consistency not achived within iterations limit!")
506            return None
507
508        raise ValueError("Unknown algorithm!")
509
510    def save_ground(self) -> None:
511        r"""Save electronic ground state."""
512        if self._egs_prefix is None:
513            parprint(
514                "Prefix to save electronic ground state not specified!"
515                + " Not saving..."
516            )
517            return None
518
519        self._atm_str.calc.write(f"{self._egs_prefix}.gpw")
520        parprint("Electronic ground state saved...")
521        return None
522
523    def save_relaxed(self) -> None:
524        r"""Save relaxed atomic structure."""
525        if self._relax_cfg.rlx_prefix is None:
526            parprint(
527                "Prefix to save relaxed atomic structure not specified!"
528                + " Not saving..."
529            )
530            return None
531
532        for ext in [".traj", ".cif"]:
533            self._atm_str.write(f"{self._relax_cfg.rlx_prefix}{ext}")
534        parprint("Relaxed atomic structure saved...")
535        return None
536
537    def plot_convergence(self) -> None:
538        r"""Plot convergence at current relaxation step."""
539        if world.rank == 0:
540            value_plots_map = self._convergence.plot("obj-value")
541            slope_plots_map = self._convergence.plot("obj-slope")
542
543            if self._conv_cfg.fig_root is not None:
544                rnd = len(str(self._relax_cfg.niter_max))
545                cnd = len(str(self._conv_cfg.niter_max))
546
547                for qnty, plots_map in zip(
548                    ["etot", "metot"], [value_plots_map, slope_plots_map]
549                ):
550                    for param, plots_seq in plots_map.items():
551                        figdir = os.path.join(
552                            self._conv_cfg.fig_root, f"{qnty}-vs-{param}"
553                        )
554                        os.makedirs(figdir, exist_ok=True)
555                        for i, plots in enumerate(plots_seq):
556                            (plot,) = tuple(plots)
557                            fn = f"{self._relax_iter_i:0{rnd}.0f}_{i:0{cnd}.0f}"
558                            figpath = os.path.join(figdir, f"{fn}.svg")
559                            fig = plot.get_figure()
560                            fig.savefig(figpath)
561                            plt.close(fig)
562
563    def plot_relaxation(self) -> Optional[plt.Axes]:
564        r"""Plot relaxation.
565
566        Returns:
567            Plot figure.
568        """
569        if world.rank == 0:
570            if self._history is None:
571                parprint("Relaxation history not saved! Skipping...")
572                return None
573
574            fig, ax = plt.subplots(tight_layout=True)
575            ax.set_xlabel(r"#")
576            ax.set_ylabel(r"$F_{\text{max}}$ / $\mathrm{eV \, Å^{-1}}$")
577            ax.set_yscale("log")
578
579            f_max_vs_step = []
580            relax_iter_start_step = 0
581            for _, atm_str_vs_step in sorted(self._history.items()):
582                if relax_iter_start_step > 0:
583                    ax.axvline(relax_iter_start_step - 0.5, c="k", alpha=0.5)
584                for atm_str in atm_str_vs_step:
585                    forces = atm_str.get_forces()
586                    f_max = np.sqrt((forces**2).sum(axis=1).max())
587                    f_max_vs_step.append(f_max)
588                relax_iter_start_step += len(atm_str_vs_step)
589            ax.plot(f_max_vs_step, "o-", color="k")
590            ax.axhline(self._relax_cfg.force_max, ls="--", c="k")
591
592            if self._relax_cfg.fig_prefix is not None:
593                fig.savefig(f"{self._relax_cfg.fig_prefix}.svg")
594            return None
595
596    @staticmethod
597    def calc_etot(
598        atm_str: Atoms,
599        *,
600        charge: Real = 0.0,
601        xc: str = "PBE",
602        basis: Mapping | str = "dzp",
603        ecut: Optional[Real] = None,
604        kptden: Real = 1.0,
605        smear: Real = 0.0,
606        parallel: Optional[Mapping] = None,
607    ) -> float:
608        r"""Calculate total energy.
609
610        Args:
611            atm_str: Atomic structure.
612            charge: Charge [$q$].
613            xc: Exchange-correlation functional.
614            basis: LCAO basis set.
615            ecut: Energy cutoff [$\mathrm{eV}$].
616            kptden: $\mathbf{k}$-point density [$\mathrm{Å}$].
617            smear: Smearing [$\mathrm{eV}$].
618            parallel: GPAW parallel options.
619
620        Returns:
621            Total energy [eV].
622        """
623        gpaw_kwargs = {
624            "charge": charge,
625            "xc": xc,
626            "kpts": {"density": kptden, "even": True},
627            "occupations": FermiDirac(smear),
628            "parallel": parallel,
629        }
630        if ecut is not None:
631            gpaw_kwargs.update({"mode": PW(ecut)})
632        else:
633            gpaw_kwargs.update({"mode": "lcao", "basis": basis})
634        atm_str.calc = GPAW(**gpaw_kwargs)
635        etot = atm_str.get_potential_energy()
636        return etot

Electronic ground state.

ElectronicGroundState( atm_str: ase.atoms.Atoms, *, charge: numbers.Real = 0.0, xc: Literal['LDA', 'PBE'] = 'PBE', conv_cfg: Union[ConvergenceSettings, collections.abc.Mapping], relax_cfg: Union[RelaxationSettings, collections.abc.Mapping], parallel: Optional[collections.abc.Mapping] = None, egs_prefix: Optional[str] = None, log_prefix: Optional[str] = None)
145    def __init__(
146        self,
147        atm_str: Atoms,
148        *,
149        charge: Real = 0.0,
150        xc: Literal["LDA", "PBE"] = "PBE",
151        conv_cfg: Union[ConvergenceSettings, Mapping],
152        relax_cfg: Union[RelaxationSettings, Mapping],
153        parallel: Optional[Mapping] = None,
154        egs_prefix: Optional[str] = None,
155        log_prefix: Optional[str] = None,
156    ) -> None:
157        r"""Initialize ElectronicGroundState object.
158
159        Args:
160            atm_str: Atomic structure.
161            charge: Charge [$q$].
162            xc: Exchange-correlation functional.
163            conv_cfg: Total energy convergence settings.
164            relax_cfg: Atomic structure relaxation settings.
165            parallel: GPAW parallelization options.
166            egs_prefix: Prefix to save electronic ground state.
167            log_prefix: Prefix to save GPAW log.
168
169        Raises:
170            TypeError: If atomic structure is not a Atoms object.
171            TypeError: If charge is not real.
172            ValueError: If exchange-correlation functional is invalid.
173        """
174        if not isinstance(atm_str, Atoms):
175            raise TypeError("Invalid atomic structure!")
176        self._atm_str = atm_str
177
178        if not isinstance(charge, Real):
179            raise TypeError("Invalid charge!")
180        self._charge = float(charge)
181
182        if xc not in {"LDA", "PBE"}:
183            raise ValueError("Invalid exchange-correlation functional!")
184        self._xc = xc
185
186        if isinstance(conv_cfg, Mapping):
187            conv_cfg = ConvergenceSettings(**conv_cfg)
188        if not isinstance(conv_cfg, ConvergenceSettings):
189            raise TypeError("Invalid total energy convergence settings!")
190        self._conv_cfg = conv_cfg
191
192        if isinstance(relax_cfg, Mapping):
193            relax_cfg = RelaxationSettings(**relax_cfg)
194        if not isinstance(relax_cfg, RelaxationSettings):
195            raise TypeError("Invalid relaxation settings!")
196        self._relax_cfg = relax_cfg
197
198        if parallel is not None and not isinstance(parallel, Mapping):
199            raise TypeError("Invalid GPAW parallelization options!")
200        self._parallel = None if parallel is None else dict(parallel)
201
202        if egs_prefix is not None and not isinstance(egs_prefix, str):
203            raise TypeError("Invalid prefix to save electronic ground state!")
204        self._egs_prefix = egs_prefix
205
206        if log_prefix is not None and not isinstance(log_prefix, str):
207            raise TypeError("Invalid prefix to save GPAW log!")
208        self._log_prefix = log_prefix
209
210        for prefix in [self._egs_prefix, self._log_prefix]:
211            if isinstance(prefix, str):
212                prefix_dir = os.path.dirname(prefix)
213                if prefix_dir != "":
214                    os.makedirs(prefix_dir, exist_ok=True)
215        with redirect_to(self._log_prefix, mode="w"):
216            pass
217
218        self._cache = {}
219        self._history = {}
220        self._etot_iter_i = 0
221        self._relax_iter_i = 0
222
223        kptden_d = self._conv_cfg.kptden
224        if isinstance(kptden_d, float):
225            kptden_d = {"value_min": kptden_d}
226        else:
227            kptden_d = kptden_d.dict()
228        kptden_d.update(
229            {
230                "direction": 1,
231                "criterion": "slope-abs",
232                "symb": r"\lambda_{\mathbf{k}}",
233                "unit": r"Å",
234            }
235        )
236        smear_d = self._conv_cfg.smear
237        if isinstance(smear_d, float):
238            smear_d = {"value_min": smear_d}
239        else:
240            smear_d = smear_d.dict()
241        smear_d.update(
242            {
243                "direction": 1,
244                "criterion": "1st-min",
245                "symb": r"\epsilon_{\text{smear}}",
246                "unit": r"eV",
247            }
248        )
249        ecut_d = self._conv_cfg.ecut
250
251        if ecut_d is not None:
252            if isinstance(ecut_d, float):
253                ecut_d = {"value_min": ecut_d}
254            else:
255                ecut_d = ecut_d.dict()
256            ecut_d.update(
257                {
258                    "direction": 1,
259                    "criterion": "slope-neg",
260                    "symb": r"\epsilon_{\text{cut}}",
261                    "unit": r"eV",
262                }
263            )
264            params = {"ecut": ecut_d, "kptden": kptden_d, "smear": smear_d}
265
266            @cache
267            def objective(ecut, kptden, smear):
268                atm_str = self._atm_str.copy()
269                with redirect_to(self._log_prefix, mode="a"):
270                    etot = type(self).calc_etot(
271                        atm_str,
272                        charge=self._charge,
273                        xc=self._xc,
274                        ecut=ecut,
275                        kptden=kptden,
276                        smear=smear,
277                        parallel=self._parallel,
278                    )
279                self._etot_iter_i += 1
280                key = [("ecut", ecut), ("kptden", kptden), ("smear", smear)]
281                if self._conv_cfg.cache_root is None:
282                    self._cache.update({tuple(sorted(key)): atm_str.calc})
283                else:
284                    egs_path = os.path.join(
285                        self._conv_cfg.cache_root,
286                        f"{self._etot_iter_i}.gpw",
287                    )
288                    atm_str.calc.write(egs_path)
289                    self._cache.update({tuple(sorted(key)): egs_path})
290                return etot
291
292        else:
293            params = {"kptden": kptden_d, "smear": smear_d}
294
295            @cache
296            def objective(kptden, smear):
297                atm_str = self._atm_str.copy()
298                with redirect_to(self._log_prefix, mode="a"):
299                    etot = type(self).calc_etot(
300                        atm_str,
301                        charge=self._charge,
302                        xc=self._xc,
303                        basis=self._conv_cfg.basis,
304                        kptden=kptden,
305                        smear=smear,
306                        parallel=self._parallel,
307                    )
308                self._etot_iter_i += 1
309                key = [("kptden", kptden), ("smear", smear)]
310                if self._conv_cfg.cache_root is None:
311                    self._cache.update({tuple(sorted(key)): atm_str.calc})
312                else:
313                    egs_path = os.path.join(
314                        self._conv_cfg.cache_root,
315                        f"{self._etot_iter_i}.gpw",
316                    )
317                    atm_str.calc.write(egs_path)
318                    self._cache.update({tuple(sorted(key)): egs_path})
319                return etot
320
321        self._convergence = MultivariateConvergence(
322            objective,
323            params,
324            crop=False,
325            req_sc=True,
326            niter_max=self._conv_cfg.niter_max,
327            obj_symb=r"E_{\text{tot}}",
328            obj_unit=r"eV",
329        )
330        self._relaxation = None

Initialize ElectronicGroundState object.

Arguments:
  • atm_str: Atomic structure.
  • charge: Charge [$q$].
  • xc: Exchange-correlation functional.
  • conv_cfg: Total energy convergence settings.
  • relax_cfg: Atomic structure relaxation settings.
  • parallel: GPAW parallelization options.
  • egs_prefix: Prefix to save electronic ground state.
  • log_prefix: Prefix to save GPAW log.
Raises:
  • TypeError: If atomic structure is not a Atoms object.
  • TypeError: If charge is not real.
  • ValueError: If exchange-correlation functional is invalid.
atm_str: ase.atoms.Atoms
332    @property
333    def atm_str(self) -> Atoms:
334        r"""Atomic structure."""
335        return self._atm_str

Atomic structure.

charge: float
337    @property
338    def charge(self) -> float:
339        r"""Change [$q$]."""
340        return self._charge

Change [$q$].

xc: str
342    @property
343    def xc(self) -> str:
344        r"""Exchange-correlation functional."""
345        return self._xc

Exchange-correlation functional.

conv_cfg: ConvergenceSettings
347    @property
348    def conv_cfg(self) -> ConvergenceSettings:
349        r"""Total energy convergence settings."""
350        return self._conv_cfg

Total energy convergence settings.

relax_cfg: RelaxationSettings
352    @property
353    def relax_cfg(self) -> RelaxationSettings:
354        r"""Atomic structure relaxation settings."""
355        return self._relax_cfg

Atomic structure relaxation settings.

parallel: dict
357    @property
358    def parallel(self) -> dict:
359        r"""GPAW parallelization options."""
360        return self._parallel

GPAW parallelization options.

egs_prefix: Optional[str]
362    @property
363    def egs_prefix(self) -> Optional[str]:
364        r"""Prefix to save electronic ground state."""
365        return self._egs_prefix

Prefix to save electronic ground state.

log_prefix: Optional[str]
367    @property
368    def log_prefix(self) -> Optional[str]:
369        r"""Prefix to save GPAW logs."""
370        return self._log_prefix

Prefix to save GPAW logs.

cache: dict[tuple[tuple[str, float], ...], typing.Union[gpaw.calculator.GPAW, str]]
372    @property
373    def cache(self) -> dict[tuple[tuple[str, float], ...], Union[GPAW, str]]:
374        r"""Electronic groud state cache."""
375        return self._cache

Electronic groud state cache.

history: Optional[dict[int, list[ase.filters.FrechetCellFilter, ...]]]
377    @property
378    def history(self) -> Optional[dict[int, list[FrechetCellFilter, ...]]]:
379        r"""Relaxation history."""
380        return self._history

Relaxation history.

etot_iter_i: int
382    @property
383    def etot_iter_i(self) -> int:
384        r"""Total energy iteration index."""
385        return self._etot_iter_i

Total energy iteration index.

relax_iter_i: int
387    @property
388    def relax_iter_i(self) -> int:
389        r"""Relaxation iteration index."""
390        return self._relax_iter_i

Relaxation iteration index.

392    @property
393    def convergence(self) -> MultivariateConvergence:
394        r"""Total energy convergence."""
395        return self._convergence

Total energy convergence.

relaxation: Optional[ase.optimize.bfgs.BFGS]
397    @property
398    def relaxation(self) -> Optional[BFGS]:
399        r"""Atomic structure relaxation."""
400        return self._relaxation

Atomic structure relaxation.

def converge(self) -> None:
402    def converge(self) -> None:
403        r"""Converge total energy.
404
405        Returns:
406            True if total energy convergence succeeded.
407        """
408        self._convergence.run()
409        params_opt = self._convergence.values_opt
410
411        key = tuple(sorted(params_opt.items()))
412        if key not in self._cache:
413            self._convergence.objective(**params_opt)
414        if self._conv_cfg.cache_root is None:
415            self._atm_str.calc = self._cache[key]
416        else:
417            self._atm_str.calc = GPAW(self._cache[key])
418        self.save_ground()
419
420        if self._convergence.converged is False:
421            parprint("Total energy convergence failed!")
422        else:
423            parprint("Total energy converged...")
424        self._relaxation = BFGS(
425            atoms=FrechetCellFilter(self._atm_str),
426            logfile="-",
427            trajectory=(
428                None
429                if self.relax_cfg.hst_prefix is None
430                else f"{self.relax_cfg.hst_prefix}.traj"
431            ),
432        )
433        return None

Converge total energy.

Returns:

True if total energy convergence succeeded.

def relax(self) -> None:
436    def relax(self) -> None:
437        r"""Relax atomic structure.
438
439        Returns:
440            True if atomic structure relaxation succeeded.
441        """
442        if self._relaxation is None:
443            parprint("Please, converge first! Skipping...")
444            return None
445
446        with redirect_to(self._log_prefix, mode="a"):
447            parprint("Relaxing atomic structure...")
448            relaxed = self._relaxation.run(fmax=self._relax_cfg.force_max)
449
450        self._atm_str = self._relaxation.atoms.atoms
451        self.save_relaxed()
452        self.save_ground()
453
454        if self._conv_cfg.cache_root is not None:
455            if world.rank == 0:
456                for egs_path in self._cache.values():
457                    os.remove(egs_path)
458
459        self._cache = {}
460        self._convergence.objective.cache_clear()
461
462        if isinstance(self._history, dict):
463            self._history.update({self._relax_iter_i: []})
464            for atm_str in Trajectory(f"{self.relax_cfg.hst_prefix}.traj"):
465                atm_str = FrechetCellFilter(atm_str)
466                self._history[self._relax_iter_i].append(atm_str)
467
468        if relaxed is False:
469            parprint("Atomic structure relaxation failed!")
470        else:
471            parprint("Atomic structure relaxed...")
472        return None

Relax atomic structure.

Returns:

True if atomic structure relaxation succeeded.

def calculate(self) -> None:
474    def calculate(self) -> None:
475        r"""Calculate electronic ground state.
476
477        Raises:
478            ValueError: If algorithm is invalid.
479        """
480        if self._relax_cfg.algorithm is None:
481            _ = self.converge()
482            self.plot_convergence()
483            return None
484
485        if self._relax_cfg.algorithm == "single-point":
486            self.converge()
487            self.plot_convergence()
488            self.relax()
489            self.plot_relaxation()
490            return None
491
492        if self._relax_cfg.algorithm == "iterative":
493            prev_values_opt = None
494            while self._relax_iter_i < self._relax_cfg.niter_max:
495                self.converge()
496                self.plot_convergence()
497                if self._convergence.values_opt == prev_values_opt:
498                    parprint("Self-consistency achieved...")
499                    self.plot_relaxation()
500                    return None
501                self.relax()
502                self.plot_relaxation()
503                prev_values_opt = self._convergence.values_opt.copy()
504                self._relax_iter_i += 1
505            parprint("Self-consistency not achived within iterations limit!")
506            return None
507
508        raise ValueError("Unknown algorithm!")

Calculate electronic ground state.

Raises:
  • ValueError: If algorithm is invalid.
def save_ground(self) -> None:
510    def save_ground(self) -> None:
511        r"""Save electronic ground state."""
512        if self._egs_prefix is None:
513            parprint(
514                "Prefix to save electronic ground state not specified!"
515                + " Not saving..."
516            )
517            return None
518
519        self._atm_str.calc.write(f"{self._egs_prefix}.gpw")
520        parprint("Electronic ground state saved...")
521        return None

Save electronic ground state.

def save_relaxed(self) -> None:
523    def save_relaxed(self) -> None:
524        r"""Save relaxed atomic structure."""
525        if self._relax_cfg.rlx_prefix is None:
526            parprint(
527                "Prefix to save relaxed atomic structure not specified!"
528                + " Not saving..."
529            )
530            return None
531
532        for ext in [".traj", ".cif"]:
533            self._atm_str.write(f"{self._relax_cfg.rlx_prefix}{ext}")
534        parprint("Relaxed atomic structure saved...")
535        return None

Save relaxed atomic structure.

def plot_convergence(self) -> None:
537    def plot_convergence(self) -> None:
538        r"""Plot convergence at current relaxation step."""
539        if world.rank == 0:
540            value_plots_map = self._convergence.plot("obj-value")
541            slope_plots_map = self._convergence.plot("obj-slope")
542
543            if self._conv_cfg.fig_root is not None:
544                rnd = len(str(self._relax_cfg.niter_max))
545                cnd = len(str(self._conv_cfg.niter_max))
546
547                for qnty, plots_map in zip(
548                    ["etot", "metot"], [value_plots_map, slope_plots_map]
549                ):
550                    for param, plots_seq in plots_map.items():
551                        figdir = os.path.join(
552                            self._conv_cfg.fig_root, f"{qnty}-vs-{param}"
553                        )
554                        os.makedirs(figdir, exist_ok=True)
555                        for i, plots in enumerate(plots_seq):
556                            (plot,) = tuple(plots)
557                            fn = f"{self._relax_iter_i:0{rnd}.0f}_{i:0{cnd}.0f}"
558                            figpath = os.path.join(figdir, f"{fn}.svg")
559                            fig = plot.get_figure()
560                            fig.savefig(figpath)
561                            plt.close(fig)

Plot convergence at current relaxation step.

def plot_relaxation(self) -> Optional[matplotlib.axes._axes.Axes]:
563    def plot_relaxation(self) -> Optional[plt.Axes]:
564        r"""Plot relaxation.
565
566        Returns:
567            Plot figure.
568        """
569        if world.rank == 0:
570            if self._history is None:
571                parprint("Relaxation history not saved! Skipping...")
572                return None
573
574            fig, ax = plt.subplots(tight_layout=True)
575            ax.set_xlabel(r"#")
576            ax.set_ylabel(r"$F_{\text{max}}$ / $\mathrm{eV \, Å^{-1}}$")
577            ax.set_yscale("log")
578
579            f_max_vs_step = []
580            relax_iter_start_step = 0
581            for _, atm_str_vs_step in sorted(self._history.items()):
582                if relax_iter_start_step > 0:
583                    ax.axvline(relax_iter_start_step - 0.5, c="k", alpha=0.5)
584                for atm_str in atm_str_vs_step:
585                    forces = atm_str.get_forces()
586                    f_max = np.sqrt((forces**2).sum(axis=1).max())
587                    f_max_vs_step.append(f_max)
588                relax_iter_start_step += len(atm_str_vs_step)
589            ax.plot(f_max_vs_step, "o-", color="k")
590            ax.axhline(self._relax_cfg.force_max, ls="--", c="k")
591
592            if self._relax_cfg.fig_prefix is not None:
593                fig.savefig(f"{self._relax_cfg.fig_prefix}.svg")
594            return None

Plot relaxation.

Returns:

Plot figure.

@staticmethod
def calc_etot( atm_str: ase.atoms.Atoms, *, charge: numbers.Real = 0.0, xc: str = 'PBE', basis: collections.abc.Mapping | str = 'dzp', ecut: Optional[numbers.Real] = None, kptden: numbers.Real = 1.0, smear: numbers.Real = 0.0, parallel: Optional[collections.abc.Mapping] = None) -> float:
596    @staticmethod
597    def calc_etot(
598        atm_str: Atoms,
599        *,
600        charge: Real = 0.0,
601        xc: str = "PBE",
602        basis: Mapping | str = "dzp",
603        ecut: Optional[Real] = None,
604        kptden: Real = 1.0,
605        smear: Real = 0.0,
606        parallel: Optional[Mapping] = None,
607    ) -> float:
608        r"""Calculate total energy.
609
610        Args:
611            atm_str: Atomic structure.
612            charge: Charge [$q$].
613            xc: Exchange-correlation functional.
614            basis: LCAO basis set.
615            ecut: Energy cutoff [$\mathrm{eV}$].
616            kptden: $\mathbf{k}$-point density [$\mathrm{Å}$].
617            smear: Smearing [$\mathrm{eV}$].
618            parallel: GPAW parallel options.
619
620        Returns:
621            Total energy [eV].
622        """
623        gpaw_kwargs = {
624            "charge": charge,
625            "xc": xc,
626            "kpts": {"density": kptden, "even": True},
627            "occupations": FermiDirac(smear),
628            "parallel": parallel,
629        }
630        if ecut is not None:
631            gpaw_kwargs.update({"mode": PW(ecut)})
632        else:
633            gpaw_kwargs.update({"mode": "lcao", "basis": basis})
634        atm_str.calc = GPAW(**gpaw_kwargs)
635        etot = atm_str.get_potential_energy()
636        return etot

Calculate total energy.

Arguments:
  • atm_str: Atomic structure.
  • charge: Charge [$q$].
  • xc: Exchange-correlation functional.
  • basis: LCAO basis set.
  • ecut: Energy cutoff [$\mathrm{eV}$].
  • kptden: $\mathbf{k}$-point density [$\mathrm{Å}$].
  • smear: Smearing [$\mathrm{eV}$].
  • parallel: GPAW parallel options.
Returns:

Total energy [eV].

def calc_ground_cli() -> None:
639def calc_ground_cli() -> None:
640    r"""Calculate electronic ground state - CLI interface."""
641    parser = ArgumentParser(description="Calculate electronic ground state")
642    parser.add_argument(
643        "config",
644        nargs="?",
645        default="./config.yml",
646        help="configuration file",
647    )
648    args = parser.parse_args()
649
650    with paropen(args.config, "r") as stream:
651        config = yaml.safe_load(stream)
652
653    atm_str_path = config.pop("atm_str_path")
654    atm_str = read(atm_str_path)
655
656    ground = ElectronicGroundState(atm_str, **config)
657    ground.calculate()

Calculate electronic ground state - CLI interface.