Source code for diffpes.inout.poscar
"""Parse a VASP POSCAR/CONTCAR file.
Extended Summary
----------------
The module reads VASP POSCAR and CONTCAR crystal structure files. It returns
a :class:`~diffpes.types.CrystalGeometry` PyTree containing lattice vectors,
fractional positions, and per-atom species.
Routine Listings
----------------
:func:`read_poscar`
Parse a VASP POSCAR/CONTCAR file.
Notes
-----
The parser supports direct and Cartesian coordinate formats,
optional selective dynamics, and automatic reciprocal lattice
computation.
"""
from pathlib import Path
import jax.numpy as jnp
import numpy as np
from beartype import beartype
from beartype.typing import TextIO
from jaxtyping import Float, jaxtyped
from numpy import ndarray as NDArray # noqa: N812
from diffpes.types import CrystalGeometry, make_crystal_geometry
[docs]
@jaxtyped(typechecker=beartype)
def read_poscar(
filename: str = "POSCAR",
) -> CrystalGeometry:
"""Parse a VASP POSCAR/CONTCAR file.
The function reads a VASP POSCAR or CONTCAR file. The file defines lattice
vectors, element symbols, atom counts, and atomic coordinates. The
coordinates can use direct or Cartesian form. The function returns a
:class:`~diffpes.types.CrystalGeometry` PyTree with fractional coordinates.
It applies the universal scaling factor to the lattice. A negative scalar
specifies the target cell volume, as defined by the VASP format.
The POSCAR file format used by VASP has the following structure:
* **Line 1**: Comment / system name (discarded).
* **Line 2**: Universal scaling factor applied to all lattice
vectors (and to Cartesian coordinates, if applicable).
* **Lines 3-5**: Three rows of three floats defining the lattice
vectors ``a``, ``b``, ``c`` in Angstroms (before scaling).
* **Line 6**: Either element symbols (VASP >= 5) or atom counts
(VASP 4). If non-numeric, it is the species line.
* **Line 6 or 7**: Atom counts per species (list of integers).
* **Next line**: Optional ``"Selective dynamics"`` flag. If
present, the following line is the coordinate-type specifier.
* **Coordinate-type line**: ``"Direct"`` / ``"Fractional"`` for
fractional coordinates, or ``"Cartesian"`` / ``"Cart"`` for
Cartesian.
* **Coordinate lines**: ``natoms`` lines, each with at least three
floats. The parser ignores additional selective-dynamics columns.
:see: :class:`~.test_poscar.TestReadPoscar`
Implementation Logic
--------------------
1. **Read and scale the lattice**::
lattice = lattice * scaling_factor
A positive value is the direct scale. For a negative value, derive the
positive scale that gives a cell volume of ``abs(raw_scale)``.
2. **Convert Cartesian coordinates when required**::
coords = np.linalg.solve(lattice.T, coords.T).T
Solving against the scaled lattice produces fractional coordinates.
3. **Return validated crystal geometry**::
return geometry
The result includes the reciprocal lattice and per-atom species.
Parameters
----------
filename : str, optional
Path to POSCAR file. Default is ``"POSCAR"``.
Returns
-------
geometry : CrystalGeometry
Crystal geometry with lattice, reciprocal lattice, fractional
positions, and per-atom species.
Raises
------
ValueError
If a negative scale accompanies a zero-volume raw lattice.
Notes
-----
The function always returns fractional coordinates. For Cartesian input,
``np.linalg.solve`` computes
``frac = lattice^{-T} @ cart^T`` with numerical stability. The parser
detects and ignores optional selective-dynamics flags. VASP-4 files can
omit the element-symbol line. In this case, the function returns an empty
``species`` tuple. Supply the species from an external source such as
POTCAR.
"""
fid: TextIO
i: int
path: Path = Path(filename)
with path.open("r") as fid:
_comment: str = fid.readline().strip()
raw_scale: float = float(fid.readline().strip())
lattice: Float[NDArray, "3 3"] = np.zeros((3, 3), dtype=np.float64)
for i in range(3):
vals: list[float] = [float(x) for x in fid.readline().split()]
lattice[i, :] = vals
scale: float = raw_scale
if raw_scale < 0.0:
raw_volume: float = abs(float(np.linalg.det(lattice)))
if raw_volume == 0.0:
msg: str = "negative POSCAR scale requires nonzero cell volume"
raise ValueError(msg)
scale = (abs(raw_scale) / raw_volume) ** (1.0 / 3.0)
lattice = lattice * scale
line: str = fid.readline().strip()
symbols: tuple[str, ...] = ()
if not any(c.isdigit() for c in line):
symbols = tuple(line.split())
line = fid.readline().strip()
atom_counts: list[int] = [int(x) for x in line.split()]
natoms: int = sum(atom_counts)
line = fid.readline().strip()
selective: bool = False
if line[0].lower() == "s":
selective = True # noqa: F841
line = fid.readline().strip()
cartesian: bool = line[0].lower() in ("c", "k")
coords: Float[NDArray, "N 3"] = np.zeros((natoms, 3), dtype=np.float64)
for i in range(natoms):
vals = [float(x) for x in fid.readline().split()[:3]]
coords[i, :] = vals
if cartesian:
coords = coords * scale
coords = np.linalg.solve(lattice.T, coords.T).T
species: tuple[str, ...] = ()
if symbols:
species = tuple(
symbol
for symbol, count in zip(symbols, atom_counts, strict=True)
for _ in range(count)
)
geometry: CrystalGeometry = make_crystal_geometry(
lattice=jnp.asarray(lattice),
positions=jnp.asarray(coords),
species=species,
)
return geometry
__all__: list[str] = [
"read_poscar",
]