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()
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.
Parameter minimum value.
Parameter maximum value.
Pamater value spacing.
Convergence threshold.
Convergence stability.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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.
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
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.
Energy cutoff [$\mathrm{eV}$].
$\mathbf{k}$-point density [$\mathrm{Å}$].
Smearing [$\mathrm{eV}$].
Maximum number of iterations.
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.
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.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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.
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
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.
Maximum force for equilibrium [$\mathrm{eV \, Å^{-1}}$].
Maximum number of iterations.
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.
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
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.
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
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.
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.
342 @property 343 def xc(self) -> str: 344 r"""Exchange-correlation functional.""" 345 return self._xc
Exchange-correlation functional.
347 @property 348 def conv_cfg(self) -> ConvergenceSettings: 349 r"""Total energy convergence settings.""" 350 return self._conv_cfg
Total energy convergence settings.
352 @property 353 def relax_cfg(self) -> RelaxationSettings: 354 r"""Atomic structure relaxation settings.""" 355 return self._relax_cfg
Atomic structure relaxation settings.
357 @property 358 def parallel(self) -> dict: 359 r"""GPAW parallelization options.""" 360 return self._parallel
GPAW parallelization options.
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.
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.
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.
377 @property 378 def history(self) -> Optional[dict[int, list[FrechetCellFilter, ...]]]: 379 r"""Relaxation history.""" 380 return self._history
Relaxation history.
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.
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.
397 @property 398 def relaxation(self) -> Optional[BFGS]: 399 r"""Atomic structure relaxation.""" 400 return self._relaxation
Atomic structure relaxation.
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.
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.
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.
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.
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.
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.
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.
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].
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.