#
# The high-throughput toolkit (httk)
# Copyright (C) 2012-2025 The httk AUTHORS
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""A string-preserving reader for VASP POSCAR/CONTCAR files.
:func:`read_poscar` parses a POSCAR/CONTCAR file into a neutral, JSON-able
mapping whose numeric fields are kept as the **verbatim strings** found in the
file. It performs no numeric conversion and imports nothing from
*httk-atomistic*; turning the mapping into a ``UnitcellStructure`` is the job of
``httk.core.load``.
"""
import logging
import math
import re
from collections.abc import Iterator
from typing import Any
from httk.core import combined_precision
from ._text import source_lines
[docs]
logger = logging.getLogger(__name__)
_POTCAR_SUFFIX = re.compile(r"^([A-Z][a-z]?)[_/.]")
# In Direct coordinates these spell crystallographic special fractions, not values rounded
# to one decimal place. Match CIF's format-specific rule, including signed forms and a
# numerically zero exponent, without changing httk-core's general decimal semantics.
_DIRECT_UNKNOWN_PRECISION = re.compile(r"^[+-]?(?:0\.0|0\.5|1\.0)(?:[eE][+-]?0+)?$")
def _strip_potcar_suffix(token: str) -> str:
"""Drop a POTCAR-flavor suffix, keeping the leading element symbol.
Only ``_``, ``/`` and ``.`` introduce a suffix, and only when the token
starts with an element-shaped ``[A-Z][a-z]?``; anything else (``vacancy``,
``D`` and unrecognized tokens) is returned unchanged so downstream error
messages stay truthful.
"""
match = _POTCAR_SUFFIX.match(token)
return match.group(1) if match is not None else token
def _next_line(lines: Iterator[str], lineno: int, what: str) -> str:
"""Return the next line's text, raising a clear error at end of file."""
try:
return next(lines)
except StopIteration:
raise ValueError(f"Malformed POSCAR: unexpected end of file at line {lineno} (expected {what}).") from None
def _parse_flag(token: str, lineno: int) -> bool:
if token in ("T", "t"):
return True
if token in ("F", "f"):
return False
raise ValueError(f"Malformed POSCAR line {lineno}: expected a selective-dynamics flag 'T' or 'F', got {token!r}.")
def _coordinate_precision(coords: list[list[str]], *, cartesian: bool) -> Any:
"""Return the coarsest token precision, exempting special Direct fractions."""
tokens = (token for row in coords for token in row)
if cartesian:
return combined_precision(tokens)
return combined_precision(None if _DIRECT_UNKNOWN_PRECISION.fullmatch(token) else token for token in tokens)
[docs]
def read_poscar(source: Any, *, precision: float | None = None) -> dict[str, Any]:
"""Parse a VASP POSCAR/CONTCAR into a neutral, string-preserving mapping.
``source`` may be a filename, opened through
:class:`httk.core.TextstreamFileView` so compressed files such as
``CONTCAR.bz2`` are decompressed transparently, or an already-open text
stream / iterable of lines.
The returned mapping has the keys ``format`` (always ``"vasp-poscar"``),
``comment``, ``scale`` and ``volume`` (both keys are always present; exactly
one is non-``None``), ``cell``, ``symbols`` (which may be ``None`` for
VASP-4; any species token shaped ``[A-Z][a-z]?`` followed by ``_``, ``/`` or
``.`` is truncated to that leading symbol, so ``Li_sv``, ``O_h`` and ``Lu/``
read as ``Li``, ``O`` and ``Lu``; every other token, including ``vacancy``,
is left untouched), ``counts``, ``cartesian``, ``coords``, and
``selective_dynamics`` (which may be ``None`` when selective dynamics is not
declared), and ``raw`` (the original decompressed text, or ``None`` when
unavailable). For filenames and binary sources, ``raw`` preserves CRLF and
provides the writer's byte-exact round-trip channel. For an open text
stream, it reflects the stream's already translated text and is not
byte-exact. Malformed input raises a clear
:class:`ValueError` naming the offending line.
Three further keys report how precisely the file wrote its numbers, each the coarsest
claim among the tokens it covers, or ``None`` when none of them claim anything:
``cell_precision``, ``scale_precision``, and ``coordinate_precision``. They are the
precisions of the tokens **as written**, deliberately not converted. In Direct mode,
conventional special fractions written exactly as ``0.0``, ``0.5``, or ``1.0`` make no
precision claim; signed forms follow the same rule. The cell vectors are still to be
multiplied by the scaling factor, and Cartesian coordinates are still to be transformed
into the fractional frame. Doing those conversions needs the assembled cell, so it
belongs to whoever builds the structure —
:func:`httk.core.load` — not to the reader.
A further key, ``precision_override``, carries the caller's ``precision`` value (or
``None``). When given, it is the Cartesian coordinate precision as a length in Å — the
same units as :meth:`~httk.atomistic.UnitcellStructure.cartesian_precision` and the
``symprec`` used by symmetry recognition — and whoever builds the structure uses it in
place of the digit-derived precision. Relaxed VASP CONTCAR coordinates are written to
full double precision, so the digit-derived precision is unrealistically tight
(~machine epsilon) and yields a symmetry tolerance that makes spglib reject nearly
every candidate; pass a realistic value (e.g. ``5e-4``) for such files. When
``precision`` is ``None`` a recommendation warning is emitted.
:param source: POSCAR/CONTCAR filename, text stream, or iterable of source lines.
:param precision: Cartesian coordinate precision as a length in Å, or ``None`` to keep
the digit-derived behavior (and emit a recommendation warning). Must be a finite
number greater than zero when given.
:return: The neutral mapping, including the original text in ``raw`` when available,
and ``precision_override`` (the passed value or ``None``).
:raises ValueError: If the input is malformed, or ``precision`` is not a finite number
greater than zero.
"""
if precision is not None:
if (
isinstance(precision, bool)
or not isinstance(precision, (int, float))
or not math.isfinite(precision)
or precision <= 0
):
raise ValueError(f"read_poscar precision must be a finite number greater than zero, got {precision!r}.")
else:
logger.warning(
"when reading VASP POSCAR/CONTCAR files it is recommended to pass a value for precision "
"(e.g. load(path, precision=5e-4)); without it the coordinate precision is inferred from the "
"number of digits written, which for full-precision CONTCAR output yields an unrealistically "
"tight symmetry tolerance.",
extra={"context": "poscar"},
)
with source_lines(source, preserve_path=True, capture_stream=True) as (lines, raw):
data = _read_poscar(iter(lines))
data["precision_override"] = precision
data["raw"] = raw
return data
def _read_poscar(lines: Iterator[str]) -> dict[str, Any]:
comment = _next_line(lines, 1, "comment").strip()
scale_line = _next_line(lines, 2, "scale/volume").strip()
try:
scale_value = float(scale_line)
except ValueError:
raise ValueError(f"Malformed POSCAR line 2: scale/volume {scale_line!r} is not a number.") from None
if scale_value < 0:
# A negative universal scaling factor means |value| is the target VOLUME.
volume: str | None = scale_line.removeprefix("-")
scale: str | None = None
else:
scale = scale_line
volume = None
cell: list[list[str]] = []
for i in range(3):
lineno = 3 + i
tokens = _next_line(lines, lineno, "a lattice-vector row").strip().split()
if len(tokens) < 3:
raise ValueError(f"Malformed POSCAR line {lineno}: expected 3 lattice-vector components, got {tokens!r}.")
cell.append(tokens[:3])
species_line = _next_line(lines, 6, "species symbols or atom counts").strip().split()
if not species_line:
raise ValueError("Malformed POSCAR line 6: expected species symbols or atom counts, got a blank line.")
try:
counts = [int(token) for token in species_line]
symbols: list[str] | None = None
counts_lineno = 6
except ValueError:
symbols = [_strip_potcar_suffix(token) for token in species_line]
counts_line = _next_line(lines, 7, "atom counts").strip().split()
try:
counts = [int(token) for token in counts_line]
except ValueError:
raise ValueError(f"Malformed POSCAR line 7: atom counts {counts_line!r} are not all integers.") from None
if len(counts) != len(symbols):
raise ValueError(f"Malformed POSCAR: {len(symbols)} species symbol(s) but {len(counts)} atom count(s).")
counts_lineno = 7
n_atoms = sum(counts)
# Optional selective-dynamics line, then the coordinate-type line.
mode_lineno = counts_lineno + 1
mode_line = _next_line(lines, mode_lineno, "coordinate type (or 'Selective dynamics')").strip()
selective = bool(mode_line) and mode_line[0] in "Ss"
if selective:
coordtype_lineno = mode_lineno + 1
coordtype = _next_line(lines, coordtype_lineno, "coordinate type").strip()
else:
coordtype_lineno = mode_lineno
coordtype = mode_line
if not coordtype:
raise ValueError(f"Malformed POSCAR line {coordtype_lineno}: missing coordinate type (Direct/Cartesian).")
cartesian = coordtype[0] in "CcKk"
coords: list[list[str]] = []
selective_dynamics: list[list[bool]] | None = [] if selective else None
for i in range(n_atoms):
lineno = coordtype_lineno + 1 + i
tokens = _next_line(lines, lineno, "an atomic coordinate row").strip().split()
if len(tokens) < 3:
raise ValueError(f"Malformed POSCAR line {lineno}: expected 3 coordinate components, got {tokens!r}.")
coords.append(tokens[:3])
if selective_dynamics is not None:
if len(tokens) < 6:
raise ValueError(
f"Malformed POSCAR line {lineno}: selective dynamics declared but flags are missing ({tokens!r})."
)
selective_dynamics.append([_parse_flag(tokens[3 + j], lineno) for j in range(3)])
return {
"format": "vasp-poscar",
"cell_precision": combined_precision(token for row in cell for token in row),
"scale_precision": combined_precision([scale]),
"coordinate_precision": _coordinate_precision(coords, cartesian=cartesian),
"comment": comment,
"scale": scale,
"volume": volume,
"cell": cell,
"symbols": symbols,
"counts": counts,
"cartesian": cartesian,
"coords": coords,
"selective_dynamics": selective_dynamics,
}