Source code for htesp.inputin
#!/usr/bin/env python
"""Reading and writing ``input.in``.
``input.in`` is the six-line file every HTESP command starts from::
1 <- start index (1-based, inclusive)
30 <- end index (EXCLUSIVE -- the loops run ii < end)
200 0 <- nkpt [kcut]
mpid-list.in <- tracking file
phband dos <- plot types, whitespace separated
DFT = QE <- QE or VASP
The original parser in ``mainprogram.py`` had three defects, all reproduced by
the tests in ``tests/test_inputin.py``:
* it guarded with ``len(lines) >= 4`` and then read ``lines[4]``, so a
four-line file raised ``IndexError``;
* a shorter file left ``start``/``end``/``element``/``nkpt`` unbound, so the
next statement raised ``NameError`` instead of reporting the real problem;
* the two branches disagreed about the *type* of line 5. Reading an existing
file gave ``lines[4].split(" ")`` -- a list, which ``mainprogram 19``
iterates to plot one type per entry, as intended. Creating a missing file
assigned the bare string ``'phband'``. A string iterates too, so the same
correct loop walked its characters and ran ``plot-scan`` six times with
``p``, ``h``, ``b``, ``a``, ``n``, ``d`` -- none of which it recognises.
Only on the run that created ``input.in``; afterwards the file was read
back as a list and the loop behaved. ``plot_types`` is a ``list[str]``
everywhere now, so both paths agree.
The sixth line is written unconditionally here. The bash layer tested
``[ $dft == 'vasp' ]`` at 31 sites, which aborts with "unary operator expected"
when the line is missing.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_FILENAME = "input.in"
[docs]
@dataclass
class InputIn:
"""The parsed contents of ``input.in``."""
start: int = 1
end: int = 2
nkpt: int = 200
kcut: int = 0
track: str = "mpid-list.in"
plot_types: list[str] = field(default_factory=lambda: ["phband"])
dft: str = "QE"
path: Path | None = None
# -- reading ---------------------------------------------------------- #
@classmethod
def parse(cls, text: str, path: os.PathLike | str | None = None) -> "InputIn":
lines = [ln.rstrip("\n") for ln in text.splitlines()]
if len(lines) < 4:
raise InputInError(
f"{path or DEFAULT_FILENAME} has {len(lines)} line(s); at least four are "
"required (start, end, nkpt, tracking file). "
"Run 'mainprogram basicinfo' for the format."
)
def _int(value: str, what: str) -> int:
try:
return int(value.split()[0])
except (ValueError, IndexError) as exc:
raise InputInError(
f"{path or DEFAULT_FILENAME}: {what} must be an integer, got {value!r}"
) from exc
start = _int(lines[0], "line 1 (start)")
end = _int(lines[1], "line 2 (end)")
kpt_tokens = lines[2].split()
if not kpt_tokens:
raise InputInError(f"{path or DEFAULT_FILENAME}: line 3 (nkpt) is empty")
nkpt = _int(kpt_tokens[0], "line 3 (nkpt)")
kcut = _int(kpt_tokens[1], "line 3 (kcut)") if len(kpt_tokens) > 1 else 0
track_tokens = lines[3].split()
if not track_tokens:
raise InputInError(f"{path or DEFAULT_FILENAME}: line 4 (tracking file) is empty")
track = track_tokens[0]
plot_types = lines[4].split() if len(lines) > 4 and lines[4].strip() else ["phband"]
dft = "QE"
for line in lines[5:]:
if "DFT" in line and "=" in line:
dft = line.split("=", 1)[1].strip() or "QE"
break
if end <= start:
raise InputInError(
f"{path or DEFAULT_FILENAME}: end ({end}) must be greater than start "
f"({start}); note that end is exclusive"
)
return cls(start=start, end=end, nkpt=nkpt, kcut=kcut, track=track,
plot_types=plot_types, dft=dft,
path=Path(path) if path else None)
# -- tolerant loading (used by the workflow layer) -------------------- #
[docs]
@classmethod
def load(cls, path: os.PathLike | str = DEFAULT_FILENAME,
config: dict | None = None) -> "InputIn":
"""Best-effort parse that never raises.
The workflow layer must keep going when a single field is malformed --
the bash layer simply used an empty variable, which is how
``[ $dft == 'vasp' ]`` came to abort with "unary operator expected" at
31 call sites. Missing fields fall back to the documented defaults and
``dft`` falls back to ``download.inp.calc`` from ``config.json``.
Unlike :meth:`parse`, ``dft`` is returned lower-cased.
"""
def _int(value, fallback):
try:
return int(str(value).strip())
except (TypeError, ValueError):
return fallback
try:
lines = Path(path).read_text().splitlines()
except OSError:
lines = []
obj = cls(start=1, end=2, nkpt=50, kcut=0, track="mpid.in",
plot_types=["phband"], dft="", path=Path(path))
if len(lines) > 0:
obj.start = _int(lines[0], obj.start)
if len(lines) > 1:
obj.end = _int(lines[1], obj.end)
if len(lines) > 2:
parts = lines[2].split()
if parts:
obj.nkpt = _int(parts[0], obj.nkpt)
if len(parts) > 1:
obj.kcut = _int(parts[1], 0)
if len(lines) > 3 and lines[3].strip():
obj.track = lines[3].split()[0]
if len(lines) > 4 and lines[4].strip():
obj.plot_types = lines[4].split()
for line in lines:
if line.strip().upper().startswith("DFT"):
parts = line.replace("=", " = ").split()
if len(parts) >= 3:
obj.dft = parts[2].strip().lower()
break
if not obj.dft:
if config:
obj.dft = str(config.get("download", {})
.get("inp", {}).get("calc", "qe")).lower()
else:
obj.dft = "qe"
return obj
@classmethod
def read(cls, path: os.PathLike | str = DEFAULT_FILENAME) -> "InputIn":
path = Path(path)
return cls.parse(path.read_text(), path)
[docs]
@classmethod
def load_or_create(cls, path: os.PathLike | str = DEFAULT_FILENAME,
dft: str | None = None) -> "InputIn":
"""Read ``input.in``, creating a sensible default when it is absent."""
path = Path(path)
if path.is_file():
return cls.read(path)
if dft is None:
from htesp.config import config
dft = str(config()["download"]["inp"]["calc"]).upper()
created = cls(dft=dft, path=path)
created.write(path)
return created
# -- writing ---------------------------------------------------------- #
def render(self) -> str:
return (
f"{self.start}\n"
f"{self.end}\n"
f"{self.nkpt} {self.kcut}\n"
f"{self.track}\n"
f"{' '.join(self.plot_types)}\n"
f"DFT = {self.dft}\n"
)
def write(self, path: os.PathLike | str | None = None) -> Path:
target = Path(path or self.path or DEFAULT_FILENAME)
target.write_text(self.render())
self.path = target
return target
# -- convenience ------------------------------------------------------ #
@property
def is_vasp(self) -> bool:
return self.dft.strip().lower() == "vasp"
@property
def is_qe(self) -> bool:
return not self.is_vasp
@property
def count(self) -> int:
"""Number of materials the range covers (``end`` is exclusive)."""
return max(0, self.end - self.start)
def summary(self) -> str:
return (
f"start: {self.start}, end: {self.end}, tracking_file: {self.track}, "
f"nkpoint: {self.nkpt}, DFT: {self.dft}"
)
[docs]
def check_track_file(self, root: os.PathLike | str = ".") -> list[str]:
"""Warn when the range does not line up with the tracking file."""
track = Path(root) / self.track
if not track.is_file():
return [f"tracking file {self.track!r} not found in {Path(root).resolve()}"]
entries = [ln for ln in track.read_text().splitlines() if ln.strip()]
problems = []
if self.start < 1:
problems.append(f"start ({self.start}) must be >= 1")
if self.end - 1 > len(entries):
problems.append(
f"end ({self.end}) is past the {len(entries)} entries of {self.track}; "
f"the last usable value is {len(entries) + 1} (end is exclusive)"
)
return problems