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