quantum-espresso-parser-midb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qe2midb/__init__.py +6 -0
- qe2midb/__main__.py +3 -0
- qe2midb/kpath.py +99 -0
- qe2midb/normalize.py +477 -0
- qe2midb/schemas/qes_030920.xsd +1033 -0
- qe2midb/schemas/qes_181201.xsd +1000 -0
- qe2midb/schemas/qes_190207.xsd +1002 -0
- qe2midb/schemas/qes_190304.xsd +1002 -0
- qe2midb/schemas/qes_190719.xsd +1003 -0
- qe2midb/schemas/qes_191206.xsd +1003 -0
- qe2midb/schemas/qes_200420.xsd +1027 -0
- qe2midb/schemas/qes_210716.xsd +1033 -0
- qe2midb/schemas/qes_211101.xsd +1201 -0
- qe2midb/schemas/qes_220601.xsd +1345 -0
- qe2midb/schemas/qes_220623.xsd +1345 -0
- qe2midb/schemas/qes_221021.xsd +1360 -0
- qe2midb/schemas/qes_230310.xsd +1367 -0
- qe2midb/schemas/qes_240411.xsd +1391 -0
- qe2midb/schemas/qes_241104.xsd +1405 -0
- qe2midb/schemas/qes_250412.xsd +1415 -0
- qe2midb/schemas/qes_250521.xsd +1441 -0
- qe2midb/schemas/qes_260128.xsd +1455 -0
- qe2midb/schemas/qes_dev_260226.xsd +1457 -0
- qe2midb/textread.py +371 -0
- qe2midb/xmlread.py +286 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/METADATA +146 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/RECORD +32 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/WHEEL +5 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/entry_points.txt +2 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/licenses/LICENSE +674 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/licenses/tests/fixtures/qe2midb/aiida/LICENSE.txt +29 -0
- quantum_espresso_parser_midb-0.1.0.dist-info/top_level.txt +1 -0
qe2midb/__init__.py
ADDED
qe2midb/__main__.py
ADDED
qe2midb/kpath.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Band-structure path from the k-points themselves (no ASE BandPath file needed).
|
|
2
|
+
|
|
3
|
+
Given k-points in crystal coordinates and the cell, builds the linear axis (1/Å, 2π
|
|
4
|
+
included), detects discontinuities (a step much larger than the typical step; the
|
|
5
|
+
convention of the normalized data is a repeated x; local rule against both neighbouring
|
|
6
|
+
steps), finds the path vertices (direction
|
|
7
|
+
changes) and labels them:
|
|
8
|
+
1. labels given explicitly (K_POINTS crystal_b comments, matdyn input), else
|
|
9
|
+
2. high-symmetry points of the lattice from ASE (if installed), else pymatgen, else
|
|
10
|
+
3. no label ('').
|
|
11
|
+
"""
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
GAMMA_NAMES = {"G", "GAMMA", "\\GAMMA", "Γ", "GM"}
|
|
15
|
+
JUMP_FACTOR = 4.0 # a step longer than JUMP_FACTOR x both neighbouring steps is a discontinuity
|
|
16
|
+
ANGLE_TOL = 1e-3 # radians; direction change that defines a vertex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reciprocal(cell):
|
|
20
|
+
"""Reciprocal vectors (rows, 1/Å, with 2π) of a cell given as rows in Å."""
|
|
21
|
+
return 2 * np.pi * np.linalg.inv(np.asarray(cell, dtype=float)).T
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pretty(label):
|
|
25
|
+
if label is None:
|
|
26
|
+
return ""
|
|
27
|
+
return "Γ" if label.strip().upper() in GAMMA_NAMES else label.strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def special_points(cell):
|
|
31
|
+
"""{label: fractional coords} of the high-symmetry points of the lattice, or {}."""
|
|
32
|
+
try:
|
|
33
|
+
from ase.cell import Cell
|
|
34
|
+
return {k: np.asarray(v, dtype=float) for k, v in Cell(np.asarray(cell)).bandpath(npoints=0).special_points.items()}
|
|
35
|
+
except Exception: # noqa: BLE001 - optional dependency or unusual cell
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
from pymatgen.core import Lattice, Structure
|
|
39
|
+
from pymatgen.symmetry.bandstructure import HighSymmKpath
|
|
40
|
+
st = Structure(Lattice(cell), ["H"], [[0, 0, 0]])
|
|
41
|
+
kp = HighSymmKpath(st, path_type="setyawan_curtarolo")
|
|
42
|
+
if np.allclose(kp.prim.lattice.matrix, st.lattice.matrix, atol=1e-6):
|
|
43
|
+
return {k: np.asarray(v, dtype=float) for k, v in kp.kpath["kpoints"].items()}
|
|
44
|
+
except Exception: # noqa: BLE001
|
|
45
|
+
pass
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _match(k, points, tol=1e-4):
|
|
50
|
+
for label, p in points.items():
|
|
51
|
+
d = np.asarray(k) - p
|
|
52
|
+
if np.all(np.abs(d - np.round(d)) < tol):
|
|
53
|
+
return label
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build(kfrac, cell, labels=None):
|
|
58
|
+
"""Linear axis, ticks and path string of a band/phonon path.
|
|
59
|
+
|
|
60
|
+
kfrac: [n][3] crystal coordinates; labels: optional {label: frac coords} with priority
|
|
61
|
+
over the lattice special points. Returns {x, ticks, path, kpoints_frac, n_labeled}."""
|
|
62
|
+
kfrac = np.asarray(kfrac, dtype=float)
|
|
63
|
+
kc = kfrac @ reciprocal(cell)
|
|
64
|
+
steps = np.linalg.norm(np.diff(kc, axis=0), axis=1)
|
|
65
|
+
# a discontinuity is a step much longer than BOTH neighbouring steps (local rule: paths
|
|
66
|
+
# with a fixed number of points per segment have different step lengths per segment)
|
|
67
|
+
jump = np.zeros_like(steps, dtype=bool)
|
|
68
|
+
for i, st in enumerate(steps):
|
|
69
|
+
neigh = [steps[j] for j in (i - 1, i + 1) if 0 <= j < len(steps) and steps[j] > 1e-9]
|
|
70
|
+
jump[i] = bool(neigh) and st > JUMP_FACTOR * max(neigh)
|
|
71
|
+
x = np.concatenate([[0.0], np.cumsum(np.where(jump, 0.0, steps))])
|
|
72
|
+
|
|
73
|
+
vertices = {0, len(kfrac) - 1}
|
|
74
|
+
for j in np.where(jump)[0]:
|
|
75
|
+
vertices.update((int(j), int(j) + 1))
|
|
76
|
+
for i in range(1, len(kfrac) - 1):
|
|
77
|
+
if jump[i - 1] or jump[i]:
|
|
78
|
+
continue
|
|
79
|
+
v1, v2 = kc[i] - kc[i - 1], kc[i + 1] - kc[i]
|
|
80
|
+
n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2)
|
|
81
|
+
if n1 < 1e-9 or n2 < 1e-9:
|
|
82
|
+
continue
|
|
83
|
+
if np.arccos(np.clip(np.dot(v1, v2) / (n1 * n2), -1, 1)) > ANGLE_TOL:
|
|
84
|
+
vertices.add(i)
|
|
85
|
+
|
|
86
|
+
points = {}
|
|
87
|
+
if labels:
|
|
88
|
+
points.update({k: np.asarray(v, dtype=float) for k, v in labels.items() if k})
|
|
89
|
+
lattice_points = special_points(cell)
|
|
90
|
+
ticks, path, n_labeled = [], "", 0
|
|
91
|
+
for i in sorted(vertices):
|
|
92
|
+
label = _match(kfrac[i], points) or _match(kfrac[i], lattice_points)
|
|
93
|
+
n_labeled += label is not None
|
|
94
|
+
ticks.append({"x": round(float(x[i]), 6), "label": pretty(label)})
|
|
95
|
+
if i > 0 and jump[i - 1]:
|
|
96
|
+
path += ","
|
|
97
|
+
path += "G" if pretty(label) == "Γ" else (label or "?")
|
|
98
|
+
return {"x": x, "ticks": ticks, "path": path, "kpoints_frac": kfrac, "n_labeled": n_labeled,
|
|
99
|
+
"n_vertices": len(vertices)}
|
qe2midb/normalize.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Build the normalized MIDb document (midb_qe.json) from a directory of QE outputs.
|
|
2
|
+
|
|
3
|
+
Files are recognized by content, not by name or folder layout: pw.x XML files are
|
|
4
|
+
classified by their <calculation>; dos.x, projwfc.x, ph.x, q2r.x, matdyn.x and dynmat.x
|
|
5
|
+
files by their headers or input namelists. Nothing comes from outside the directory.
|
|
6
|
+
|
|
7
|
+
python -m qe2midb.normalize DIR [-o midb_qe.json] [--id NAME] [--kind crystal|molecule]
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from qe2midb import kpath as KP
|
|
19
|
+
from qe2midb import textread as T
|
|
20
|
+
from qe2midb.xmlread import BOHR_ANG, PwXml, QEXMLError, sniff
|
|
21
|
+
|
|
22
|
+
FORMAT_VERSION = "1.0"
|
|
23
|
+
CM1_TO_THZ = 0.0299792458
|
|
24
|
+
MAX_TEXT_BYTES = 200 * 2**20
|
|
25
|
+
SKIP_DIR_SUFFIXES = (".save",)
|
|
26
|
+
VACUUM_MOLECULE = 5.0 # Å of vacuum in every direction -> molecule (with Gamma-only k)
|
|
27
|
+
PDOS_PER_ATOM_MAX = 20 # "auto": per-atom PDOS entries only up to this many atoms
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _r(a, nd=5):
|
|
31
|
+
return np.round(np.asarray(a, dtype=float), nd).tolist()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ====================================================================== discovery
|
|
35
|
+
class Discovery:
|
|
36
|
+
"""Inventory of the QE files of a directory, classified by content."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, directory, recursive=True):
|
|
39
|
+
self.directory = os.path.abspath(directory)
|
|
40
|
+
self.xml = defaultdict(list) # calculation -> [PwXml]
|
|
41
|
+
self.ignored_xml = [] # (path, reason)
|
|
42
|
+
self.text = defaultdict(list) # kind -> [path]
|
|
43
|
+
self.inputs = defaultdict(list) # program -> [path]
|
|
44
|
+
self.pdos_prefixes = []
|
|
45
|
+
self.programs = {} # path -> (PROGRAM, version)
|
|
46
|
+
self.warnings = []
|
|
47
|
+
self._scan(recursive)
|
|
48
|
+
|
|
49
|
+
def _files(self, recursive):
|
|
50
|
+
for root, dirs, files in os.walk(self.directory):
|
|
51
|
+
if root.endswith(SKIP_DIR_SUFFIXES): # <prefix>.save: only its XML copy
|
|
52
|
+
dirs[:] = []
|
|
53
|
+
files = [f for f in files if f == "data-file-schema.xml"]
|
|
54
|
+
elif not recursive:
|
|
55
|
+
dirs[:] = [d for d in dirs if d.endswith(SKIP_DIR_SUFFIXES)]
|
|
56
|
+
for f in files:
|
|
57
|
+
yield os.path.join(root, f)
|
|
58
|
+
|
|
59
|
+
def _scan(self, recursive):
|
|
60
|
+
seen_xml = {}
|
|
61
|
+
for p in sorted(self._files(recursive)):
|
|
62
|
+
name = os.path.basename(p)
|
|
63
|
+
try:
|
|
64
|
+
size = os.path.getsize(p)
|
|
65
|
+
except OSError:
|
|
66
|
+
continue
|
|
67
|
+
if name.endswith(".xml"):
|
|
68
|
+
ok, _, _ = sniff(p)
|
|
69
|
+
if not ok:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
doc = PwXml(p)
|
|
73
|
+
except QEXMLError as error:
|
|
74
|
+
self.ignored_xml.append((p, str(error)))
|
|
75
|
+
continue
|
|
76
|
+
if doc.calculation is None:
|
|
77
|
+
self.ignored_xml.append((p, "no <input> section (rewritten by hp.x or similar)"))
|
|
78
|
+
continue
|
|
79
|
+
key = (doc.calculation, round(doc.summary()["etot_eV"], 6),
|
|
80
|
+
len(doc.output["band_structure"].get("ks_energies") or []))
|
|
81
|
+
if key in seen_xml: # <prefix>.xml and data-file-schema.xml copies
|
|
82
|
+
continue
|
|
83
|
+
seen_xml[key] = p
|
|
84
|
+
self.xml[doc.calculation].append(doc)
|
|
85
|
+
continue
|
|
86
|
+
if size > MAX_TEXT_BYTES or "pdos_atm#" in name:
|
|
87
|
+
continue
|
|
88
|
+
if name.endswith(".pdos_tot"):
|
|
89
|
+
self.pdos_prefixes.append(p[: -len(".pdos_tot")])
|
|
90
|
+
continue
|
|
91
|
+
h = T.head(p, 3000)
|
|
92
|
+
if "\x00" in h:
|
|
93
|
+
continue
|
|
94
|
+
prog, ver = T.program_version(p)
|
|
95
|
+
if prog:
|
|
96
|
+
self.programs[p] = (prog, ver)
|
|
97
|
+
self.text["stdout:" + prog].append(p)
|
|
98
|
+
continue
|
|
99
|
+
kind = T.qe_input_kind(p)
|
|
100
|
+
if kind:
|
|
101
|
+
self.inputs[kind].append(p)
|
|
102
|
+
elif T.is_dos(p):
|
|
103
|
+
self.text["dos"].append(p)
|
|
104
|
+
elif T.is_dyn(p):
|
|
105
|
+
self.text["dyn"].append(p)
|
|
106
|
+
elif T.is_matdyn_freq(p):
|
|
107
|
+
self.text["matdyn_freq"].append(p)
|
|
108
|
+
elif T.is_phdos(p):
|
|
109
|
+
self.text["phdos"].append(p)
|
|
110
|
+
elif T.is_modes_file(p):
|
|
111
|
+
self.text["modes"].append(p)
|
|
112
|
+
elif name.endswith("dyn0") and T.dyn0_grid(p):
|
|
113
|
+
self.text["dyn0"].append(p)
|
|
114
|
+
|
|
115
|
+
# -------------------------------------------------------------- choices
|
|
116
|
+
@staticmethod
|
|
117
|
+
def latest(items, key=os.path.getmtime):
|
|
118
|
+
return max(items, key=key) if items else None
|
|
119
|
+
|
|
120
|
+
def pw(self, calculation):
|
|
121
|
+
docs = self.xml.get(calculation, [])
|
|
122
|
+
return max(docs, key=lambda d: os.path.getmtime(d.path)) if docs else None
|
|
123
|
+
|
|
124
|
+
def report(self):
|
|
125
|
+
return {"pw_xml": {k: [os.path.relpath(d.path, self.directory) for d in v] for k, v in self.xml.items()},
|
|
126
|
+
"ignored_xml": [(os.path.relpath(p, self.directory), r) for p, r in self.ignored_xml],
|
|
127
|
+
"text": {k: [os.path.relpath(p, self.directory) for p in v] for k, v in self.text.items()},
|
|
128
|
+
"inputs": {k: [os.path.relpath(p, self.directory) for p in v] for k, v in self.inputs.items()},
|
|
129
|
+
"pdos_prefixes": [os.path.relpath(p, self.directory) for p in self.pdos_prefixes],
|
|
130
|
+
"warnings": self.warnings}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ====================================================================== helpers
|
|
134
|
+
def hill_formula(elements):
|
|
135
|
+
counts = defaultdict(int)
|
|
136
|
+
for e in elements:
|
|
137
|
+
counts[e] += 1
|
|
138
|
+
order = (["C", "H"] + sorted(k for k in counts if k not in ("C", "H"))) if "C" in counts else sorted(counts)
|
|
139
|
+
return "".join(f"{e}{counts[e] if counts[e] > 1 else ''}" for e in order if e in counts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def vacuum_widths(cell, positions):
|
|
143
|
+
"""Empty space (Å) along each lattice direction, perpendicular to the other two vectors."""
|
|
144
|
+
cell = np.asarray(cell, dtype=float)
|
|
145
|
+
out = []
|
|
146
|
+
for i in range(3):
|
|
147
|
+
n = np.cross(cell[(i + 1) % 3], cell[(i + 2) % 3])
|
|
148
|
+
n /= np.linalg.norm(n)
|
|
149
|
+
width = abs(np.dot(cell[i], n))
|
|
150
|
+
proj = np.sort((np.asarray(positions) @ n) % width)
|
|
151
|
+
gaps = np.diff(np.concatenate([proj, [proj[0] + width]]))
|
|
152
|
+
out.append(float(gaps.max()))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def guess_kind(method, cell, positions):
|
|
157
|
+
k = method.get("kgrid")
|
|
158
|
+
gamma = method.get("gamma_only") or k is None or list(k) == [1, 1, 1]
|
|
159
|
+
vac = vacuum_widths(cell, positions)
|
|
160
|
+
return ("molecule" if gamma and min(vac) > VACUUM_MOLECULE else "crystal"), vac
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def band_edges(eig, nelec, noncolin=False):
|
|
164
|
+
"""VBM/CBM (eV) for integer filling without LSDA; None if metallic."""
|
|
165
|
+
per_band = 1 if noncolin else 2
|
|
166
|
+
nocc = int(round(nelec / per_band))
|
|
167
|
+
if eig.shape[0] != 1 or abs(nelec / per_band - nocc) > 1e-6 or eig.shape[2] <= nocc:
|
|
168
|
+
return None
|
|
169
|
+
vbm, cbm = float(eig[0, :, nocc - 1].max()), float(eig[0, :, nocc].min())
|
|
170
|
+
return (vbm, cbm) if cbm > vbm + 1e-3 else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_linear(positions):
|
|
174
|
+
if len(positions) <= 2:
|
|
175
|
+
return True
|
|
176
|
+
p = np.asarray(positions) - np.mean(positions, axis=0)
|
|
177
|
+
return np.linalg.svd(p, compute_uv=False)[1] < 1e-3
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def spacegroup(cell, frac, elements):
|
|
181
|
+
try:
|
|
182
|
+
import spglib
|
|
183
|
+
nums = {e: i + 1 for i, e in enumerate(sorted(set(elements)))}
|
|
184
|
+
ds = spglib.get_symmetry_dataset((cell, frac, [nums[e] for e in elements]), symprec=1e-3)
|
|
185
|
+
if ds is None:
|
|
186
|
+
return None
|
|
187
|
+
symbol = ds.international if hasattr(ds, "international") else ds["international"]
|
|
188
|
+
number = ds.number if hasattr(ds, "number") else ds["number"]
|
|
189
|
+
return f"{symbol} ({number})"
|
|
190
|
+
except Exception: # noqa: BLE001 - optional
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def frac_from_cart_alat(qcart, cell, alat_bohr):
|
|
195
|
+
"""q (cartesian, 2π/alat) -> crystal coordinates."""
|
|
196
|
+
return np.asarray(qcart) @ (np.asarray(cell) / (alat_bohr * BOHR_ANG)).T
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ====================================================================== normalize
|
|
200
|
+
def normalize(directory, system_id=None, kind=None, pseudo_library=None, recursive=True, pdos_per_atom="auto"):
|
|
201
|
+
"""pdos_per_atom: "auto" (per-atom entries up to PDOS_PER_ATOM_MAX atoms), "all" or "none"."""
|
|
202
|
+
disc = Discovery(directory, recursive)
|
|
203
|
+
warn = disc.warnings
|
|
204
|
+
base = disc.pw("scf") or disc.pw("vc-relax") or disc.pw("relax") or disc.pw("nscf") or disc.pw("bands")
|
|
205
|
+
if base is None:
|
|
206
|
+
why = "; ".join(f"{os.path.relpath(p, directory)}: {r}" for p, r in disc.ignored_xml[:3])
|
|
207
|
+
raise ValueError(f"no usable pw.x XML in {directory}" + (f" ({why})" if why else ""))
|
|
208
|
+
if disc.pw("scf") is None:
|
|
209
|
+
warn.append(f"no scf XML; structure and energies from the {base.calculation} run")
|
|
210
|
+
st, method, summ = base.structure(), base.method(), base.summary()
|
|
211
|
+
method_source = f"{base.calculation} xml"
|
|
212
|
+
if base.calculation in ("nscf", "bands"):
|
|
213
|
+
# nscf/bands XML store etot = 0 and their own k-points/occupations: take the
|
|
214
|
+
# energy from the scf stdout and the scf parameters from the scf input, if present
|
|
215
|
+
summ.pop("etot_eV", None)
|
|
216
|
+
energies = [e for e in (T.pw_stdout_scf_energy(p) for p, (prog, _) in disc.programs.items()
|
|
217
|
+
if prog == "PWSCF") if e is not None]
|
|
218
|
+
if len(energies) == 1:
|
|
219
|
+
summ["etot_eV"] = energies[0]
|
|
220
|
+
warn.append("total energy taken from the scf stdout (nscf/bands XML store 0)")
|
|
221
|
+
else:
|
|
222
|
+
warn.append("total energy unknown: no single converged scf stdout in the directory")
|
|
223
|
+
scf_inputs = [T.pw_input(p) for p in disc.inputs.get("pw", [])]
|
|
224
|
+
scf_inputs = [i for i in scf_inputs if i["calculation"] == "scf"]
|
|
225
|
+
if len(scf_inputs) == 1:
|
|
226
|
+
method.update(kgrid=scf_inputs[0]["kgrid"], occupations=scf_inputs[0]["occupations"],
|
|
227
|
+
smearing=scf_inputs[0].get("smearing"))
|
|
228
|
+
method_source = f"{base.calculation} xml + scf input"
|
|
229
|
+
else:
|
|
230
|
+
method.update(kgrid=None)
|
|
231
|
+
warn.append("scf k-grid unknown (no single scf input in the directory)")
|
|
232
|
+
cell, pos, elements = st["cell"], st["positions"], st["elements"]
|
|
233
|
+
frac = pos @ np.linalg.inv(cell)
|
|
234
|
+
kind_guess, vacuum = guess_kind(method, cell, pos)
|
|
235
|
+
kind_source = "given" if kind else "heuristic"
|
|
236
|
+
kind = kind or kind_guess
|
|
237
|
+
if kind != kind_guess:
|
|
238
|
+
warn.append(f"kind {kind!r} given, heuristic says {kind_guess!r} (vacuum {np.round(vacuum, 1).tolist()} Å)")
|
|
239
|
+
crystal = kind == "crystal"
|
|
240
|
+
smap = base.species_map()
|
|
241
|
+
|
|
242
|
+
# ---- programs and versions
|
|
243
|
+
versions = {}
|
|
244
|
+
for p, (prog, ver) in disc.programs.items():
|
|
245
|
+
versions.setdefault(prog, set()).add(ver)
|
|
246
|
+
versions.setdefault("PWSCF(xml)", set()).update(d.qe_version for v in disc.xml.values() for d in v)
|
|
247
|
+
versions = {k: sorted(v) for k, v in versions.items()}
|
|
248
|
+
if len({v for vs in versions.values() for v in vs}) > 1:
|
|
249
|
+
warn.append(f"more than one QE version in the directory: {versions}")
|
|
250
|
+
|
|
251
|
+
nscf = disc.pw("nscf")
|
|
252
|
+
# ---- phonon inputs/outputs
|
|
253
|
+
ph_in = disc.latest(disc.inputs.get("ph", []))
|
|
254
|
+
phin = T.ph_input(ph_in) if ph_in else None
|
|
255
|
+
dyn0 = [p for p in disc.text.get("dyn0", [])]
|
|
256
|
+
qgrid = (phin or {}).get("qgrid") or (T.dyn0_grid(dyn0[0]) if dyn0 else None)
|
|
257
|
+
|
|
258
|
+
pseudos = {smap.get(k, k) if list(smap.values()).count(smap.get(k, k)) == 1 else k: v
|
|
259
|
+
for k, v in method["pseudopotentials"].items()}
|
|
260
|
+
doc = {
|
|
261
|
+
"id": system_id or os.path.basename(os.path.abspath(directory)) or base.prefix,
|
|
262
|
+
"formula": hill_formula(elements), "kind": kind,
|
|
263
|
+
"method": {
|
|
264
|
+
"code": f"Quantum ESPRESSO {base.qe_version}", "qe_version": base.qe_version,
|
|
265
|
+
"xml_schema": base.schema_name, "xc": method["xc"],
|
|
266
|
+
"pseudopotentials": pseudos, "pseudo_library": pseudo_library,
|
|
267
|
+
"ecutwfc_Ry": method["ecutwfc_Ry"], "ecutrho_Ry": method["ecutrho_Ry"],
|
|
268
|
+
"nspin": method["nspin"], "noncolin": method["noncolin"], "spinorbit": method["spinorbit"],
|
|
269
|
+
"hubbard": method["hubbard"],
|
|
270
|
+
"kgrid_scf": method["kgrid"],
|
|
271
|
+
"kgrid_dos": (nscf.method()["kgrid"] if nscf is not None else None) if crystal else None,
|
|
272
|
+
"qgrid": qgrid if crystal else ([1, 1, 1] if (phin or disc.text.get("modes")) else None),
|
|
273
|
+
"smearing": method["smearing"],
|
|
274
|
+
"dos_method": None, "parameters_source": method_source,
|
|
275
|
+
},
|
|
276
|
+
"structure": {
|
|
277
|
+
"cell_angstrom": _r(cell, 6), "species": list(elements),
|
|
278
|
+
"positions_frac": _r(frac, 6), "positions_cart_angstrom": _r(pos, 6),
|
|
279
|
+
"spacegroup": spacegroup(cell, frac, elements) if crystal else None,
|
|
280
|
+
"volume_angstrom3": round(abs(float(np.linalg.det(cell))), 4),
|
|
281
|
+
"lattice_abc_angstrom": _r(np.linalg.norm(cell, axis=1), 5),
|
|
282
|
+
"angles_deg": _r([np.degrees(np.arccos(np.dot(cell[j], cell[k]) / np.linalg.norm(cell[j]) / np.linalg.norm(cell[k])))
|
|
283
|
+
for j, k in ((1, 2), (0, 2), (0, 1))], 4),
|
|
284
|
+
},
|
|
285
|
+
"energetics": dict(summ),
|
|
286
|
+
}
|
|
287
|
+
if any(l != e for l, e in zip(st["labels"], elements)):
|
|
288
|
+
doc["structure"]["species_labels"] = list(st["labels"])
|
|
289
|
+
en = doc["energetics"]
|
|
290
|
+
|
|
291
|
+
# ---- energy reference
|
|
292
|
+
ref, ref_name = en.get("efermi_eV"), "E_F"
|
|
293
|
+
if not crystal:
|
|
294
|
+
ref, ref_name = en.get("homo_eV", en.get("efermi_eV")), "HOMO"
|
|
295
|
+
if "lumo_eV" in en and "homo_eV" in en:
|
|
296
|
+
en["gap_eV"] = en["lumo_eV"] - en["homo_eV"]
|
|
297
|
+
else:
|
|
298
|
+
en["gap_eV"] = None
|
|
299
|
+
warn.append("molecule without empty bands (no LUMO): HOMO-LUMO gap unknown")
|
|
300
|
+
|
|
301
|
+
# ---- bands
|
|
302
|
+
bands = disc.pw("bands")
|
|
303
|
+
eig_b = None
|
|
304
|
+
if bands is not None and crystal:
|
|
305
|
+
eig_b, _, _ = bands.eigenvalues()
|
|
306
|
+
if crystal:
|
|
307
|
+
sources = [d for d in (nscf, bands) if d is not None] or [base]
|
|
308
|
+
edges = [band_edges(d.eigenvalues()[0], summ["nelec"], method["noncolin"]) for d in sources]
|
|
309
|
+
if all(edges):
|
|
310
|
+
en["vbm_eV"] = max(e[0] for e in edges)
|
|
311
|
+
en["cbm_eV"] = min(e[1] for e in edges)
|
|
312
|
+
en["gap_eV"] = en["cbm_eV"] - en["vbm_eV"]
|
|
313
|
+
ref, ref_name = en["vbm_eV"], "VBM"
|
|
314
|
+
elif method["occupations"] in ("fixed", None) and en.get("homo_eV") is not None:
|
|
315
|
+
# insulator computed without empty bands: VBM known, gap unknown
|
|
316
|
+
en["vbm_eV"] = en["homo_eV"]
|
|
317
|
+
en["gap_eV"] = None
|
|
318
|
+
ref, ref_name = en["vbm_eV"], "VBM"
|
|
319
|
+
warn.append("fixed occupations without empty bands: band gap unknown")
|
|
320
|
+
else:
|
|
321
|
+
en["gap_eV"] = 0.0
|
|
322
|
+
if ref is None:
|
|
323
|
+
raise ValueError("no reference energy (Fermi level / HOMO) in the XML")
|
|
324
|
+
if eig_b is not None:
|
|
325
|
+
labels = {}
|
|
326
|
+
for p in disc.inputs.get("pw", []):
|
|
327
|
+
if "bands" in T.head(p, 20000).lower():
|
|
328
|
+
labels.update({lab: k for k, lab in T.pw_kpoint_labels(p) if lab})
|
|
329
|
+
path = KP.build(bands.kpoints_crystal(), cell, labels=labels or None)
|
|
330
|
+
if path["n_labeled"] < path["n_vertices"]:
|
|
331
|
+
warn.append(f"band path: {path['n_vertices'] - path['n_labeled']} of {path['n_vertices']} vertices without label")
|
|
332
|
+
doc["bands"] = {"path": path["path"], "x": _r(path["x"], 6), "ticks": path["ticks"],
|
|
333
|
+
"kpoints_frac": _r(path["kpoints_frac"], 6), "energies_eV": _r(eig_b - ref, 4)}
|
|
334
|
+
doc["energy_reference"] = {"name": ref_name, "value_eV": ref}
|
|
335
|
+
|
|
336
|
+
# ---- DOS / PDOS
|
|
337
|
+
dos_files = disc.text.get("dos", [])
|
|
338
|
+
if dos_files:
|
|
339
|
+
dd = T.dos_file(disc.latest(dos_files))
|
|
340
|
+
doc["dos"] = {"energy_eV": _r(dd["energy"] - ref, 3), "total": _r(dd["total"], 5),
|
|
341
|
+
"integrated": _r(dd["integrated"], 4)}
|
|
342
|
+
if disc.pdos_prefixes:
|
|
343
|
+
prefix = disc.latest(disc.pdos_prefixes, key=lambda p: os.path.getmtime(p + ".pdos_tot"))
|
|
344
|
+
try:
|
|
345
|
+
pd = T.pdos_files(prefix, smap, noncolin=method["noncolin"])
|
|
346
|
+
proj = disc.latest(disc.text.get("stdout:PROJWFC", []))
|
|
347
|
+
keep_atoms = pdos_per_atom == "all" or (pdos_per_atom == "auto" and len(elements) <= PDOS_PER_ATOM_MAX)
|
|
348
|
+
if not keep_atoms:
|
|
349
|
+
warn.append(f"per-atom PDOS omitted ({len(elements)} atoms; species/orbital sums kept)")
|
|
350
|
+
doc["pdos"] = {
|
|
351
|
+
"energy_eV": _r(pd["energy"] - ref, 3),
|
|
352
|
+
"entries": [{**{k: v for k, v in e.items() if k not in ("values", "label")},
|
|
353
|
+
"values": _r(e["values"], 5)} for e in pd["entries"]] if keep_atoms else [],
|
|
354
|
+
"species_orbital": _sum_species(pd["entries"]),
|
|
355
|
+
"lowdin_spilling": T.lowdin_spilling(proj) if proj else None,
|
|
356
|
+
}
|
|
357
|
+
except ValueError as error:
|
|
358
|
+
warn.append(f"PDOS skipped: {error}")
|
|
359
|
+
if nscf is not None and nscf.method()["occupations"] in ("tetrahedra", "tetrahedra_opt", "tetrahedra_lin"):
|
|
360
|
+
doc["method"]["dos_method"] = nscf.method()["occupations"]
|
|
361
|
+
else:
|
|
362
|
+
for prog in ("dos", "projwfc"):
|
|
363
|
+
path = disc.latest(disc.inputs.get(prog, []))
|
|
364
|
+
nl = (T.namelist(open(path, errors="replace").read(), prog) or {}) if path else {}
|
|
365
|
+
if nl.get("degauss"):
|
|
366
|
+
doc["method"]["dos_method"] = f"gaussian {float(nl['degauss'].lower().replace('d', 'e')):g} Ry"
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
# ---- phonons
|
|
370
|
+
ph = _phonons(disc, doc, cell, pos, elements, base, crystal, warn)
|
|
371
|
+
if ph:
|
|
372
|
+
doc["phonons"] = ph
|
|
373
|
+
|
|
374
|
+
steps = sorted(set(disc.xml) | ({"ph"} if ph else set()) | ({"dos"} if "dos" in doc else set()))
|
|
375
|
+
doc["provenance"] = {
|
|
376
|
+
"generated": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
377
|
+
"generator": f"qe2midb {FORMAT_VERSION}", "source_dir": os.path.basename(os.path.abspath(directory)),
|
|
378
|
+
"steps_done": steps, "qe_version_by_program": versions,
|
|
379
|
+
"xml": {"file": os.path.relpath(base.path, directory), "schema": base.schema_name,
|
|
380
|
+
"declared_schema": base.declared_schema, "validation_errors": base.n_validation_errors},
|
|
381
|
+
"kind_source": kind_source, "vacuum_angstrom": _r(vacuum, 2),
|
|
382
|
+
"warnings": warn,
|
|
383
|
+
"energies_raw_note": "bands/dos/pdos referenciados a energy_reference; valores absolutos em energetics",
|
|
384
|
+
}
|
|
385
|
+
return doc, disc
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _sum_species(entries):
|
|
389
|
+
acc = {}
|
|
390
|
+
for e in entries:
|
|
391
|
+
k = (e["species"], e["orbital"])
|
|
392
|
+
acc[k] = acc[k] + e["values"] if k in acc else np.array(e["values"], dtype=float)
|
|
393
|
+
return [{"species": s, "orbital": o, "values": _r(v, 5)} for (s, o), v in sorted(acc.items())]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _phonons(disc, doc, cell, pos, elements, base, crystal, warn):
|
|
397
|
+
ph = {"unit": "cm-1"}
|
|
398
|
+
# Gamma modes: dynmat.x filout (single q block at Gamma)
|
|
399
|
+
gamma = None
|
|
400
|
+
for p in disc.text.get("modes", []):
|
|
401
|
+
blocks = T.modes_file(p)
|
|
402
|
+
if len(blocks) == 1 and np.allclose(blocks[0]["q"], 0.0, atol=1e-8):
|
|
403
|
+
gamma = blocks[0]["modes"]
|
|
404
|
+
break
|
|
405
|
+
dyn_gamma = next((p for p in disc.text.get("dyn", []) if T.is_gamma_dyn(p)), None)
|
|
406
|
+
eps, zs = T.epsilon_zstar(dyn_gamma) if dyn_gamma else (None, [])
|
|
407
|
+
if gamma:
|
|
408
|
+
ir = {}
|
|
409
|
+
if crystal and zs:
|
|
410
|
+
out = disc.latest(disc.text.get("stdout:DYNMAT", []))
|
|
411
|
+
ir = {r["mode"]: r["ir"] for r in T.dynmat_ir(out)} if out else {}
|
|
412
|
+
ph["gamma_modes"] = [{"freq_cm1": round(m["freq_cm1"], 3), "freq_THz": round(m["freq_THz"], 5),
|
|
413
|
+
"ir_intensity": ir.get(i + 1), "displacement": _r(m["eigvec"], 5)}
|
|
414
|
+
for i, m in enumerate(gamma)]
|
|
415
|
+
if crystal:
|
|
416
|
+
# bands.x writes the same '&plot nbnd=..' format: a phonon file has 3*nat branches
|
|
417
|
+
freq_files = [p for p in disc.text.get("matdyn_freq", []) if T.plot_file_nbnd(p) == 3 * len(elements)]
|
|
418
|
+
dos_runs = {os.path.basename(T.matdyn_input(p)["flfrq"]) for p in disc.inputs.get("matdyn", [])
|
|
419
|
+
if T.matdyn_input(p)["dos"]}
|
|
420
|
+
path_files = [p for p in freq_files if os.path.basename(p) not in dos_runs] or freq_files
|
|
421
|
+
if path_files:
|
|
422
|
+
qcart, freqs = T.matdyn_freq(disc.latest(path_files))
|
|
423
|
+
labels = {}
|
|
424
|
+
for p in disc.inputs.get("matdyn", []):
|
|
425
|
+
mi = T.matdyn_input(p)
|
|
426
|
+
if mi["q_in_cryst_coord"]:
|
|
427
|
+
labels.update({lab: q for q, _, lab in mi["qpoints"] if lab})
|
|
428
|
+
path = KP.build(frac_from_cart_alat(qcart, cell, base.structure()["alat_bohr"]), cell, labels or None)
|
|
429
|
+
ph.update(path=path["path"], x=_r(path["x"], 6), ticks=path["ticks"], frequencies=_r(freqs, 3))
|
|
430
|
+
ph["min_freq_cm1"] = round(float(freqs.min()), 2)
|
|
431
|
+
dos_files = disc.text.get("phdos", [])
|
|
432
|
+
if dos_files:
|
|
433
|
+
d = T.phdos(disc.latest(dos_files), nat=len(elements))
|
|
434
|
+
ph["dos"] = {"freq_cm1": _r(d["freq"], 2), "total": _r(d["total"], 6)}
|
|
435
|
+
if d["per_atom"] is not None:
|
|
436
|
+
ph["dos"]["per_atom"] = _r(d["per_atom"], 6)
|
|
437
|
+
if eps:
|
|
438
|
+
ph["epsilon_inf"] = _r(eps, 5)
|
|
439
|
+
ph["born_charges"] = [{"atom": f"{s}{i + 1}", "tensor": _r(z, 5)}
|
|
440
|
+
for i, (s, z) in enumerate(zip(elements, zs))]
|
|
441
|
+
elif gamma:
|
|
442
|
+
nzero = 5 if is_linear(pos) else 6
|
|
443
|
+
freqs = sorted(m["freq_cm1"] for m in gamma)
|
|
444
|
+
ph["n_vibrational_modes"] = len(elements) * 3 - nzero
|
|
445
|
+
ph["vibrational_freqs_cm1"] = [round(f, 2) for f in freqs[nzero:]]
|
|
446
|
+
return ph if len(ph) > 1 else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main(argv=None):
|
|
450
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
451
|
+
ap.add_argument("directory")
|
|
452
|
+
ap.add_argument("-o", "--output", help="output file (default: DIR/midb_qe.json)")
|
|
453
|
+
ap.add_argument("--id", help="system label (default: name of the directory)")
|
|
454
|
+
ap.add_argument("--kind", choices=("crystal", "molecule"), help="override the crystal/molecule heuristic")
|
|
455
|
+
ap.add_argument("--pseudo-library", help="name of the pseudopotential library (metadata only)")
|
|
456
|
+
ap.add_argument("--no-recursive", action="store_true", help="do not descend into sub-directories")
|
|
457
|
+
ap.add_argument("--pdos-per-atom", choices=("auto", "all", "none"), default="auto",
|
|
458
|
+
help=f"per-atom PDOS entries (auto: only up to {PDOS_PER_ATOM_MAX} atoms)")
|
|
459
|
+
ap.add_argument("--report", action="store_true", help="print the file inventory")
|
|
460
|
+
a = ap.parse_args(argv)
|
|
461
|
+
doc, disc = normalize(a.directory, a.id, a.kind, a.pseudo_library, recursive=not a.no_recursive,
|
|
462
|
+
pdos_per_atom=a.pdos_per_atom)
|
|
463
|
+
out = a.output or os.path.join(a.directory, "midb_qe.json")
|
|
464
|
+
with open(out, "w") as f:
|
|
465
|
+
json.dump(doc, f, ensure_ascii=False, separators=(",", ":"))
|
|
466
|
+
sections = [k for k in ("bands", "dos", "pdos", "phonons") if k in doc]
|
|
467
|
+
print(f"{doc['id']}: {doc['formula']} {doc['kind']} QE {doc['method']['qe_version']} -> {out} "
|
|
468
|
+
f"({os.path.getsize(out) / 1024:.0f} KiB; {', '.join(sections) or 'structure/energies only'})")
|
|
469
|
+
for w in doc["provenance"]["warnings"]:
|
|
470
|
+
print(" aviso:", w)
|
|
471
|
+
if a.report:
|
|
472
|
+
print(json.dumps(disc.report(), indent=1))
|
|
473
|
+
return 0
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
sys.exit(main())
|