"""Lazy, streaming access to the bounded metadata and final results of OUTCAR."""
import os
import re
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from itertools import islice
from types import MappingProxyType, TracebackType
from typing import Any, Self
from ._text import source_lines
# Ported from old/httk/src/httk/iface/vasp_if.py:get_computation_info.
_PARAMETERS = (
"ENCUT",
"NKPTS",
"POTIM",
"NSW",
"IBRION",
"ISIF",
"NELM",
"EDIFF",
"EDIFFG",
"ISMEAR",
"SIGMA",
"ISPIN",
"GGA",
"LEXCH",
)
_NUMERIC_PARAMETERS = frozenset(_PARAMETERS[:-2])
_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[EeDd][+-]?\d+)?"
_IONIC_MARKER = re.compile(r"\bIteration\s+\d+\s*\(")
_FRAME_MARKER = re.compile(r"\bIteration\s+\d+\s*\(\s*1\s*\)")
_VERSION_TOKEN = re.compile(r"^vasp\.")
_TITEL = re.compile(r"^\s*TITEL\s*=\s*(.*?)\s*$")
_COMPLETION = "General timing and accounting informations"
_ELASTIC_HEADINGS = (
"TOTAL ELASTIC MODULI",
"SYMMETRIZED ELASTIC MODULI",
"ELASTIC MODULI CONTR FROM IONIC RELAXATION",
)
@dataclass(frozen=True)
[docs]
class FinalEnergies:
"""Store energy lexemes from the last complete or partial energy block.
:param free_energy: Free-energy lexeme from the block, when present.
:param energy_without_entropy: Energy-without-entropy lexeme, when present.
:param energy_sigma0: Sigma-zero energy lexeme, when present.
:param final: Whether the block was complete when parsing ended.
"""
[docs]
free_energy: str | None
[docs]
energy_without_entropy: str | None
[docs]
energy_sigma0: str | None
@dataclass(frozen=True)
[docs]
class OutcarFrame:
"""Store one complete ionic-step snapshot with VASP numeric lexemes unchanged.
:param index: Zero-based ionic-step index.
:param cell: Lattice-vector lexemes, when the step contains a cell.
:param positions: Position lexemes, when the step contains positions.
:param forces: Force lexemes, when the step contains forces.
:param stress_kbar: Six stress lexemes in VASP order, when present.
:param free_energy: Free-energy lexeme, when present.
:param energy_without_entropy: Energy-without-entropy lexeme, when present.
:param energy_sigma0: Sigma-zero energy lexeme, when present.
:param temperature: Temperature lexeme, when present.
"""
[docs]
cell: tuple[tuple[str, str, str], ...] | None
[docs]
positions: tuple[tuple[str, str, str], ...] | None
[docs]
forces: tuple[tuple[str, str, str], ...] | None
[docs]
stress_kbar: tuple[str, ...] | None
[docs]
free_energy: str | None
[docs]
energy_without_entropy: str | None
[docs]
energy_sigma0: str | None
[docs]
temperature: str | None
@staticmethod
def _floats(rows: tuple[tuple[str, str, str], ...] | None) -> tuple[tuple[float, float, float], ...] | None:
return None if rows is None else tuple((float(row[0]), float(row[1]), float(row[2])) for row in rows)
[docs]
def cell_floats(self) -> tuple[tuple[float, float, float], ...] | None:
"""Convert the cell lexemes to floating-point values when present.
:return: Converted cell values, or ``None`` when the frame has no cell.
"""
return self._floats(self.cell)
[docs]
def positions_floats(self) -> tuple[tuple[float, float, float], ...] | None:
"""Convert the position lexemes to floating-point values when present.
:return: Converted position values, or ``None`` when the frame has no positions.
"""
return self._floats(self.positions)
[docs]
def forces_floats(self) -> tuple[tuple[float, float, float], ...] | None:
"""Convert the force lexemes to floating-point values when present.
:return: Converted force values, or ``None`` when the frame has no forces.
"""
return self._floats(self.forces)
[docs]
def stress_gpa_voigt(self) -> tuple[float, ...] | None:
"""Convert stress to tensile-positive GPa Voigt order ``xx, yy, zz, yz, xz, xy``.
The conversion multiplies kbar by ``0.1``, reverses VASP's
compressive-positive sign, and reorders the shear components.
:return: Stress in tensile-positive GPa Voigt order, or ``None`` when absent.
"""
if self.stress_kbar is None:
return None
values = tuple(float(token) for token in self.stress_kbar)
# VASP writes [xx yy zz xy yz zx] in kB, with compressive-positive signs.
return tuple(-values[index] * 0.1 for index in (0, 1, 2, 4, 5, 3))
@dataclass(frozen=True)
[docs]
class ElasticModuliBlock:
"""Store one six-by-six elastic-moduli table.
:param heading: Heading identifying the table in the source.
:param rows: Table rows with their source numeric lexemes.
"""
[docs]
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class _Prologue:
version_string: str
version_numbers: tuple[int, ...]
nions: int | None
ions_per_type: tuple[int, ...] | None
parameters: Mapping[str, str]
xc: str | None
potcar_titles: tuple[str, ...]
issues: tuple[str, ...]
parameter_lines: Mapping[str, int]
@dataclass(frozen=True)
class _FullPass:
final_energies: FinalEnergies
completed: bool
completion_evidence: tuple[str, ...]
issues: tuple[str, ...]
nframes: int
last_frame: OutcarFrame | None
stresses: tuple[tuple[str, ...], ...]
elastic_moduli: tuple[ElasticModuliBlock, ...]
magnetization: tuple[float, ...] | None
noncollinear_magnetization: bool
def _issue(issues: list[str], line: int, message: str) -> None:
issues.append(f"line {line}: {message}")
def _version_numbers(version: str) -> tuple[int, ...]:
if not version:
return ()
numbers: list[int] = []
for part in version.removeprefix("vasp.").split("."):
if not part.isdigit():
break
numbers.append(int(part))
return tuple(numbers)
def _number(token: str) -> bool:
return re.fullmatch(_NUMBER, token) is not None
def _stress_row(line: str) -> tuple[str, ...] | None:
match = re.search(r"\bin kB\b(.*)$", line)
if match is None:
return None
tokens = match.group(1).split()
return tuple(tokens[:6]) if len(tokens) >= 6 else None
class _EnergyTracker:
def __init__(self) -> None:
self.seen = False
self.active = False
self.incomplete = False
self.block_line = 0
self.truncated_lines: list[int] = []
self.free_energy: str | None = None
self.energy_without_entropy: str | None = None
self.energy_sigma0: str | None = None
def feed(self, line: str, lineno: int) -> None:
if "FREE ENERGIE OF THE ION-ELECTRON SYSTEM" in line:
if self.active:
self.truncated_lines.append(self.block_line)
self.incomplete = True
self.seen = True
self.active = True
self.block_line = lineno
self.free_energy = None
self.energy_without_entropy = None
self.energy_sigma0 = None
return
if not self.active:
return
match = re.search(r"\bfree\s+energy\s+TOTEN\s*=\s*(\S+)", line)
if match:
self.free_energy = match.group(1)
match = re.search(r"\benergy\s+without\s+entropy\s*=\s*(\S+)", line)
if match:
self.energy_without_entropy = match.group(1)
match = re.search(r"energy\(sigma->0\)\s*=\s*(\S+)", line)
if match:
self.energy_sigma0 = match.group(1)
if self.free_energy is not None and self.energy_without_entropy is not None and self.energy_sigma0 is not None:
self.active = False
def complete(self) -> bool:
return self.seen and not self.active and not self.incomplete
def finish(self, issues: list[str]) -> None:
for line in self.truncated_lines:
_issue(issues, line, "truncated FREE ENERGIE block")
if self.active:
_issue(issues, self.block_line, "truncated FREE ENERGIE block")
class _FrameBuilder:
def __init__(self, start_line: int) -> None:
self.start_line = start_line
self.cell_seen = False
self.cell_pending = 0
self.cell_rows: list[tuple[str, str, str]] = []
self.positions_active = False
self.positions_seen = False
self.position_rows: list[tuple[str, str, str]] = []
self.force_rows: list[tuple[str, str, str]] = []
self.stress: tuple[str, ...] | None = None
self.energy = _EnergyTracker()
self.temperature: str | None = None
def _finish_positions(self) -> None:
self.positions_active = False
def feed(self, line: str, lineno: int) -> None:
stripped = line.strip()
if "direct lattice vectors" in line:
self.cell_seen = True
self.cell_pending = 3
self.cell_rows = []
return
if self.cell_pending:
if not stripped:
return
tokens = stripped.split()
if len(tokens) >= 3 and all(_number(token) for token in tokens[:3]):
self.cell_rows.append(tuple(tokens[:3])) # type: ignore[arg-type]
self.cell_pending -= 1
return
self.cell_pending = -1
if "POSITION" in line and "TOTAL-FORCE" in line:
self.positions_active = True
self.positions_seen = True
self.position_rows = []
return
if self.positions_active:
if not stripped or stripped.startswith("-"):
return
if stripped.lower().startswith("total drift"):
self._finish_positions()
else:
tokens = stripped.split()
if len(tokens) >= 6 and all(_number(token) for token in tokens[:6]):
self.position_rows.append((tokens[0], tokens[1], tokens[2]))
self.force_rows.append((tokens[3], tokens[4], tokens[5]))
return
self._finish_positions()
self.energy.feed(line, lineno)
temperature = re.search(r"\btemperature\s+(\S+)\s*K\b", line)
if temperature:
self.temperature = temperature.group(1)
def set_stress(self, stress: tuple[str, ...]) -> None:
self.stress = stress
def frame(self, index: int, expected_ions: int | None) -> OutcarFrame | None:
if self.cell_pending or self.positions_active or not self.position_rows or not self.energy.complete():
return None
if self.cell_seen and len(self.cell_rows) != 3:
return None
if len(self.force_rows) != len(self.position_rows):
return None
if expected_ions is not None and len(self.position_rows) != expected_ions:
return None
return OutcarFrame(
index,
tuple(self.cell_rows) if self.cell_seen else None,
tuple(self.position_rows),
tuple(self.force_rows),
self.stress,
self.energy.free_energy,
self.energy.energy_without_entropy,
self.energy.energy_sigma0,
self.temperature,
)
def count_issue(self, index: int, expected_ions: int | None) -> str | None:
if not self.positions_seen:
return None
actual = len(self.position_rows)
if expected_ions is not None and actual != expected_ions:
return f"frame {index}: POSITION/TOTAL-FORCE row count {actual}, expected {expected_ions}"
if self.positions_active:
return f"frame {index}: incomplete POSITION/TOTAL-FORCE block after {actual} rows"
return None
def boundary_complete(self) -> bool:
return self.stress is not None or (self.positions_seen and not self.positions_active)
def has_data(self) -> bool:
return self.cell_seen or self.positions_seen or self.stress is not None
class _ElasticTracker:
def __init__(self) -> None:
self.heading: str | None = None
self.rows: list[tuple[str, ...]] = []
def _finish(self, blocks: list[ElasticModuliBlock], issues: list[str], line: int) -> None:
if self.heading is None:
return
if len(self.rows) == 6:
blocks.append(ElasticModuliBlock(self.heading, tuple(self.rows)))
else:
_issue(issues, line, "truncated elastic-moduli block")
self.heading = None
self.rows = []
def feed(self, line: str, lineno: int, blocks: list[ElasticModuliBlock], issues: list[str]) -> None:
upper = line.upper()
heading = next((item for item in _ELASTIC_HEADINGS if item in upper), None)
if heading is not None:
self._finish(blocks, issues, lineno)
self.heading = line.rstrip("\r\n")
return
if self.heading is None:
return
tokens = line.split()
if len(tokens) >= 7 and not _number(tokens[0]) and all(_number(token) for token in tokens[1:7]):
self.rows.append(tuple(tokens[1:7]))
elif len(tokens) >= 6 and all(_number(token) for token in tokens[:6]):
self.rows.append(tuple(tokens[:6]))
if len(self.rows) == 6:
self._finish(blocks, issues, lineno)
def finish(self, blocks: list[ElasticModuliBlock], issues: list[str], line: int) -> None:
self._finish(blocks, issues, line)
def _is_separator(stripped: str) -> bool:
return len(stripped) >= 4 and set(stripped) == {"-"}
class _MagnetizationTracker:
"""Track the final ``magnetization (x)`` block and its per-ion total moments.
Only the collinear ``magnetization (x)`` table is parsed. Each new header
resets the candidate so that the last block in the file always wins, and a
truncated or malformed final block (from a killed job) yields ``None``. A
``magnetization (y)`` or ``(z)`` header seen after the final ``(x)`` header
sets :attr:`noncollinear`, signalling that the ``(x)`` totals are only one
projection of a noncollinear moment.
"""
_IDLE = 0
_AWAIT_ION_HEADER = 1
_AWAIT_OPEN_SEP = 2
_ROWS = 3
_AWAIT_TOT = 4
_DONE = 5
def __init__(self) -> None:
self.seen = False
self.header_line = 0
self._phase = self._IDLE
self._rows: list[float] = []
self._failed = False
self.totals: tuple[float, ...] | None = None
self.noncollinear = False
def _reset(self, lineno: int) -> None:
self.seen = True
self.header_line = lineno
self._phase = self._AWAIT_ION_HEADER
self._rows = []
self._failed = False
self.totals = None
self.noncollinear = False
def feed(self, line: str, lineno: int) -> None:
lowered = line.lower()
if "magnetization (x)" in lowered:
self._reset(lineno)
return
if self.seen and ("magnetization (y)" in lowered or "magnetization (z)" in lowered):
self.noncollinear = True
return
if self._phase in (self._IDLE, self._DONE):
return
stripped = line.strip()
if self._phase == self._AWAIT_ION_HEADER:
if stripped.startswith("# of ion"):
self._phase = self._AWAIT_OPEN_SEP
return
if self._phase == self._AWAIT_OPEN_SEP:
if _is_separator(stripped):
self._phase = self._ROWS
elif stripped:
self._failed = True
return
if self._phase == self._ROWS:
if not stripped:
return
if _is_separator(stripped):
self._phase = self._AWAIT_TOT
return
self._feed_row(stripped)
return
if self._phase == self._AWAIT_TOT:
if not stripped:
return
if stripped.lower().startswith("tot") and not self._failed and self._rows:
self.totals = tuple(self._rows)
self._phase = self._DONE
def _feed_row(self, stripped: str) -> None:
tokens = stripped.split()
if len(tokens) < 3 or not tokens[0].isdigit() or int(tokens[0]) <= 0:
self._failed = True
return
if not all(_number(token) for token in tokens[1:]):
self._failed = True
return
self._rows.append(float(tokens[-1]))
def finish(self, issues: list[str]) -> None:
if self.seen and self.totals is None:
_issue(issues, self.header_line, "malformed magnetization (x) block")
def _iter_parsed_frames(
lines: Iterator[str],
*,
issues: list[str] | None = None,
stresses: list[tuple[str, ...]] | None = None,
energy: _EnergyTracker | None = None,
completion_evidence: list[str] | None = None,
elastic: _ElasticTracker | None = None,
elastic_blocks: list[ElasticModuliBlock] | None = None,
magnetization: _MagnetizationTracker | None = None,
line_counter: list[int] | None = None,
nions: int | None = None,
) -> Iterator[OutcarFrame]:
builder: _FrameBuilder | None = None
next_index = 0
expected_ions = nions
last_line = 0
elastic_issues = issues if issues is not None else []
def finish_builder(current: _FrameBuilder) -> OutcarFrame | None:
nonlocal next_index, expected_ions
frame = current.frame(next_index, expected_ions)
if frame is not None:
if expected_ions is None:
expected_ions = len(frame.positions or ())
next_index += 1
return frame
if issues is not None and current.has_data():
message = current.count_issue(next_index, expected_ions) or "truncated ionic frame"
_issue(issues, current.start_line, message)
return None
for lineno, line in enumerate(lines, 1):
last_line = lineno
if line_counter is not None:
line_counter[0] = lineno
if energy is not None:
energy.feed(line, lineno)
if magnetization is not None:
magnetization.feed(line, lineno)
if completion_evidence is not None and _COMPLETION in line:
completion_evidence.append(line.rstrip("\r\n"))
stress = _stress_row(line)
if stress is not None:
if stresses is not None:
stresses.append(stress)
if builder is None:
builder = _FrameBuilder(lineno)
elif builder.boundary_complete():
frame = finish_builder(builder)
if frame is not None:
yield frame
builder = _FrameBuilder(lineno)
builder.set_stress(stress)
if elastic is not None and elastic_blocks is not None:
elastic.feed(line, lineno, elastic_blocks, elastic_issues)
starts_section = "direct lattice vectors" in line or ("POSITION" in line and "TOTAL-FORCE" in line)
if builder is not None and starts_section and builder.positions_seen and not builder.positions_active:
frame = finish_builder(builder)
if frame is not None:
yield frame
builder = _FrameBuilder(lineno)
if _FRAME_MARKER.search(line):
if builder is not None:
frame = finish_builder(builder)
if frame is not None:
yield frame
builder = _FrameBuilder(lineno)
continue
if builder is not None:
builder.feed(line, lineno)
if builder is not None:
frame = finish_builder(builder)
if frame is not None:
yield frame
if elastic is not None and elastic_blocks is not None:
elastic.finish(elastic_blocks, elastic_issues, last_line + 1)
[docs]
class OutcarFile:
"""Lazy OUTCAR metadata reader whose scans reopen the source each time.
OUTCAR paths, including compressed paths, are accepted by deliberate
forward-streaming divergence from :class:`~httk.io.vasp.wavecar.WavecarFile`;
random frame access re-streams the file. Construction validates only that
the path exists. Prologue and full scans are lazy: the first prologue access
scans to the ionic marker and can traverse the whole file when no marker
exists. The full pass streams the source once and caches summary fields,
all stress rows, and all elastic-moduli blocks. No source handle is
retained, so :meth:`close` only marks this lazy object closed. The public
:attr:`path` property returns the source filename.
:param filename: Filesystem path to an OUTCAR, optionally compressed.
"""
def __init__(self, filename: str | os.PathLike[str]) -> None:
if not isinstance(filename, (str, os.PathLike)):
raise TypeError("OUTCAR source must be a filesystem filename, not a live stream.")
path = os.fsdecode(os.fspath(filename))
if not os.path.isfile(path):
raise FileNotFoundError(f"OUTCAR file does not exist: {path}")
self._filename = path
self._closed = False
self._prologue: _Prologue | None = None
self._full: _FullPass | None = None
@property
[docs]
def closed(self) -> bool:
"""Whether this lazy reader has been closed."""
return self._closed
[docs]
def close(self) -> None:
"""Mark this lazy object closed; it owns no persistent stream."""
self._closed = True
@property
[docs]
def path(self) -> str:
"""Return the source filename used to construct this lazy reader."""
self._require_open()
return self._filename
def __enter__(self) -> Self:
self._require_open()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.close()
def _require_open(self) -> None:
if self.closed:
raise ValueError("Cannot access a closed OUTCAR file.")
def _ensure_prologue(self) -> _Prologue:
if self._prologue is not None:
return self._prologue
version = ""
nions: int | None = None
ions_per_type: tuple[int, ...] | None = None
parameters: dict[str, str] = {}
parameter_lines: dict[str, int] = {}
titles: list[str] = []
issues: list[str] = []
with source_lines(self._filename) as (lines, _raw):
for lineno, line in enumerate(lines, 1):
if _IONIC_MARKER.search(line):
break
if not version:
version = next((token for token in line.split() if _VERSION_TOKEN.match(token)), "")
nions_match = re.search(r"\bNIONS\s*=\s*(\d+)", line)
if nions is None and nions_match is not None:
nions = int(nions_match.group(1))
ions_match = re.search(r"\bions\s+per\s+type\s*=\s*(.*)$", line, re.IGNORECASE)
if ions_per_type is None and ions_match is not None:
tokens = ions_match.group(1).split()
try:
ions_per_type = tuple(int(token) for token in tokens)
except ValueError:
_issue(issues, lineno, "unparseable ions per type")
titel = _TITEL.match(line)
if titel:
title = titel.group(1).strip()
if title not in titles: # v1 deduplicated exact title strings, preserving first-seen order.
titles.append(title)
for key in _PARAMETERS:
assignment = re.search(rf"\b{key}\s*=", line)
if assignment is None or key in parameters:
continue
value = re.search(rf"\b{key}\s*=\s*(\S+?)(?=\s|;|$)", line)
if value is None:
_issue(issues, lineno, f"unparseable {key} parameter")
continue
token = value.group(1)
parameters[key] = token
parameter_lines[key] = lineno
if key in _NUMERIC_PARAMETERS and re.fullmatch(_NUMBER, token) is None:
_issue(issues, lineno, f"unparseable {key} parameter value {token!r}")
gga = parameters.get("GGA")
xc_tag = parameters.get("LEXCH") if gga == "--" else gga
xc = vasp_xc_tags.get(xc_tag) if xc_tag is not None else None
if xc_tag is not None and xc is None:
_issue(issues, parameter_lines.get("LEXCH" if gga == "--" else "GGA", 0), f"unknown XC tag {xc_tag!r}")
self._prologue = _Prologue(
version,
_version_numbers(version),
nions,
ions_per_type,
MappingProxyType(parameters),
xc,
tuple(titles),
tuple(issues),
MappingProxyType(parameter_lines),
)
return self._prologue
def _ensure_full(self) -> _FullPass:
if self._full is not None:
return self._full
prologue = self._ensure_prologue()
issues = list(prologue.issues)
energy = _EnergyTracker()
completion_evidence: list[str] = []
stresses: list[tuple[str, ...]] = []
elastic_blocks: list[ElasticModuliBlock] = []
elastic = _ElasticTracker()
magnetization = _MagnetizationTracker()
line_counter = [0]
nframes = 0
last_frame: OutcarFrame | None = None
with source_lines(self._filename) as (lines, _raw):
for frame in _iter_parsed_frames(
iter(lines),
issues=issues,
stresses=stresses,
energy=energy,
completion_evidence=completion_evidence,
elastic=elastic,
elastic_blocks=elastic_blocks,
magnetization=magnetization,
line_counter=line_counter,
nions=prologue.nions,
):
nframes += 1
last_frame = frame
energy.finish(issues)
magnetization.finish(issues)
if not completion_evidence:
_issue(issues, line_counter[0] + 1, "missing completion footer")
self._full = _FullPass(
FinalEnergies(
energy.free_energy,
energy.energy_without_entropy,
energy.energy_sigma0,
energy.seen,
),
bool(completion_evidence),
tuple(completion_evidence),
tuple(issues),
nframes,
last_frame,
tuple(stresses),
tuple(elastic_blocks),
magnetization.totals,
magnetization.noncollinear,
)
return self._full
@property
[docs]
def version_string(self) -> str:
"""Return the VASP version string found during the prologue scan."""
self._require_open()
return self._ensure_prologue().version_string
@property
[docs]
def version_numbers(self) -> tuple[int, ...]:
"""Return the numeric components of the VASP version string."""
self._require_open()
return self._ensure_prologue().version_numbers
@property
[docs]
def parameters(self) -> Mapping[str, str]:
"""Return the first recognized VASP parameter lexeme for each parameter."""
self._require_open()
return self._ensure_prologue().parameters
@property
[docs]
def ions_per_type(self) -> tuple[int, ...] | None:
"""Return the number of ions for each potential type, when reported."""
self._require_open()
return self._ensure_prologue().ions_per_type
@property
[docs]
def xc(self) -> str | None:
"""Return the recognized exchange-correlation description, when available."""
self._require_open()
return self._ensure_prologue().xc
@property
[docs]
def potcar_titles(self) -> tuple[str, ...]:
"""Return distinct POTCAR titles in first-seen order."""
self._require_open()
return self._ensure_prologue().potcar_titles
[docs]
def frames(self) -> Iterator[OutcarFrame]:
"""Stream complete ionic frames without retaining the sequence.
:return: An iterator yielding one :class:`OutcarFrame` at a time.
"""
self._require_open()
return self._frames()
def _frames(self) -> Iterator[OutcarFrame]:
self._require_open()
prologue = self._ensure_prologue()
with source_lines(self._filename) as (lines, _raw):
yield from _iter_parsed_frames(iter(lines), nions=prologue.nions)
[docs]
def frame(self, index: int) -> OutcarFrame | None:
"""Return one frame by rescanning from the start of the file.
:param index: Zero-based frame index.
:return: The requested frame, or ``None`` when it is beyond the file.
:raises ValueError: If ``index`` is negative or not an integer.
"""
if not isinstance(index, int) or index < 0:
raise ValueError("OUTCAR frame index must be a non-negative integer.")
# ponytail: frame(i) is O(file); add a byte-offset index only if random access matters.
return next(islice(self.frames(), index, index + 1), None)
@property
[docs]
def final_energies(self) -> FinalEnergies:
"""Return energies from the last complete or partial energy block."""
self._require_open()
return self._ensure_full().final_energies
@property
[docs]
def nframes(self) -> int:
"""Return the number of complete ionic frames after the full pass."""
self._require_open()
return self._ensure_full().nframes
@property
[docs]
def last_frame(self) -> OutcarFrame | None:
"""Return the last complete ionic frame, when one exists."""
self._require_open()
return self._ensure_full().last_frame
[docs]
def stresses(self) -> tuple[tuple[str, ...], ...]:
"""Return all six-token ``in kB`` rows in file order.
:return: Stress rows retaining their source numeric lexemes.
"""
self._require_open()
return self._ensure_full().stresses
@property
[docs]
def elastic_moduli(self) -> tuple[ElasticModuliBlock, ...]:
"""Return parsed elastic-moduli tables in source order."""
self._require_open()
return self._ensure_full().elastic_moduli
@property
[docs]
def magnetization(self) -> tuple[float, ...] | None:
"""Return per-ion total magnetic moments from the final ``magnetization (x)`` block.
The values are the last (``tot``) column of each ion row in the last
``magnetization (x)`` block in the file, in Bohr magnetons. The
per-orbital columns and the ``magnetization (y)`` / ``(z)`` blocks
themselves are out of scope, so for a noncollinear run these values are
only the *x* projection of each moment. A caller treating them as a
collinear axis projection must check :attr:`noncollinear_magnetization`
first. A malformed or truncated final block never raises: it yields
``None`` and records an entry in :attr:`issues`, so an OUTCAR from a
killed job is ordinary input.
:return: The per-ion total moments, or ``None`` for a non-spin-polarized
run or an unusable final block.
"""
self._require_open()
return self._ensure_full().magnetization
@property
[docs]
def noncollinear_magnetization(self) -> bool:
"""Whether a ``magnetization (y)`` or ``(z)`` block follows the final ``(x)`` block.
When ``True`` the :attr:`magnetization` values are only the *x*
projection of a noncollinear moment and must not be treated as a
collinear axis magnitude.
:return: ``True`` for a noncollinear final block, ``False`` otherwise,
including when the file has no magnetization block.
"""
self._require_open()
return self._ensure_full().noncollinear_magnetization
@property
[docs]
def completed(self) -> bool:
"""Whether the source contains completion-footer evidence."""
self._require_open()
return self._ensure_full().completed
@property
[docs]
def completion_evidence(self) -> tuple[str, ...]:
"""Return completion-footer lines found during the full pass."""
self._require_open()
return self._ensure_full().completion_evidence
@property
[docs]
def issues(self) -> tuple[str, ...]:
"""Return parsing issues collected during the available scans."""
self._require_open()
return self._ensure_full().issues
[docs]
def read_outcar(source: Any) -> dict[str, Any]:
"""Return a lazy OUTCAR payload; only filesystem filenames are accepted.
:param source: Filesystem path to an OUTCAR, optionally compressed.
:return: A neutral payload containing the lazy OUTCAR reader.
:raises TypeError: If ``source`` is not a filesystem path.
:raises FileNotFoundError: If the path does not exist.
"""
return {"format": "vasp-outcar", "outcar": OutcarFile(source)}