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/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
|
+
)
|