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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- 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
|