diff --git a/.codespell/ignore_words.txt b/.codespell/ignore_words.txt index e2ee211b..eccf37a8 100644 --- a/.codespell/ignore_words.txt +++ b/.codespell/ignore_words.txt @@ -22,3 +22,7 @@ CONECT ;; /src/diffpy/structure/parsers/p_xcfg.py:452 ;; used in a function BU + +;; /src/diffpy/structure/parsers/p_vesta.py:452 +;; abbreviation for Structure in vesta +STRUC diff --git a/news/vesta_view.rst b/news/vesta_view.rst new file mode 100644 index 00000000..b8564ee6 --- /dev/null +++ b/news/vesta_view.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added parser for vesta specific files and viewer for vesta + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/structure/apps/vesta_viewer.py b/src/diffpy/structure/apps/vesta_viewer.py new file mode 100644 index 00000000..b90ae0f4 --- /dev/null +++ b/src/diffpy/structure/apps/vesta_viewer.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python +############################################################################## +# +# diffpy.structure by DANSE Diffraction group +# Simon J. L. Billinge +# (c) 2026 University of California, Santa Barbara. +# All rights reserved. +# +# File coded by: Simon J. L. Billinge, Rundong Hua +# +# See AUTHORS.txt for a list of people who contributed. +# See LICENSE_DANSE.txt for license information. +# +############################################################################## +"""View structure file in VESTA. + +Usage: ``vestaview [options] strufile`` + +Vestaview understands more `Structure` formats than VESTA. It converts +`strufile` to a temporary VESTA or CIF file which is opened in VESTA. +See supported file formats: ``inputFormats`` + +Options: + -f, --formula + Override chemical formula in `strufile`. The formula defines + elements in the same order as in `strufile`, e.g., ``Na4Cl4``. + + -w, --watch + Watch input file for changes. + + --viewer=VIEWER + The structure viewer program, by default "vesta". + The program will be executed as "VIEWER structurefile". + + --formats=FORMATS + Comma-separated list of file formats that are understood + by the VIEWER, by default ``"vesta,cif"``. Files of other + formats will be converted to the first listed format. + + -h, --help + Display this message and exit. + + -V, --version + Show script version and exit. + +Notes +----- +VESTA is the actively maintained successor to AtomEye. Unlike AtomEye, +VESTA natively reads CIF, its own ``.vesta`` format, and several other +crystallographic file types, so format conversion is only required for +formats not in that set. + +AtomEye XCFG format is no longer a default target format but the XCFG +parser (``P_xcfg``) remains available in ``diffpy.structure.parsers`` +for backward compatibility. +""" + +import os +import re +import signal +import sys +from pathlib import Path + +from diffpy.structure.structureerrors import StructureFormatError + +pd = { + "formula": None, + "watch": False, + "viewer": "vesta", + "formats": ["vesta", "cif"], +} + + +def usage(style=None): + """Show usage info. for ``style=="brief"`` show only first 2 lines. + + Parameters + ---------- + style : str, optional + The usage display style. + """ + myname = Path(sys.argv[0]).name + msg = __doc__.replace("vestaview", myname) + if style == "brief": + msg = f"{msg.splitlines()[1]}\n" f"Try `{myname} --help' for more information." + else: + from diffpy.structure.parsers import input_formats + + fmts = [fmt for fmt in input_formats() if fmt != "auto"] + msg = msg.replace("inputFormats", " ".join(fmts)) + print(msg) + + +def version(): + """Print the script version.""" + from diffpy.structure import __version__ + + print(f"vestaview {__version__}") + + +def load_structure_file(filename, format="auto"): + """Load structure from the specified file. + + Parameters + ---------- + filename : str or Path + The path to the structure file. + format : str, optional + The file format, by default ``"auto"``. + + Returns + ------- + tuple + The loaded ``(Structure, fileformat)`` pair. + """ + from diffpy.structure import Structure + + stru = Structure() + parser = stru.read(str(filename), format) + return stru, parser.format + + +def convert_structure_file(pd): + """Convert ``strufile`` to a temporary file understood by the + viewer. + + On the first call, a temporary directory is created and stored in + ``pd``. Subsequent calls in watch mode reuse the directory. + + The VESTA viewer natively reads ``.vesta`` and ``.cif`` files, so if + the source is already in one of the formats listed in + ``pd["formats"]`` and no formula override is requested, the file is + copied unchanged. Otherwise the structure is loaded and re-written in + the first format listed in ``pd["formats"]``. + + Parameters + ---------- + pd : dict + The parameter dictionary containing at minimum ``"strufile"`` + and ``"formats"`` keys. It is modified in place to add + ``"tmpdir"`` and ``"tmpfile"`` on the first call. + """ + if "tmpdir" not in pd: + from tempfile import mkdtemp + + pd["tmpdir"] = Path(mkdtemp()) + strufile = Path(pd["strufile"]) + tmpfile = pd["tmpdir"] / strufile.name + tmpfile_tmp = Path(f"{tmpfile}.tmp") + pd["tmpfile"] = tmpfile + stru = None + fmt = pd.get("fmt", "auto") + if fmt == "auto": + stru, fmt = load_structure_file(strufile) + pd["fmt"] = fmt + if fmt in pd["formats"] and pd["formula"] is None: + import shutil + + shutil.copyfile(strufile, tmpfile_tmp) + tmpfile_tmp.replace(tmpfile) + return + if stru is None: + stru = load_structure_file(strufile, fmt)[0] + if pd["formula"]: + formula = pd["formula"] + if len(formula) != len(stru): + emsg = f"Formula has {len(formula)} atoms while structure has " f"{len(stru)}" + raise RuntimeError(emsg) + for atom, element in zip(stru, formula): + atom.element = element + elif fmt == "rawxyz": + for atom in stru: + if atom.element == "": + atom.element = "C" + stru.write(str(tmpfile_tmp), pd["formats"][0]) + tmpfile_tmp.replace(tmpfile) + + +def watch_structure_file(pd): + """Watch ``strufile`` for modifications and reconvert when changed. + + Polls the modification timestamps of ``pd["strufile"]`` and + ``pd["tmpfile"]`` once per second. When the source is newer, the + file is reconverted via :func:`convert_structure_file`. + + Parameters + ---------- + pd : dict + The parameter dictionary as used by + :func:`convert_structure_file`. + """ + from time import sleep + + strufile = Path(pd["strufile"]) + tmpfile = Path(pd["tmpfile"]) + while pd["watch"]: + if tmpfile.stat().st_mtime < strufile.stat().st_mtime: + convert_structure_file(pd) + sleep(1) + + +def clean_up(pd): + """Remove temporary file and directory created during conversion. + + Parameters + ---------- + pd : dict + The parameter dictionary that may contain ``"tmpfile"`` and + ``"tmpdir"`` entries to be removed. + """ + tmpfile = pd.pop("tmpfile", None) + if tmpfile is not None and Path(tmpfile).exists(): + Path(tmpfile).unlink() + tmpdir = pd.pop("tmpdir", None) + if tmpdir is not None and Path(tmpdir).exists(): + Path(tmpdir).rmdir() + + +def parse_formula(formula): + """Parse chemical formula and return a list of elements. + + Parameters + ---------- + formula : str + The chemical formula string such as ``"Na4Cl4"`` or ``"H2O"``. + + Returns + ------- + list of str + The ordered list of element symbols with repetition matching the + formula. + + Raises + ------ + RuntimeError + Raised when ``formula`` does not start with an uppercase letter + or contains a non-integer count. + """ + formula = re.sub(r"\s", "", formula) + if not re.match(r"^[A-Z]", formula): + raise RuntimeError(f"InvalidFormula '{formula}'") + + elcnt = re.split(r"([A-Z][a-z]?)", formula)[1:] + ellst = [] + try: + for i in range(0, len(elcnt), 2): + element = elcnt[i] + count = int(elcnt[i + 1]) if elcnt[i + 1] else 1 + ellst.extend([element] * count) + except ValueError: + emsg = f"Invalid formula, {elcnt[i + 1]!r} is not valid count" + raise RuntimeError(emsg) + return ellst + + +def die(exit_status=0, pd=None): + """Clean up temporary files and exit with ``exit_status``. + + Parameters + ---------- + exit_status : int, optional + The exit code passed to :func:`sys.exit`, by default 0. + pd : dict, optional + The parameter dictionary forwarded to :func:`clean_up`. + """ + clean_up({} if pd is None else pd) + sys.exit(exit_status) + + +def signal_handler(signum, stackframe): + """Handle OS signals by reverting to the default handler and + exiting. + + On ``SIGCHLD`` the child exit status is harvested via + :func:`os.wait`; on all other signals :func:`die` is called with + exit status 1. + + Parameters + ---------- + signum : int + The signal number. + stackframe : frame + The current stack frame. Unused. + """ + del stackframe + signal.signal(signum, signal.SIG_DFL) + if signum == signal.SIGCHLD: + _, exit_status = os.wait() + exit_status = (exit_status >> 8) + (exit_status & 0x00FF) + die(exit_status, pd) + else: + die(1, pd) + + +def main(): + """Entry point for the ``vestaview`` command-line tool.""" + import getopt + + pd["watch"] = False + try: + opts, args = getopt.getopt( + sys.argv[1:], + "f:whV", + ["formula=", "watch", "viewer=", "formats=", "help", "version"], + ) + except getopt.GetoptError as errmsg: + print(errmsg, file=sys.stderr) + die(2) + + for option, argument in opts: + if option in ("-f", "--formula"): + try: + pd["formula"] = parse_formula(argument) + except RuntimeError as err: + print(err, file=sys.stderr) + die(2) + elif option in ("-w", "--watch"): + pd["watch"] = True + elif option == "--viewer": + pd["viewer"] = argument + elif option == "--formats": + pd["formats"] = [word.strip() for word in argument.split(",")] + elif option in ("-h", "--help"): + usage() + die() + elif option in ("-V", "--version"): + version() + die() + if len(args) < 1: + usage("brief") + die() + if len(args) > 1: + print("too many structure files", file=sys.stderr) + die(2) + pd["strufile"] = Path(args[0]) + signal.signal(signal.SIGHUP, signal_handler) + signal.signal(signal.SIGQUIT, signal_handler) + signal.signal(signal.SIGSEGV, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + env = os.environ.copy() + try: + convert_structure_file(pd) + spawnargs = ( + pd["viewer"], + pd["viewer"], + str(pd["tmpfile"]), + env, + ) + if pd["watch"]: + signal.signal(signal.SIGCHLD, signal_handler) + os.spawnlpe(os.P_NOWAIT, *spawnargs) + watch_structure_file(pd) + else: + status = os.spawnlpe(os.P_WAIT, *spawnargs) + die(status, pd) + except IOError as err: + print(f"{args[0]}: {err.strerror}", file=sys.stderr) + die(1, pd) + except StructureFormatError as err: + print(f"{args[0]}: {err}", file=sys.stderr) + die(1, pd) + + +if __name__ == "__main__": + main() diff --git a/src/diffpy/structure/parsers/p_vesta.py b/src/diffpy/structure/parsers/p_vesta.py new file mode 100644 index 00000000..1be850c0 --- /dev/null +++ b/src/diffpy/structure/parsers/p_vesta.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python +############################################################################## +# +# diffpy.structure by DANSE Diffraction group +# Simon J. L. Billinge +# (c) 2026 University of California, Santa Barbara. +# All rights reserved. +# +# File coded by: Simon J. L. Billinge, Rundong Hua +# +# See AUTHORS.txt for a list of people who contributed. +# See LICENSE_DANSE.txt for license information. +# +############################################################################## +"""Parser for VESTA format used by VESTA (Visualization for Electronic +and Structural Analysis). + +This module replaces the AtomEye XCFG parser (P_xcfg). The XCFG parser and +all its original attributes are preserved for backward compatibility. +VESTA is the actively maintained successor viewer. + +Attributes +---------- +AtomicMass : dict + Dictionary of atomic masses for elements. +""" + +import re +import sys + +import numpy + +from diffpy.structure import Structure +from diffpy.structure.parsers import StructureParser +from diffpy.structure.parsers.p_xcfg import AtomicMass +from diffpy.structure.structureerrors import StructureFormatError + + +# Constants ------------------------------------------------------------------ +class P_vesta(StructureParser): + """Parser for VESTA native structure format (.vesta). + + VESTA (Visualization for Electronic and Structural Analysis) is the + actively maintained successor to AtomEye. This parser writes the + native VESTA format understood by VESTA 3.x and later. + + Attributes + ---------- + format : str + Format name, default "vesta". + + Notes + ----- + The ``cluster_boundary`` attribute is retained from the original + AtomEye/XCFG parser for API compatibility; it is not used by VESTA + because VESTA handles periodicity natively. + """ + + cluster_boundary = 2 + """int: Width of boundary around corners of non-periodic cluster. + Retained from the original AtomEye/XCFG parser for API compatibility. + VESTA handles periodicity natively so this value has no effect on output. + """ + + def __init__(self): + StructureParser.__init__(self) + self.format = "vesta" + return + + def parse_lines(self, lines): + """Parse list of lines in VESTA format. + + Reads the ``STRUC``, ``ATOMT``, and ``COORD`` sections of a + ``.vesta`` file to reconstruct a :class:`~diffpy.structure.Structure`. + + Parameters + ---------- + lines : list of str + Lines of a VESTA format file. + + Returns + ------- + Structure + Parsed structure instance. + + Raises + ------ + StructureFormatError + When the file does not conform to the VESTA format. + """ + stru = Structure() + p_nl = 0 + + # Strip trailing blank lines for a clean iteration boundary. + stop = len(lines) + for line in reversed(lines): + if line.strip(): + break + stop -= 1 + ilines = iter(lines[:stop]) + + try: + # Lattice parameters parsed from STRUC block: + # a b c alpha beta gamma + latt_abc = None + latt_abg = None + atom_types = {} + + # Raw fractional coordinates collected from COORD block: + # list of (atom_type_index, x, y, z, occupancy) + raw_coords = [] + + section = None # tracks current block keyword + + for line in ilines: + p_nl += 1 + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + # Detect section transitions. + upper = stripped.split()[0].upper() + if upper in ( + "CRYSTAL", + "TITLE", + "GROUP", + "STRUC", + "ATOMT", + "COORD", + "BOUND", + "SBOND", + "VECTR", + "VECTS", + "STYLE", + "SCENE", + "EOF", + ): + section = upper + continue + + # ---- STRUC section: lattice parameters ----------------- + if section == "STRUC": + words = stripped.split() + # First data line: a b c alpha beta gamma space_group + if latt_abc is None and len(words) >= 6: + try: + latt_abc = [float(w) for w in words[:3]] + latt_abg = [float(w) for w in words[3:6]] + except ValueError: + pass + continue + + # ---- ATOMT section: atom-type definitions --------------- + if section == "ATOMT": + # Format: index Symbol radius r g b style # mass= + words = stripped.split() + if len(words) >= 2: + try: + idx = int(words[0]) + symbol = words[1] + atom_types[idx] = {"symbol": symbol, "mass": None} + # Recover mass from the trailing comment if present. + mass_match = re.search(r"#\s*mass\s*=\s*([0-9.eE+\-]+)", stripped) + if mass_match: + atom_types[idx]["mass"] = float(mass_match.group(1)) + else: + # Fall back to the built-in lookup table. + atom_types[idx]["mass"] = AtomicMass.get(symbol, 0.0) + except ValueError: + pass + continue + + # ---- COORD section: atomic coordinates ----------------- + if section == "COORD": + # Format: seq type_index x y z occupancy ... + words = stripped.split() + if len(words) >= 6: + try: + type_idx = int(words[1]) + x, y, z = float(words[2]), float(words[3]), float(words[4]) + occ = float(words[5]) + raw_coords.append((type_idx, x, y, z, occ)) + except ValueError: + pass + continue + if latt_abc is None: + emsg = "VESTA file is missing STRUC lattice parameters" + raise StructureFormatError(emsg) + + stru.lattice.setLatPar( + a=latt_abc[0], + b=latt_abc[1], + c=latt_abc[2], + alpha=latt_abg[0], + beta=latt_abg[1], + gamma=latt_abg[2], + ) + for type_idx, x, y, z, occ in raw_coords: + type_info = atom_types.get(type_idx, {"symbol": "X", "mass": 0.0}) + element = type_info["symbol"] + mass = type_info["mass"] + stru.add_new_atom(element, xyz=[x, y, z]) + stru[-1].occupancy = occ + if mass is None: + mass = AtomicMass.get(element, 0.0) + stru[-1].mass = mass + + except (ValueError, IndexError): + emsg = "%d: file is not in VESTA format" % p_nl + exc_type, exc_value, exc_traceback = sys.exc_info() + e = StructureFormatError(emsg) + raise e.with_traceback(exc_traceback) + + return stru + + def to_lines(self, stru): + """Convert Structure *stru* to a list of lines in VESTA format. + + Produces a ``.vesta`` file readable by VESTA 3.x and later, + containing ``STRUC``, ``ATOMT``, and ``COORD`` sections derived + from the structure's lattice and atomic data. + + Parameters + ---------- + stru : Structure + Structure to be converted. + + Returns + ------- + list of str + Lines of a VESTA format file. + + Raises + ------ + StructureFormatError + Cannot convert empty structure to VESTA format. + """ + if len(stru) == 0: + emsg = "cannot convert empty structure to VESTA format" + raise StructureFormatError(emsg) + + lines = [] + lines.append("#VESTA_FORMAT_VERSION 3.5.0") + lines.append("") + lines.append("CRYSTAL") + lines.append("") + lines.append("TITLE") + title = getattr(stru, "title", "") or "Structure" + lines.append(title) + lines.append("") + latt = stru.lattice + a, b, c, alpha, beta, gamma = latt.cell_parms() + lines.append("STRUC") + # Line 1: a b c alpha beta gamma space_group_number + lines.append(" %.8g %.8g %.8g %.8g %.8g %.8g 1" % (a, b, c, alpha, beta, gamma)) + # Line 2: origin shift (0 0 0) followed by space-group symbol placeholder + lines.append(" 0.000000 0.000000 0.000000") + lines.append("") + element_order = [] + seen = set() + for a_obj in stru: + el = a_obj.element + if el not in seen: + seen.add(el) + element_order.append(el) + type_index = {el: i + 1 for i, el in enumerate(element_order)} + lines.append("ATOMT") + for el in element_order: + idx = type_index[el] + mass = AtomicMass.get(el, 0.0) + lines.append(" %d %s %.4f 1.0000 1.0000 1.0000 204 # mass=%.7g" % (idx, el, 0.5, mass)) + lines.append("") + lines.append("COORD") + for seq, a_obj in enumerate(stru, start=1): + el = a_obj.element + tidx = type_index[el] + x, y, z = a_obj.xyz + occ = getattr(a_obj, "occupancy", 1.0) + # Isotropic displacement parameter (Uiso), defaulting to 0. + uiso = _get_uiso(a_obj) + lines.append(" %d %d %.8g %.8g %.8g %.4f %.4f" % (seq, tidx, x, y, z, occ, uiso)) + lines.append(" 0 0 0 0 0") + lines.append("") + lines.append("BOUND") + lines.append(" 0.0 1.0 0.0 1.0 0.0 1.0") + lines.append(" 0 0 0 0 0") + lines.append("") + lines.append("EOF") + return lines + + +# End of class P_vesta + + +# Routines ------------------------------------------------------------------- + + +def get_parser(): + """Return new parser object for VESTA format. + + Returns + ------- + P_vesta + Instance of :class:`P_vesta`. + """ + return P_vesta() + + +# Local Helpers -------------------------------------------------------------- + + +def _get_uiso(a): + """Return isotropic displacement parameter for atom *a*. + + Tries ``Uisoequiv`` first, then falls back to the mean of the + diagonal of the anisotropic U tensor, then to zero. + + Parameters + ---------- + a : Atom + Atom instance. + + Returns + ------- + float + Isotropic U value in Ų. + """ + if hasattr(a, "Uisoequiv"): + return float(a.Uisoequiv) + try: + return float(numpy.trace(a.U) / 3.0) + except Exception: + return 0.0 + + +def _assign_auxiliaries(a, fields, auxiliaries, no_velocity): + """Assign auxiliary properties for an + :class:`~diffpy.structure.Atom` object. + + Retained from the original AtomEye/XCFG parser for backward + compatibility with code that calls this helper directly. + + Parameters + ---------- + a : Atom + The Atom instance for which auxiliary properties need to be set. + fields : list + Floating-point values for the current row of the processed file. + auxiliaries : dict + Dictionary of zero-based indices and names of auxiliary properties. + no_velocity : bool + When ``False``, set atom velocity ``a.v`` to ``fields[3:6]``. + Use ``fields[3:6]`` for auxiliary values otherwise. + """ + if not no_velocity: + a.v = numpy.asarray(fields[3:6], dtype=float) + auxfirst = 3 if no_velocity else 6 + for i, prop in auxiliaries.items(): + value = fields[auxfirst + i] + if prop == "Uiso": + a.Uisoequiv = value + elif prop == "Biso": + a.Bisoequiv = value + elif prop[0] in "BU" and all(d in "123" for d in prop[1:]): + nm = prop if prop[1] <= prop[2] else prop[0] + prop[2] + prop[1] + a.anisotropy = True + setattr(a, nm, value) + else: + setattr(a, prop, value) + return diff --git a/src/diffpy/structure/parsers/p_xcfg.py b/src/diffpy/structure/parsers/p_xcfg.py index 7965767a..e94605c6 100644 --- a/src/diffpy/structure/parsers/p_xcfg.py +++ b/src/diffpy/structure/parsers/p_xcfg.py @@ -353,7 +353,7 @@ def to_lines(self, stru): lo_xyz = allxyz.min(axis=0) hi_xyz = allxyz.max(axis=0) max_range_xyz = (hi_xyz - lo_xyz).max() - if numpy.allclose(stru.lattice.abcABG(), (1, 1, 1, 90, 90, 90)): + if numpy.allclose(stru.lattice.cell_parms(), (1, 1, 1, 90, 90, 90)): max_range_xyz += self.cluster_boundary # range of CFG coordinates must be less than 1 p_A = numpy.ceil(max_range_xyz + 1.0e-13)