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 ADDED
@@ -0,0 +1,6 @@
1
+ """Quantum ESPRESSO outputs -> normalized MIDb document (midb_qe.json).
2
+
3
+ from qe2midb.normalize import normalize
4
+ doc, discovery = normalize("calc_dir")
5
+ """
6
+ from qe2midb.normalize import FORMAT_VERSION # noqa: F401
qe2midb/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from qe2midb.normalize import main
2
+
3
+ raise SystemExit(main())
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())