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
quantui/security.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ QuantUI Security Module
3
+
4
+ Provides a catchable SecurityError exception for the local teaching interface.
5
+
6
+ Path-traversal hardening, SLURM resource limits, concurrent job enforcement,
7
+ and email/mail-event validation have been removed — they are only relevant
8
+ in a multi-user cluster environment. Local resource sanity checks live in
9
+ utils.session_can_handle() instead.
10
+ """
11
+
12
+
13
+ class SecurityError(Exception):
14
+ """Raised when a security constraint is violated."""
@@ -0,0 +1,622 @@
1
+ """
2
+ In-session quantum chemistry calculation using PySCF directly.
3
+
4
+ Runs SCF calculations in the current Jupyter kernel and returns structured
5
+ data. PySCF's verbose output is routed through mol.stdout so the notebook
6
+ can display live SCF iterations in a widget.
7
+
8
+ Platform notes
9
+ --------------
10
+ PySCF is **Linux / macOS / WSL only** — not available on native Windows.
11
+ This module imports PySCF lazily inside :func:`run_in_session` so it can
12
+ be imported safely on any platform without raising at import time.
13
+
14
+ Typical notebook usage
15
+ ----------------------
16
+ >>> from quantui import run_in_session, SessionResult
17
+ >>> result = run_in_session(molecule, method="RHF", basis="6-31G")
18
+ >>> print(result.summary())
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from typing import IO, Any, List, Optional
27
+
28
+ from .molecule import Molecule
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # NIST 2018 CODATA — consistent with PySCF's internal constant
33
+ HARTREE_TO_EV: float = 27.211386245988
34
+
35
+
36
+ # ============================================================================
37
+ # Result dataclass
38
+ # ============================================================================
39
+
40
+
41
+ @dataclass
42
+ class SessionResult:
43
+ """
44
+ Structured output from a completed in-session quantum chemistry calculation.
45
+
46
+ Attributes:
47
+ energy_hartree: Total SCF energy in Hartrees.
48
+ homo_lumo_gap_ev: HOMO-LUMO gap in electronvolts, or ``None`` if the
49
+ gap cannot be determined (e.g. open-shell UHF with complex orbital
50
+ occupations, or too few occupied orbitals).
51
+ converged: ``True`` if the SCF iterations reached the convergence
52
+ threshold; ``False`` if the maximum iteration count was hit.
53
+ n_iterations: Number of SCF macro-iterations completed. May be
54
+ ``-1`` if the underlying calculator does not expose this.
55
+ method: Calculation method used (e.g. ``'RHF'``, ``'UHF'``).
56
+ basis: Basis set used (e.g. ``'6-31G'``, ``'STO-3G'``).
57
+ formula: Hill-notation molecular formula of the input molecule.
58
+ """
59
+
60
+ energy_hartree: float
61
+ homo_lumo_gap_ev: Optional[float]
62
+ converged: bool
63
+ n_iterations: int
64
+ method: str
65
+ basis: str
66
+ formula: str
67
+ atom_symbols: Optional[List[str]] = None
68
+ mulliken_charges: Optional[List[float]] = None
69
+ dipole_moment_debye: Optional[float] = None
70
+ mp2_correlation_hartree: Optional[float] = None
71
+ # CCSD post-HF correlation energy (Hartree), populated when method is
72
+ # ``"CCSD"`` or ``"CCSD(T)"``. ``None`` for HF/DFT/MP2 paths. The
73
+ # ``energy_hartree`` field already includes this correlation when set
74
+ # (matches the existing ``mp2_correlation_hartree`` convention).
75
+ ccsd_correlation_hartree: Optional[float] = None
76
+ # CCSD(T) perturbative-triples correction (Hartree), populated only when
77
+ # method is ``"CCSD(T)"``. ``None`` for plain CCSD. Again, included in
78
+ # ``energy_hartree`` when set.
79
+ ccsd_t_correction_hartree: Optional[float] = None
80
+ # GPU offload status. ``gpu_used`` is True only when the
81
+ # SCF object was successfully migrated to gpu4pyscf for this run.
82
+ # ``gpu_name`` carries the CUDA device name when ``gpu_used`` is True so
83
+ # the result card can show *which* GPU ran the calc.
84
+ gpu_used: bool = False
85
+ gpu_name: Optional[str] = None
86
+ solvent: Optional[str] = None
87
+ mo_energy_hartree: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF
88
+ mo_occ: Optional[Any] = None # np.ndarray (n_mo,) or (2, n_mo) UHF
89
+ mo_coeff: Optional[Any] = None # np.ndarray (n_ao, n_mo) or (2, n_ao, n_mo) UHF
90
+ pyscf_mol_atom: Optional[Any] = None # list of (symbol, [x,y,z]) tuples (Angstrom)
91
+ pyscf_mol_basis: Optional[str] = None # basis set string for cube generation
92
+
93
+ @property
94
+ def energy_ev(self) -> float:
95
+ """Total energy converted to electronvolts."""
96
+ return self.energy_hartree * HARTREE_TO_EV
97
+
98
+ def summary(self) -> str:
99
+ """Return a multi-line human-readable result summary suitable for printing."""
100
+ lines = [
101
+ "=" * 60,
102
+ "Calculation Results",
103
+ "=" * 60,
104
+ f" Molecule : {self.formula}",
105
+ f" Method/Basis : {self.method}/{self.basis}",
106
+ f" SCF converged : {'Yes' if self.converged else '❌ NO — treat results with caution'}",
107
+ f" Iterations : {self.n_iterations}",
108
+ f" Total energy : {self.energy_hartree:.8f} Ha",
109
+ ]
110
+ if self.homo_lumo_gap_ev is not None:
111
+ lines.append(f" HOMO-LUMO gap : {self.homo_lumo_gap_ev:.4f} eV")
112
+ lines += [
113
+ "=" * 60,
114
+ (
115
+ "✅ Calculation completed successfully!"
116
+ if self.converged
117
+ else "⚠️ SCF did not converge — try a different starting geometry, basis, or method."
118
+ ),
119
+ "=" * 60,
120
+ ]
121
+ return "\n".join(lines)
122
+
123
+
124
+ # ============================================================================
125
+ # Main function
126
+ # ============================================================================
127
+
128
+
129
+ # Maps QuantUI display names → PySCF xc strings where they differ.
130
+ #
131
+ # ``wB97X-D`` is a special case: PySCF + dftd3 cannot compose
132
+ # ``mf.xc = "wb97x-d"`` cleanly (it's on dftd3's black-list — see
133
+ # pyscf/pyscf#2069). The workaround that matches what our UI label
134
+ # already claims ("wB97X-D — Range-Separated Hybrid + D3 Dispersion")
135
+ # is to use the bare ``wb97x`` functional and apply D3 via dftd3
136
+ # externally — same pattern as PBE-D3 below. This is D3, not the
137
+ # original Chai 2008 D2; the empirical dispersion energies differ by
138
+ # a few percent for most systems but the functional family is the same.
139
+ _XC_ALIAS: dict = {
140
+ "M06-L": "m06l",
141
+ "wB97X-D": "wb97x", # bare functional; D3 applied via _NEEDS_D3
142
+ "CAM-B3LYP": "camb3lyp",
143
+ "PBE-D3": "pbe", # base functional; D3 applied separately
144
+ }
145
+ # Methods that require Grimme D3 dispersion correction via pyscf.dftd3.
146
+ _NEEDS_D3: frozenset = frozenset({"PBE-D3", "wB97X-D"})
147
+
148
+
149
+ def resolve_xc(method: str) -> str:
150
+ """Map a QuantUI display method name to a PySCF xc string.
151
+
152
+ Uses ``_XC_ALIAS`` case-insensitively so callers can pass either
153
+ the display form (``"wB97X-D"``) or the upper form. Methods not
154
+ in the alias table pass through unchanged.
155
+
156
+ This is the single source of truth for QuantUI → PySCF xc-name
157
+ translation. Every DFT entry point — ``session_calc``, ``freq_calc``,
158
+ ``tddft_calc``, ``optimizer``, ``freq_ir_workers``, ``nmr_calc``,
159
+ and the script-export path in ``config.py`` — should use this
160
+ helper rather than passing ``method`` to PySCF directly. (Previously
161
+ they didn't, which is why wB97X-D errored in tier 3
162
+ SP calcs but ALSO would have errored in freq / opt / tddft.)
163
+ """
164
+ method_upper = method.upper()
165
+ _key = next((k for k in _XC_ALIAS if k.upper() == method_upper), method)
166
+ return _XC_ALIAS.get(_key, method)
167
+
168
+
169
+ def needs_d3(method: str) -> bool:
170
+ """Return True when ``method`` requires external D3 dispersion.
171
+
172
+ The DFT entry points should call this AFTER setting ``mf.xc`` to
173
+ decide whether to wrap the SCF object in ``pyscf.dftd3.dftd3(mf)``.
174
+ """
175
+ method_upper = method.upper()
176
+ _key = next((k for k in _XC_ALIAS if k.upper() == method_upper), method)
177
+ return _key in _NEEDS_D3
178
+
179
+
180
+ def maybe_apply_d3(mf, method: str, progress_stream=None):
181
+ """Wrap ``mf`` in ``pyscf.dftd3.dftd3(mf)`` if ``method`` requires D3.
182
+
183
+ Returns the (possibly wrapped) mf object. On ``pyscf.dftd3``
184
+ ImportError, returns the original ``mf`` unmodified and surfaces
185
+ a warning via ``progress_stream`` (if provided) so the user sees
186
+ that the result is missing the dispersion correction.
187
+ """
188
+ if not needs_d3(method):
189
+ return mf
190
+ try:
191
+ from pyscf import dftd3 as _dftd3
192
+
193
+ return _dftd3.dftd3(mf)
194
+ except ImportError:
195
+ if progress_stream is not None:
196
+ try:
197
+ progress_stream.write(
198
+ f"\n⚠ pyscf.dftd3 not available — running {method} "
199
+ "without D3 correction.\n"
200
+ )
201
+ except Exception: # noqa: BLE001 — cleanup (stream may be closed)
202
+ pass
203
+ return mf
204
+
205
+
206
+ def run_in_session(
207
+ molecule: Molecule,
208
+ method: str = "RHF",
209
+ basis: str = "6-31G",
210
+ verbose: int = 4,
211
+ progress_stream: Optional[IO[str]] = None,
212
+ solvent: Optional[str] = None,
213
+ ) -> SessionResult:
214
+ """
215
+ Run a quantum chemistry calculation in the current kernel using PySCF.
216
+
217
+ Returns a :class:`SessionResult` with structured data — energy,
218
+ HOMO-LUMO gap, convergence status — rather than requiring callers to
219
+ parse PySCF stdout.
220
+
221
+ PySCF's verbose log is routed to *progress_stream* (or ``sys.stdout``
222
+ if not provided) so live SCF iteration output can still be displayed in a
223
+ Jupyter widget by passing a stream-backed output widget.
224
+
225
+ Args:
226
+ molecule: Validated :class:`~quantui.molecule.Molecule` object.
227
+ method: SCF method — ``'RHF'`` for closed-shell molecules or
228
+ ``'UHF'`` for open-shell / radical species. Default: ``'RHF'``.
229
+ basis: Basis set name as recognised by PySCF (e.g. ``'STO-3G'``,
230
+ ``'6-31G'``, ``'6-31G*'``, ``'cc-pVDZ'``). Default: ``'6-31G'``.
231
+ verbose: PySCF verbosity level (0 = silent … 9 = very detailed).
232
+ Level 3 prints per-iteration SCF energies; level 4 adds
233
+ convergence diagnostics. Default: 4.
234
+ progress_stream: Optional writable text stream. All PySCF output
235
+ during the calculation is written here. Pass a widget-backed
236
+ stream (e.g. ``_WidgetStream``) in the notebook for live display;
237
+ leave ``None`` to write to ``sys.stdout``.
238
+
239
+ Returns:
240
+ :class:`SessionResult` containing energy, HOMO-LUMO gap, convergence
241
+ information, and metadata.
242
+
243
+ Raises:
244
+ ImportError: If PySCF is not installed.
245
+ ValueError: If an unsupported method is requested.
246
+ RuntimeError: If PySCF raises an unexpected exception during the
247
+ calculation (original exception is chained).
248
+ """
249
+ # --- Dependency check ---
250
+ try:
251
+ from pyscf import dft, gto, scf
252
+ except ImportError as exc:
253
+ raise ImportError(
254
+ "PySCF is not installed — cannot run in-session calculations.\n"
255
+ " conda install -c conda-forge pyscf\n"
256
+ "Note: PySCF is Linux / macOS / WSL only."
257
+ ) from exc
258
+
259
+ stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
260
+
261
+ # Capture C-level (fd-2) stderr from libcint / BLAS
262
+ # / LAPACK and relay it to ``stream`` on exit. Without this wrapper, the
263
+ # bytes surface as red text above the cell output in Voilà / Jupyter.
264
+ # POSIX-only; no-op on Windows. See quantui/c_stderr.py for design.
265
+ from quantui.c_stderr import capture_c_stderr
266
+
267
+ with capture_c_stderr(stream):
268
+ return _run_session_calc_body(
269
+ molecule=molecule,
270
+ method=method,
271
+ basis=basis,
272
+ verbose=verbose,
273
+ progress_stream=progress_stream,
274
+ solvent=solvent,
275
+ _dft=dft,
276
+ _gto=gto,
277
+ _scf=scf,
278
+ stream=stream,
279
+ )
280
+
281
+
282
+ def _run_session_calc_body(
283
+ *,
284
+ molecule: Molecule,
285
+ method: str,
286
+ basis: str,
287
+ verbose: int,
288
+ progress_stream: Optional[IO[str]],
289
+ solvent: Optional[str],
290
+ _dft: Any,
291
+ _gto: Any,
292
+ _scf: Any,
293
+ stream: IO[str],
294
+ ) -> SessionResult:
295
+ """Inner body of :func:`run_session_calc` — see public docstring.
296
+
297
+ Split out so the public entry can wrap the C-heavy work in the
298
+ ``capture_c_stderr`` context manager without re-indenting ~150 lines.
299
+ Imports of ``pyscf`` are passed through so the dependency check stays
300
+ in the public entry (where its ImportError can reach the user via
301
+ Python's normal stderr).
302
+ """
303
+ dft, gto, scf = _dft, _gto, _scf
304
+
305
+ # --- Validate method ---
306
+ from . import config as _config
307
+
308
+ if method.upper() not in [m.upper() for m in _config.SUPPORTED_METHODS]:
309
+ raise ValueError(
310
+ f"Unsupported method '{method}'. "
311
+ f"Supported: {', '.join(_config.SUPPORTED_METHODS)}"
312
+ )
313
+
314
+ # --- Build PySCF Mole object ---
315
+ mol = gto.Mole()
316
+ mol.atom = molecule.to_pyscf_format()
317
+ mol.basis = basis
318
+ mol.charge = molecule.charge
319
+ mol.spin = molecule.multiplicity - 1
320
+ mol.verbose = verbose
321
+ mol.stdout = stream
322
+ mol.build()
323
+
324
+ # --- Select SCF method ---
325
+ method_upper = method.upper()
326
+
327
+ if method_upper == "RHF":
328
+ mf = scf.RHF(mol)
329
+ elif method_upper == "UHF":
330
+ mf = scf.UHF(mol)
331
+ elif method_upper == "MP2":
332
+ # ``scf.RHF(mol)`` is a factory: for a closed-shell molecule
333
+ # (mol.spin == 0) it returns a true RHF object; for an open-shell
334
+ # molecule it auto-dispatches to ROHF instead (verified against
335
+ # PySCF's own factory behavior — this is not a QuantUI branch).
336
+ # ``mp.MP2(mf)`` below then further auto-dispatches: RMP2 on an
337
+ # RHF reference, UMP2 (ROHF-based) on an ROHF reference. Both are
338
+ # standard, well-defined methods; MP2 is not restricted to
339
+ # closed-shell input here.
340
+ mf = scf.RHF(mol)
341
+ elif method_upper in ("CCSD", "CCSD(T)"):
342
+ # Same auto-dispatch as MP2 above: scf.RHF(mol) yields RHF for
343
+ # closed-shell input and ROHF for open-shell input, and
344
+ # cc.CCSD(mf) below correspondingly dispatches to RCCSD or
345
+ # ROHF-based UCCSD. The correlation energy (and optional
346
+ # perturbative-triples correction) is added post-SCF below.
347
+ mf = scf.RHF(mol)
348
+ else:
349
+ # DFT: resolve alias then auto-select RKS / UKS. ``resolve_xc``
350
+ # handles the wB97X-D → wb97x + external D3 dispersion mapping
351
+ # (see _XC_ALIAS docstring).
352
+ if mol.spin == 0:
353
+ mf = dft.RKS(mol)
354
+ else:
355
+ mf = dft.UKS(mol)
356
+ mf.xc = resolve_xc(method)
357
+ mf = maybe_apply_d3(mf, method, progress_stream=progress_stream)
358
+
359
+ # --- Wrap with implicit solvent (PCM) if requested ---
360
+ if solvent is not None:
361
+ from . import config as _cfg
362
+
363
+ _eps = _cfg.SOLVENT_OPTIONS.get(solvent)
364
+ if _eps is not None:
365
+ try:
366
+ from pyscf.solvent import PCM as _PCM
367
+
368
+ mf = _PCM(mf)
369
+ mf.with_solvent.eps = _eps
370
+ except (
371
+ Exception
372
+ ) as exc: # noqa: BLE001 — optional probe (PySCF version drift)
373
+ logger.debug(
374
+ "PCM solvent unavailable, falling back to gas phase: %s", exc
375
+ )
376
+ if progress_stream is not None:
377
+ progress_stream.write(
378
+ "\n⚠ PCM solvent unavailable — running in gas phase.\n"
379
+ )
380
+
381
+ # --- Try GPU offload ---
382
+ # Migrate the SCF object to gpu4pyscf when (a) the package is installed,
383
+ # (b) a CUDA device is available, and (c) the method is supported.
384
+ # Failures fall back to CPU silently — the calc still runs. The
385
+ # ``gpu_used`` + ``gpu_name`` fields on the SessionResult carry the
386
+ # outcome so the UI can show which device produced the numbers.
387
+ from .gpu_offload import try_to_gpu as _try_to_gpu
388
+
389
+ mf, gpu_used, gpu_name = _try_to_gpu(mf, method_upper)
390
+ if gpu_used and progress_stream is not None:
391
+ try:
392
+ progress_stream.write(f"\n🚀 GPU offload active — running on {gpu_name}\n")
393
+ except Exception: # noqa: BLE001 — cleanup (progress stream may be closed)
394
+ pass
395
+
396
+ # --- Cooperative cancellation ---
397
+ # Attach the run's cancel predicate (carried on the progress stream) to
398
+ # the SCF callback so a Cancel click stops between SCF cycles even when the
399
+ # calc is running with sparse/no streamed output.
400
+ from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream
401
+ from .log_utils import emit_status
402
+
403
+ _cancel_check = cancel_check_from_stream(stream)
404
+ attach_scf_cancel_callback(mf, _cancel_check)
405
+
406
+ # --- Run SCF ---
407
+ emit_status(stream, "Running SCF…")
408
+ try:
409
+ energy_hartree = float(mf.kernel())
410
+ except Exception as exc:
411
+ raise RuntimeError(
412
+ f"PySCF calculation failed for {molecule.get_formula()} "
413
+ f"({method}/{basis}): {exc}"
414
+ ) from exc
415
+
416
+ # --- MP2 correlation energy (post-HF) ---
417
+ mp2_correlation_hartree: Optional[float] = None
418
+ if method_upper == "MP2":
419
+ try:
420
+ from pyscf import mp as _mp
421
+
422
+ emit_status(stream, "Running MP2 correlation…")
423
+ _mp2 = _mp.MP2(mf)
424
+ _e_corr, _ = _mp2.kernel()
425
+ mp2_correlation_hartree = float(_e_corr)
426
+ energy_hartree += float(_e_corr)
427
+ except Exception as exc:
428
+ raise RuntimeError(
429
+ f"MP2 correction failed for {molecule.get_formula()}: {exc}"
430
+ ) from exc
431
+
432
+ # --- Coupled cluster correlation ---
433
+ # CCSD adds singles + doubles excitations on top of the RHF reference;
434
+ # CCSD(T) adds a perturbative-triples correction on top of CCSD. Both
435
+ # report their corrections as separate result fields so the UI can
436
+ # show the HF reference + correlation breakdown (mirrors the MP2 path).
437
+ ccsd_correlation_hartree: Optional[float] = None
438
+ ccsd_t_correction_hartree: Optional[float] = None
439
+ if method_upper in ("CCSD", "CCSD(T)"):
440
+ try:
441
+ from pyscf import cc as _cc
442
+
443
+ emit_status(stream, "Running CCSD correlation…")
444
+ _ccsd_obj = _cc.CCSD(mf)
445
+ _e_corr_ccsd, _t1, _t2 = _ccsd_obj.kernel()
446
+ ccsd_correlation_hartree = float(_e_corr_ccsd)
447
+ energy_hartree += float(_e_corr_ccsd)
448
+ except Exception as exc:
449
+ raise RuntimeError(
450
+ f"CCSD correction failed for {molecule.get_formula()}: {exc}"
451
+ ) from exc
452
+ if method_upper == "CCSD(T)":
453
+ try:
454
+ emit_status(stream, "Computing CCSD(T) triples…")
455
+ _e_t = _ccsd_obj.ccsd_t()
456
+ ccsd_t_correction_hartree = float(_e_t)
457
+ energy_hartree += float(_e_t)
458
+ except Exception as exc:
459
+ raise RuntimeError(
460
+ f"CCSD(T) triples correction failed "
461
+ f"for {molecule.get_formula()}: {exc}"
462
+ ) from exc
463
+
464
+ # --- Extract results from the mean-field object ---
465
+ converged = bool(getattr(mf, "converged", False))
466
+ n_iterations = int(getattr(mf, "cycles", -1))
467
+
468
+ import numpy as _np
469
+
470
+ def _to_numpy_array(arr: Any) -> Any:
471
+ """Convert ``arr`` to a NumPy array, transferring from GPU if needed.
472
+
473
+ gpu4pyscf returns CuPy arrays (``mf.mo_occ`` / ``mo_energy`` / ``mo_coeff``
474
+ on a GPU-offloaded run). ``numpy.array(cupy_array)`` raises (NumPy refuses
475
+ implicit device→host transfers), so probe for CuPy's ``.get()`` host copy
476
+ first. Returns ``None`` unchanged.
477
+ """
478
+ if arr is None:
479
+ return None
480
+ # CuPy arrays have a ``.get()`` method (synchronous device→host copy).
481
+ # Probe for it rather than importing cupy, so the CPU-only path doesn't
482
+ # pull cupy onto the import graph.
483
+ get = getattr(arr, "get", None)
484
+ if callable(get) and type(arr).__module__.startswith("cupy"):
485
+ return _np.asarray(get())
486
+ return _np.asarray(arr)
487
+
488
+ homo_lumo_gap_ev: Optional[float] = None
489
+ try:
490
+ # Route through _to_numpy_array: on a GPU-offloaded run mf.mo_occ /
491
+ # mo_energy are CuPy arrays, and the old ``_np.array(mo_occ_ref)`` here
492
+ # raised (silently → gap None). Same CuPy fix the MO-array extraction
493
+ # below already had; this block was overlooked.
494
+ mo_energy = _to_numpy_array(mf.mo_energy)
495
+ mo_occ = _to_numpy_array(mf.mo_occ)
496
+ if mo_energy.ndim == 2:
497
+ # UHF: mo_energy is (2, n_mo) — use alpha spin for the gap estimate
498
+ mo_energy_ref = mo_energy[0]
499
+ mo_occ_ref = mo_occ[0]
500
+ else:
501
+ mo_energy_ref = mo_energy
502
+ mo_occ_ref = mo_occ
503
+
504
+ n_occ = int((mo_occ_ref > 0).sum())
505
+ if 0 < n_occ < len(mo_energy_ref):
506
+ homo_lumo_gap_ev = float(
507
+ (mo_energy_ref[n_occ] - mo_energy_ref[n_occ - 1]) * HARTREE_TO_EV
508
+ )
509
+ except Exception as exc:
510
+ logger.debug("HOMO-LUMO gap extraction failed (non-fatal): %s", exc)
511
+
512
+ mulliken_charges: Optional[List[float]] = None
513
+ dipole_moment_debye: Optional[float] = None
514
+ # Audit fix (2026-07-14): both mf.mulliken_pop() and mf.dip_moment()
515
+ # are well-defined and work correctly for a genuine UHF object (verified
516
+ # empirically against PySCF) — the previous ``method_upper != "UHF"``
517
+ # guard around this whole block was an unnecessary restriction that
518
+ # left the result card blank for both properties on every UHF run,
519
+ # while UKS (open-shell DFT) went through the identical extraction
520
+ # successfully.
521
+ try:
522
+ # gpu4pyscf doesn't implement population analysis on the GPU object
523
+ # (``mf.mulliken_pop`` is NotImplemented), so on a GPU-offloaded run
524
+ # fall back to the host (CPU) object via ``to_cpu()``. ``chg`` is
525
+ # then host NumPy; _to_numpy_array also covers the CuPy case.
526
+ mf_pop = mf
527
+ if not callable(getattr(mf, "mulliken_pop", None)) and callable(
528
+ getattr(mf, "to_cpu", None)
529
+ ):
530
+ mf_pop = mf.to_cpu()
531
+ _, chg = mf_pop.mulliken_pop(verbose=0)
532
+ mulliken_charges = [float(c) for c in _to_numpy_array(chg)]
533
+ except Exception as exc:
534
+ logger.debug("Mulliken population extraction failed: %s", exc)
535
+ try:
536
+ dip = _to_numpy_array(mf.dip_moment(verbose=0))
537
+ dipole_moment_debye = float(_np.linalg.norm(dip))
538
+ except Exception as exc:
539
+ logger.debug("Dipole moment extraction failed: %s", exc)
540
+
541
+ # MO arrays for orbital visualization (non-fatal if extraction fails).
542
+ # Uses the same ``_to_numpy_array`` CuPy→host helper defined above
543
+ # (GPU-offload note, fix 2026-05-25):
544
+ # when gpu4pyscf migrated ``mf`` to the GPU, ``mf.mo_energy`` / ``mo_coeff``
545
+ # / ``mo_occ`` are CuPy arrays. ``numpy.array(cupy_array)`` raises (numpy
546
+ # refuses implicit device transfers), which silently shipped a
547
+ # ``SessionResult`` with all MO fields ``None`` → ``save_orbitals`` no-op
548
+ # and "Not available" in the Energies + Isosurface panels on replay.
549
+ _mo_energy_ha_arr: Optional[Any] = None
550
+ _mo_occ_arr: Optional[Any] = None
551
+ _mo_coeff_arr: Optional[Any] = None
552
+ _pyscf_mol_atom: Optional[Any] = None
553
+ _pyscf_mol_basis: Optional[str] = None
554
+
555
+ try:
556
+ _mo_energy_ha_arr = _to_numpy_array(mf.mo_energy)
557
+ _mo_occ_arr = _to_numpy_array(mf.mo_occ)
558
+ _mo_coeff_arr = _to_numpy_array(mf.mo_coeff)
559
+ _pyscf_mol_atom = [
560
+ (atom, list(map(float, coords)))
561
+ for atom, coords in zip(molecule.atoms, molecule.coordinates)
562
+ ]
563
+ _pyscf_mol_basis = basis
564
+ except Exception as exc:
565
+ # A silent failure here ships a
566
+ # SessionResult with mo_coeff=None, which makes save_orbitals
567
+ # no-op and breaks Energies + Isosurface panels on history
568
+ # replay. Surface to the event log so a future regression is
569
+ # visible in `quantui log tail` immediately.
570
+ logger.warning(
571
+ "MO array extraction failed for %s (%s/%s): %s",
572
+ molecule.get_formula(),
573
+ method,
574
+ basis,
575
+ exc,
576
+ )
577
+ try:
578
+ from . import calc_log as _clog
579
+
580
+ _clog.log_event(
581
+ "mo_array_extract_failed",
582
+ f"{method}/{basis} on {molecule.get_formula()}",
583
+ error=str(exc)[:300],
584
+ gpu_used=gpu_used,
585
+ )
586
+ except Exception: # noqa: BLE001 — telemetry self-guard
587
+ pass
588
+
589
+ formula = molecule.get_formula()
590
+ logger.info(
591
+ "Session calculation: %s %s/%s E=%.8f Ha converged=%s iters=%d",
592
+ formula,
593
+ method,
594
+ basis,
595
+ energy_hartree,
596
+ converged,
597
+ n_iterations,
598
+ )
599
+
600
+ return SessionResult(
601
+ energy_hartree=energy_hartree,
602
+ homo_lumo_gap_ev=homo_lumo_gap_ev,
603
+ converged=converged,
604
+ n_iterations=n_iterations,
605
+ method=method,
606
+ basis=basis,
607
+ formula=formula,
608
+ atom_symbols=list(molecule.atoms),
609
+ mulliken_charges=mulliken_charges,
610
+ dipole_moment_debye=dipole_moment_debye,
611
+ mp2_correlation_hartree=mp2_correlation_hartree,
612
+ ccsd_correlation_hartree=ccsd_correlation_hartree,
613
+ ccsd_t_correction_hartree=ccsd_t_correction_hartree,
614
+ gpu_used=gpu_used,
615
+ gpu_name=gpu_name,
616
+ solvent=solvent,
617
+ mo_energy_hartree=_mo_energy_ha_arr,
618
+ mo_occ=_mo_occ_arr,
619
+ mo_coeff=_mo_coeff_arr,
620
+ pyscf_mol_atom=_pyscf_mol_atom,
621
+ pyscf_mol_basis=_pyscf_mol_basis,
622
+ )