quantui 0.5.1__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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. quantui-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,902 @@
1
+ """
2
+ results_storage — Persist and reload QuantUI calculation results.
3
+
4
+ Each calculation is saved to a timestamped subdirectory::
5
+
6
+ <results_dir>/<timestamp>_<formula>_<method>_<basis>/
7
+ result.json — structured metadata + energy values (versioned)
8
+ pyscf.log — raw PySCF stdout (may be absent for short runs)
9
+
10
+ The ``result.json`` schema carries a ``_schema_version`` field so future
11
+ fields (geometry, IR/UV-Vis spectra file paths, etc.) can be added without
12
+ breaking existing readers. A ``"spectra"`` key is reserved now as an empty
13
+ dict to make the intended extension point obvious.
14
+
15
+ Results directory
16
+ -----------------
17
+ Defaults to ``Path("results")`` relative to the working directory, or to
18
+ the value of the ``QUANTUI_RESULTS_DIR`` environment variable if set.
19
+ The Apptainer container sets this to ``$HOME/.quantui/results`` so that
20
+ results survive across kernel restarts and land in the user's home
21
+ directory (which is bind-mounted and writable).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ from datetime import datetime
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING, Any, Optional
32
+
33
+ from .config import BOHR_TO_ANGSTROM as _BOHR_TO_ANGSTROM
34
+
35
+ if TYPE_CHECKING:
36
+ pass # result types accepted via duck typing; no hard import needed
37
+
38
+ _SCHEMA_VERSION = 2
39
+
40
+ # Molden's [FR-COORD] block is defined (theochem.ru.nl/molden/molden_format.html)
41
+ # to always be in Bohr, regardless of the unit tag on [Atoms] — a Molden-format
42
+ # quirk. pyscf_mol_atom (the source for both blocks) is Angstrom throughout
43
+ # QuantUI, so [FR-COORD] needs an explicit conversion; [Atoms]/[GTO]/[MO] (via
44
+ # molden.from_mo / molden.header, built from a mol with implicit unit="Angstrom")
45
+ # do not. Derived from config.BOHR_TO_ANGSTROM (pyscf.data.nist.BOHR) rather
46
+ # than a separately hand-typed literal, so this stays consistent with the
47
+ # other Bohr<->Angstrom conversions in the codebase.
48
+ _ANGSTROM_TO_BOHR = 1.0 / _BOHR_TO_ANGSTROM
49
+
50
+
51
+ def _default_results_dir() -> Path:
52
+ env = os.environ.get("QUANTUI_RESULTS_DIR")
53
+ return Path(env) if env else Path("results")
54
+
55
+
56
+ def _safe_name(s: str) -> str:
57
+ """Replace characters that are unsafe in directory names with 'x'."""
58
+ return re.sub(r"[^\w\-]", "x", s)
59
+
60
+
61
+ def _opt_float(x: object) -> Optional[float]:
62
+ """Coerce an optional (possibly numpy) scalar to a JSON-safe float or None."""
63
+ if x is None:
64
+ return None
65
+ try:
66
+ return float(x) # type: ignore[arg-type]
67
+ except (TypeError, ValueError):
68
+ return None
69
+
70
+
71
+ def _opt_int(x: object) -> Optional[int]:
72
+ """Coerce an optional (possibly numpy) scalar to a JSON-safe int or None.
73
+
74
+ ``json.dumps`` accepts ``numpy.float64``/``numpy.float32`` transparently
75
+ (they subclass ``float``), but ``numpy.int64``/``numpy.bool_`` do not
76
+ subclass ``int``/``bool`` and raise ``TypeError`` unconverted — this
77
+ normalizes any duck-typed result's numpy scalar to a plain ``int``.
78
+ """
79
+ if x is None:
80
+ return None
81
+ try:
82
+ return int(x) # type: ignore[arg-type]
83
+ except (TypeError, ValueError):
84
+ return None
85
+
86
+
87
+ def _opt_float_list(x: object) -> Optional[list]:
88
+ """Coerce an optional iterable of numbers to a JSON-safe list of floats."""
89
+ if x is None:
90
+ return None
91
+ try:
92
+ return [float(v) for v in x] # type: ignore[union-attr]
93
+ except (TypeError, ValueError):
94
+ return None
95
+
96
+
97
+ def _opt_str_list(x: object) -> Optional[list]:
98
+ """Coerce an optional iterable to a JSON-safe list of strings."""
99
+ if x is None:
100
+ return None
101
+ try:
102
+ return [str(v) for v in x] # type: ignore[union-attr]
103
+ except TypeError:
104
+ return None
105
+
106
+
107
+ def save_result(
108
+ result: object,
109
+ pyscf_log: str = "",
110
+ results_dir: Optional[Path] = None,
111
+ calc_type: str = "single_point",
112
+ spectra: Optional[dict] = None,
113
+ extras: Optional[dict] = None,
114
+ ) -> Path:
115
+ """Write *result* to a new timestamped subdirectory of *results_dir*.
116
+
117
+ Accepts any result type that exposes ``.formula``, ``.method``,
118
+ ``.basis``, ``.energy_hartree``, and ``.converged`` attributes
119
+ (``SessionResult``, ``OptimizationResult``, ``FreqResult``,
120
+ ``TDDFTResult``). Missing optional fields (``homo_lumo_gap_ev``,
121
+ ``n_iterations``) are stored as ``null``.
122
+
123
+ Parameters
124
+ ----------
125
+ result:
126
+ Any completed calculation result object.
127
+ pyscf_log:
128
+ Raw PySCF stdout captured during the run. Written to
129
+ ``pyscf.log`` inside the result directory when non-empty.
130
+ results_dir:
131
+ Override the default results directory.
132
+ calc_type:
133
+ Calculation type string stored in ``result.json`` for display
134
+ in the History browser. One of ``"single_point"``,
135
+ ``"geometry_opt"``, ``"frequency"``, ``"tddft"``.
136
+ spectra:
137
+ Dict of spectra data (IR frequencies, UV-Vis excitations, …)
138
+ stored under the ``"spectra"`` key in ``result.json``.
139
+ extras:
140
+ Optional dict of additional fields to merge into ``result.json``.
141
+ Used by the calibration runner to tag results with a
142
+ ``calibration_run_id`` marker so the History browser can show
143
+ a small badge distinguishing them from user-initiated calcs.
144
+ Keys clash with built-in result.json fields (``timestamp``,
145
+ ``formula``, etc.) overwrite them — by design, since the
146
+ caller is asserting they want to override.
147
+
148
+ Returns
149
+ -------
150
+ Path
151
+ The directory that was created.
152
+ """
153
+ _HARTREE_TO_EV = 27.211386245988 # local fallback
154
+
155
+ base = results_dir if results_dir is not None else _default_results_dir()
156
+ ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")
157
+ dirname = "_".join(
158
+ [
159
+ ts,
160
+ _safe_name(getattr(result, "formula", "unknown")),
161
+ _safe_name(getattr(result, "method", "unknown")),
162
+ _safe_name(getattr(result, "basis", "unknown")),
163
+ ]
164
+ )
165
+ dest = base / dirname
166
+ # Windows timer resolution can produce identical microsecond timestamps for
167
+ # back-to-back calls; append a counter to guarantee a unique directory.
168
+ _collision = 1
169
+ while dest.exists():
170
+ dest = base / f"{dirname}_{_collision}"
171
+ _collision += 1
172
+ dest.mkdir(parents=True)
173
+
174
+ _e_ha_raw = getattr(result, "energy_hartree", float("nan"))
175
+ _e_ha = _opt_float(_e_ha_raw)
176
+ if _e_ha is None:
177
+ _e_ha = float("nan")
178
+ # energy_ev may be a property (SessionResult) or absent (OptimizationResult
179
+ # and new types also define it as a property, so getattr works for all).
180
+ _e_ev = _opt_float(getattr(result, "energy_ev", _e_ha * _HARTREE_TO_EV))
181
+ if _e_ev is None:
182
+ _e_ev = _e_ha * _HARTREE_TO_EV
183
+
184
+ _converged = getattr(result, "converged", None)
185
+
186
+ data: dict = {
187
+ "_schema_version": _SCHEMA_VERSION,
188
+ "timestamp": ts,
189
+ "calc_type": calc_type,
190
+ "formula": getattr(result, "formula", "?"),
191
+ "method": getattr(result, "method", "?"),
192
+ "basis": getattr(result, "basis", "?"),
193
+ "energy_hartree": _e_ha,
194
+ "energy_ev": _e_ev,
195
+ "homo_lumo_gap_ev": _opt_float(getattr(result, "homo_lumo_gap_ev", None)),
196
+ "converged": None if _converged is None else bool(_converged),
197
+ "n_iterations": _opt_int(getattr(result, "n_iterations", -1)),
198
+ # Post-HF correlation breakdown — None for HF/DFT. Persisted so the
199
+ # saved-result card can show the HF reference + correlation rows.
200
+ "mp2_correlation_hartree": _opt_float(
201
+ getattr(result, "mp2_correlation_hartree", None)
202
+ ),
203
+ "ccsd_correlation_hartree": _opt_float(
204
+ getattr(result, "ccsd_correlation_hartree", None)
205
+ ),
206
+ "ccsd_t_correction_hartree": _opt_float(
207
+ getattr(result, "ccsd_t_correction_hartree", None)
208
+ ),
209
+ # Persisted so the saved-result card matches the live card
210
+ # (formatter-parity fix). Additive — absent on older results, where the
211
+ # history card falls back exactly as before (CPU / no dipole / no
212
+ # charges). Coerced JSON-safe (numpy scalars/arrays → float/list).
213
+ "solvent": getattr(result, "solvent", None),
214
+ "gpu_used": bool(getattr(result, "gpu_used", False)),
215
+ "gpu_name": getattr(result, "gpu_name", None),
216
+ "dipole_moment_debye": _opt_float(getattr(result, "dipole_moment_debye", None)),
217
+ "mulliken_charges": _opt_float_list(getattr(result, "mulliken_charges", None)),
218
+ "atom_symbols": _opt_str_list(getattr(result, "atom_symbols", None)),
219
+ "spectra": spectra if spectra is not None else {},
220
+ }
221
+ if extras:
222
+ data.update(extras)
223
+ (dest / "result.json").write_text(json.dumps(data, indent=2))
224
+
225
+ if pyscf_log:
226
+ (dest / "pyscf.log").write_text(pyscf_log)
227
+
228
+ return dest
229
+
230
+
231
+ _COLLISION_SUFFIX_RE = re.compile(r"^(.*)_(\d+)$")
232
+
233
+
234
+ def _result_dir_sort_key(d: Path) -> tuple:
235
+ """Sort key that orders same-timestamp collision suffixes numerically.
236
+
237
+ Directory names are ``<timestamp>_<formula>_<method>_<basis>``, with a
238
+ ``_<N>`` counter appended on same-microsecond collisions (N=1, 2, ...).
239
+ A plain string sort put ``..._10`` before ``..._2`` (lexicographic, not
240
+ numeric); split the trailing counter and sort on it as an int instead.
241
+ """
242
+ m = _COLLISION_SUFFIX_RE.match(d.name)
243
+ if m:
244
+ return (m.group(1), int(m.group(2)))
245
+ return (d.name, -1)
246
+
247
+
248
+ def list_results(results_dir: Optional[Path] = None) -> list:
249
+ """Return result directories sorted newest-first.
250
+
251
+ Only directories containing a ``result.json`` file are included.
252
+ """
253
+ base = results_dir if results_dir is not None else _default_results_dir()
254
+ if not base.exists():
255
+ return []
256
+ return sorted(
257
+ (d for d in base.iterdir() if d.is_dir() and (d / "result.json").exists()),
258
+ key=_result_dir_sort_key,
259
+ reverse=True,
260
+ )
261
+
262
+
263
+ def load_result(result_dir: Path) -> dict:
264
+ """Return the parsed ``result.json`` from *result_dir*."""
265
+ data: dict = json.loads((result_dir / "result.json").read_text())
266
+ return data
267
+
268
+
269
+ def save_orbitals(result_dir: Path, result: object) -> None:
270
+ """Persist MO data to *result_dir*/orbitals.npz and orbitals_meta.json.
271
+
272
+ Saves ``mo_energy_hartree``, ``mo_occ``, and ``mo_coeff`` as a compressed
273
+ NumPy archive and ``pyscf_mol_atom`` / ``pyscf_mol_basis`` as JSON so the
274
+ orbital diagram and isosurface can be replayed from history.
275
+ """
276
+ import numpy as _np
277
+
278
+ mo_e = getattr(result, "mo_energy_hartree", None)
279
+ mo_occ = getattr(result, "mo_occ", None)
280
+ mo_coeff = getattr(result, "mo_coeff", None)
281
+ mol_atom = getattr(result, "pyscf_mol_atom", None)
282
+ mol_basis = getattr(result, "pyscf_mol_basis", None)
283
+
284
+ if mo_e is None and mo_occ is None:
285
+ return
286
+
287
+ arrays: dict = {}
288
+ if mo_e is not None:
289
+ arrays["mo_energy_hartree"] = _np.asarray(mo_e)
290
+ if mo_occ is not None:
291
+ arrays["mo_occ"] = _np.asarray(mo_occ)
292
+ if mo_coeff is not None:
293
+ arrays["mo_coeff"] = _np.asarray(mo_coeff)
294
+ if arrays:
295
+ _np.savez_compressed(str(result_dir / "orbitals.npz"), **arrays)
296
+
297
+ meta: dict = {}
298
+ if mol_atom is not None:
299
+ # Convert list-of-tuples to JSON-safe list-of-lists.
300
+ meta["mol_atom"] = [[sym, list(coords)] for sym, coords in mol_atom]
301
+ if mol_basis is not None:
302
+ meta["mol_basis"] = mol_basis
303
+ if meta:
304
+ (result_dir / "orbitals_meta.json").write_text(json.dumps(meta))
305
+
306
+
307
+ def save_molden(
308
+ result_dir: Path,
309
+ *,
310
+ mo_energy_hartree=None,
311
+ mo_occ=None,
312
+ mo_coeff=None,
313
+ pyscf_mol_atom=None,
314
+ pyscf_mol_basis: Optional[str] = None,
315
+ charge: int = 0,
316
+ multiplicity: int = 1,
317
+ frequencies_cm1: Optional[list] = None,
318
+ normal_modes=None,
319
+ filename: str = "result.molden",
320
+ ) -> Optional[Path]:
321
+ """Write a Molden-format file alongside ``result.json``.
322
+
323
+ Molden is the lingua franca for orbital + vibration interop with
324
+ Avogadro / IQmol / Jmol / Multiwfn. This helper writes whichever data
325
+ is available — both orbitals and vibrations, just orbitals, or just
326
+ the structure + vibrations — using the appropriate pyscf.tools.molden
327
+ entry point.
328
+
329
+ Behaviour:
330
+
331
+ - ``mo_coeff`` present → ``pyscf.tools.molden.from_mo(mol, ..., mo_coeff,
332
+ ene=mo_energy, occ=mo_occ)`` writes ``[Atoms]`` + ``[GTO]`` + ``[MO]``.
333
+ - ``mo_coeff`` absent but vibrations present → ``pyscf.tools.molden.header``
334
+ writes only the structure header; we append ``[FREQ]`` +
335
+ ``[FR-COORD]`` + ``[FR-NORM-COORD]`` manually so Avogadro can animate.
336
+ - Neither present → returns ``None`` (nothing meaningful to export).
337
+
338
+ Best-effort: PySCF / Molden writer failures are caught and the
339
+ function returns ``None`` rather than propagating. Callers should
340
+ log but not fail the calc on a missing Molden file.
341
+
342
+ Returns the path to the written file on success, ``None`` otherwise.
343
+ """
344
+ try:
345
+ from pyscf import gto
346
+ from pyscf.tools import molden as _molden
347
+ except Exception:
348
+ return None
349
+
350
+ has_mo = (
351
+ mo_coeff is not None and mo_energy_hartree is not None and mo_occ is not None
352
+ )
353
+ has_vib = bool(frequencies_cm1) and bool(normal_modes)
354
+ if not (has_mo or has_vib):
355
+ return None
356
+
357
+ if not pyscf_mol_atom or not pyscf_mol_basis:
358
+ return None
359
+
360
+ try:
361
+ mol = gto.Mole()
362
+ mol.atom = [(str(sym), list(coords)) for sym, coords in pyscf_mol_atom]
363
+ mol.basis = pyscf_mol_basis
364
+ mol.charge = int(charge)
365
+ mol.spin = max(0, int(multiplicity) - 1)
366
+ mol.verbose = 0
367
+ mol.build()
368
+ except Exception:
369
+ return None
370
+
371
+ dest = result_dir / filename
372
+ try:
373
+ if has_mo:
374
+ _molden.from_mo(
375
+ mol,
376
+ str(dest),
377
+ mo_coeff,
378
+ ene=mo_energy_hartree,
379
+ occ=mo_occ,
380
+ )
381
+ else:
382
+ # Structure-only header; vibration blocks appended below.
383
+ with open(dest, "w", encoding="utf-8") as fh:
384
+ _molden.header(mol, fh)
385
+ except Exception:
386
+ return None
387
+
388
+ if has_vib:
389
+ try:
390
+ _append_molden_vibrations(
391
+ dest,
392
+ frequencies_cm1=frequencies_cm1,
393
+ normal_modes=normal_modes,
394
+ pyscf_mol_atom=pyscf_mol_atom,
395
+ )
396
+ except Exception:
397
+ pass # Best-effort: the orbital block (or header) is already written.
398
+
399
+ return dest
400
+
401
+
402
+ def _append_molden_vibrations(
403
+ path: Path,
404
+ *,
405
+ frequencies_cm1: list,
406
+ normal_modes,
407
+ pyscf_mol_atom,
408
+ ) -> None:
409
+ """Append Molden ``[FREQ]`` + ``[FR-COORD]`` + ``[FR-NORM-COORD]`` blocks.
410
+
411
+ Used by :func:`save_molden` after the structure (and optionally MO)
412
+ sections are in place. Format follows the Molden spec — Avogadro and
413
+ IQmol both accept this layout for animated normal-mode display.
414
+
415
+ ``frequencies_cm1`` is a flat list of N modes (length matches
416
+ ``normal_modes``). ``normal_modes`` is a list of length-N entries,
417
+ each a list of per-atom (x, y, z) displacement triples. The
418
+ ``[FR-COORD]`` block repeats the equilibrium geometry from
419
+ ``pyscf_mol_atom`` (converted Angstrom -> Bohr; the Molden spec
420
+ requires ``[FR-COORD]`` in Bohr regardless of ``[Atoms]``'s unit tag)
421
+ so the file is self-contained.
422
+ """
423
+ with open(path, "a", encoding="utf-8") as fh:
424
+ fh.write("\n[FREQ]\n")
425
+ for freq in frequencies_cm1:
426
+ fh.write(f"{float(freq):.6f}\n")
427
+
428
+ fh.write("\n[FR-COORD]\n")
429
+ for sym, coords in pyscf_mol_atom:
430
+ fh.write(
431
+ f"{sym} {float(coords[0]) * _ANGSTROM_TO_BOHR:.6f} "
432
+ f"{float(coords[1]) * _ANGSTROM_TO_BOHR:.6f} "
433
+ f"{float(coords[2]) * _ANGSTROM_TO_BOHR:.6f}\n"
434
+ )
435
+
436
+ fh.write("\n[FR-NORM-COORD]\n")
437
+ for i, mode in enumerate(normal_modes, start=1):
438
+ fh.write(f"vibration {i}\n")
439
+ for atom_vec in mode:
440
+ fh.write(
441
+ f" {float(atom_vec[0]):.6f} {float(atom_vec[1]):.6f} "
442
+ f"{float(atom_vec[2]):.6f}\n"
443
+ )
444
+
445
+
446
+ def save_trajectory_xyz(
447
+ result_dir: Path,
448
+ *,
449
+ frames: list,
450
+ energies: list,
451
+ filename: str = "trajectory.xyz",
452
+ ) -> Optional[Path]:
453
+ """Write a multi-frame XYZ trajectory file.
454
+
455
+ Universal format readable by Avogadro, VMD, OVITO, Jmol, Pymol,
456
+ OpenBabel, ASE (``ase.io.read``), and basically any molecular tool
457
+ that handles XYZ. Each frame's comment line carries the energy in
458
+ Hartree when known (parsed by tools that follow the extended-XYZ
459
+ convention).
460
+
461
+ Parameters
462
+ ----------
463
+ result_dir:
464
+ Directory returned by :func:`save_result`.
465
+ frames:
466
+ List of :class:`~quantui.molecule.Molecule` objects, one per
467
+ trajectory step.
468
+ energies:
469
+ Parallel list of total energies in Hartree. Missing entries are
470
+ written as plain frame numbers in the comment line.
471
+ filename:
472
+ Output filename inside *result_dir*. Defaults to
473
+ ``trajectory.xyz``.
474
+
475
+ Returns the path on success, ``None`` if ``frames`` is empty or the
476
+ write fails. Best-effort: failures don't propagate.
477
+ """
478
+ if not frames:
479
+ return None
480
+
481
+ out_path = result_dir / filename
482
+ try:
483
+ with open(out_path, "w", encoding="utf-8") as fh:
484
+ for i, mol in enumerate(frames):
485
+ atoms = list(mol.atoms)
486
+ coords = mol.coordinates
487
+ fh.write(f"{len(atoms)}\n")
488
+ # Extended-XYZ comment line: include energy when known
489
+ # so downstream parsers (ASE, OVITO) can pick it up.
490
+ if i < len(energies) and energies[i] is not None:
491
+ fh.write(f"energy={float(energies[i]):.10f} Hartree\n")
492
+ else:
493
+ fh.write(f"frame {i}\n")
494
+ for sym, xyz in zip(atoms, coords):
495
+ fh.write(
496
+ f"{sym} {float(xyz[0]):.6f} "
497
+ f"{float(xyz[1]):.6f} {float(xyz[2]):.6f}\n"
498
+ )
499
+ except Exception:
500
+ return None
501
+ return out_path
502
+
503
+
504
+ def save_trajectory_ase(
505
+ result_dir: Path,
506
+ *,
507
+ frames: list,
508
+ energies: list,
509
+ filename: str = "trajectory.traj",
510
+ ) -> Optional[Path]:
511
+ """Write an ASE Trajectory (.traj) file.
512
+
513
+ Lets users open the result in ``ase gui trajectory.traj``, slice
514
+ frames (``trajectory.traj@0:10:2``), and use ASE-GUI's interactive
515
+ editing tools to modify the structure as a starting point for
516
+ follow-up calcs. Also enables ASE-Python-side post-processing
517
+ (custom analyses, force diagnostics, etc.). Per-frame energies are
518
+ attached via :class:`ase.calculators.singlepoint.SinglePointCalculator`
519
+ so ``ase gui -g "d(0,1),e-E[0]"`` can plot derived quantities.
520
+
521
+ Parameters
522
+ ----------
523
+ result_dir, frames, energies:
524
+ Same convention as :func:`save_trajectory_xyz`.
525
+ filename:
526
+ Output filename inside *result_dir*. Defaults to
527
+ ``trajectory.traj``.
528
+
529
+ Returns the path on success, ``None`` if ASE is unavailable, frames
530
+ is empty, or the writer raises. Best-effort: failures don't
531
+ propagate.
532
+ """
533
+ if not frames:
534
+ return None
535
+ try:
536
+ from ase import Atoms
537
+ from ase.calculators.singlepoint import SinglePointCalculator
538
+ from ase.io.trajectory import Trajectory
539
+ except Exception:
540
+ return None
541
+
542
+ _HARTREE_TO_EV = 27.211386245988 # ASE uses eV for the calculator energy
543
+ out_path = result_dir / filename
544
+ try:
545
+ traj = Trajectory(str(out_path), "w")
546
+ try:
547
+ for i, mol in enumerate(frames):
548
+ atoms = Atoms(
549
+ symbols=list(mol.atoms),
550
+ positions=[list(row) for row in mol.coordinates],
551
+ )
552
+ if i < len(energies) and energies[i] is not None:
553
+ atoms.calc = SinglePointCalculator(
554
+ atoms,
555
+ energy=float(energies[i]) * _HARTREE_TO_EV,
556
+ )
557
+ traj.write(atoms)
558
+ finally:
559
+ traj.close()
560
+ except Exception:
561
+ return None
562
+ return out_path
563
+
564
+
565
+ def export_cube(
566
+ src_cube_path: Path,
567
+ result_dir: Path,
568
+ *,
569
+ orbital_label: str = "orbital",
570
+ ) -> Optional[Path]:
571
+ """Copy a cube file to the top-level result dir with a friendly name.
572
+
573
+ Internal cube files live in ``<result_dir>/isosurfaces/`` with
574
+ timestamped filenames (``H2O_HOMO_2026-05-23_19-30-00.cube``) — fine
575
+ for replay but verbose to share. This helper makes a copy at
576
+ ``<result_dir>/<orbital_label>.cube`` so the user can hand a cube
577
+ to Avogadro / VMD / Multiwfn without scrolling through timestamp
578
+ suffixes.
579
+
580
+ Returns the destination path on success, ``None`` if the source
581
+ doesn't exist or the copy fails. Overwrites any existing
582
+ ``<orbital_label>.cube`` at the top level — by design, the user is
583
+ explicitly asking for "the active cube under a friendly name".
584
+ """
585
+ import re as _re
586
+ import shutil
587
+
588
+ if not src_cube_path.exists():
589
+ return None
590
+ safe_label = _re.sub(r"[^A-Za-z0-9_.-]+", "_", orbital_label).strip("._")
591
+ if not safe_label:
592
+ safe_label = "orbital"
593
+ dest = result_dir / f"{safe_label}.cube"
594
+ try:
595
+ shutil.copy2(src_cube_path, dest)
596
+ except Exception:
597
+ return None
598
+ return dest
599
+
600
+
601
+ def export_result_bundle(
602
+ result_dir: Path,
603
+ *,
604
+ output_dir: Optional[Path] = None,
605
+ ) -> Optional[Path]:
606
+ """Zip an entire result directory for sharing.
607
+
608
+ Produces ``<output_dir>/<result_dir_name>.zip`` containing every
609
+ file the calc wrote — ``result.json``, ``pyscf.log``, ``orbitals.npz``,
610
+ ``trajectory.json`` / ``.xyz`` / ``.traj``, the ``isosurfaces/``
611
+ folder, the ``.molden`` companion, every panel-data CSV, etc. The
612
+ one-zip artifact is what students typically need to email a result
613
+ to a collaborator or attach to a writeup.
614
+
615
+ ``output_dir`` defaults to ``result_dir.parent`` (sibling of the
616
+ result folder) — keeps the zip next to the original directory so
617
+ the user finds it from the Files tab.
618
+
619
+ Returns the path to the zip on success, ``None`` if the result dir
620
+ doesn't exist or ``shutil.make_archive`` raises.
621
+ """
622
+ import shutil
623
+
624
+ if not result_dir.exists() or not result_dir.is_dir():
625
+ return None
626
+ base = output_dir if output_dir is not None else result_dir.parent
627
+ try:
628
+ base.mkdir(parents=True, exist_ok=True)
629
+ except OSError:
630
+ return None
631
+ # ``make_archive`` returns the full path of the created archive
632
+ # (including the extension). It accepts a base name without
633
+ # extension and the format (``"zip"``); root_dir + base_dir control
634
+ # what's inside.
635
+ archive_basename = str(base / result_dir.name)
636
+ try:
637
+ archive_path = shutil.make_archive(
638
+ base_name=archive_basename,
639
+ format="zip",
640
+ root_dir=str(result_dir.parent),
641
+ base_dir=result_dir.name,
642
+ )
643
+ except Exception:
644
+ return None
645
+ return Path(archive_path)
646
+
647
+
648
+ def load_orbitals(result_dir: Path):
649
+ """Reload MO data saved by :func:`save_orbitals`.
650
+
651
+ Returns a ``SimpleNamespace`` with ``mo_energy_hartree``, ``mo_occ``,
652
+ ``mo_coeff``, ``pyscf_mol_atom``, ``pyscf_mol_basis``, and ``formula``
653
+ (empty string if not known).
654
+
655
+ Raises
656
+ ------
657
+ FileNotFoundError
658
+ If ``orbitals.npz`` does not exist in *result_dir*.
659
+ """
660
+ import types
661
+
662
+ import numpy as _np
663
+
664
+ npz_path = result_dir / "orbitals.npz"
665
+ if not npz_path.exists():
666
+ raise FileNotFoundError(npz_path)
667
+
668
+ data = _np.load(str(npz_path))
669
+ stub = types.SimpleNamespace(
670
+ mo_energy_hartree=(
671
+ data["mo_energy_hartree"] if "mo_energy_hartree" in data else None
672
+ ),
673
+ mo_occ=data["mo_occ"] if "mo_occ" in data else None,
674
+ mo_coeff=data["mo_coeff"] if "mo_coeff" in data else None,
675
+ pyscf_mol_atom=None,
676
+ pyscf_mol_basis=None,
677
+ formula="",
678
+ )
679
+ meta_path = result_dir / "orbitals_meta.json"
680
+ if meta_path.exists():
681
+ meta = json.loads(meta_path.read_text())
682
+ stub.pyscf_mol_atom = meta.get("mol_atom")
683
+ stub.pyscf_mol_basis = meta.get("mol_basis")
684
+ return stub
685
+
686
+
687
+ def save_trajectory(
688
+ result_dir: Path,
689
+ trajectory: list,
690
+ energies: list,
691
+ filename: str = "trajectory.json",
692
+ ) -> None:
693
+ """Persist geometry-optimisation trajectory to *result_dir*/*filename*.
694
+
695
+ Parameters
696
+ ----------
697
+ result_dir:
698
+ Directory returned by :func:`save_result`.
699
+ trajectory:
700
+ List of ``Molecule`` objects (one per optimisation step).
701
+ energies:
702
+ List of total energies in Hartree, parallel to *trajectory*.
703
+ filename:
704
+ Output filename inside *result_dir*. Defaults to ``trajectory.json``.
705
+ Pass ``preopt_trajectory.json`` for the DFT-geometry-optimization
706
+ trajectory that runs before a Frequency / TD-DFT calc. (The
707
+ filename keeps the historical ``preopt_`` prefix for back-compat
708
+ with saved-result replay — renaming would break older results.)
709
+ """
710
+ if not trajectory:
711
+ return
712
+ mol0 = trajectory[0]
713
+ data = {
714
+ "atoms": list(mol0.atoms),
715
+ "charge": mol0.charge,
716
+ "multiplicity": mol0.multiplicity,
717
+ "steps": [
718
+ {
719
+ "coords": [list(row) for row in mol.coordinates],
720
+ "energy": energies[i] if i < len(energies) else None,
721
+ }
722
+ for i, mol in enumerate(trajectory)
723
+ ],
724
+ }
725
+ (result_dir / filename).write_text(json.dumps(data))
726
+
727
+
728
+ def load_trajectory(result_dir: Path, filename: str = "trajectory.json"):
729
+ """Reload a saved trajectory as (molecules, energies).
730
+
731
+ Returns
732
+ -------
733
+ tuple[list, list]
734
+ ``(trajectory, energies_hartree)`` where *trajectory* is a list of
735
+ ``Molecule`` objects and *energies_hartree* is a parallel list of
736
+ floats (``None`` entries are dropped to an empty list if all absent).
737
+
738
+ Raises
739
+ ------
740
+ FileNotFoundError
741
+ If ``trajectory.json`` does not exist in *result_dir*.
742
+ """
743
+ from quantui.molecule import Molecule
744
+
745
+ raw = json.loads((result_dir / filename).read_text())
746
+ atoms = raw["atoms"]
747
+ charge = raw.get("charge", 0)
748
+ mult = raw.get("multiplicity", 1)
749
+ trajectory = []
750
+ energies = []
751
+ for step in raw["steps"]:
752
+ trajectory.append(
753
+ Molecule(atoms, step["coords"], charge=charge, multiplicity=mult)
754
+ )
755
+ energies.append(step["energy"])
756
+ # If every energy is None the list is meaningless; return empty instead.
757
+ if all(e is None for e in energies):
758
+ energies = []
759
+ return trajectory, energies
760
+
761
+
762
+ def save_thumbnail(result_dir: Path, data: dict) -> None:
763
+ """Generate a compact PNG thumbnail card for the saved result.
764
+
765
+ Silently skips if matplotlib is unavailable or any error occurs.
766
+ """
767
+ try:
768
+ import matplotlib
769
+
770
+ matplotlib.use("Agg")
771
+ import matplotlib.pyplot as plt
772
+ except ImportError:
773
+ return
774
+
775
+ # Fix (2026-07-14): only the matplotlib import itself was
776
+ # guarded — figure construction, text rendering, and fig.savefig() (a
777
+ # real filesystem write, so it can hit disk-full / permission errors)
778
+ # could all raise past this function despite the docstring's promise
779
+ # to silently skip "any error". Wrap the whole body so that promise
780
+ # actually holds; fig.close() still runs via finally regardless of
781
+ # where in the body a failure happened.
782
+ fig = None
783
+ try:
784
+ fig = _build_thumbnail_figure(plt, data)
785
+ fig.savefig(
786
+ str(result_dir / "thumbnail.png"),
787
+ dpi=144,
788
+ bbox_inches="tight",
789
+ facecolor=fig.get_facecolor(),
790
+ pad_inches=0.05,
791
+ )
792
+ except Exception:
793
+ pass
794
+ finally:
795
+ if fig is not None:
796
+ plt.close(fig)
797
+
798
+
799
+ def _build_thumbnail_figure(plt: Any, data: dict) -> Any:
800
+ """Build (but don't save) the thumbnail matplotlib Figure for :func:`save_thumbnail`."""
801
+ _colors: dict = {
802
+ "single_point": ("#2563eb", "#dbeafe"),
803
+ "geometry_opt": ("#7c3aed", "#ede9fe"),
804
+ "frequency": ("#15803d", "#dcfce7"),
805
+ "tddft": ("#b45309", "#fef3c7"),
806
+ "nmr": ("#0d9488", "#ccfbf1"),
807
+ "reorganization_energy": ("#be123c", "#ffe4e6"),
808
+ }
809
+ _ct_labels: dict = {
810
+ "single_point": "Single Point",
811
+ "geometry_opt": "Geometry Opt",
812
+ "frequency": "Frequency",
813
+ "tddft": "TD-DFT",
814
+ "nmr": "NMR",
815
+ "reorganization_energy": "Reorg Energy",
816
+ }
817
+ ct = data.get("calc_type", "")
818
+ fg, bg = _colors.get(ct, ("#555555", "#f3f4f6"))
819
+ ct_label = _ct_labels.get(ct, ct.replace("_", " ").title())
820
+
821
+ # (2026-05-25): bumped figsize 2.4→3.6 + dpi 72→144
822
+ # so the History-card text is readable on 1× displays. Source PNG goes
823
+ # from 173×108 px (~8 KB) to 518×324 px (~25 KB); the History dropdown
824
+ # downscales to its native ~250–300 px width, so the user sees crisp
825
+ # anti-aliased text rather than the blurry letters from the old config.
826
+ fig = plt.figure(figsize=(3.6, 2.25), facecolor=bg)
827
+ ax = fig.add_axes([0, 0, 1, 1])
828
+ ax.set_facecolor(bg)
829
+ ax.set_xlim(0, 1)
830
+ ax.set_ylim(0, 1)
831
+ ax.axis("off")
832
+
833
+ # Colored header strip
834
+ ax.axhspan(0.80, 1.0, color=fg)
835
+ ax.text(
836
+ 0.5,
837
+ 0.90,
838
+ ct_label,
839
+ ha="center",
840
+ va="center",
841
+ fontsize=9,
842
+ fontweight="bold",
843
+ color="white",
844
+ transform=ax.transAxes,
845
+ )
846
+
847
+ # Formula
848
+ ax.text(
849
+ 0.5,
850
+ 0.65,
851
+ data.get("formula", "?"),
852
+ ha="center",
853
+ va="center",
854
+ fontsize=13,
855
+ fontweight="bold",
856
+ color=fg,
857
+ transform=ax.transAxes,
858
+ )
859
+
860
+ # Method / basis
861
+ ax.text(
862
+ 0.5,
863
+ 0.50,
864
+ f'{data.get("method", "?")} / {data.get("basis", "?")}',
865
+ ha="center",
866
+ va="center",
867
+ fontsize=8,
868
+ color="#444444",
869
+ transform=ax.transAxes,
870
+ )
871
+
872
+ # Energy
873
+ e_ha = data.get("energy_hartree")
874
+ if e_ha is not None and e_ha == e_ha: # skip NaN
875
+ ax.text(
876
+ 0.5,
877
+ 0.34,
878
+ f"E = {e_ha:.5f} Ha",
879
+ ha="center",
880
+ va="center",
881
+ fontsize=7,
882
+ color="#333333",
883
+ transform=ax.transAxes,
884
+ family="monospace",
885
+ )
886
+
887
+ # Converged indicator
888
+ conv = data.get("converged")
889
+ if conv is not None:
890
+ ax.text(
891
+ 0.5,
892
+ 0.16,
893
+ "✓ Converged" if conv else "✗ Not converged",
894
+ ha="center",
895
+ va="center",
896
+ fontsize=7.5,
897
+ fontweight="bold",
898
+ color="#15803d" if conv else "#c00000",
899
+ transform=ax.transAxes,
900
+ )
901
+
902
+ return fig