Skip to content

Commit 68695b0

Browse files
authored
Merge pull request #188 from stevenhua0320/vesta_view
feat: add vesta viewer
2 parents 6086938 + 52748fb commit 68695b0

File tree

5 files changed

+764
-1
lines changed

5 files changed

+764
-1
lines changed

.codespell/ignore_words.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ CONECT
2222
;; /src/diffpy/structure/parsers/p_xcfg.py:452
2323
;; used in a function
2424
BU
25+
26+
;; /src/diffpy/structure/parsers/p_vesta.py:452
27+
;; abbreviation for Structure in vesta
28+
STRUC

news/vesta_view.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
**Added:**
2+
3+
* Added parser for vesta specific files and viewer for vesta
4+
5+
**Changed:**
6+
7+
* <news item>
8+
9+
**Deprecated:**
10+
11+
* <news item>
12+
13+
**Removed:**
14+
15+
* <news item>
16+
17+
**Fixed:**
18+
19+
* <news item>
20+
21+
**Security:**
22+
23+
* <news item>
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
#!/usr/bin/env python
2+
##############################################################################
3+
#
4+
# diffpy.structure by DANSE Diffraction group
5+
# Simon J. L. Billinge
6+
# (c) 2026 University of California, Santa Barbara.
7+
# All rights reserved.
8+
#
9+
# File coded by: Simon J. L. Billinge, Rundong Hua
10+
#
11+
# See AUTHORS.txt for a list of people who contributed.
12+
# See LICENSE_DANSE.txt for license information.
13+
#
14+
##############################################################################
15+
"""View structure file in VESTA.
16+
17+
Usage: ``vestaview [options] strufile``
18+
19+
Vestaview understands more `Structure` formats than VESTA. It converts
20+
`strufile` to a temporary VESTA or CIF file which is opened in VESTA.
21+
See supported file formats: ``inputFormats``
22+
23+
Options:
24+
-f, --formula
25+
Override chemical formula in `strufile`. The formula defines
26+
elements in the same order as in `strufile`, e.g., ``Na4Cl4``.
27+
28+
-w, --watch
29+
Watch input file for changes.
30+
31+
--viewer=VIEWER
32+
The structure viewer program, by default "vesta".
33+
The program will be executed as "VIEWER structurefile".
34+
35+
--formats=FORMATS
36+
Comma-separated list of file formats that are understood
37+
by the VIEWER, by default ``"vesta,cif"``. Files of other
38+
formats will be converted to the first listed format.
39+
40+
-h, --help
41+
Display this message and exit.
42+
43+
-V, --version
44+
Show script version and exit.
45+
46+
Notes
47+
-----
48+
VESTA is the actively maintained successor to AtomEye. Unlike AtomEye,
49+
VESTA natively reads CIF, its own ``.vesta`` format, and several other
50+
crystallographic file types, so format conversion is only required for
51+
formats not in that set.
52+
53+
AtomEye XCFG format is no longer a default target format but the XCFG
54+
parser (``P_xcfg``) remains available in ``diffpy.structure.parsers``
55+
for backward compatibility.
56+
"""
57+
58+
import os
59+
import re
60+
import signal
61+
import sys
62+
from pathlib import Path
63+
64+
from diffpy.structure.structureerrors import StructureFormatError
65+
66+
pd = {
67+
"formula": None,
68+
"watch": False,
69+
"viewer": "vesta",
70+
"formats": ["vesta", "cif"],
71+
}
72+
73+
74+
def usage(style=None):
75+
"""Show usage info. for ``style=="brief"`` show only first 2 lines.
76+
77+
Parameters
78+
----------
79+
style : str, optional
80+
The usage display style.
81+
"""
82+
myname = Path(sys.argv[0]).name
83+
msg = __doc__.replace("vestaview", myname)
84+
if style == "brief":
85+
msg = f"{msg.splitlines()[1]}\n" f"Try `{myname} --help' for more information."
86+
else:
87+
from diffpy.structure.parsers import input_formats
88+
89+
fmts = [fmt for fmt in input_formats() if fmt != "auto"]
90+
msg = msg.replace("inputFormats", " ".join(fmts))
91+
print(msg)
92+
93+
94+
def version():
95+
"""Print the script version."""
96+
from diffpy.structure import __version__
97+
98+
print(f"vestaview {__version__}")
99+
100+
101+
def load_structure_file(filename, format="auto"):
102+
"""Load structure from the specified file.
103+
104+
Parameters
105+
----------
106+
filename : str or Path
107+
The path to the structure file.
108+
format : str, optional
109+
The file format, by default ``"auto"``.
110+
111+
Returns
112+
-------
113+
tuple
114+
The loaded ``(Structure, fileformat)`` pair.
115+
"""
116+
from diffpy.structure import Structure
117+
118+
stru = Structure()
119+
parser = stru.read(str(filename), format)
120+
return stru, parser.format
121+
122+
123+
def convert_structure_file(pd):
124+
"""Convert ``strufile`` to a temporary file understood by the
125+
viewer.
126+
127+
On the first call, a temporary directory is created and stored in
128+
``pd``. Subsequent calls in watch mode reuse the directory.
129+
130+
The VESTA viewer natively reads ``.vesta`` and ``.cif`` files, so if
131+
the source is already in one of the formats listed in
132+
``pd["formats"]`` and no formula override is requested, the file is
133+
copied unchanged. Otherwise the structure is loaded and re-written in
134+
the first format listed in ``pd["formats"]``.
135+
136+
Parameters
137+
----------
138+
pd : dict
139+
The parameter dictionary containing at minimum ``"strufile"``
140+
and ``"formats"`` keys. It is modified in place to add
141+
``"tmpdir"`` and ``"tmpfile"`` on the first call.
142+
"""
143+
if "tmpdir" not in pd:
144+
from tempfile import mkdtemp
145+
146+
pd["tmpdir"] = Path(mkdtemp())
147+
strufile = Path(pd["strufile"])
148+
tmpfile = pd["tmpdir"] / strufile.name
149+
tmpfile_tmp = Path(f"{tmpfile}.tmp")
150+
pd["tmpfile"] = tmpfile
151+
stru = None
152+
fmt = pd.get("fmt", "auto")
153+
if fmt == "auto":
154+
stru, fmt = load_structure_file(strufile)
155+
pd["fmt"] = fmt
156+
if fmt in pd["formats"] and pd["formula"] is None:
157+
import shutil
158+
159+
shutil.copyfile(strufile, tmpfile_tmp)
160+
tmpfile_tmp.replace(tmpfile)
161+
return
162+
if stru is None:
163+
stru = load_structure_file(strufile, fmt)[0]
164+
if pd["formula"]:
165+
formula = pd["formula"]
166+
if len(formula) != len(stru):
167+
emsg = f"Formula has {len(formula)} atoms while structure has " f"{len(stru)}"
168+
raise RuntimeError(emsg)
169+
for atom, element in zip(stru, formula):
170+
atom.element = element
171+
elif fmt == "rawxyz":
172+
for atom in stru:
173+
if atom.element == "":
174+
atom.element = "C"
175+
stru.write(str(tmpfile_tmp), pd["formats"][0])
176+
tmpfile_tmp.replace(tmpfile)
177+
178+
179+
def watch_structure_file(pd):
180+
"""Watch ``strufile`` for modifications and reconvert when changed.
181+
182+
Polls the modification timestamps of ``pd["strufile"]`` and
183+
``pd["tmpfile"]`` once per second. When the source is newer, the
184+
file is reconverted via :func:`convert_structure_file`.
185+
186+
Parameters
187+
----------
188+
pd : dict
189+
The parameter dictionary as used by
190+
:func:`convert_structure_file`.
191+
"""
192+
from time import sleep
193+
194+
strufile = Path(pd["strufile"])
195+
tmpfile = Path(pd["tmpfile"])
196+
while pd["watch"]:
197+
if tmpfile.stat().st_mtime < strufile.stat().st_mtime:
198+
convert_structure_file(pd)
199+
sleep(1)
200+
201+
202+
def clean_up(pd):
203+
"""Remove temporary file and directory created during conversion.
204+
205+
Parameters
206+
----------
207+
pd : dict
208+
The parameter dictionary that may contain ``"tmpfile"`` and
209+
``"tmpdir"`` entries to be removed.
210+
"""
211+
tmpfile = pd.pop("tmpfile", None)
212+
if tmpfile is not None and Path(tmpfile).exists():
213+
Path(tmpfile).unlink()
214+
tmpdir = pd.pop("tmpdir", None)
215+
if tmpdir is not None and Path(tmpdir).exists():
216+
Path(tmpdir).rmdir()
217+
218+
219+
def parse_formula(formula):
220+
"""Parse chemical formula and return a list of elements.
221+
222+
Parameters
223+
----------
224+
formula : str
225+
The chemical formula string such as ``"Na4Cl4"`` or ``"H2O"``.
226+
227+
Returns
228+
-------
229+
list of str
230+
The ordered list of element symbols with repetition matching the
231+
formula.
232+
233+
Raises
234+
------
235+
RuntimeError
236+
Raised when ``formula`` does not start with an uppercase letter
237+
or contains a non-integer count.
238+
"""
239+
formula = re.sub(r"\s", "", formula)
240+
if not re.match(r"^[A-Z]", formula):
241+
raise RuntimeError(f"InvalidFormula '{formula}'")
242+
243+
elcnt = re.split(r"([A-Z][a-z]?)", formula)[1:]
244+
ellst = []
245+
try:
246+
for i in range(0, len(elcnt), 2):
247+
element = elcnt[i]
248+
count = int(elcnt[i + 1]) if elcnt[i + 1] else 1
249+
ellst.extend([element] * count)
250+
except ValueError:
251+
emsg = f"Invalid formula, {elcnt[i + 1]!r} is not valid count"
252+
raise RuntimeError(emsg)
253+
return ellst
254+
255+
256+
def die(exit_status=0, pd=None):
257+
"""Clean up temporary files and exit with ``exit_status``.
258+
259+
Parameters
260+
----------
261+
exit_status : int, optional
262+
The exit code passed to :func:`sys.exit`, by default 0.
263+
pd : dict, optional
264+
The parameter dictionary forwarded to :func:`clean_up`.
265+
"""
266+
clean_up({} if pd is None else pd)
267+
sys.exit(exit_status)
268+
269+
270+
def signal_handler(signum, stackframe):
271+
"""Handle OS signals by reverting to the default handler and
272+
exiting.
273+
274+
On ``SIGCHLD`` the child exit status is harvested via
275+
:func:`os.wait`; on all other signals :func:`die` is called with
276+
exit status 1.
277+
278+
Parameters
279+
----------
280+
signum : int
281+
The signal number.
282+
stackframe : frame
283+
The current stack frame. Unused.
284+
"""
285+
del stackframe
286+
signal.signal(signum, signal.SIG_DFL)
287+
if signum == signal.SIGCHLD:
288+
_, exit_status = os.wait()
289+
exit_status = (exit_status >> 8) + (exit_status & 0x00FF)
290+
die(exit_status, pd)
291+
else:
292+
die(1, pd)
293+
294+
295+
def main():
296+
"""Entry point for the ``vestaview`` command-line tool."""
297+
import getopt
298+
299+
pd["watch"] = False
300+
try:
301+
opts, args = getopt.getopt(
302+
sys.argv[1:],
303+
"f:whV",
304+
["formula=", "watch", "viewer=", "formats=", "help", "version"],
305+
)
306+
except getopt.GetoptError as errmsg:
307+
print(errmsg, file=sys.stderr)
308+
die(2)
309+
310+
for option, argument in opts:
311+
if option in ("-f", "--formula"):
312+
try:
313+
pd["formula"] = parse_formula(argument)
314+
except RuntimeError as err:
315+
print(err, file=sys.stderr)
316+
die(2)
317+
elif option in ("-w", "--watch"):
318+
pd["watch"] = True
319+
elif option == "--viewer":
320+
pd["viewer"] = argument
321+
elif option == "--formats":
322+
pd["formats"] = [word.strip() for word in argument.split(",")]
323+
elif option in ("-h", "--help"):
324+
usage()
325+
die()
326+
elif option in ("-V", "--version"):
327+
version()
328+
die()
329+
if len(args) < 1:
330+
usage("brief")
331+
die()
332+
if len(args) > 1:
333+
print("too many structure files", file=sys.stderr)
334+
die(2)
335+
pd["strufile"] = Path(args[0])
336+
signal.signal(signal.SIGHUP, signal_handler)
337+
signal.signal(signal.SIGQUIT, signal_handler)
338+
signal.signal(signal.SIGSEGV, signal_handler)
339+
signal.signal(signal.SIGTERM, signal_handler)
340+
signal.signal(signal.SIGINT, signal_handler)
341+
env = os.environ.copy()
342+
try:
343+
convert_structure_file(pd)
344+
spawnargs = (
345+
pd["viewer"],
346+
pd["viewer"],
347+
str(pd["tmpfile"]),
348+
env,
349+
)
350+
if pd["watch"]:
351+
signal.signal(signal.SIGCHLD, signal_handler)
352+
os.spawnlpe(os.P_NOWAIT, *spawnargs)
353+
watch_structure_file(pd)
354+
else:
355+
status = os.spawnlpe(os.P_WAIT, *spawnargs)
356+
die(status, pd)
357+
except IOError as err:
358+
print(f"{args[0]}: {err.strerror}", file=sys.stderr)
359+
die(1, pd)
360+
except StructureFormatError as err:
361+
print(f"{args[0]}: {err}", file=sys.stderr)
362+
die(1, pd)
363+
364+
365+
if __name__ == "__main__":
366+
main()

0 commit comments

Comments
 (0)