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/freq_calc.py
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vibrational frequency analysis using PySCF's analytical Hessian.
|
|
3
|
+
|
|
4
|
+
Runs an SCF calculation and then computes the analytical Hessian to
|
|
5
|
+
obtain vibrational frequencies, zero-point vibrational energy (ZPVE),
|
|
6
|
+
and (where available) IR intensities.
|
|
7
|
+
|
|
8
|
+
Platform notes
|
|
9
|
+
--------------
|
|
10
|
+
Requires PySCF — Linux / macOS / WSL only.
|
|
11
|
+
|
|
12
|
+
Educational value
|
|
13
|
+
-----------------
|
|
14
|
+
* Students see which vibrational modes are IR-active and which are not.
|
|
15
|
+
* ZPVE correction shows how quantum mechanical zero-point motion contributes
|
|
16
|
+
to molecular stability.
|
|
17
|
+
* Imaginary frequencies flag a transition state or saddle point — the
|
|
18
|
+
geometry should be optimised first.
|
|
19
|
+
|
|
20
|
+
Typical usage
|
|
21
|
+
-------------
|
|
22
|
+
>>> from quantui.freq_calc import run_freq_calc
|
|
23
|
+
>>> result = run_freq_calc(molecule, method="RHF", basis="STO-3G")
|
|
24
|
+
>>> print(result.frequencies_cm1[:6]) # first 6 vibrational modes
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import IO, Any, List, Optional
|
|
34
|
+
|
|
35
|
+
from .molecule import Molecule
|
|
36
|
+
from .session_calc import HARTREE_TO_EV
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# 1 cm^-1 = h·c·100 / E_h (NIST 2018 CODATA)
|
|
41
|
+
_CM1_TO_HARTREE: float = 4.556335252912e-6
|
|
42
|
+
|
|
43
|
+
# Exact: 1 Hartree = HARTREE_TO_EV * e * N_A joules/mol
|
|
44
|
+
_HARTREE_TO_JMOL: float = 2625499.6 # J/mol per Hartree (NIST 2018 CODATA)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ============================================================================
|
|
48
|
+
# Result dataclass
|
|
49
|
+
# ============================================================================
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ThermoData:
|
|
54
|
+
"""Thermochemical data from the harmonic approximation at 298.15 K / 1 atm.
|
|
55
|
+
|
|
56
|
+
All energies are in Hartrees; entropy is in J/(mol·K).
|
|
57
|
+
H and G include the SCF electronic energy.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
zpve_hartree: float
|
|
61
|
+
H_hartree: float
|
|
62
|
+
S_jmol: float
|
|
63
|
+
G_hartree: float
|
|
64
|
+
temperature_k: float = 298.15
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FreqResult:
|
|
69
|
+
"""Structured output from a vibrational frequency analysis.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
energy_hartree: SCF energy at the input geometry in Hartrees.
|
|
73
|
+
homo_lumo_gap_ev: HOMO-LUMO gap in eV, or ``None``.
|
|
74
|
+
converged: ``True`` if the SCF converged.
|
|
75
|
+
n_iterations: Number of SCF macro-iterations.
|
|
76
|
+
method: Calculation method (e.g. ``'RHF'``, ``'B3LYP'``).
|
|
77
|
+
basis: Basis set (e.g. ``'STO-3G'``).
|
|
78
|
+
formula: Hill-notation molecular formula.
|
|
79
|
+
frequencies_cm1: Vibrational frequencies in cm⁻¹. Negative values
|
|
80
|
+
indicate imaginary frequencies (transition-state modes).
|
|
81
|
+
ir_intensities: IR intensities in km/mol per mode. Empty list if
|
|
82
|
+
the IR calculation is not available.
|
|
83
|
+
zpve_hartree: Zero-point vibrational energy in Hartrees, computed as
|
|
84
|
+
½·Σ(ν_i) for all positive-frequency modes.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
energy_hartree: float
|
|
88
|
+
homo_lumo_gap_ev: Optional[float]
|
|
89
|
+
converged: bool
|
|
90
|
+
n_iterations: int
|
|
91
|
+
method: str
|
|
92
|
+
basis: str
|
|
93
|
+
formula: str
|
|
94
|
+
frequencies_cm1: List[float] = field(default_factory=list)
|
|
95
|
+
ir_intensities: List[float] = field(default_factory=list)
|
|
96
|
+
zpve_hartree: float = 0.0
|
|
97
|
+
thermo: Optional[ThermoData] = None
|
|
98
|
+
displacements: Optional[List] = None
|
|
99
|
+
"""Normalized displacement vectors from PySCF harmonic analysis.
|
|
100
|
+
|
|
101
|
+
Shape: ``(n_modes, n_atoms, 3)`` stored as a nested Python list.
|
|
102
|
+
``None`` if the Hessian calculation failed or PySCF version does not
|
|
103
|
+
provide ``norm_mode``.
|
|
104
|
+
"""
|
|
105
|
+
mo_energy_hartree: Optional[List] = None
|
|
106
|
+
mo_occ: Optional[List] = None
|
|
107
|
+
pyscf_mol_atom: Optional[List] = None
|
|
108
|
+
pyscf_mol_basis: Optional[str] = None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def energy_ev(self) -> float:
|
|
112
|
+
"""SCF energy in electronvolts."""
|
|
113
|
+
return self.energy_hartree * HARTREE_TO_EV
|
|
114
|
+
|
|
115
|
+
def n_real_modes(self) -> int:
|
|
116
|
+
"""Number of real (positive-frequency) vibrational modes."""
|
|
117
|
+
return sum(1 for f in self.frequencies_cm1 if f > 0)
|
|
118
|
+
|
|
119
|
+
def n_imaginary_modes(self) -> int:
|
|
120
|
+
"""Number of imaginary (negative-frequency) modes."""
|
|
121
|
+
return sum(1 for f in self.frequencies_cm1 if f < 0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ============================================================================
|
|
125
|
+
# Main function
|
|
126
|
+
# ============================================================================
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_freq_calc(
|
|
130
|
+
molecule: Molecule,
|
|
131
|
+
method: str = "RHF",
|
|
132
|
+
basis: str = "STO-3G",
|
|
133
|
+
progress_stream: Optional[IO[str]] = None,
|
|
134
|
+
) -> FreqResult:
|
|
135
|
+
"""Run SCF + analytical Hessian to obtain vibrational frequencies.
|
|
136
|
+
|
|
137
|
+
The function first converges the SCF energy, then computes the analytical
|
|
138
|
+
Hessian and performs a normal-mode analysis to extract frequencies and
|
|
139
|
+
(optionally) IR intensities.
|
|
140
|
+
|
|
141
|
+
For physically meaningful frequencies, the input geometry should be at
|
|
142
|
+
(or near) a local energy minimum. Frequencies from an unoptimised
|
|
143
|
+
geometry will be large and potentially imaginary.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
molecule: Validated :class:`~quantui.molecule.Molecule`. Should be
|
|
147
|
+
an optimised geometry for best results.
|
|
148
|
+
method: SCF method — ``'RHF'``, ``'UHF'``, or a DFT functional
|
|
149
|
+
name (e.g. ``'B3LYP'``). Default: ``'RHF'``.
|
|
150
|
+
basis: Basis set name. Default: ``'STO-3G'``.
|
|
151
|
+
progress_stream: Optional writable text stream for live PySCF output.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
:class:`FreqResult` with frequencies, ZPVE, and SCF properties.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
ImportError: If PySCF is not installed.
|
|
158
|
+
RuntimeError: If the SCF calculation fails. If the Hessian
|
|
159
|
+
computation fails, frequencies are omitted and a warning is
|
|
160
|
+
written to progress_stream — no exception is raised.
|
|
161
|
+
"""
|
|
162
|
+
# Post-HF methods (MP2/CCSD/CCSD(T)) have no special-casing below —
|
|
163
|
+
# without this guard, method='CCSD' silently falls into the DFT
|
|
164
|
+
# branch (sets mf.xc = "CCSD") and fails deep inside PySCF with a
|
|
165
|
+
# cryptic "LibXCFunctional: name 'CCSD' not found" instead of a clear
|
|
166
|
+
# message. No post-HF Hessian is wired up here, so these methods are
|
|
167
|
+
# single-point only (see session_calc.py).
|
|
168
|
+
from . import config as _config
|
|
169
|
+
|
|
170
|
+
if method.strip().upper() in _config.POST_HF_METHODS:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"'{method}' is a post-HF method and cannot be used for "
|
|
173
|
+
"frequency analysis — QuantUI only has an analytical Hessian "
|
|
174
|
+
"wired up for HF/DFT methods here. Use RHF, UHF, or a DFT "
|
|
175
|
+
"functional instead."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
from pyscf import dft, gto, scf
|
|
180
|
+
from pyscf.hessian import thermo as pyscf_thermo
|
|
181
|
+
except ImportError as exc:
|
|
182
|
+
raise ImportError(
|
|
183
|
+
"PySCF is not installed — cannot run frequency analysis.\n"
|
|
184
|
+
"PySCF requires Linux, macOS, or WSL."
|
|
185
|
+
) from exc
|
|
186
|
+
|
|
187
|
+
stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
|
|
188
|
+
|
|
189
|
+
# See quantui/c_stderr.py — captures fd-2 stderr
|
|
190
|
+
# from libcint / BLAS / LAPACK / Hessian C code and relays to ``stream``
|
|
191
|
+
# on exit. POSIX-only; no-op on Windows.
|
|
192
|
+
from quantui.c_stderr import capture_c_stderr
|
|
193
|
+
|
|
194
|
+
with capture_c_stderr(stream):
|
|
195
|
+
return _run_freq_calc_body(
|
|
196
|
+
molecule=molecule,
|
|
197
|
+
method=method,
|
|
198
|
+
basis=basis,
|
|
199
|
+
progress_stream=progress_stream,
|
|
200
|
+
_dft=dft,
|
|
201
|
+
_gto=gto,
|
|
202
|
+
_scf=scf,
|
|
203
|
+
_pyscf_thermo=pyscf_thermo,
|
|
204
|
+
stream=stream,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _run_freq_calc_body(
|
|
209
|
+
*,
|
|
210
|
+
molecule: Molecule,
|
|
211
|
+
method: str,
|
|
212
|
+
basis: str,
|
|
213
|
+
progress_stream: Optional[IO[str]],
|
|
214
|
+
_dft: Any,
|
|
215
|
+
_gto: Any,
|
|
216
|
+
_scf: Any,
|
|
217
|
+
_pyscf_thermo: Any,
|
|
218
|
+
stream: IO[str],
|
|
219
|
+
) -> FreqResult:
|
|
220
|
+
"""Inner body of :func:`run_freq_calc` (split out for stderr-capture wrap)."""
|
|
221
|
+
dft, gto, scf, pyscf_thermo = _dft, _gto, _scf, _pyscf_thermo
|
|
222
|
+
|
|
223
|
+
def _status(msg: str) -> None:
|
|
224
|
+
"""Emit a status marker line consumable by QuantUI's log capture."""
|
|
225
|
+
try:
|
|
226
|
+
stream.write(f"\n[QuantUI_STATUS] {msg}\n")
|
|
227
|
+
except Exception: # noqa: BLE001 — cleanup (stream may be closed)
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# ── Build Mole object ────────────────────────────────────────────────────
|
|
231
|
+
mol = gto.Mole()
|
|
232
|
+
mol.atom = molecule.to_pyscf_format()
|
|
233
|
+
mol.basis = basis
|
|
234
|
+
mol.charge = molecule.charge
|
|
235
|
+
mol.spin = molecule.multiplicity - 1
|
|
236
|
+
mol.verbose = 4
|
|
237
|
+
mol.stdout = stream
|
|
238
|
+
mol.build()
|
|
239
|
+
|
|
240
|
+
# ── SCF ──────────────────────────────────────────────────────────────────
|
|
241
|
+
method_upper = method.upper()
|
|
242
|
+
if method_upper == "RHF":
|
|
243
|
+
mf = scf.RHF(mol)
|
|
244
|
+
elif method_upper == "UHF":
|
|
245
|
+
mf = scf.UHF(mol)
|
|
246
|
+
else:
|
|
247
|
+
# Route through resolve_xc + maybe_apply_d3 so
|
|
248
|
+
# methods like wB97X-D (PySCF rejects "wb97x-d") map to the
|
|
249
|
+
# bare functional + external D3 dispersion.
|
|
250
|
+
from .session_calc import maybe_apply_d3, resolve_xc
|
|
251
|
+
|
|
252
|
+
mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol)
|
|
253
|
+
mf.xc = resolve_xc(method)
|
|
254
|
+
mf = maybe_apply_d3(mf, method, progress_stream=stream)
|
|
255
|
+
|
|
256
|
+
# Cooperative cancel between SCF cycles (the Hessian block that
|
|
257
|
+
# follows is a single long native call the callback can't interrupt).
|
|
258
|
+
from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream
|
|
259
|
+
|
|
260
|
+
attach_scf_cancel_callback(mf, cancel_check_from_stream(stream))
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
energy_hartree = float(mf.kernel())
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}"
|
|
267
|
+
) from exc
|
|
268
|
+
|
|
269
|
+
_status("SCF converged. Computing analytical Hessian...")
|
|
270
|
+
|
|
271
|
+
converged = bool(getattr(mf, "converged", False))
|
|
272
|
+
n_iterations = int(getattr(mf, "cycles", -1))
|
|
273
|
+
|
|
274
|
+
# ── HOMO-LUMO gap (non-fatal) ────────────────────────────────────────────
|
|
275
|
+
homo_lumo_gap_ev: Optional[float] = None
|
|
276
|
+
try:
|
|
277
|
+
import numpy as _np
|
|
278
|
+
|
|
279
|
+
mo_occ = mf.mo_occ
|
|
280
|
+
mo_energy = mf.mo_energy
|
|
281
|
+
if isinstance(mo_energy, (list, _np.ndarray)) and hasattr(
|
|
282
|
+
mo_energy[0], "__len__"
|
|
283
|
+
):
|
|
284
|
+
mo_e_ref, mo_occ_ref = mo_energy[0], mo_occ[0]
|
|
285
|
+
else:
|
|
286
|
+
mo_e_ref, mo_occ_ref = mo_energy, mo_occ
|
|
287
|
+
n_occ = int((_np.array(mo_occ_ref) > 0).sum())
|
|
288
|
+
if 0 < n_occ < len(mo_e_ref):
|
|
289
|
+
homo_lumo_gap_ev = float(
|
|
290
|
+
(mo_e_ref[n_occ] - mo_e_ref[n_occ - 1]) * HARTREE_TO_EV
|
|
291
|
+
)
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
logger.debug("HOMO-LUMO gap extraction failed in freq calc: %s", exc)
|
|
294
|
+
|
|
295
|
+
# ── MO data for orbital energy diagram (best-effort) ─────────────────────
|
|
296
|
+
mo_energy_hartree: Optional[List] = None
|
|
297
|
+
mo_occ_list: Optional[List] = None
|
|
298
|
+
pyscf_mol_atom: Optional[List] = None
|
|
299
|
+
try:
|
|
300
|
+
import numpy as _np_mo
|
|
301
|
+
|
|
302
|
+
_moe = mf.mo_energy
|
|
303
|
+
_moo = mf.mo_occ
|
|
304
|
+
if isinstance(_moe, (list, _np_mo.ndarray)) and hasattr(_moe[0], "__len__"):
|
|
305
|
+
_moe, _moo = _moe[0], _moo[0]
|
|
306
|
+
mo_energy_hartree = _np_mo.asarray(_moe, dtype=float).tolist()
|
|
307
|
+
mo_occ_list = _np_mo.asarray(_moo, dtype=float).tolist()
|
|
308
|
+
# Build from molecule.atoms/coordinates (Angstrom) rather than
|
|
309
|
+
# mol._atom, which PySCF always stores internally in Bohr. Every
|
|
310
|
+
# consumer of pyscf_mol_atom (Molden export, cube generation,
|
|
311
|
+
# session_calc's/optimizer's own construction of this field)
|
|
312
|
+
# assumes Angstrom; using mol._atom here silently shipped Bohr
|
|
313
|
+
# coordinates ~1.89x too large.
|
|
314
|
+
pyscf_mol_atom = [
|
|
315
|
+
(atom, list(map(float, coords)))
|
|
316
|
+
for atom, coords in zip(molecule.atoms, molecule.coordinates)
|
|
317
|
+
]
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
# Silent failure here ships a FreqResult with no MO data,
|
|
320
|
+
# breaking the Energies panel on history replay. Log to surface
|
|
321
|
+
# in the Log tab.
|
|
322
|
+
logger.warning(
|
|
323
|
+
"MO data extraction failed in freq calc for %s: %s",
|
|
324
|
+
molecule.get_formula(),
|
|
325
|
+
exc,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# ── Hessian + frequency analysis ─────────────────────────────────────────
|
|
329
|
+
frequencies_cm1: List[float] = []
|
|
330
|
+
ir_intensities: List[float] = []
|
|
331
|
+
zpve_hartree: float = 0.0
|
|
332
|
+
displacements: Optional[List] = None
|
|
333
|
+
thermo_data: Optional[ThermoData] = None
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
hess_obj = mf.Hessian()
|
|
337
|
+
hess_obj.verbose = mol.verbose
|
|
338
|
+
hess_obj.stdout = stream
|
|
339
|
+
|
|
340
|
+
h = hess_obj.kernel()
|
|
341
|
+
|
|
342
|
+
_status("Analytical Hessian complete. Running harmonic analysis...")
|
|
343
|
+
|
|
344
|
+
freq_info = pyscf_thermo.harmonic_analysis(mol, h)
|
|
345
|
+
|
|
346
|
+
# freq_wavenumber entries may be complex numbers when PySCF uses a
|
|
347
|
+
# complex square-root convention for imaginary modes. Map them to
|
|
348
|
+
# signed real values: negative = imaginary frequency.
|
|
349
|
+
raw_freqs = freq_info["freq_wavenumber"]
|
|
350
|
+
frequencies_cm1 = []
|
|
351
|
+
for f in raw_freqs:
|
|
352
|
+
if hasattr(f, "imag") and abs(f.imag) > abs(f.real):
|
|
353
|
+
frequencies_cm1.append(float(-abs(f.imag)))
|
|
354
|
+
else:
|
|
355
|
+
frequencies_cm1.append(float(f.real if hasattr(f, "real") else f))
|
|
356
|
+
|
|
357
|
+
# ZPVE = ½ · Σ ν_i (positive modes only), converted cm⁻¹ → Hartree
|
|
358
|
+
zpve_hartree = sum(0.5 * f * _CM1_TO_HARTREE for f in frequencies_cm1 if f > 0)
|
|
359
|
+
|
|
360
|
+
# Normalized displacement vectors: shape (n_modes, n_atoms, 3).
|
|
361
|
+
# Stored as a nested Python list for JSON-friendliness and to avoid
|
|
362
|
+
# a hard numpy dependency in the dataclass.
|
|
363
|
+
try:
|
|
364
|
+
import numpy as _np
|
|
365
|
+
|
|
366
|
+
norm_mode = freq_info.get("norm_mode")
|
|
367
|
+
if norm_mode is not None:
|
|
368
|
+
# norm_mode has shape (n_modes, n_atoms*3) or (n_modes, n_atoms, 3);
|
|
369
|
+
# reshape to (n_modes, n_atoms, 3) if needed.
|
|
370
|
+
nm = _np.array(norm_mode, dtype=float)
|
|
371
|
+
n_modes_out = nm.shape[0]
|
|
372
|
+
n_atoms = len(molecule.atoms)
|
|
373
|
+
if nm.ndim == 2:
|
|
374
|
+
nm = nm.reshape(n_modes_out, n_atoms, 3)
|
|
375
|
+
displacements = nm.tolist()
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
logger.debug("Normal-mode displacement extraction failed: %s", exc)
|
|
378
|
+
displacements = None
|
|
379
|
+
|
|
380
|
+
# Numerical IR intensities via finite-difference dipole derivatives.
|
|
381
|
+
# pyscf.prop.infrared is absent from released pyscf/pyscf-properties;
|
|
382
|
+
# we compute ∂μ/∂R by displacing each atom ±DELTA, then project onto
|
|
383
|
+
# the harmonic normal modes.
|
|
384
|
+
# Reference: Porezag & Pederson, Phys. Rev. B 54, 7830 (1996).
|
|
385
|
+
if displacements is not None and frequencies_cm1:
|
|
386
|
+
try:
|
|
387
|
+
import numpy as _np_ir
|
|
388
|
+
|
|
389
|
+
from .config import BOHR_TO_ANGSTROM as _BOHR_TO_ANG
|
|
390
|
+
|
|
391
|
+
_DELTA = 0.01 # Bohr
|
|
392
|
+
_KM_MOL_FAC = 42.255 # (D/Å)²/amu → km/mol
|
|
393
|
+
|
|
394
|
+
_n_ir = mol.natm
|
|
395
|
+
_ir_total_solves = _n_ir * 3 * 2
|
|
396
|
+
_ir_done_solves = 0
|
|
397
|
+
_coords0 = mol.atom_coords().copy()
|
|
398
|
+
_dm0 = mf.make_rdm1()
|
|
399
|
+
_dpdx = _np_ir.zeros((_n_ir * 3, 3))
|
|
400
|
+
_xc = getattr(mf, "xc", None)
|
|
401
|
+
# Fix (2026-07-14): whether the inner displaced-SCF
|
|
402
|
+
# loop needs an unrestricted (UHF/UKS) object is determined
|
|
403
|
+
# by _dm0's actual shape — (2, nao, nao) for UHF/UKS/ROHF,
|
|
404
|
+
# (nao, nao) for RHF/RKS — NOT by mol.spin == 0. Those two
|
|
405
|
+
# signals only agree when the user's method choice matches
|
|
406
|
+
# the molecule's natural spin state. They diverge when a
|
|
407
|
+
# user explicitly selects UHF for a closed-shell molecule
|
|
408
|
+
# (mol.spin == 0 but the parent mf, and therefore _dm0, is
|
|
409
|
+
# still UHF-shaped): the inner loop used to build RHF from
|
|
410
|
+
# mol.spin == 0, then feed it the UHF-shaped _dm0, which
|
|
411
|
+
# raised a shape-mismatch ValueError inside PySCF and
|
|
412
|
+
# silently dropped IR intensities for the whole calc (caught
|
|
413
|
+
# by the broad except below).
|
|
414
|
+
_dm0_is_unrestricted = _np_ir.asarray(_dm0).ndim == 3
|
|
415
|
+
|
|
416
|
+
_status(
|
|
417
|
+
"Numerical IR intensities: "
|
|
418
|
+
f"{_ir_done_solves}/{_ir_total_solves} finite-difference displacement SCFs done (6 per atom) "
|
|
419
|
+
f"({_ir_total_solves - _ir_done_solves} remaining)"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Inner-SCF helper: builds the right RHF/UHF/RKS/UKS object
|
|
423
|
+
# for the current ``mol`` geometry, attempts gpu4pyscf
|
|
424
|
+
# offload (GPU extension to the IR-intensity loop —
|
|
425
|
+
# without this wrap, the per-displacement SCFs run on CPU
|
|
426
|
+
# even when the outer SCF was GPU-offloaded), and returns
|
|
427
|
+
# the dipole moment as a numpy array. Used for both +Δ and
|
|
428
|
+
# -Δ steps so the +/-/half-loop logic stays compact.
|
|
429
|
+
from quantui.gpu_offload import try_to_gpu as _try_to_gpu_inner
|
|
430
|
+
|
|
431
|
+
def _displaced_scf_dipole() -> _np_ir.ndarray:
|
|
432
|
+
if _xc is not None:
|
|
433
|
+
_mf_d = dft.UKS(mol) if _dm0_is_unrestricted else dft.RKS(mol)
|
|
434
|
+
_mf_d.xc = _xc
|
|
435
|
+
else:
|
|
436
|
+
_mf_d = scf.UHF(mol) if _dm0_is_unrestricted else scf.RHF(mol)
|
|
437
|
+
_mf_d.verbose = 0
|
|
438
|
+
_mf_d.stdout = stream
|
|
439
|
+
# ``method_upper="RHF"`` is a label — try_to_gpu only
|
|
440
|
+
# uses it to skip CCSD(T). For RHF/UHF/DFT the wrapper
|
|
441
|
+
# attempts ``mf.to_gpu()`` and falls back to CPU on any
|
|
442
|
+
# failure, so this is safe to call unconditionally.
|
|
443
|
+
_mf_d, _used_gpu, _gpu_name = _try_to_gpu_inner(_mf_d, "RHF")
|
|
444
|
+
_mf_d.kernel(dm0=_dm0)
|
|
445
|
+
return _np_ir.array(_mf_d.dip_moment(verbose=0))
|
|
446
|
+
|
|
447
|
+
# Opt-in parallel path (Pass B). When (a) the user has
|
|
448
|
+
# set ``QUANTUI_FREQ_PARALLEL=1``, (b) no GPU is available,
|
|
449
|
+
# (c) the host has >= 4 cores, and (d) the molecule has >= 2
|
|
450
|
+
# atoms, we fan the per-displacement SCFs out across a
|
|
451
|
+
# ProcessPoolExecutor. The decision is centralised in
|
|
452
|
+
# ``freq_ir_workers.parallel_enabled_for_run`` so tests
|
|
453
|
+
# can pin the contract.
|
|
454
|
+
from quantui import freq_ir_workers as _ir_par
|
|
455
|
+
from quantui.gpu_offload import is_gpu_available
|
|
456
|
+
|
|
457
|
+
_gpu_ok, _ = is_gpu_available()
|
|
458
|
+
_cpu_count = os.cpu_count() or 1
|
|
459
|
+
_use_parallel = _ir_par.parallel_enabled_for_run(
|
|
460
|
+
cpu_count=_cpu_count,
|
|
461
|
+
displacement_count=_ir_total_solves,
|
|
462
|
+
gpu_available=_gpu_ok,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
_mol_v = mol.verbose
|
|
466
|
+
mol.verbose = 0
|
|
467
|
+
_parallel_failed = False
|
|
468
|
+
try:
|
|
469
|
+
if _use_parallel:
|
|
470
|
+
try:
|
|
471
|
+
# Stash dm0 once on disk so workers can map-load it
|
|
472
|
+
# via initargs (avoids per-task pickling).
|
|
473
|
+
import concurrent.futures as _cf
|
|
474
|
+
import multiprocessing as _mp
|
|
475
|
+
import pickle as _pickle
|
|
476
|
+
import tempfile as _tempfile
|
|
477
|
+
|
|
478
|
+
_n_workers = _ir_par.pick_worker_count(
|
|
479
|
+
_cpu_count, _ir_total_solves
|
|
480
|
+
)
|
|
481
|
+
_threads_each = _ir_par.threads_per_worker(
|
|
482
|
+
_cpu_count, _n_workers
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# Build all 6N task arguments first; pickling-safe
|
|
486
|
+
# flat lists per-displacement.
|
|
487
|
+
_tasks: list[tuple[int, int, int, list[float]]] = []
|
|
488
|
+
for _I in range(_n_ir):
|
|
489
|
+
for _ax in range(3):
|
|
490
|
+
_cp = _coords0.copy()
|
|
491
|
+
_cp[_I, _ax] += _DELTA
|
|
492
|
+
_tasks.append((_I, _ax, +1, _cp.flatten().tolist()))
|
|
493
|
+
_cm = _coords0.copy()
|
|
494
|
+
_cm[_I, _ax] -= _DELTA
|
|
495
|
+
_tasks.append((_I, _ax, -1, _cm.flatten().tolist()))
|
|
496
|
+
|
|
497
|
+
_dm0_handle = _tempfile.NamedTemporaryFile(
|
|
498
|
+
delete=False, suffix=".dm0.pkl"
|
|
499
|
+
)
|
|
500
|
+
try:
|
|
501
|
+
_pickle.dump(_dm0, _dm0_handle)
|
|
502
|
+
_dm0_handle.close()
|
|
503
|
+
|
|
504
|
+
# Pyscf-format atom string for worker rebuild.
|
|
505
|
+
_atom_str = molecule.to_pyscf_format()
|
|
506
|
+
_spin = molecule.multiplicity - 1
|
|
507
|
+
_charge = molecule.charge
|
|
508
|
+
_ctx = _mp.get_context("spawn")
|
|
509
|
+
with _cf.ProcessPoolExecutor(
|
|
510
|
+
max_workers=_n_workers,
|
|
511
|
+
mp_context=_ctx,
|
|
512
|
+
initializer=_ir_par.init_worker,
|
|
513
|
+
initargs=(
|
|
514
|
+
_atom_str,
|
|
515
|
+
basis,
|
|
516
|
+
_charge,
|
|
517
|
+
_spin,
|
|
518
|
+
_xc,
|
|
519
|
+
_dm0_handle.name,
|
|
520
|
+
_threads_each,
|
|
521
|
+
),
|
|
522
|
+
) as _pool:
|
|
523
|
+
# Submit all and store futures keyed by task
|
|
524
|
+
# index so we can assemble +/- per (I, ax).
|
|
525
|
+
_futs = {
|
|
526
|
+
_pool.submit(
|
|
527
|
+
_ir_par.run_displaced_scf, _task[3]
|
|
528
|
+
): _task
|
|
529
|
+
for _task in _tasks
|
|
530
|
+
}
|
|
531
|
+
# Accumulate results into a temporary map
|
|
532
|
+
# ``(I, ax, sign) -> dipole_array``.
|
|
533
|
+
_dipoles: dict = {}
|
|
534
|
+
for _fut in _cf.as_completed(_futs):
|
|
535
|
+
_I, _ax, _sign, _coords_done = _futs[_fut]
|
|
536
|
+
_dipoles[(_I, _ax, _sign)] = _fut.result()
|
|
537
|
+
_ir_done_solves += 1
|
|
538
|
+
_status(
|
|
539
|
+
"Numerical IR intensities (parallel ×"
|
|
540
|
+
f"{_n_workers}): "
|
|
541
|
+
f"{_ir_done_solves}/{_ir_total_solves} "
|
|
542
|
+
"finite-difference displacement SCFs done (6 per atom) "
|
|
543
|
+
f"({_ir_total_solves - _ir_done_solves} "
|
|
544
|
+
"remaining)"
|
|
545
|
+
)
|
|
546
|
+
finally:
|
|
547
|
+
try:
|
|
548
|
+
os.unlink(_dm0_handle.name)
|
|
549
|
+
except OSError:
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
# Assemble dpdx now that all dipoles are in hand.
|
|
553
|
+
for _I in range(_n_ir):
|
|
554
|
+
for _ax in range(3):
|
|
555
|
+
_mu_p = _dipoles[(_I, _ax, +1)]
|
|
556
|
+
_mu_m = _dipoles[(_I, _ax, -1)]
|
|
557
|
+
_dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA)
|
|
558
|
+
except Exception as _par_exc:
|
|
559
|
+
logger.warning(
|
|
560
|
+
"Parallel IR-intensity computation failed (%s); falling back to serial.",
|
|
561
|
+
_par_exc,
|
|
562
|
+
)
|
|
563
|
+
_status(
|
|
564
|
+
"Parallel IR intensities failed; falling back to serial computation."
|
|
565
|
+
)
|
|
566
|
+
_parallel_failed = True
|
|
567
|
+
# Reset so the serial loop's progress messages
|
|
568
|
+
# below start clean rather than continuing from
|
|
569
|
+
# wherever the failed parallel attempt left off.
|
|
570
|
+
_ir_done_solves = 0
|
|
571
|
+
if not _use_parallel or _parallel_failed:
|
|
572
|
+
for _I in range(_n_ir):
|
|
573
|
+
for _ax in range(3):
|
|
574
|
+
# +Δ displacement
|
|
575
|
+
_cp = _coords0.copy()
|
|
576
|
+
_cp[_I, _ax] += _DELTA
|
|
577
|
+
mol.set_geom_(_cp, unit="Bohr")
|
|
578
|
+
_mu_p = _displaced_scf_dipole()
|
|
579
|
+
_ir_done_solves += 1
|
|
580
|
+
_status(
|
|
581
|
+
"Numerical IR intensities: "
|
|
582
|
+
f"{_ir_done_solves}/{_ir_total_solves} "
|
|
583
|
+
"finite-difference displacement SCFs done (6 per atom) "
|
|
584
|
+
f"({_ir_total_solves - _ir_done_solves} "
|
|
585
|
+
"remaining)"
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# -Δ displacement
|
|
589
|
+
_cm = _coords0.copy()
|
|
590
|
+
_cm[_I, _ax] -= _DELTA
|
|
591
|
+
mol.set_geom_(_cm, unit="Bohr")
|
|
592
|
+
_mu_m = _displaced_scf_dipole()
|
|
593
|
+
_ir_done_solves += 1
|
|
594
|
+
_status(
|
|
595
|
+
"Numerical IR intensities: "
|
|
596
|
+
f"{_ir_done_solves}/{_ir_total_solves} "
|
|
597
|
+
"finite-difference displacement SCFs done (6 per atom) "
|
|
598
|
+
f"({_ir_total_solves - _ir_done_solves} "
|
|
599
|
+
"remaining)"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
_dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA)
|
|
603
|
+
finally:
|
|
604
|
+
mol.set_geom_(_coords0, unit="Bohr")
|
|
605
|
+
mol.verbose = _mol_v
|
|
606
|
+
|
|
607
|
+
_dpdx_AA = _dpdx / _BOHR_TO_ANG
|
|
608
|
+
_nm_flat = _np_ir.array(displacements).reshape(len(frequencies_cm1), -1)
|
|
609
|
+
_dpdQ = _nm_flat @ _dpdx_AA
|
|
610
|
+
_ir = (_KM_MOL_FAC * (_dpdQ**2).sum(axis=1)).tolist()
|
|
611
|
+
if len(_ir) == len(frequencies_cm1):
|
|
612
|
+
ir_intensities = _ir
|
|
613
|
+
_status(
|
|
614
|
+
"Numerical IR intensities complete. Computing thermochemistry..."
|
|
615
|
+
)
|
|
616
|
+
except Exception as _ir_exc:
|
|
617
|
+
logger.warning("Numerical IR intensities failed: %s", _ir_exc)
|
|
618
|
+
_status(
|
|
619
|
+
"Numerical IR intensities failed; continuing without IR intensities."
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Thermochemistry at 298.15 K / 1 atm — best-effort
|
|
623
|
+
try:
|
|
624
|
+
import numpy as _np
|
|
625
|
+
|
|
626
|
+
_status("Computing thermochemistry...")
|
|
627
|
+
|
|
628
|
+
_freq_au = freq_info.get("freq_au")
|
|
629
|
+
if _freq_au is None:
|
|
630
|
+
_freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE
|
|
631
|
+
else:
|
|
632
|
+
# PySCF may return complex freq_au for imaginary modes; take real parts.
|
|
633
|
+
_freq_au = _np.array(
|
|
634
|
+
[f.real if hasattr(f, "real") else f for f in _freq_au],
|
|
635
|
+
dtype=float,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# PySCF 2.x thermo() may or may not accept the pressure argument.
|
|
639
|
+
try:
|
|
640
|
+
_tout = pyscf_thermo.thermo(mf, _freq_au, 298.15, 101325)
|
|
641
|
+
except TypeError:
|
|
642
|
+
_tout = pyscf_thermo.thermo(mf, _freq_au, 298.15)
|
|
643
|
+
|
|
644
|
+
# PySCF 2.x returns (value, unit_string) tuples; earlier versions
|
|
645
|
+
# return plain floats. _tv() extracts the numeric value either way.
|
|
646
|
+
def _tv(v):
|
|
647
|
+
if isinstance(v, (tuple, list)):
|
|
648
|
+
return float(v[0])
|
|
649
|
+
if hasattr(v, "item"):
|
|
650
|
+
return float(v.item())
|
|
651
|
+
return float(v)
|
|
652
|
+
|
|
653
|
+
# PySCF 2.x (>=2.6) uses "H_tot"/"S_tot"; earlier versions used "H"/"S".
|
|
654
|
+
_H_raw, _S_raw, _Z_raw = None, None, None
|
|
655
|
+
for _k in ("H_tot", "H", "Htot", "H_0K"):
|
|
656
|
+
if _tout.get(_k) is not None:
|
|
657
|
+
_H_raw = _tout[_k]
|
|
658
|
+
break
|
|
659
|
+
for _k in ("S_tot", "S", "Stot"):
|
|
660
|
+
if _tout.get(_k) is not None:
|
|
661
|
+
_S_raw = _tout[_k]
|
|
662
|
+
break
|
|
663
|
+
for _k in ("ZPE", "zpve", "ZPE_vib"):
|
|
664
|
+
if _tout.get(_k) is not None:
|
|
665
|
+
_Z_raw = _tout[_k]
|
|
666
|
+
break
|
|
667
|
+
if _H_raw is None or _S_raw is None:
|
|
668
|
+
raise KeyError(
|
|
669
|
+
f"Missing H or S in thermo dict (keys: {sorted(_tout.keys())})"
|
|
670
|
+
)
|
|
671
|
+
_H = _tv(_H_raw)
|
|
672
|
+
_S = _tv(_S_raw) # J/(mol·K)
|
|
673
|
+
_zpve = _tv(_Z_raw) if _Z_raw is not None else zpve_hartree
|
|
674
|
+
_G = _H - 298.15 * _S / _HARTREE_TO_JMOL
|
|
675
|
+
thermo_data = ThermoData(
|
|
676
|
+
zpve_hartree=_zpve,
|
|
677
|
+
H_hartree=_H,
|
|
678
|
+
S_jmol=_S,
|
|
679
|
+
G_hartree=_G,
|
|
680
|
+
)
|
|
681
|
+
_status("Frequency backend complete.")
|
|
682
|
+
except Exception as _exc:
|
|
683
|
+
logger.warning("Thermochemistry failed: %s", _exc)
|
|
684
|
+
_status("Thermochemistry failed; frequency backend complete.")
|
|
685
|
+
|
|
686
|
+
except Exception as exc:
|
|
687
|
+
logger.warning("Hessian/frequency computation failed: %s", exc)
|
|
688
|
+
_status("Hessian/frequency step failed.")
|
|
689
|
+
if progress_stream is not None:
|
|
690
|
+
try:
|
|
691
|
+
progress_stream.write(f"\n⚠ Hessian failed: {exc}\n")
|
|
692
|
+
except Exception: # noqa: BLE001 — cleanup (stream may be closed)
|
|
693
|
+
pass
|
|
694
|
+
|
|
695
|
+
return FreqResult(
|
|
696
|
+
energy_hartree=energy_hartree,
|
|
697
|
+
homo_lumo_gap_ev=homo_lumo_gap_ev,
|
|
698
|
+
converged=converged,
|
|
699
|
+
n_iterations=n_iterations,
|
|
700
|
+
method=method,
|
|
701
|
+
basis=basis,
|
|
702
|
+
formula=molecule.get_formula(),
|
|
703
|
+
frequencies_cm1=frequencies_cm1,
|
|
704
|
+
ir_intensities=ir_intensities,
|
|
705
|
+
zpve_hartree=zpve_hartree,
|
|
706
|
+
thermo=thermo_data,
|
|
707
|
+
displacements=displacements,
|
|
708
|
+
mo_energy_hartree=mo_energy_hartree,
|
|
709
|
+
mo_occ=mo_occ_list,
|
|
710
|
+
pyscf_mol_atom=pyscf_mol_atom,
|
|
711
|
+
pyscf_mol_basis=basis,
|
|
712
|
+
)
|