Loading structures and ensembles, with clear errors about what is supported.
Crystal.load and Molecule.load are separate entry points that raise a bare
KeyError on anything they do not recognise, and the caller has to know in
advance which one to use. Often you do not - a file named geometry.in may be
either - so this module dispatches on its own and always returns the same
thing: a list of frames.
One file may hold several structures - a trajectory, a multi-block CIF - and
several files may be viewed as one ensemble, so a single structure is just the
one-frame case rather than a separate path through the code.
Frame
dataclass
One structure out of a possibly larger ensemble.
source is the file it came from. The other two are both "which one is
this", but they answer different questions and so cannot share a field: a
CIF data block names a structure, while a trajectory comment describes
a state of one structure. Only the former belongs in a title.
Source code in chmpy/fmt/load.py
| @dataclass
class Frame:
"""One structure out of a possibly larger ensemble.
`source` is the file it came from. The other two are both "which one is
this", but they answer different questions and so cannot share a field: a
CIF data block *names a structure*, while a trajectory comment *describes
a state* of one structure. Only the former belongs in a title.
"""
structure: object
source: str
label: str = "" # what this frame is a snapshot of: a trajectory comment
block: str = "" # what names this structure in the file: a CIF data block
@property
def is_crystal(self):
return isinstance(self.structure, Crystal)
@property
def title(self):
"""What the file calls this structure.
Preference order: the name the structure carries, then the block that
names it in the file, then the file name. A multi-block CIF should
show the block, since the file name is the same for every structure
in it.
"""
name = getattr(self.structure, "titl", None) or getattr(
self.structure, "name", None
)
if not name or name in GENERIC_NAMES:
name = self.block or Path(self.source).stem
return str(name)
|
title
property
What the file calls this structure.
Preference order: the name the structure carries, then the block that
names it in the file, then the file name. A multi-block CIF should
show the block, since the file name is the same for every structure
in it.
Bases: ValueError
Raised when nothing knows how to read a file.
Source code in chmpy/fmt/load.py
| class UnknownFormat(ValueError):
"""Raised when nothing knows how to read a file."""
def __init__(self, path, reason=""):
detail = f" ({reason})" if reason else ""
super().__init__(
f"don't know how to read {Path(path).name}{detail}\n"
f"supported formats:\n{supported()}"
)
|
load_file(path)
Every structure in one file.
Source code in chmpy/fmt/load.py
| def load_file(path) -> list[Frame]:
"""Every structure in one file."""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"no such file: {path}")
suffix, name = path.suffix.lower(), path.name
# the other multi-frame formats go to ASE, which already reads them as one
if suffix == ".xyz":
return _from_trajectory(path)
if name in CRYSTAL_NAMES or suffix in CRYSTAL_SUFFIXES:
return _from_crystal_file(path)
if name in EITHER_NAMES or suffix in EITHER_SUFFIXES:
return _from_aims(path)
if suffix in MOLECULE_SUFFIXES:
return [Frame(Molecule.load(str(path)), path.name)]
return _from_ase(path)
|
load_frames(sources)
Every structure across several files, viewed as one ensemble.
A directory contributes every file in it that can be read, sorted by name,
which is the usual shape of an optimisation or a set of candidates.
Source code in chmpy/fmt/load.py
| def load_frames(sources) -> list[Frame]:
"""Every structure across several files, viewed as one ensemble.
A directory contributes every file in it that can be read, sorted by name,
which is the usual shape of an optimisation or a set of candidates.
"""
if isinstance(sources, str | Path):
sources = [sources]
paths = []
for source in sources:
path = Path(source)
if path.is_dir():
paths.extend(sorted(p for p in path.iterdir() if p.is_file()))
else:
paths.append(path)
frames, failures = [], []
for path in paths:
try:
frames.extend(load_file(path))
except (UnknownFormat, FileNotFoundError, ValueError) as exc:
failures.append((path, exc))
if not frames:
if len(failures) == 1:
raise failures[0][1]
detail = "\n".join(f" {p.name}: {e}".split("\n")[0] for p, e in failures)
raise UnknownFormat(paths[0] if paths else "?", f"nothing readable\n{detail}")
return frames
|
load_stdin(text)
Read an ensemble from piped text, guessing the format from content.
Source code in chmpy/fmt/load.py
| def load_stdin(text) -> list[Frame]:
"""Read an ensemble from piped text, guessing the format from content."""
import tempfile
name = sniff(text)
# every reader here takes a path, and a temporary file is a great deal
# simpler than threading an optional string through all of them
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / name
path.write_text(text)
frames = load_file(path)
for frame in frames:
frame.source = "<stdin>"
return frames
|
sniff(text)
Guess a format from file contents, for input arriving without a name.
Source code in chmpy/fmt/load.py
| def sniff(text):
"""Guess a format from file contents, for input arriving without a name."""
# aims declares its atoms with a keyword, so it is recognisable whether or
# not the geometry happens to be periodic
if any(
line.lstrip().startswith(("lattice_vector", "atom ", "atom_frac"))
for line in text.splitlines()
):
return "geometry.in"
if "data_" in text or "_cell_length_a" in text or "loop_" in text:
return "structure.cif"
first = text.lstrip().split("\n", 1)[0].strip()
if first.isdigit():
return "structure.xyz"
raise UnknownFormat("<stdin>", "cannot tell what format this is")
|
supported()
A human-readable summary of what can be loaded.
Source code in chmpy/fmt/load.py
| def supported() -> str:
"""A human-readable summary of what can be loaded."""
groups = [
("periodic", {**CRYSTAL_SUFFIXES, **CRYSTAL_NAMES}),
("molecular", MOLECULE_SUFFIXES),
("either", {**EITHER_SUFFIXES, **EITHER_NAMES}),
("multi-frame", TRAJECTORY_SUFFIXES),
]
lines = []
for name, table in groups:
entries = sorted({f"{k} ({v})" for k, v in table.items()})
lines.append(f" {name:12s} {', '.join(entries)}")
lines.append(" anything else is passed to ASE, if it is installed")
return "\n".join(lines)
|