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
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."""
|
quantui/session_calc.py
ADDED
|
@@ -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
|
+
)
|