Source code for abtem.potentials.gpaw

"""Module to handle ab initio electrostatic potentials from the DFT code GPAW."""

from __future__ import annotations

import io
import threading
import warnings
from collections import defaultdict
from dataclasses import dataclass
from functools import partial
from numbers import Number
from typing import Any, List, Tuple, Union

import dask
import dask.array as da
import numpy as np
from ase import Atoms, units
from ase.data import atomic_numbers, chemical_symbols
from dask.delayed import Delayed
from scipy.interpolate import interp1d

from abtem.atoms import is_cell_orthogonal, plane_to_axes
from abtem.core.axes import AxisMetadata
from abtem.core.backend import copy_to_device
from abtem.core.electron_configurations import (
    config_str_to_config_tuples,
    electron_configurations,
)
from abtem.core.ensemble import _wrap_with_array
from abtem.core.fft import fft_crop
from abtem.core.utils import itemset
from abtem.inelastic.phonons import (
    BaseFrozenPhonons,
    DummyFrozenPhonons,
    FrozenPhonons,
    _safe_read_atoms,
)
from abtem.parametrizations import EwaldParametrization, LobatoParametrization
from abtem.potentials.charge_density import _interpolate_slice
from abtem.potentials.iam import Potential, PotentialArray, _PotentialBuilder
from abtem.slicing import _integrate_planes

try:
    from gpaw import GPAW
except ImportError:
    GPAW = None


if GPAW is not None:
    from gpaw.atom.shapefunc import shape_functions
    from gpaw.mpi import SerialCommunicator


# GPAW keeps one FFT plan per grid shape and dtype for the whole process
# (``gpaw.fftw.create_plans`` caches them in a module-level dict), and each plan
# owns the scratch arrays (``tmp_R``, ``tmp_Q``) that every transform writes its
# input to and reads its output from. All calculators on the same grid share
# these arrays, so two threads evaluating GPAW at once overwrite each other's
# transforms: a read gives a wrong potential without an error, and concurrent
# ``_DummyGPAW.setups`` calls can make GPAW itself fail. Dask's threaded
# scheduler runs the tasks of a multi-file ``GPAWPotential`` in parallel, and
# tasks of an earlier build can still be running when the caller's thread
# returns to GPAW. Every abTEM call that makes a GPAW calculator compute on its
# grids (reading a ``.gpw`` file, initializing, evaluating a density or
# potential) therefore holds this lock; attribute reads, and GPAW's radial atom
# solvers, which create no FFT plans, do not. The lock is re-entrant because
# ``_read_gpw`` calls ``_DummyGPAW.from_gpaw``.
_GPAW_LOCK = threading.RLock()


@dataclass
class _DummyGPAW:
    setup_mode: str
    setup_xc: str
    nt_sG: np.ndarray
    gd: Any
    D_asp: np.ndarray
    atoms: Atoms
    Q_aL: dict
    valence_potential: np.ndarray

    def __len__(self):
        return 1

    @property
    def setups(self):
        with _GPAW_LOCK:
            gpaw = GPAW(txt=None, mode=self.setup_mode, xc=self.setup_xc)
            gpaw.initialize(self.atoms)
            return gpaw.setups

    @classmethod
    def from_gpaw(cls, gpaw, lazy: bool = True):
        # if lazy:
        #    return dask.delayed(cls.from_gpaw)(gpaw, lazy=False)

        with _GPAW_LOCK:
            return cls._from_gpaw(gpaw)

    @classmethod
    def _from_gpaw(cls, gpaw):
        atoms = gpaw.atoms.copy()
        atoms.calc = None

        valence_potential = gpaw.get_electrostatic_potential()

        # GPAW's new-style calculator (default since GPAW 26) renames the
        # compensation charge coefficients from Q_aL to ccc_aL. Read it after
        # get_electrostatic_potential(), which fills in Q_aL on a restarted
        # old-style calculator.
        Q_aL = getattr(gpaw.density, "Q_aL", None)
        if Q_aL is None:
            Q_aL = gpaw.density.ccc_aL

        kwargs = {
            "setup_mode": gpaw.parameters.mode,
            "setup_xc": gpaw.parameters.xc,
            "nt_sG": gpaw.density.nt_sG.copy(),
            "gd": gpaw.density.gd.new_descriptor(comm=SerialCommunicator()),
            "D_asp": dict(gpaw.density.D_asp),
            "atoms": atoms,
            "valence_potential": valence_potential,
            "Q_aL": dict(Q_aL),
        }

        return cls(**kwargs)

    @classmethod
    def from_file(cls, path: str, lazy: bool = True):
        if lazy:
            return dask.delayed(_read_gpw)(path)

        return _read_gpw(path)

    @classmethod
    def from_generic(cls, calculator, lazy: bool = True):
        if isinstance(calculator, str):
            return cls.from_file(calculator, lazy=lazy)
        elif hasattr(calculator, "density"):
            return cls.from_gpaw(calculator, lazy=lazy)
        elif isinstance(calculator, cls):
            return calculator
        else:
            raise RuntimeError()


def _read_gpw(path: str) -> _DummyGPAW:
    # Module level, so that a task calling it pickles it by reference; a
    # classmethod in a task is pickled by value together with the globals it
    # uses, and the lock cannot be pickled.
    with _GPAW_LOCK:
        return _DummyGPAW.from_gpaw(GPAW(path))


class _LastRead:
    # Reads the ``.gpw`` placeholders of one eager ``_partition_args`` call and
    # keeps only the calculator read last. Consecutive blocks that share a
    # placeholder (one path with ``FrozenPhonons``) reuse it; a block with
    # another placeholder drops it before reading. An eager build therefore
    # holds one read calculator while it generates slices, and two while it
    # reads the next file, since the caller still holds the previous block's
    # potential. The object lives in that call's block array and goes with
    # it, so no calculator outlives the build.
    def __init__(self):
        self._lock = threading.Lock()
        self._placeholder = None
        self._calculator = None

    def read(self, placeholder: Delayed) -> "_DummyGPAW":
        with self._lock:
            if placeholder is not self._placeholder:
                self._placeholder = None
                self._calculator = None
                # Synchronous: one task, and no thread pool started from
                # inside a caller's task. ``_read_gpw`` takes the GPAW lock.
                self._calculator = placeholder.compute(scheduler="synchronous")
                self._placeholder = placeholder
            return self._calculator


class _PendingRead:
    # A ``.gpw`` placeholder in an eager block, read when the block's potential
    # is made (``GPAWPotential._gpaw_potential``), not when the blocks are.
    __slots__ = ("placeholder", "reads")

    def __init__(self, placeholder: Delayed, reads: _LastRead):
        self.placeholder = placeholder
        self.reads = reads


def _defer_placeholders(calculators, reads: _LastRead):
    # ``from_file`` leaves each ``.gpw`` path as a ``Delayed`` read. A lazy
    # graph resolves it as a task argument; eager blocks read it one block at
    # a time through ``reads``.
    if isinstance(calculators, list):
        return [_defer_placeholders(calculator, reads) for calculator in calculators]
    if isinstance(calculators, Delayed):
        return _PendingRead(calculators, reads)
    return calculators


def _read_pending(calculators):
    if isinstance(calculators, list):
        return [_read_pending(calculator) for calculator in calculators]
    if isinstance(calculators, _PendingRead):
        return calculators.reads.read(calculators.placeholder)
    return calculators


[docs] def get_core_correction_interpolators(setups, D_asp, Q_aL, rcgauss): interpolators = [] for a, D_sp in D_asp.items(): setup = setups[a] c = setup.xc_correction rgd = c.rgd params = setup.data.shape_function.copy() params["lmax"] = 0 ghat_g = shape_functions(rgd, **params)[0] Z_g = shape_functions(rgd, "gauss", rcgauss, lmax=0)[0] * setup.Z D_q = np.dot(D_sp.sum(0), c.B_pqL[:, :, 0]) dn_g = np.dot(D_q, (c.n_qg - c.nt_qg)) * np.sqrt(4 * np.pi) dn_g += 4 * np.pi * (c.nc_g - c.nct_g) dn_g -= Z_g dn_g -= Q_aL[a][0] * ghat_g * np.sqrt(4 * np.pi) dv_g = rgd.poisson(dn_g) / np.sqrt(4 * np.pi) dv_g[1:] /= rgd.r_g[1:] dv_g[0] = dv_g[1] dv_g[-1] = 0.0 interpolator = interp1d( units.Bohr * rgd.r_g, -dv_g / np.sqrt(4 * np.pi) * units.Ha, fill_value=0, bounds_error=False, ) interpolators.append(interpolator) return interpolators
def _same_elements_and_cell(atoms, other): """Whether two atoms have the same elements in the same order and cell.""" return ( len(atoms) == len(other) and np.array_equal(atoms.numbers, other.numbers) and np.allclose(np.array(atoms.cell), np.array(other.cell)) )
[docs] def integrate_slice(array, gpts, a, b, thickness): """ Integrate the planes of `array` along its last axis between the heights `a` and `b`, and Fourier interpolate the result to `gpts`. Plane k is the sample at the height k * dz, with dz the plane spacing, and stands for the heights from k * dz - dz / 2 to k * dz + dz / 2; the grid is periodic, so plane 0 also stands for the heights within dz / 2 below the thickness. A slice takes each plane times the length of the plane's heights that lie between `a` and `b`. Consecutive slices share their limit, so the weights of every plane add up to dz. A limit on a plane splits it between the slices on either side (the trapezoidal rule), so the integral of a slice is centred on the slice. Taking the planes from floor(a / dz) to floor(b / dz) whole (a left Riemann sum) put it dz / 2 below the slice. """ nz = array.shape[2] dz = thickness / nz # The limits in plane spacings; the grid ends at plane nz. start, stop = a / dz, min(b / dz, nz) if stop <= start: return np.zeros(gpts, dtype=array.dtype) slice_array = _integrate_planes(array, start, stop) * dz old_shape = slice_array.shape slice_array = np.fft.fftn(slice_array) slice_array = fft_crop(slice_array, gpts) slice_array = np.fft.ifftn(slice_array).real * np.prod(gpts) / np.prod(old_shape) return slice_array
class _DummyParametrization: def __init__(self, potential): self._potential = potential super().__init__() def potential(self, symbol): return self._potential @property def sigmas(self): return {} def _generate_slices( interpolators, valence_potential, atoms, gpts, slice_thickness, plane="xy", first_slice=0, last_slice=None, device=None, ): # `atoms` is the calculator's atoms or whole copies of them one after another # (`atoms * repetitions`), so atom i of every copy, atoms[i::n], takes the core # correction of atom i of the calculator. n = len(interpolators) potentials = [] for i, interpolator in enumerate(interpolators): parametrization = _DummyParametrization(interpolator) potential = Potential( gpts=gpts, atoms=atoms[i::n], parametrization=parametrization, slice_thickness=slice_thickness, projection="finite", plane=plane, device=device, ) potentials.append(potential) if last_slice is None: last_slice = len(potential) potential_generators = [ potential.generate_slices(first_slice, last_slice) for potential in potentials ] transform_valence_potential = None if potential.plane != "xy": if not is_cell_orthogonal(atoms.cell): raise NotImplementedError axes = plane_to_axes(potential.plane) valence_potential = np.moveaxis(valence_potential, axes[:2], (0, 1)) transform_valence_potential = False # else: # atoms = ewald_potential.frozen_phonons.atoms transformed_atoms = potential.get_transformed_atoms() if np.allclose(transformed_atoms.cell, atoms.cell): transform_valence_potential = False elif transform_valence_potential is None: transform_valence_potential = True for i, slice_idx in enumerate(range(first_slice, last_slice)): slic = next(potential_generators[0]) a, b = potential.get_sliced_atoms().slice_limits[slice_idx] for potential_generator in potential_generators[1:]: slic.array[:] += next(potential_generator).array # The valence potential is on the host; its slice is moved to the device # of the slice array. if transform_valence_potential: valence_slice = _interpolate_slice( valence_potential, atoms.cell, potential.gpts, potential.sampling, a, b ) else: valence_slice = integrate_slice( valence_potential, potential.gpts, a, b, potential.thickness ) slic.array[:] -= copy_to_device(valence_slice, slic.array) yield slic
[docs] class GPAWPotential(_PotentialBuilder): """ Calculate the electrostatic potential from a (set of) converged GPAW DFT calculation(s). Frozen phonons can be included either by specifying multiple GPAW calculators corresponding to the different phonon configurations, or approximately for a single calculator by using the `frozen_phonons` keyword. Parameters ---------- calculators : (list of) gpaw.calculator.GPAW or (list of) str GPAW calculator or path to GPAW calculator or list of calculators or paths. Atoms are read from the calculator. gpts : one or two int, optional Number of grid points in `x` and `y` describing each slice of the potential. Provide either "sampling" (spacing between consecutive grid points) or "gpts" (total number of grid points). sampling : one or two float, optional Sampling of the potential in `x` and `y` [1 / Ã…]. Provide either "sampling" or "gpts". slice_thickness : float or sequence of float, optional Thickness of the potential slices in the propagation direction in [Ã…] (default is 0.5 Ã…). If given as a float the number of slices is calculated by dividing the slice thickness into the `z`-height of supercell. The slice thickness may be given as a sequence of values for each slice, in which case an error will be thrown if the sum of slice thicknesses is not equal to the height of the atoms. exit_planes : int or tuple of int, optional The `exit_planes` argument can be used to calculate thickness series. Providing `exit_planes` as a tuple of int indicates that the tuple contains the slice indices after which an exit plane is desired, and hence during a multislice simulation a measurement is created. If `exit_planes` is an integer a measurement will be collected every `exit_planes` number of slices. plane : str or two tuples of three float, optional The plane relative to the provided atoms mapped to `xy` plane of the potential, i.e. provided plane is perpendicular to the propagation direction. If string, it must be a concatenation of two of 'x', 'y' and 'z'; the default value 'xy' indicates that potential slices are cuts along the `xy`-plane of the atoms. The plane may also be specified with two arbitrary 3D vectors, which are mapped to the `x` and `y` directions of the potential. The length of the vectors has no influence. If the vectors are not perpendicular, the second vector is rotated in the plane to become perpendicular to the first. Providing a value of ((1., 0., 0.), (0., 1., 0.)) is equivalent to providing 'xy'. origin : three float, optional The origin relative to the provided Atoms mapped to the origin of the Potential. This is equivalent to translating the atoms. The default is (0., 0., 0.) box : three float, optional The extent of the potential in `x`, `y` and `z`. If not given this is determined from the atoms' cell. If the box size does not match an integer number of the atoms' supercell, an affine transformation may be necessary to preserve periodicity, determined by the `periodic` keyword periodic : bool If a transformation of the atomic structure is required, `periodic` determines how the atomic structure is transformed. If True (default), the periodicity of the Atoms is preserved, which may require applying a small affine transformation to the atoms. If False, the transformed potential is effectively cut out of a larger repeated potential, which may not preserve periodicity. frozen_phonons : abtem.AbstractFrozenPhonons, optional Approximates frozen phonons for a single GPAW calculator by displacing only the nuclear core potentials. Supercedes the atoms from the calculator. A trajectory (:class:`~abtem.inelastic.phonons.AtomsEnsemble`) is not accepted: pass one calculator per frame instead. repetitions : tuple of int Repeats the atoms by integer amounts in the `x`, `y` and `z` directions before applying frozen phonon displacements to calculate the potential contribution of the nuclear cores. Necessary when using frozen phonons. gridrefinement : int Necessary interpolation of the charge density into a finer grid for improved numerical precision. Allowed values are '2' and '4'. device : str, optional The device used for calculating the potential, 'cpu' or 'gpu'. The default is determined by the user configuration file. """
[docs] def __init__( self, calculators: Union["GPAW", List["GPAW"], List[str], str], gpts: Union[int, Tuple[int, int]] = None, sampling: Union[float, Tuple[float, float]] = None, slice_thickness: float = 1.0, exit_planes: int = None, plane: str = "xy", origin: Tuple[float, float, float] = (0.0, 0.0, 0.0), box: Tuple[float, float, float] = None, periodic: bool = True, frozen_phonons: BaseFrozenPhonons = None, repetitions: Tuple[int, int, int] = (1, 1, 1), gridrefinement: int = 4, device: str = None, ): if GPAW is None: raise RuntimeError( "This functionality of abTEM requires GPAW, see https://wiki.fysik.dtu.dk/gpaw/." ) if isinstance(calculators, (tuple, list)): atoms = _safe_read_atoms(calculators[0]) num_configs = len(calculators) if frozen_phonons is not None: raise ValueError() calculators = [ _DummyGPAW.from_generic(calculator) for calculator in calculators ] frozen_phonons = DummyFrozenPhonons(atoms, num_configs=num_configs) else: atoms = _safe_read_atoms(calculators) calculators = _DummyGPAW.from_generic(calculators) if frozen_phonons is None: frozen_phonons = DummyFrozenPhonons(atoms, num_configs=None) elif frozen_phonons.ensemble_shape and not isinstance( frozen_phonons, FrozenPhonons ): raise ValueError( "One calculator takes `FrozenPhonons`; for a trajectory, pass " "one calculator per frame." ) elif not _same_elements_and_cell(frozen_phonons.atoms, atoms): raise ValueError( "The frozen phonons must have the calculator's atoms: the same " "elements in the same order and the same cell, with positions " "that may differ. Repeat the cell with `repetitions`." ) self._calculators = calculators self._frozen_phonons = frozen_phonons self._gridrefinement = gridrefinement self._repetitions = tuple(repetitions) cell = frozen_phonons.atoms.cell * repetitions frozen_phonons.atoms.calc = None super().__init__( array_object=PotentialArray, gpts=gpts, sampling=sampling, cell=cell, slice_thickness=slice_thickness, exit_planes=exit_planes, device=device, plane=plane, origin=origin, box=box, periodic=periodic, )
@property def frozen_phonons(self): return self._frozen_phonons @property def num_configurations(self) -> int: """Number of potential configurations; 1 when there is no ensemble axis.""" return len(self.frozen_phonons) @property def repetitions(self): return self._repetitions @property def gridrefinement(self): return self._gridrefinement @property def calculators(self): return self._calculators def _get_all_electron_density(self): calculator = ( self.calculators[0] if isinstance(self.calculators, list) else self.calculators ) try: calculator = calculator.compute() except AttributeError: pass # assert len(self.calculators) == 1 calculator = _DummyGPAW.from_generic(calculator) atoms = self.frozen_phonons.atoms if self.repetitions != (1, 1, 1): cell_cv = calculator.gd.cell_cv * self.repetitions N_c = tuple( n_c * rep for n_c, rep in zip(calculator.gd.N_c, self.repetitions) ) gd = calculator.gd.new_descriptor(N_c=N_c, cell_cv=cell_cv) atoms = atoms * self.repetitions nt_sG = np.tile(calculator.nt_sG, self.repetitions) else: gd = calculator.gd nt_sG = calculator.nt_sG random_atoms = self.frozen_phonons.randomize(atoms) calc = GPAW(txt=None, mode=calculator.setup_mode, xc=calculator.setup_xc) calc.initialize(random_atoms) return self._get_all_electron_density( nt_sG=nt_sG, gd=gd, D_asp=calculator.D_asp, setups=calc.setups, gridrefinement=self.gridrefinement, atoms=random_atoms, ) def _get_ewald_potential(self): ewald_parametrization = EwaldParametrization(width=3) atoms = self.frozen_phonons.atoms * self.repetitions atoms = self.frozen_phonons.randomize(atoms) ewald_potential = Potential( atoms=atoms, gpts=self.gpts, sampling=self.sampling, parametrization=ewald_parametrization, slice_thickness=self.slice_thickness, projection="finite", # integral_method="quadrature", plane=self.plane, box=self.box, origin=self.origin, exit_planes=self.exit_planes, device=self.device, ) return ewald_potential
[docs] def generate_slices(self, first_slice: int = 0, last_slice: int = None): """ Generate the slices for the potential. Parameters ---------- first_slice : int, optional Index of the first slice of the generated potential. last_slice : int, optional Index of the last slice of the generated potential. Returns ------- slices : generator of np.ndarray Generator for the array of slices. """ if last_slice is None: last_slice = len(self) calculator = ( self.calculators[0] if isinstance(self.calculators, list) else self.calculators ) try: calculator = calculator.compute() except AttributeError: pass # assert len(self.calculators) == 1 calculator = _DummyGPAW.from_generic(calculator) atoms = self.frozen_phonons.atoms valence_potential = calculator.valence_potential if self.repetitions != (1, 1, 1): # The valence potential is periodic with the calculator's cell. valence_potential = np.tile(valence_potential, self.repetitions) # cell_cv = calculator.gd.cell_cv * self.repetitions # N_c = tuple( # n_c * rep for n_c, rep in zip(calculator.gd.N_c, self.repetitions) # ) # gd = calculator.gd.new_descriptor(N_c=N_c, cell_cv=cell_cv) atoms = atoms * self.repetitions # nt_sG = np.tile(calculator.nt_sG, self.repetitions) # else: # gd = calculator.gd # nt_sG = calculator.nt_sG random_atoms = self.frozen_phonons.randomize(atoms) interpolators = get_core_correction_interpolators( calculator.setups, calculator.D_asp, calculator.Q_aL, 0.001 ) # calc = GPAW(txt=None, mode=calculator.setup_mode, xc=calculator.setup_xc) # calc.initialize(random_atoms) # ewald_potential = self._get_ewald_potential() # array = self._get_all_electron_density() # array = calculator.valence_potential for slic in _generate_slices( interpolators, plane=self.plane, valence_potential=valence_potential, atoms=random_atoms, gpts=self.gpts, slice_thickness=self.slice_thickness, first_slice=first_slice, last_slice=last_slice, device=self.device, ): yield slic
# for slic in _generate_slices( # array, ewald_potential, first_slice=first_slice, last_slice=last_slice # ): # yield slic @property def ensemble_axes_metadata(self) -> List[AxisMetadata]: return self._frozen_phonons.ensemble_axes_metadata @property def num_frozen_phonons(self): return self.num_configurations @property def ensemble_shape(self): return self._frozen_phonons.ensemble_shape @staticmethod def _gpaw_potential(*args, frozen_phonons_partial, **kwargs): args = args[0] if hasattr(args, "item"): args = args.item() if args["frozen_phonons"] is not None: frozen_phonons = frozen_phonons_partial(args["frozen_phonons"]) frozen_phonons = frozen_phonons.item() else: frozen_phonons = None calculators = _read_pending(args["calculators"]) new_potential = GPAWPotential( calculators, frozen_phonons=frozen_phonons, **kwargs ) return _wrap_with_array(new_potential) def _from_partitioned_args(self): kwargs = self._copy_kwargs(exclude=("calculators", "frozen_phonons")) frozen_phonons_partial = self.frozen_phonons._from_partitioned_args() return partial( self._gpaw_potential, frozen_phonons_partial=frozen_phonons_partial, **kwargs, ) def _partition_args(self, chunks: int = 1, lazy: bool = True): chunks = self._validate_ensemble_chunks(chunks) def frozen_phonons(calculators, frozen_phonons): arr = np.zeros((1,), dtype=object) itemset( arr, 0, {"calculators": calculators, "frozen_phonons": frozen_phonons} ) return arr calculators = self.calculators if not lazy: calculators = _defer_placeholders(calculators, _LastRead()) if isinstance(self.frozen_phonons, FrozenPhonons): array = np.zeros(len(self.frozen_phonons), dtype=object) partitioned = self.frozen_phonons._partition_args(chunks, lazy=lazy)[0] # Iterating a dask array yields per-element dask array slices; # passing those into dask.delayed() and wrapping the result with # another from_delayed()/concatenate() confuses dask's shape # inference (IndexError deep in dask.array.core during compute). # Delayed objects from to_delayed() compose correctly instead. fp_chunks = partitioned.to_delayed().ravel() if lazy else partitioned for i, fp in enumerate(fp_chunks): if lazy: block = dask.delayed(frozen_phonons)(calculators, fp) itemset(array, i, da.from_delayed(block, shape=(1,), dtype=object)) else: itemset(array, i, frozen_phonons(calculators, fp)) if lazy: array = da.concatenate(list(array)) else: if len(self.ensemble_shape) == 0 and lazy: block = dask.delayed(frozen_phonons)(calculators, None) array = da.from_delayed(block, shape=(), dtype=object) elif len(self.ensemble_shape) == 0 and not lazy: array = frozen_phonons(calculators, None) array = _wrap_with_array(array, ndims=0) else: array = np.zeros(self.ensemble_shape[0], dtype=object) for i, calculator in enumerate(calculators): calculator = [calculator] if lazy: calculator = dask.delayed(calculator) block = da.from_delayed( dask.delayed(frozen_phonons)(calculator, None), shape=(1,), dtype=object, ) else: block = frozen_phonons(calculator, None) itemset(array, i, block) if lazy: array = da.concatenate(list(array)) return (array,)
# return (array,) # # if lazy: # # print(array) # # return (da.concatenate(list(array)),) # # else: # # return (array,)
[docs] class GPAWParametrization: """ Calculate an Independent Atomic Model (IAM) potential based on a GPAW DFT calculation. """
[docs] def __init__(self, nodes=None, integration_step=0.002): self._nodes = nodes self._integration_step = integration_step self._potential_functions = {}
def _get_added_electrons(self, symbol, charge): if not charge: return [] charge = int(np.sign(charge) * np.ceil(np.abs(charge))) number = atomic_numbers[symbol] config = config_str_to_config_tuples( electron_configurations[chemical_symbols[number]] ) ionic_config = config_str_to_config_tuples( electron_configurations[chemical_symbols[number - charge]] ) config = defaultdict(lambda: 0, {shell[:2]: shell[2] for shell in config}) ionic_config = defaultdict( lambda: 0, {shell[:2]: shell[2] for shell in ionic_config} ) electrons = [] for key in set(config.keys()).union(set(ionic_config.keys())): difference = config[key] - ionic_config[key] for i in range(np.abs(difference)): electrons.append(key + (-np.sign(difference),)) return electrons def _get_all_electron_atom(self, symbol, charge=0.0): if isinstance(symbol, Number): symbol = chemical_symbols[symbol] added_electrons = self._get_added_electrons(symbol, charge) try: return self._run_all_electron_atom(symbol, added_electrons, spinpol=True) except AssertionError: # GPAW's radial solver fails to find a bound state for some open-shell # atoms (e.g. Pm, Sm, Eu, Pu) in the spin-polarized calculation, # independent of the SCF mixing. The spin-polarized and unpolarized # total densities differ by only ~0.1 electrons, which is negligible # for the independent-atom potential. warnings.warn( f"The spin-polarized all-electron calculation for {symbol} failed " "to converge in GPAW; falling back to a non-spin-polarized " "calculation." ) return self._run_all_electron_atom(symbol, added_electrons, spinpol=False) @staticmethod def _run_all_electron_atom(symbol, added_electrons, spinpol): from gpaw.atom.aeatom import AllElectronAtom ae = AllElectronAtom(symbol, spinpol=spinpol, xc="PBE", log=io.StringIO()) for n, l, df in added_electrons: ae.add(n, l, df) if added_electrons: # The occupations perturbed by add() need a more conservative # mixing schedule to reach self-consistency than the default, # which is tuned for the unperturbed (neutral) configuration. ae.run(mix=0.005, maxiter=5000, dnmax=1e-5) else: ae.run() ae.scalar_relativistic = True ae.refine() return ae
[docs] def charge(self, symbol: str, charge: float = 0.0): """ Calculate the radial charge density for an atom. Parameters ---------- symbol : str Chemical symbol of the atomic element. charge : float, optional Charge the atom by the given fractional number of electrons. Returns ------- charge : callable Function of the radial charge density with parameter 'r' corresponding to the radial distance from the core. """ ae = self._get_all_electron_atom(symbol, charge) r = ae.rgd.r_g * units.Bohr n = ae.n_sg.sum(0) / units.Bohr**3 return interp1d(r, n, fill_value="extrapolate", bounds_error=False)
def x_ray_scattering_factor(self, symbol: str, charge: float = 0.0): if isinstance(symbol, (int, np.int32, np.int64)): symbol = chemical_symbols[symbol] from hankel import SymmetricFourierTransform ht = SymmetricFourierTransform(ndim=3, h=self._integration_step, N=self._nodes) k = np.linspace(0.001, 12, 2000) n = self.charge(symbol, charge) # we do not calculate the Fourier Transform at k=0 for numerical reason fx = ht.transform(n, k[1:] * 2 * np.pi)[0] # instead it is replaced by the exact value fx(0)=Z fx = np.concatenate([[atomic_numbers[symbol]], fx]) fx = interp1d( k, fx, fill_value=(atomic_numbers[symbol], 0.0), bounds_error=False ) return fx def _to_lobato(self, symbol, charge): fx = self.x_ray_scattering_factor(symbol, charge) n = self.charge(symbol, charge) r = np.linspace(0, 20, 10000) Z = np.trapezoid(4 * np.pi * r**2 * n(r), dx=np.diff(r)) fe0 = np.trapezoid(4 * np.pi * r**4 * n(r), dx=np.diff(r)) / units.Bohr / 3 k = np.linspace(0.0, 12, 100) fe = np.zeros(len(k)) fe[k != 0] = (Z - fx(k[k > 0])) / k[k > 0] ** 2 / (2 * np.pi**2 * units.Bohr) fe[k == 0] = fe0 if isinstance(symbol, str): symbol = atomic_numbers[symbol] parametrization_aeatom = LobatoParametrization({}) parametrization_aeatom.fit(symbol, k, fe) return parametrization_aeatom def potential(self, symbol: str, charge: float = 0.0): return self._to_lobato(symbol, charge).potential(symbol, charge) def scattering_factor(self, symbol, charge: float = 0.0): return self._to_lobato(symbol, charge).scattering_factor(symbol, charge)
# def potential(self, symbol: str, charge: float = 0.0): # return interp1d(k, fe, fill_value=(fe0, 0.), bounds_error=False) # def potential(self, symbol: str, charge: float = 0.0): # """ # Calculate the radial electrostatic potential for an atom. # # Parameters # ---------- # symbol : str # Chemical symbol of the atomic element. # charge : float, optional # Charge the atom by the given fractional number of electrons. # # Returns # ------- # potential : callable # Function of the radial electrostatic potential with parameter 'r' # corresponding to the radial distance from the core. # """ # # ae = self._get_all_electron_atom(symbol, charge) # r = ae.rgd.r_g * units.Bohr # # ve = -ae.rgd.poisson(ae.n_sg.sum(0)) # ve = interp1d(r, ve, fill_value="extrapolate", bounds_error=False) # # vr = ( # lambda r: atomic_numbers[symbol] / r / (4 * np.pi * eps0) # + ve(r) / r * units.Hartree * units.Bohr # ) # return vr