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/nmr_calc.py ADDED
@@ -0,0 +1,437 @@
1
+ """
2
+ NMR chemical shift prediction using PySCF GIAO.
3
+
4
+ Computes isotropic NMR shielding tensors via GIAO (Gauge-Including
5
+ Atomic Orbitals) and converts to ¹H/¹³C chemical shifts relative to
6
+ TMS using tabulated reference constants from config.py.
7
+
8
+ Typical usage::
9
+
10
+ from quantui.nmr_calc import run_nmr_calc
11
+ result = run_nmr_calc(molecule, method="B3LYP", basis="6-31G*")
12
+ for atom_idx, delta_ppm in result.h_shifts():
13
+ print(f"H-{atom_idx+1}: {delta_ppm:.2f} ppm")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from typing import Any, Dict, List, Tuple
22
+
23
+ from .molecule import Molecule
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class NMRResult:
30
+ """Structured output from an NMR shielding calculation."""
31
+
32
+ atom_symbols: List[str]
33
+ shielding_iso_ppm: List[float]
34
+ chemical_shifts_ppm: Dict[int, float] # atom_index → δ (ppm), ¹H and ¹³C only
35
+ method: str
36
+ basis: str
37
+ formula: str
38
+ reference_compound: str = "TMS"
39
+ converged: bool = True
40
+ # Fix (2026-07-14): which config.NMR_REFERENCE_SHIELDINGS entry
41
+ # was actually applied, and whether it's an exact match for method/basis
42
+ # or a fallback. NMR_REFERENCE_SHIELDINGS only tabulates a handful of
43
+ # method/basis combinations; any other combination previously fell back
44
+ # to the B3LYP/6-31G* constants with no record of it anywhere, so
45
+ # chemical shifts could be silently offset by several ppm with no way
46
+ # for the student to know the reference wasn't calibrated for their
47
+ # method/basis.
48
+ reference_key: str = ""
49
+ is_fallback_reference: bool = False
50
+
51
+ def h_shifts(self) -> List[Tuple[int, float]]:
52
+ """(atom_index, δ ppm) pairs for all H atoms in molecule order."""
53
+ return [
54
+ (i, d)
55
+ for i, d in sorted(self.chemical_shifts_ppm.items())
56
+ if self.atom_symbols[i] == "H"
57
+ ]
58
+
59
+ def c_shifts(self) -> List[Tuple[int, float]]:
60
+ """(atom_index, δ ppm) pairs for all C atoms in molecule order."""
61
+ return [
62
+ (i, d)
63
+ for i, d in sorted(self.chemical_shifts_ppm.items())
64
+ if self.atom_symbols[i] == "C"
65
+ ]
66
+
67
+
68
+ def resolve_nmr_reference(
69
+ method: str, basis: str
70
+ ) -> Tuple[Dict[str, float], str, bool]:
71
+ """Resolve TMS reference shielding constants for ``method``/``basis``.
72
+
73
+ Looks up ``config.NMR_REFERENCE_SHIELDINGS`` case-insensitively (keys
74
+ there are declared as e.g. ``"B3LYP/6-31G*"``). Returns
75
+ ``(ref_map, matched_key, is_fallback)``:
76
+
77
+ - ``ref_map``: the ``{"H": ..., "C": ...}`` shielding constants to use.
78
+ - ``matched_key``: the table key that was actually applied.
79
+ - ``is_fallback``: ``True`` when no entry exists for this exact
80
+ method/basis and ``config.NMR_DEFAULT_REFERENCE`` (B3LYP/6-31G*) was
81
+ substituted instead — chemical shifts computed with a substituted
82
+ reference can be off by several ppm relative to properly calibrated
83
+ constants for the requested level of theory.
84
+ """
85
+ from . import config as _config
86
+
87
+ requested_key = f"{method}/{basis}"
88
+ requested_upper = requested_key.upper()
89
+ for table_key, ref_map in _config.NMR_REFERENCE_SHIELDINGS.items():
90
+ if table_key.upper() == requested_upper:
91
+ return ref_map, table_key, False
92
+ return _config.NMR_DEFAULT_REFERENCE, "B3LYP/6-31G*", True
93
+
94
+
95
+ def run_nmr_calc(
96
+ molecule: Molecule,
97
+ method: str = "B3LYP",
98
+ basis: str = "6-31G*",
99
+ progress_stream=None,
100
+ ) -> NMRResult:
101
+ """Run NMR shielding calculation and return ¹H/¹³C chemical shifts.
102
+
103
+ Uses PySCF GIAO (Gauge-Including Atomic Orbitals) formalism.
104
+ Chemical shifts are reported relative to TMS using reference constants
105
+ from :data:`~quantui.config.NMR_REFERENCE_SHIELDINGS`.
106
+
107
+ Args:
108
+ molecule: Validated :class:`~quantui.molecule.Molecule` object.
109
+ method: SCF or DFT method. Recommended: B3LYP.
110
+ basis: Basis set. Recommended: 6-31G* or better.
111
+ progress_stream: Optional writable text stream for PySCF output.
112
+
113
+ Returns:
114
+ :class:`NMRResult` with per-atom shieldings and ¹H/¹³C shifts.
115
+
116
+ Raises:
117
+ ImportError: If PySCF is not installed.
118
+ RuntimeError: If the SCF or GIAO-NMR calculation fails.
119
+ """
120
+ # Post-HF methods (MP2/CCSD/CCSD(T)) have no special-casing below —
121
+ # without this guard, method='CCSD' silently falls into the DFT
122
+ # branch (sets mf.xc = "CCSD") and fails deep inside PySCF with a
123
+ # cryptic "LibXCFunctional: name 'CCSD' not found" instead of a clear
124
+ # message. GIAO-NMR shielding is not defined for these methods here.
125
+ from . import config as _config
126
+
127
+ if method.strip().upper() in _config.POST_HF_METHODS:
128
+ raise ValueError(
129
+ f"'{method}' is a post-HF method and cannot be used for NMR "
130
+ "shielding — use RHF, UHF, or a DFT functional instead."
131
+ )
132
+
133
+ try:
134
+ from pyscf import dft, gto, scf
135
+ except ImportError as exc:
136
+ raise ImportError(
137
+ "PySCF is not installed — cannot run NMR calculations.\n"
138
+ "Note: PySCF is Linux / macOS / WSL only."
139
+ ) from exc
140
+
141
+ stream = progress_stream if progress_stream is not None else sys.stdout
142
+
143
+ # See quantui/c_stderr.py — captures fd-2 stderr
144
+ # from libcint / BLAS / LAPACK / GIAO / NMR-CPHF C code and relays to
145
+ # ``stream`` on exit. POSIX-only; no-op on Windows.
146
+ from quantui.c_stderr import capture_c_stderr
147
+
148
+ with capture_c_stderr(stream):
149
+ return _run_nmr_calc_body(
150
+ molecule=molecule,
151
+ method=method,
152
+ basis=basis,
153
+ progress_stream=progress_stream,
154
+ _dft=dft,
155
+ _gto=gto,
156
+ _scf=scf,
157
+ stream=stream,
158
+ )
159
+
160
+
161
+ # Fix (2026-07-14): bump this whenever the patch bodies below
162
+ # change — it doubles as the idempotency sentinel's value, so a bumped
163
+ # version forces re-patching instead of silently keeping stale closures
164
+ # from an older QuantUI version installed earlier in the process.
165
+ _NMR_COMPAT_PATCH_VERSION = 1
166
+ _NMR_PATCH_VERSION_ATTR = "_quantui_nmr_compat_patch_version"
167
+
168
+
169
+ def _ensure_nmr_compat_patches_applied() -> None:
170
+ """Idempotently patch pyscf.prop.nmr for QuantUI-specific compatibility fixes.
171
+
172
+ Both patches below used to be applied unconditionally on every NMR
173
+ calculation, re-defining the same closures and reassigning the same
174
+ module attributes on every call even though nothing about the
175
+ installed pyscf/pyscf-properties changes between calls. Each patch
176
+ is now a one-time, idempotent operation per process: a sentinel
177
+ attribute (versioned via ``_NMR_COMPAT_PATCH_VERSION``) on the
178
+ currently-installed function is checked first, so repeated NMR runs
179
+ are no-ops here.
180
+ """
181
+ # pyscf-properties 0.1.0 gen_vind hardcodes reshape(3, nmo, nocc).
182
+ # pyscf 2.x krylov reduces the batch below 3 via linear-dependency masking,
183
+ # causing "cannot reshape array of size N into shape (3,nmo,nocc)".
184
+ # Patch gen_vind to use reshape(-1, nmo, nocc) so any batch size works.
185
+ try:
186
+ from functools import reduce as _reduce_nmr
187
+
188
+ import numpy as _np
189
+ import pyscf.prop.nmr.rhf as _prop_nmr_rhf
190
+ from pyscf import lib as _pyscf_lib_nmr
191
+
192
+ if (
193
+ getattr(_prop_nmr_rhf.gen_vind, _NMR_PATCH_VERSION_ATTR, 0)
194
+ < _NMR_COMPAT_PATCH_VERSION
195
+ ):
196
+
197
+ def _fixed_gen_vind(mf_arg, mo_coeff, mo_occ):
198
+ vresp = mf_arg.gen_response(singlet=True, hermi=2)
199
+ occidx = mo_occ > 0
200
+ orbo = mo_coeff[:, occidx]
201
+ nocc = orbo.shape[1]
202
+ _nao, nmo = mo_coeff.shape
203
+
204
+ def vind(mo1):
205
+ _mo1 = _np.asarray(mo1).reshape(-1, nmo, nocc)
206
+ dm1 = _np.asarray(
207
+ [
208
+ _reduce_nmr(_np.dot, (mo_coeff, x * 2, orbo.T.conj()))
209
+ for x in _mo1
210
+ ]
211
+ )
212
+ dm1 = dm1 - dm1.transpose(0, 2, 1).conj()
213
+ v1mo = _pyscf_lib_nmr.einsum(
214
+ "xpq,pi,qj->xij", vresp(dm1), mo_coeff.conj(), orbo
215
+ )
216
+ return v1mo.ravel()
217
+
218
+ return vind
219
+
220
+ setattr(_fixed_gen_vind, _NMR_PATCH_VERSION_ATTR, _NMR_COMPAT_PATCH_VERSION)
221
+ _prop_nmr_rhf.gen_vind = _fixed_gen_vind
222
+ except (ImportError, AttributeError) as exc: # noqa: BLE001 — optional probe
223
+ logger.debug("pyscf.prop.nmr.rhf.gen_vind patch not applied: %s", exc)
224
+
225
+ # pyscf-properties 0.1.0 get_vxc_giao computes
226
+ # blksize = min(int(X*BLKSIZE)*BLKSIZE, ngrids)
227
+ # which equals ngrids when ngrids < X*BLKSIZE, and ngrids may not be
228
+ # divisible by BLKSIZE. pyscf 2.x block_loop asserts blksize%BLKSIZE==0.
229
+ # Patch get_vxc_giao to round blksize down to the nearest BLKSIZE multiple.
230
+ try:
231
+ import numpy as _np_rks
232
+ import pyscf.prop.nmr.rks as _prop_nmr_rks
233
+ from pyscf.dft import numint as _numint_rks
234
+
235
+ if (
236
+ getattr(_prop_nmr_rks.get_vxc_giao, _NMR_PATCH_VERSION_ATTR, 0)
237
+ < _NMR_COMPAT_PATCH_VERSION
238
+ ):
239
+
240
+ def _fixed_get_vxc_giao(
241
+ ni, mol, grids, xc_code, dms, max_memory=2000, verbose=None
242
+ ):
243
+ xctype = ni._xc_type(xc_code)
244
+ make_rho, nset, nao = ni._gen_rho_evaluator(mol, dms, hermi=1)
245
+ ngrids = len(grids.weights)
246
+ _BLKSIZE = _numint_rks.BLKSIZE
247
+ _raw_blk = int(max_memory / 12 * 1e6 / 8 / nao / _BLKSIZE) * _BLKSIZE
248
+ blksize = max(_BLKSIZE, (min(_raw_blk, ngrids) // _BLKSIZE) * _BLKSIZE)
249
+ shls_slice = (0, mol.nbas)
250
+ ao_loc = mol.ao_loc_nr()
251
+
252
+ vmat = _np_rks.zeros((3, nao, nao))
253
+ if xctype == "LDA":
254
+ buf = _np_rks.empty((4, blksize, nao))
255
+ ao_deriv = 0
256
+ for ao, mask, weight, coords in ni.block_loop(
257
+ mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf
258
+ ):
259
+ rho = make_rho(0, ao, mask, "LDA")
260
+ vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1]
261
+ vrho = vxc[0]
262
+ aow = _np_rks.einsum("pi,p->pi", ao, weight * vrho)
263
+ giao = mol.eval_gto(
264
+ "GTOval_ig", coords, comp=3, non0tab=mask, out=buf[1:]
265
+ )
266
+ vmat[0] += _numint_rks._dot_ao_ao(
267
+ mol, aow, giao[0], mask, shls_slice, ao_loc
268
+ )
269
+ vmat[1] += _numint_rks._dot_ao_ao(
270
+ mol, aow, giao[1], mask, shls_slice, ao_loc
271
+ )
272
+ vmat[2] += _numint_rks._dot_ao_ao(
273
+ mol, aow, giao[2], mask, shls_slice, ao_loc
274
+ )
275
+ rho = vxc = vrho = aow = None
276
+ elif xctype == "GGA":
277
+ buf = _np_rks.empty((10, blksize, nao))
278
+ ao_deriv = 1
279
+ for ao, mask, weight, coords in ni.block_loop(
280
+ mol, grids, nao, ao_deriv, max_memory, blksize=blksize, buf=buf
281
+ ):
282
+ rho = make_rho(0, ao, mask, "GGA")
283
+ vxc = ni.eval_xc(xc_code, rho, 0, deriv=1)[1]
284
+ vrho, vsigma = vxc[:2]
285
+ wv = _np_rks.empty_like(rho)
286
+ wv[0] = weight * vrho
287
+ wv[1:] = rho[1:] * (weight * vsigma * 2)
288
+ aow = _np_rks.einsum("npi,np->pi", ao[:4], wv)
289
+ giao = mol.eval_gto(
290
+ "GTOval_ig", coords, 3, non0tab=mask, out=buf[4:]
291
+ )
292
+ vmat[0] += _numint_rks._dot_ao_ao(
293
+ mol, aow, giao[0], mask, shls_slice, ao_loc
294
+ )
295
+ vmat[1] += _numint_rks._dot_ao_ao(
296
+ mol, aow, giao[1], mask, shls_slice, ao_loc
297
+ )
298
+ vmat[2] += _numint_rks._dot_ao_ao(
299
+ mol, aow, giao[2], mask, shls_slice, ao_loc
300
+ )
301
+ giao = mol.eval_gto(
302
+ "GTOval_ipig", coords, 9, non0tab=mask, out=buf[1:]
303
+ )
304
+ _prop_nmr_rks._gga_sum_(
305
+ vmat, mol, ao, giao, wv, mask, shls_slice, ao_loc
306
+ )
307
+ rho = vxc = vrho = vsigma = wv = aow = None
308
+ elif xctype == "MGGA":
309
+ raise NotImplementedError("meta-GGA")
310
+
311
+ return vmat - vmat.transpose(0, 2, 1)
312
+
313
+ setattr(
314
+ _fixed_get_vxc_giao, _NMR_PATCH_VERSION_ATTR, _NMR_COMPAT_PATCH_VERSION
315
+ )
316
+ _prop_nmr_rks.get_vxc_giao = _fixed_get_vxc_giao
317
+ except (ImportError, AttributeError) as exc: # noqa: BLE001 — optional probe
318
+ logger.debug("pyscf.prop.nmr.rks.get_vxc_giao patch not applied: %s", exc)
319
+
320
+
321
+ def _run_nmr_calc_body(
322
+ *,
323
+ molecule: Molecule,
324
+ method: str,
325
+ basis: str,
326
+ progress_stream: Any,
327
+ _dft: Any,
328
+ _gto: Any,
329
+ _scf: Any,
330
+ stream: Any,
331
+ ) -> NMRResult:
332
+ """Inner body of :func:`run_nmr_calc` (split out for stderr-capture wrap)."""
333
+ dft, gto, scf = _dft, _gto, _scf
334
+
335
+ import numpy as _np
336
+
337
+ from . import config as _config
338
+ from .session_calc import maybe_apply_d3, resolve_xc
339
+
340
+ mol = gto.Mole()
341
+ mol.atom = molecule.to_pyscf_format()
342
+ mol.basis = basis
343
+ mol.charge = molecule.charge
344
+ mol.spin = molecule.multiplicity - 1
345
+ mol.verbose = 4
346
+ mol.stdout = stream
347
+ mol.build()
348
+
349
+ method_upper = method.upper()
350
+ if method_upper == "RHF":
351
+ mf = scf.RHF(mol)
352
+ elif method_upper == "UHF":
353
+ mf = scf.UHF(mol)
354
+ else:
355
+ # Route through resolve_xc + maybe_apply_d3 so
356
+ # wB97X-D / PBE-D3 work for NMR calcs (was using raw _XC_ALIAS
357
+ # lookup before, which would fail for wB97X-D after the alias
358
+ # change to "wb97x" + external D3).
359
+ mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol)
360
+ mf.xc = resolve_xc(method)
361
+ mf = maybe_apply_d3(mf, method, progress_stream=stream)
362
+
363
+ # Cooperative cancel between SCF cycles.
364
+ from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream
365
+ from .log_utils import emit_status
366
+
367
+ attach_scf_cancel_callback(mf, cancel_check_from_stream(stream))
368
+
369
+ emit_status(stream, "Running SCF…")
370
+ try:
371
+ mf.kernel()
372
+ except Exception as exc:
373
+ raise RuntimeError(
374
+ f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}"
375
+ ) from exc
376
+
377
+ converged = bool(getattr(mf, "converged", False))
378
+
379
+ # pyscf.nmr does not exist in released pyscf; use pyscf.prop.nmr (pyscf-properties).
380
+ _pyscf_nmr: Any = None
381
+ try:
382
+ import pyscf.prop.nmr
383
+
384
+ _pyscf_nmr = pyscf.prop.nmr
385
+ except ImportError as exc:
386
+ raise ImportError(
387
+ "PySCF NMR module not found. "
388
+ "Install pyscf-properties: pip install pyscf-properties"
389
+ ) from exc
390
+
391
+ _ensure_nmr_compat_patches_applied()
392
+
393
+ emit_status(stream, "Computing NMR shielding tensors (GIAO)…")
394
+ try:
395
+ if method_upper == "RHF":
396
+ nmr_obj = _pyscf_nmr.RHF(mf)
397
+ elif method_upper == "UHF":
398
+ nmr_obj = _pyscf_nmr.UHF(mf)
399
+ else:
400
+ nmr_obj = _pyscf_nmr.RKS(mf) if mol.spin == 0 else _pyscf_nmr.UKS(mf)
401
+ tensors = nmr_obj.kernel()
402
+ except Exception as exc:
403
+ raise RuntimeError(
404
+ f"NMR shielding failed for {molecule.get_formula()}: {exc}"
405
+ ) from exc
406
+
407
+ shielding_iso: List[float] = []
408
+ for tensor in tensors:
409
+ arr = _np.array(tensor)
410
+ if arr.ndim == 2:
411
+ shielding_iso.append(float(_np.trace(arr) / 3.0))
412
+ else:
413
+ shielding_iso.append(float(arr))
414
+
415
+ ref_map, matched_ref_key, is_fallback_ref = resolve_nmr_reference(method, basis)
416
+ ref_H = float(ref_map.get("H", _config.NMR_DEFAULT_REFERENCE["H"]))
417
+ ref_C = float(ref_map.get("C", _config.NMR_DEFAULT_REFERENCE["C"]))
418
+
419
+ atoms = list(molecule.atoms)
420
+ chemical_shifts: Dict[int, float] = {}
421
+ for i, (atom, sigma) in enumerate(zip(atoms, shielding_iso)):
422
+ if atom == "H":
423
+ chemical_shifts[i] = round(ref_H - sigma, 2)
424
+ elif atom == "C":
425
+ chemical_shifts[i] = round(ref_C - sigma, 2)
426
+
427
+ return NMRResult(
428
+ atom_symbols=atoms,
429
+ shielding_iso_ppm=shielding_iso,
430
+ chemical_shifts_ppm=chemical_shifts,
431
+ method=method,
432
+ basis=basis,
433
+ formula=molecule.get_formula(),
434
+ converged=converged,
435
+ reference_key=matched_ref_key,
436
+ is_fallback_reference=is_fallback_ref,
437
+ )