Source code for berny.geomlib

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
from __future__ import annotations

from io import StringIO
from itertools import chain, groupby, product, repeat
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any

import numpy as np
from numpy import pi
from numpy.linalg import inv, norm
from numpy.typing import ArrayLike, NDArray

from .species_data import get_property

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator

__all__ = ['Geometry', 'loads', 'readfile']

FloatArray = NDArray[np.floating[Any]]
IntArray = NDArray[np.integer[Any]]
BoolArray = NDArray[np.bool_]


[docs] class Geometry: """Represents a single molecule or a crystal. Iterating over a geometry yields 2-tuples of symbols and coordinates. :func:`len` returns the number of atoms in a geometry. The class supports :func:`format` with the same available formats as :meth:`dump`. Args: species: list of element symbols. coords: atomic coordinates in angstroms, shape ``(N, 3)``. lattice: lattice vectors, shape ``(3, 3)``, or :data:`None` for a molecule. """ def __init__( self, species: list[str], coords: ArrayLike, lattice: ArrayLike | None = None, ) -> None: self.species = species self.coords: FloatArray = np.array(coords, dtype=float) self.lattice: FloatArray | None = ( np.array(lattice, dtype=float) if lattice is not None else None )
[docs] @classmethod def from_atoms( cls, atoms: Iterable[tuple[str, ArrayLike]], lattice: ArrayLike | None = None, unit: float = 1.0, ) -> Geometry: """Alternative constructor. Args: atoms: iterable of ``(element_symbol, coordinate)`` 2-tuples. lattice: lattice vectors, or :data:`None` for a molecule. unit: value to multiply atomic coordinates with. """ atoms = list(atoms) species = [sp for sp, _ in atoms] coords = [np.array(coord, dtype=float) * unit for _, coord in atoms] return cls(species, coords, lattice)
def __repr__(self) -> str: s = repr(self.formula) if self.lattice is not None: s += ' in a lattice' return f'<{self.__class__.__name__} {s}>' def __iter__(self) -> Iterator[tuple[str, FloatArray]]: yield from zip(self.species, self.coords) def __len__(self) -> int: return len(self.species) @property def formula(self) -> str: """Chemical formula of the molecule or a unit cell.""" composition = sorted( (sp, len(list(g))) for sp, g in groupby(sorted(self.species)) ) return ''.join(f"{sp}{n if n > 1 else ''}" for sp, n in composition) def __format__(self, fmt: str) -> str: """Return the geometry represented as a string, delegates to :meth:`dump`.""" fp = StringIO() self.dump(fp, fmt) return fp.getvalue() dumps = __format__
[docs] def dump(self, f: IO[str], fmt: str) -> None: """Save the geometry into a file. :param file f: file object :param str fmt: geometry format, one of ``""``, ``"xyz"``, ``"aims"``. """ if not fmt: f.write(repr(self)) elif fmt == 'xyz': f.write(f'{len(self)}\n') f.write(f'Formula: {self.formula}\n') for specie, coord in self: coords_str = ' '.join(f'{x:15.8}' for x in coord) f.write(f'{specie:>2} {coords_str}\n') elif fmt == 'aims': f.write(f'# Formula: {self.formula}\n') if self.lattice is not None: for vec in self.lattice: vec_str = ' '.join(f'{x:15.8}' for x in vec) f.write(f'lattice_vector {vec_str}\n') for specie, coord in self: coords_str = ' '.join(f'{x:15.8}' for x in coord) f.write(f'atom {coords_str} {specie:>2}\n') else: raise ValueError(f'Unknown format: {fmt!r}')
[docs] def copy(self) -> Geometry: """Make a copy of the geometry.""" return Geometry( list(self.species), self.coords.copy(), self.lattice.copy() if self.lattice is not None else None, )
[docs] def write(self, filename: str) -> None: """Write the geometry into a file, delegates to :meth:`dump`. Args: filename: path that will be overwritten. """ ext = Path(filename).suffix if ext == '.xyz': fmt = 'xyz' elif ext == '.aims' or Path(filename).name == 'geometry.in': fmt = 'aims' else: raise ValueError('Unknown file extension') with open(filename, 'w', encoding='utf-8') as f: self.dump(f, fmt)
[docs] def super_circum(self, radius: float) -> IntArray | None: """ Supercell dimensions such that the supercell circumsribes a sphere. :param float radius: circumscribed radius in angstroms Returns :data:`None` when geometry is not a crystal. """ if self.lattice is None: return None rec_lattice = 2 * pi * inv(self.lattice.T) layer_sep = np.array( [ sum(vec * rvec / norm(rvec)) for vec, rvec in zip(self.lattice, rec_lattice) ] ) return np.array(np.ceil(radius / layer_sep + 0.5), dtype=int)
[docs] def supercell( self, ranges: Iterable[tuple[int, int]] = ((-1, 1), (-1, 1), (-1, 1)), cutoff: float | None = None, ) -> Geometry: """ Create a crystal supercell. :param list ranges: list of 2-tuples specifying the range of multiples of the unit-cell vectors :param float cutoff: if given, the ranges are determined such that the supercell contains a sphere with the radius qual to the cutoff Returns a copy of itself when geometry is not a crystal. """ if self.lattice is None: return self.copy() if cutoff: circum = self.super_circum(cutoff) assert circum is not None ranges = [(-r, r) for r in circum] ranges = list(ranges) latt_vectors = np.array( [(0, 0, 0)] + [ sum(k * vec for k, vec in zip(shift, self.lattice)) for shift in product(*[range(a, b + 1) for a, b in ranges]) if shift != (0, 0, 0) ] ) species = list(chain.from_iterable(repeat(self.species, len(latt_vectors)))) coords = (self.coords[None, :, :] + latt_vectors[:, None, :]).reshape((-1, 3)) lattice = self.lattice * np.array([b - a for a, b in ranges])[:, None] return Geometry(species, coords, lattice)
[docs] def dist_diff(self, other: Geometry | None = None) -> tuple[FloatArray, FloatArray]: r""" Calculate distances and vectors between atoms. Args: other (:class:`~berny.Geometry`): calculate distances between two geometries if given or within a geometry if not Returns: :math:`R_{ij}:=|\mathbf R_i-\mathbf R_j|` and :math:`R_{ij\alpha}:=(\mathbf R_i)_\alpha-(\mathbf R_j)_\alpha`. """ if other is None: other = self diff = self.coords[:, None, :] - other.coords[None, :, :] dist = np.sqrt(np.sum(diff**2, 2)) dist[np.diag_indices(len(self))] = np.inf return dist, diff
[docs] def dist(self, other: Geometry | None = None) -> FloatArray: """Alias for the first element of :meth:`dist_diff`.""" return self.dist_diff(other)[0]
[docs] def bondmatrix(self, scale: float = 1.3) -> BoolArray: r""" Calculate the covalent connectedness matrix. :param float scale: threshold for accepting a distance as a covalent bond Returns: :math:`b_{ij}:=R_{ij}<\text{scale}\times (R_i^\text{cov}+R_j^\text{cov})`. """ dist = self.dist(self) radii = np.array([get_property(sp, 'covalent_radius') for sp in self.species]) result: BoolArray = dist < scale * (radii[None, :] + radii[:, None]) return result
[docs] def rho(self) -> FloatArray: r""" Calculate a measure of covalentness. Returns: :math:`\rho_{ij}:=\exp\big(-R_{ij}/(R_i^\text{cov}+R_j^\text{cov})\big)`. """ geom = self.supercell() dist = geom.dist(geom) radii = np.array([get_property(sp, 'covalent_radius') for sp in geom.species]) result: FloatArray = np.exp(-dist / (radii[None, :] + radii[:, None]) + 1) return result
@property def masses(self) -> FloatArray: """Numpy array of atomic masses.""" return np.array([get_property(sp, 'mass') for sp in self.species]) @property def cms(self) -> FloatArray: r"""Calculate the center of mass, :math:`\mathbf R_\text{CMS}`.""" masses = self.masses result: FloatArray = np.sum(masses[:, None] * self.coords, 0) / masses.sum() return result @property def inertia(self) -> FloatArray: r"""Calculate the moment of inertia. .. math:: I_{\alpha\beta}:= \sum_im_i\big(r_i^2\delta_{\alpha\beta}-(\mathbf r_i)_\alpha(\mathbf r_i)_\beta\big),\qquad \mathbf r_i=\mathbf R_i-\mathbf R_\text{CMS} """ coords_w = np.sqrt(self.masses)[:, None] * (self.coords - self.cms) A = np.array([np.diag(np.full(3, r)) for r in np.sum(coords_w**2, 1)]) B = coords_w[:, :, None] * coords_w[:, None, :] result: FloatArray = np.sum(A - B, 0) return result
[docs] def load(fp: IO[str], fmt: str) -> Geometry: """Read a geometry from a file object. Args: fp: file object. fmt: geometry format, one of ``"xyz"`` or ``"aims"``. """ if fmt == 'xyz': n = int(fp.readline()) fp.readline() species: list[str] = [] coords: list[list[float]] = [] for _ in range(n): l = fp.readline().split() species.append(l[0]) coords.append([float(x) for x in l[1:4]]) return Geometry(species, coords) if fmt == 'aims': species = [] coords = [] lattice: list[list[float]] = [] while True: line = fp.readline() if not line: break line = line.strip() if not line or line.startswith('#'): continue tokens = line.split() what = tokens[0] if what == 'atom': species.append(tokens[4]) coords.append([float(x) for x in tokens[1:4]]) elif what == 'lattice_vector': lattice.append([float(x) for x in tokens[1:4]]) if lattice: assert len(lattice) == 3 return Geometry(species, coords, lattice) return Geometry(species, coords) raise ValueError(f'Unknown format: {fmt!r}')
[docs] def loads(s: str, fmt: str) -> Geometry: """Read a geometry from a string, delegates to :func:`load`. Args: s: string with geometry. fmt: geometry format (see :func:`load`). """ fp = StringIO(s) return load(fp, fmt)
[docs] def readfile(path: str, fmt: str | None = None) -> Geometry: """Read a geometry from a file path, delegates to :func:`load`. Args: path: path to a geometry file. fmt: format; if not given, derived from the file extension. """ if not fmt: ext = Path(path).suffix if ext == '.xyz': fmt = 'xyz' elif ext == '.aims' or Path(path).name == 'geometry.in': fmt = 'aims' else: raise ValueError(f'Cannot infer format from path {path!r}') with open(path, encoding='utf-8') as f: return load(f, fmt)