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/optimizer.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QM geometry optimization using ASE-BFGS + PySCF gradients.
|
|
3
|
+
|
|
4
|
+
Performs a full quantum mechanical geometry optimization by coupling the
|
|
5
|
+
ASE BFGS optimizer with a thin PySCF wrapper calculator. Atoms are
|
|
6
|
+
moved iteratively until the maximum force on any atom falls below the
|
|
7
|
+
convergence threshold (``fmax``).
|
|
8
|
+
|
|
9
|
+
Returns both the final optimized molecule and the complete list of
|
|
10
|
+
intermediate frames as a trajectory — enabling step-by-step
|
|
11
|
+
visualization of the relaxation path in the notebook.
|
|
12
|
+
|
|
13
|
+
Platform notes
|
|
14
|
+
--------------
|
|
15
|
+
Requires PySCF — **Linux / macOS / WSL only**. ASE >= 3.22 required.
|
|
16
|
+
This module imports PySCF lazily so it can be imported safely on Windows.
|
|
17
|
+
|
|
18
|
+
Implementation note
|
|
19
|
+
-------------------
|
|
20
|
+
ASE does not ship an ``ase.calculators.pyscf`` module. Instead this
|
|
21
|
+
module defines ``_QuantUIPySCFCalc``, a minimal ASE Calculator that
|
|
22
|
+
calls PySCF's SCF kernel and analytical nuclear-gradient driver directly.
|
|
23
|
+
|
|
24
|
+
Educational value
|
|
25
|
+
-----------------
|
|
26
|
+
* Students see the molecule relax step-by-step (trajectory slider in the
|
|
27
|
+
notebook's 3D viewer).
|
|
28
|
+
* The energy-vs-step plot shows convergence behaviour.
|
|
29
|
+
* RMSD between initial and final geometry quantifies the structural change.
|
|
30
|
+
* Teaches that real molecular properties require an optimized geometry.
|
|
31
|
+
|
|
32
|
+
Typical usage
|
|
33
|
+
-------------
|
|
34
|
+
>>> from quantui import optimize_geometry
|
|
35
|
+
>>> result = optimize_geometry(molecule, method="RHF", basis="STO-3G")
|
|
36
|
+
>>> print(result.summary())
|
|
37
|
+
>>> # result.trajectory is a list[Molecule] for the step-through viewer
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import io
|
|
44
|
+
import logging
|
|
45
|
+
import math
|
|
46
|
+
import sys
|
|
47
|
+
import tempfile
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import IO, Any, List, Optional
|
|
51
|
+
|
|
52
|
+
from .ase_bridge import ASE_AVAILABLE, atoms_to_molecule, molecule_to_atoms
|
|
53
|
+
from .config import BOHR_TO_ANGSTROM as _BOHR_TO_ANG
|
|
54
|
+
from .molecule import Molecule
|
|
55
|
+
from .session_calc import HARTREE_TO_EV
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
# Defaults also exposed in config.py for the notebook UI
|
|
60
|
+
DEFAULT_FMAX: float = 0.05 # eV/Å — tight enough for educational use
|
|
61
|
+
DEFAULT_OPT_STEPS: int = 200 # generous upper limit for small molecules
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Minimal ASE Calculator wrapping PySCF
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
# Defined conditionally so the module can be imported on Windows (no ASE).
|
|
69
|
+
try:
|
|
70
|
+
from ase.calculators.calculator import ( # type: ignore[import]
|
|
71
|
+
Calculator,
|
|
72
|
+
all_changes,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
class _QuantUIPySCFCalc(Calculator):
|
|
76
|
+
"""
|
|
77
|
+
Thin ASE Calculator that drives PySCF SCF + analytical gradients.
|
|
78
|
+
|
|
79
|
+
ASE does not provide an ``ase.calculators.pyscf`` module, so this
|
|
80
|
+
class replaces it. It builds a PySCF ``Mole`` from the current
|
|
81
|
+
ASE ``Atoms`` object at each step, runs the SCF, computes the
|
|
82
|
+
nuclear gradient, and converts both to ASE units (eV and eV/Å).
|
|
83
|
+
|
|
84
|
+
All PySCF output is routed to a ``StringIO`` sink so the notebook
|
|
85
|
+
output stays clean; BFGS step progress is handled by ASE.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
implemented_properties: List[str] = ["energy", "forces"]
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
method: str = "RHF",
|
|
93
|
+
basis: str = "STO-3G",
|
|
94
|
+
charge: int = 0,
|
|
95
|
+
spin: int = 0,
|
|
96
|
+
cancel_check=None,
|
|
97
|
+
progress_stream=None,
|
|
98
|
+
status_label: str = "Optimizing geometry",
|
|
99
|
+
expected_steps=None,
|
|
100
|
+
**kwargs,
|
|
101
|
+
) -> None:
|
|
102
|
+
super().__init__(**kwargs)
|
|
103
|
+
self.method = method
|
|
104
|
+
self.basis = basis
|
|
105
|
+
self.charge = charge
|
|
106
|
+
self.spin = spin
|
|
107
|
+
# Cooperative-cancel predicate; checked per step + wired into
|
|
108
|
+
# the per-step SCF callback (the SCF runs silent here, so the
|
|
109
|
+
# stream-based cancel can't see it).
|
|
110
|
+
self.cancel_check = cancel_check
|
|
111
|
+
# Progress stream + label for per-step status
|
|
112
|
+
# heartbeats (the SCF runs at verbose=0, so nothing else surfaces
|
|
113
|
+
# progress during a step). ``_eval_count`` counts force evaluations.
|
|
114
|
+
self.progress_stream = progress_stream
|
|
115
|
+
self.status_label = status_label
|
|
116
|
+
self.expected_steps = expected_steps # history-based ~N prior
|
|
117
|
+
self._eval_count = 0
|
|
118
|
+
|
|
119
|
+
def calculate(
|
|
120
|
+
self,
|
|
121
|
+
atoms=None,
|
|
122
|
+
properties=("energy", "forces"),
|
|
123
|
+
system_changes=all_changes,
|
|
124
|
+
) -> None:
|
|
125
|
+
super().calculate(atoms, properties, system_changes)
|
|
126
|
+
|
|
127
|
+
# Bail before starting this step's SCF if cancel was clicked
|
|
128
|
+
# (fires between BFGS force evaluations, independent of ASE output).
|
|
129
|
+
from .cancellation import (
|
|
130
|
+
attach_scf_cancel_callback,
|
|
131
|
+
raise_if_cancelled,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
raise_if_cancelled(self.cancel_check)
|
|
135
|
+
|
|
136
|
+
# Heartbeat so the status line advances during the
|
|
137
|
+
# (silent) per-step SCF + gradient.
|
|
138
|
+
self._eval_count += 1
|
|
139
|
+
from .log_utils import emit_status
|
|
140
|
+
|
|
141
|
+
_step_of = (
|
|
142
|
+
f"{self._eval_count}/~{int(self.expected_steps)}"
|
|
143
|
+
if self.expected_steps
|
|
144
|
+
else f"{self._eval_count}"
|
|
145
|
+
)
|
|
146
|
+
emit_status(
|
|
147
|
+
self.progress_stream,
|
|
148
|
+
f"{self.status_label} — SCF + gradient (step {_step_of})…",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
import numpy as np
|
|
152
|
+
from pyscf import dft, gto, scf
|
|
153
|
+
|
|
154
|
+
_sink = io.StringIO() # absorb all PySCF output
|
|
155
|
+
|
|
156
|
+
if self.atoms is None:
|
|
157
|
+
raise RuntimeError("No Atoms object attached to calculator.")
|
|
158
|
+
|
|
159
|
+
# Build PySCF molecule from the current ASE geometry
|
|
160
|
+
_atom_list_for_cube = [
|
|
161
|
+
(sym, pos)
|
|
162
|
+
for sym, pos in zip(
|
|
163
|
+
self.atoms.get_chemical_symbols(),
|
|
164
|
+
self.atoms.get_positions().tolist(),
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
mol = gto.Mole()
|
|
168
|
+
mol.atom = _atom_list_for_cube
|
|
169
|
+
mol.basis = self.basis
|
|
170
|
+
mol.charge = self.charge
|
|
171
|
+
mol.spin = self.spin
|
|
172
|
+
mol.unit = "Angstrom"
|
|
173
|
+
mol.verbose = 0
|
|
174
|
+
mol.stdout = _sink
|
|
175
|
+
mol.build()
|
|
176
|
+
|
|
177
|
+
# Select SCF method
|
|
178
|
+
method_upper = self.method.upper()
|
|
179
|
+
if method_upper in ("RHF", "HF"):
|
|
180
|
+
mf = scf.RHF(mol)
|
|
181
|
+
elif method_upper == "UHF":
|
|
182
|
+
mf = scf.UHF(mol)
|
|
183
|
+
else:
|
|
184
|
+
# DFT functional. Route through resolve_xc +
|
|
185
|
+
# maybe_apply_d3 so wB97X-D / PBE-D3 work mid-optimization.
|
|
186
|
+
from .session_calc import maybe_apply_d3, resolve_xc
|
|
187
|
+
|
|
188
|
+
mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol)
|
|
189
|
+
mf.xc = resolve_xc(self.method)
|
|
190
|
+
mf = maybe_apply_d3(mf, self.method)
|
|
191
|
+
|
|
192
|
+
mf.verbose = 0
|
|
193
|
+
mf.stdout = _sink
|
|
194
|
+
|
|
195
|
+
# Per-SCF-cycle heartbeat during the (silent) step,
|
|
196
|
+
# so the status advances mid-SCF, not just per optimizer step.
|
|
197
|
+
_k = self._eval_count
|
|
198
|
+
|
|
199
|
+
def _scf_progress(envs, _k=_k) -> None:
|
|
200
|
+
cyc = envs.get("cycle") if hasattr(envs, "get") else None
|
|
201
|
+
if cyc is None:
|
|
202
|
+
return
|
|
203
|
+
emit_status(
|
|
204
|
+
self.progress_stream,
|
|
205
|
+
f"{self.status_label} — step {_k}, SCF cycle {cyc + 1}…",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
attach_scf_cancel_callback(mf, self.cancel_check, progress_cb=_scf_progress)
|
|
209
|
+
mf.kernel()
|
|
210
|
+
|
|
211
|
+
# Save final SCF state for orbital visualization
|
|
212
|
+
self._last_mf = mf
|
|
213
|
+
self._last_atom_list = _atom_list_for_cube
|
|
214
|
+
|
|
215
|
+
# Analytical nuclear gradient (Hartree/Bohr)
|
|
216
|
+
grad_driver = mf.nuc_grad_method()
|
|
217
|
+
grad_driver.verbose = 0
|
|
218
|
+
grad_driver.stdout = _sink
|
|
219
|
+
g_ha_bohr = grad_driver.kernel() # shape (n_atoms, 3)
|
|
220
|
+
|
|
221
|
+
# Convert to ASE units and store results
|
|
222
|
+
# Force = -gradient; 1 Ha/Bohr = HARTREE_TO_EV / _BOHR_TO_ANG eV/Å
|
|
223
|
+
self.results["energy"] = float(mf.e_tot) * HARTREE_TO_EV
|
|
224
|
+
self.results["forces"] = (
|
|
225
|
+
-np.asarray(g_ha_bohr) * HARTREE_TO_EV / _BOHR_TO_ANG
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
except ImportError:
|
|
229
|
+
# ASE not installed — _QuantUIPySCFCalc is unavailable.
|
|
230
|
+
# optimize_geometry() will raise a clear ImportError before ever using it.
|
|
231
|
+
_QuantUIPySCFCalc = None # type: ignore[assignment,misc]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ============================================================================
|
|
235
|
+
# Result dataclass
|
|
236
|
+
# ============================================================================
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class OptimizationResult:
|
|
241
|
+
"""
|
|
242
|
+
Structured output from a completed QM geometry optimization.
|
|
243
|
+
|
|
244
|
+
Attributes:
|
|
245
|
+
molecule: Final optimized :class:`~quantui.molecule.Molecule`.
|
|
246
|
+
trajectory: All frames as a list of Molecule objects, starting from
|
|
247
|
+
the *input* geometry and ending at the optimized geometry.
|
|
248
|
+
Length is ``n_steps + 1``.
|
|
249
|
+
energies_hartree: SCF energy in Hartrees at each trajectory frame.
|
|
250
|
+
Same length as ``trajectory``.
|
|
251
|
+
converged: ``True`` if the maximum atomic force dropped below
|
|
252
|
+
``fmax`` within the allowed number of steps.
|
|
253
|
+
n_steps: Number of BFGS optimizer steps taken (``len(trajectory) - 1``).
|
|
254
|
+
method: Calculation method used (e.g. ``'RHF'``).
|
|
255
|
+
basis: Basis set used (e.g. ``'STO-3G'``).
|
|
256
|
+
formula: Hill-notation molecular formula of the input molecule.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
molecule: Molecule
|
|
260
|
+
trajectory: List[Molecule]
|
|
261
|
+
energies_hartree: List[float]
|
|
262
|
+
converged: bool
|
|
263
|
+
n_steps: int
|
|
264
|
+
method: str
|
|
265
|
+
basis: str
|
|
266
|
+
formula: str
|
|
267
|
+
mo_energy_hartree: Optional[Any] = None # from final SCF step
|
|
268
|
+
mo_occ: Optional[Any] = None
|
|
269
|
+
mo_coeff: Optional[Any] = None
|
|
270
|
+
pyscf_mol_atom: Optional[Any] = None # atom list at final geometry (Angstrom)
|
|
271
|
+
pyscf_mol_basis: Optional[str] = None
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def energy_hartree(self) -> float:
|
|
275
|
+
"""Final energy in Hartrees (last trajectory frame)."""
|
|
276
|
+
return self.energies_hartree[-1] if self.energies_hartree else float("nan")
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def energy_ev(self) -> float:
|
|
280
|
+
"""Final energy in electronvolts."""
|
|
281
|
+
return self.energy_hartree * HARTREE_TO_EV
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def energy_change_hartree(self) -> float:
|
|
285
|
+
"""Total energy change from the first to the last frame (Ha)."""
|
|
286
|
+
if len(self.energies_hartree) < 2:
|
|
287
|
+
return 0.0
|
|
288
|
+
return self.energies_hartree[-1] - self.energies_hartree[0]
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def rmsd_angstrom(self) -> float:
|
|
292
|
+
"""
|
|
293
|
+
Root-mean-square displacement (Å) between the initial and final geometry.
|
|
294
|
+
|
|
295
|
+
Measures how much the structure changed during optimization.
|
|
296
|
+
Uses pure Python so it works without numpy.
|
|
297
|
+
"""
|
|
298
|
+
if len(self.trajectory) < 2:
|
|
299
|
+
return 0.0
|
|
300
|
+
initial = self.trajectory[0].coordinates
|
|
301
|
+
final = self.trajectory[-1].coordinates
|
|
302
|
+
n = len(initial)
|
|
303
|
+
if n == 0:
|
|
304
|
+
return 0.0
|
|
305
|
+
total = sum(
|
|
306
|
+
(fx - ix) ** 2 + (fy - iy) ** 2 + (fz - iz) ** 2
|
|
307
|
+
for (ix, iy, iz), (fx, fy, fz) in zip(initial, final)
|
|
308
|
+
)
|
|
309
|
+
return math.sqrt(total / n)
|
|
310
|
+
|
|
311
|
+
def summary(self) -> str:
|
|
312
|
+
"""Return a multi-line human-readable result summary."""
|
|
313
|
+
lines = [
|
|
314
|
+
"=" * 60,
|
|
315
|
+
"Geometry Optimization Results",
|
|
316
|
+
"=" * 60,
|
|
317
|
+
f" Molecule : {self.formula}",
|
|
318
|
+
f" Method/Basis : {self.method}/{self.basis}",
|
|
319
|
+
f" Converged : {'Yes' if self.converged else '❌ NO — max steps reached'}",
|
|
320
|
+
f" Steps taken : {self.n_steps}",
|
|
321
|
+
f" Final energy : {self.energy_hartree:.8f} Ha",
|
|
322
|
+
f" Energy change : {self.energy_change_hartree:+.6f} Ha",
|
|
323
|
+
f" Geometry RMSD : {self.rmsd_angstrom:.4f} Å",
|
|
324
|
+
"=" * 60,
|
|
325
|
+
]
|
|
326
|
+
if self.converged:
|
|
327
|
+
lines.append("✅ Optimization converged successfully!")
|
|
328
|
+
else:
|
|
329
|
+
lines.append(
|
|
330
|
+
"⚠️ Optimization did not converge.\n"
|
|
331
|
+
" Try increasing Max Steps, loosening Force Threshold,\n"
|
|
332
|
+
" or using LJ pre-optimization to improve the starting geometry."
|
|
333
|
+
)
|
|
334
|
+
lines.append("=" * 60)
|
|
335
|
+
return "\n".join(lines)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ============================================================================
|
|
339
|
+
# Main function
|
|
340
|
+
# ============================================================================
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def optimize_geometry(
|
|
344
|
+
molecule: Molecule,
|
|
345
|
+
method: str = "RHF",
|
|
346
|
+
basis: str = "STO-3G",
|
|
347
|
+
fmax: float = DEFAULT_FMAX,
|
|
348
|
+
steps: int = DEFAULT_OPT_STEPS,
|
|
349
|
+
progress_stream: Optional[IO[str]] = None,
|
|
350
|
+
status_label: str = "Optimizing geometry",
|
|
351
|
+
report_fraction: bool = True,
|
|
352
|
+
expected_steps: Optional[int] = None,
|
|
353
|
+
) -> OptimizationResult:
|
|
354
|
+
"""
|
|
355
|
+
Optimize a molecular geometry at the QM level using ASE-BFGS + PySCF.
|
|
356
|
+
|
|
357
|
+
Runs a BFGS quasi-Newton geometry optimization. At each step the
|
|
358
|
+
PySCF mean-field calculator provides the energy and analytical
|
|
359
|
+
nuclear gradients (forces). The BFGS optimizer moves the atoms
|
|
360
|
+
toward lower energy until convergence.
|
|
361
|
+
|
|
362
|
+
The full trajectory (one :class:`~quantui.molecule.Molecule` per
|
|
363
|
+
optimizer step, including the initial geometry) is stored in
|
|
364
|
+
:attr:`OptimizationResult.trajectory` for step-through visualization.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
molecule: Starting geometry as a validated
|
|
368
|
+
:class:`~quantui.molecule.Molecule`.
|
|
369
|
+
method: SCF method — ``'RHF'`` or ``'UHF'``. Default: ``'RHF'``.
|
|
370
|
+
For optimization ``'RHF'`` is recommended unless the molecule
|
|
371
|
+
is an open-shell radical.
|
|
372
|
+
basis: Basis set. ``'STO-3G'`` is fastest; ``'6-31G'`` or
|
|
373
|
+
``'6-31G*'`` give more chemically accurate geometries but
|
|
374
|
+
are significantly slower. Default: ``'STO-3G'``.
|
|
375
|
+
fmax: Force convergence threshold in eV/Å. Optimization stops
|
|
376
|
+
when the maximum force on any atom is below this value.
|
|
377
|
+
Default: 0.05 eV/Å (a standard tight threshold).
|
|
378
|
+
steps: Maximum number of BFGS optimizer steps. Default: 200.
|
|
379
|
+
progress_stream: Optional writable text stream. BFGS step
|
|
380
|
+
progress (step number and maximum force) is written here.
|
|
381
|
+
Pass a widget-backed stream in the notebook for live output;
|
|
382
|
+
leave ``None`` to write to ``sys.stdout``.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
:class:`OptimizationResult` containing the optimized molecule,
|
|
386
|
+
full trajectory, per-step energies, convergence status, and
|
|
387
|
+
summary statistics.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
ImportError: If ASE or PySCF is not installed.
|
|
391
|
+
RuntimeError: If the optimization raises an unexpected exception
|
|
392
|
+
(original exception is chained).
|
|
393
|
+
|
|
394
|
+
Note:
|
|
395
|
+
PySCF verbose output is suppressed during optimization to keep
|
|
396
|
+
the progress stream clean. BFGS writes a concise per-step table
|
|
397
|
+
(step number and maximum force) to *progress_stream*.
|
|
398
|
+
"""
|
|
399
|
+
# --- Dependency checks ---
|
|
400
|
+
if not ASE_AVAILABLE or _QuantUIPySCFCalc is None:
|
|
401
|
+
raise ImportError(
|
|
402
|
+
"ASE is not installed — cannot run geometry optimization.\n"
|
|
403
|
+
" pip install 'ase>=3.22.0'\n"
|
|
404
|
+
" # or: conda install -c conda-forge ase"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Post-HF methods (MP2/CCSD/CCSD(T)) have no special-casing in
|
|
408
|
+
# _QuantUIPySCFCalc — without this guard, method='CCSD' silently falls
|
|
409
|
+
# into the DFT branch (sets mf.xc = "CCSD") and fails deep inside PySCF
|
|
410
|
+
# with a cryptic "LibXCFunctional: name 'CCSD' not found" instead of a
|
|
411
|
+
# clear message. No analytical post-HF nuclear gradients are wired up
|
|
412
|
+
# here, so these methods are single-point only (see session_calc.py).
|
|
413
|
+
from . import config as _config
|
|
414
|
+
|
|
415
|
+
if method.strip().upper() in _config.POST_HF_METHODS:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
f"'{method}' is a post-HF method and cannot be used for geometry "
|
|
418
|
+
"optimization — QuantUI only has analytical gradients wired up "
|
|
419
|
+
"for HF/DFT methods here. Optimize with RHF, UHF, or a DFT "
|
|
420
|
+
f"functional, then run a Single Point calculation with '{method}' "
|
|
421
|
+
"on the optimized geometry."
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
try:
|
|
425
|
+
import pyscf as _pyscf # noqa: F401 — presence check
|
|
426
|
+
except ImportError as exc:
|
|
427
|
+
raise ImportError(
|
|
428
|
+
"PySCF is not installed — cannot run geometry optimization.\n"
|
|
429
|
+
" conda install -c conda-forge pyscf\n"
|
|
430
|
+
"Note: PySCF is Linux / macOS / WSL only."
|
|
431
|
+
) from exc
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
from ase.optimize import BFGS # type: ignore[import]
|
|
435
|
+
except ImportError as exc:
|
|
436
|
+
raise ImportError(
|
|
437
|
+
"ase.optimize.BFGS is not available.\n"
|
|
438
|
+
"Ensure ASE >= 3.22.0: pip install 'ase>=3.22.0'"
|
|
439
|
+
) from exc
|
|
440
|
+
|
|
441
|
+
_stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
|
|
442
|
+
_null = io.StringIO()
|
|
443
|
+
|
|
444
|
+
# --- Set up ASE Atoms + PySCF calculator ---
|
|
445
|
+
from .cancellation import cancel_check_from_stream, raise_if_cancelled
|
|
446
|
+
|
|
447
|
+
_cancel_check = cancel_check_from_stream(_stream)
|
|
448
|
+
atoms = molecule_to_atoms(molecule)
|
|
449
|
+
atoms.calc = _QuantUIPySCFCalc(
|
|
450
|
+
method=method,
|
|
451
|
+
basis=basis,
|
|
452
|
+
charge=molecule.charge,
|
|
453
|
+
spin=molecule.multiplicity - 1,
|
|
454
|
+
cancel_check=_cancel_check,
|
|
455
|
+
progress_stream=_stream,
|
|
456
|
+
status_label=status_label,
|
|
457
|
+
expected_steps=expected_steps,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# PySCF gradients (called by ASE-BFGS at every
|
|
461
|
+
# step) emit fd-2 stderr from libcint / BLAS. Wrap the full BFGS run
|
|
462
|
+
# in capture_c_stderr so those bytes go to _stream instead of the red-
|
|
463
|
+
# text channel. POSIX-only; no-op on Windows.
|
|
464
|
+
from quantui.c_stderr import capture_c_stderr
|
|
465
|
+
|
|
466
|
+
# --- Run optimization with trajectory file ---
|
|
467
|
+
converged = False
|
|
468
|
+
try:
|
|
469
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
470
|
+
traj_path = Path(tmpdir) / "opt.traj"
|
|
471
|
+
|
|
472
|
+
dyn = BFGS(
|
|
473
|
+
atoms,
|
|
474
|
+
trajectory=str(traj_path),
|
|
475
|
+
logfile=_stream, # BFGS step table → progress_stream
|
|
476
|
+
)
|
|
477
|
+
# Check cancel after every BFGS step (belt-and-suspenders
|
|
478
|
+
# with the per-step calculator check above).
|
|
479
|
+
if _cancel_check is not None:
|
|
480
|
+
dyn.attach(lambda: raise_if_cancelled(_cancel_check), interval=1)
|
|
481
|
+
|
|
482
|
+
# Estimate completion from the fmax-convergence trend
|
|
483
|
+
# (log-scale between the first step's fmax and the target). Data-free
|
|
484
|
+
# and self-correcting. Skipped when report_fraction is False (e.g.
|
|
485
|
+
# reorg drives several sub-optimizations, whose 0→1 resets would make
|
|
486
|
+
# an overall remaining-time estimate oscillate).
|
|
487
|
+
if report_fraction:
|
|
488
|
+
from .log_utils import emit_progress
|
|
489
|
+
|
|
490
|
+
_fmax0: list = [] # first-step fmax, captured on first callback
|
|
491
|
+
|
|
492
|
+
def _report_opt_fraction() -> None:
|
|
493
|
+
try:
|
|
494
|
+
forces = atoms.get_forces()
|
|
495
|
+
fmax_now = float(math.sqrt((forces**2).sum(axis=1).max()))
|
|
496
|
+
except Exception: # noqa: BLE001 — progress is best-effort
|
|
497
|
+
return
|
|
498
|
+
if fmax_now <= 0:
|
|
499
|
+
return
|
|
500
|
+
if not _fmax0:
|
|
501
|
+
_fmax0.append(fmax_now)
|
|
502
|
+
return
|
|
503
|
+
denom = math.log(_fmax0[0] / fmax) if fmax > 0 else 0.0
|
|
504
|
+
frac = math.log(_fmax0[0] / fmax_now) / denom if denom > 0 else 0.0
|
|
505
|
+
# Floor with the history-based step prior so early steps
|
|
506
|
+
# (where the fmax trend is noisy / near 0) still advance.
|
|
507
|
+
if expected_steps:
|
|
508
|
+
step = getattr(atoms.calc, "_eval_count", 0)
|
|
509
|
+
frac = max(frac, min(step / float(expected_steps), 0.9))
|
|
510
|
+
emit_progress(_stream, max(0.0, min(frac, 0.99)))
|
|
511
|
+
|
|
512
|
+
dyn.attach(_report_opt_fraction, interval=1)
|
|
513
|
+
|
|
514
|
+
with capture_c_stderr(_stream), contextlib.redirect_stdout(_null):
|
|
515
|
+
converged = bool(dyn.run(fmax=fmax, steps=steps))
|
|
516
|
+
|
|
517
|
+
# --- Read trajectory frames ---
|
|
518
|
+
from ase.io.trajectory import Trajectory # type: ignore[import]
|
|
519
|
+
|
|
520
|
+
traj_frames = list(Trajectory(str(traj_path)))
|
|
521
|
+
|
|
522
|
+
except Exception as exc:
|
|
523
|
+
raise RuntimeError(
|
|
524
|
+
f"Geometry optimization failed for {molecule.get_formula()} "
|
|
525
|
+
f"({method}/{basis}): {exc}"
|
|
526
|
+
) from exc
|
|
527
|
+
|
|
528
|
+
# Convert ASE frames → Molecule objects and extract stored energies
|
|
529
|
+
charge = molecule.charge
|
|
530
|
+
mult = molecule.multiplicity
|
|
531
|
+
|
|
532
|
+
trajectory: List[Molecule] = []
|
|
533
|
+
energies_hartree: List[float] = []
|
|
534
|
+
|
|
535
|
+
for frame in traj_frames:
|
|
536
|
+
mol_frame = atoms_to_molecule(frame, charge=charge, multiplicity=mult)
|
|
537
|
+
trajectory.append(mol_frame)
|
|
538
|
+
# Each frame has a SinglePointCalculator with the stored energy (eV)
|
|
539
|
+
try:
|
|
540
|
+
e_ev = frame.get_potential_energy()
|
|
541
|
+
energies_hartree.append(e_ev / HARTREE_TO_EV)
|
|
542
|
+
except Exception: # noqa: BLE001 — NaN fallback for missing per-frame energy
|
|
543
|
+
energies_hartree.append(float("nan"))
|
|
544
|
+
|
|
545
|
+
if not trajectory:
|
|
546
|
+
# Edge case: no frames written — return the final atoms state
|
|
547
|
+
trajectory = [atoms_to_molecule(atoms, charge=charge, multiplicity=mult)]
|
|
548
|
+
try:
|
|
549
|
+
e_ev = atoms.get_potential_energy()
|
|
550
|
+
energies_hartree = [e_ev / HARTREE_TO_EV]
|
|
551
|
+
except Exception: # noqa: BLE001 — NaN fallback for missing final energy
|
|
552
|
+
energies_hartree = [float("nan")]
|
|
553
|
+
|
|
554
|
+
n_steps = max(0, len(trajectory) - 1)
|
|
555
|
+
formula = molecule.get_formula()
|
|
556
|
+
|
|
557
|
+
# Extract MO data from the final SCF step (non-fatal)
|
|
558
|
+
_opt_mo_energy: Optional[Any] = None
|
|
559
|
+
_opt_mo_occ: Optional[Any] = None
|
|
560
|
+
_opt_mo_coeff: Optional[Any] = None
|
|
561
|
+
_opt_mol_atom: Optional[Any] = None
|
|
562
|
+
_opt_mol_basis: Optional[str] = None
|
|
563
|
+
try:
|
|
564
|
+
import numpy as _np_mo
|
|
565
|
+
|
|
566
|
+
_last_mf = getattr(atoms.calc, "_last_mf", None)
|
|
567
|
+
_last_atom_list = getattr(atoms.calc, "_last_atom_list", None)
|
|
568
|
+
if _last_mf is not None:
|
|
569
|
+
_opt_mo_energy = _np_mo.array(_last_mf.mo_energy)
|
|
570
|
+
_opt_mo_occ = _np_mo.array(_last_mf.mo_occ)
|
|
571
|
+
_opt_mo_coeff = _np_mo.array(_last_mf.mo_coeff)
|
|
572
|
+
_opt_mol_atom = _last_atom_list
|
|
573
|
+
_opt_mol_basis = basis
|
|
574
|
+
except Exception as exc:
|
|
575
|
+
# Silent failure here ships an OptimizationResult with no MO data,
|
|
576
|
+
# breaking Energies + Isosurface panels on history replay.
|
|
577
|
+
# (Same root-cause class as session_calc.)
|
|
578
|
+
logger.warning(
|
|
579
|
+
"Final-step MO extraction failed in optimizer for %s: %s",
|
|
580
|
+
molecule.get_formula(),
|
|
581
|
+
exc,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# Write a final MO summary to the progress stream (replaces per-step verbose output
|
|
585
|
+
# which is suppressed to avoid thousands of SCF lines for long optimizations).
|
|
586
|
+
if _opt_mo_energy is not None and _opt_mo_occ is not None:
|
|
587
|
+
try:
|
|
588
|
+
import numpy as _np_summary
|
|
589
|
+
|
|
590
|
+
_HARTREE_TO_EV_s = 27.211386245988
|
|
591
|
+
_e_ev_raw = _np_summary.asarray(_opt_mo_energy) * _HARTREE_TO_EV_s
|
|
592
|
+
_occ_raw = _np_summary.asarray(_opt_mo_occ)
|
|
593
|
+
# For UHF the arrays are (2, n_mo); use alpha spin for summary.
|
|
594
|
+
if _e_ev_raw.ndim == 2:
|
|
595
|
+
_e_ev_1d = _e_ev_raw[0]
|
|
596
|
+
_occ_1d = _occ_raw[0]
|
|
597
|
+
else:
|
|
598
|
+
_e_ev_1d = _e_ev_raw
|
|
599
|
+
_occ_1d = _occ_raw
|
|
600
|
+
_homo_idx = (
|
|
601
|
+
int(_np_summary.where(_occ_1d > 0)[0][-1])
|
|
602
|
+
if (_occ_1d > 0).any()
|
|
603
|
+
else -1
|
|
604
|
+
)
|
|
605
|
+
_lumo_idx = (
|
|
606
|
+
int(_np_summary.where(_occ_1d == 0)[0][0])
|
|
607
|
+
if (_occ_1d == 0).any()
|
|
608
|
+
else -1
|
|
609
|
+
)
|
|
610
|
+
_stream.write(
|
|
611
|
+
"\n── Final SCF (optimised geometry) ────────────────────────────────────\n"
|
|
612
|
+
)
|
|
613
|
+
if _homo_idx >= 0:
|
|
614
|
+
_stream.write(
|
|
615
|
+
f" HOMO (MO #{_homo_idx}): {_e_ev_1d[_homo_idx]:.4f} eV\n"
|
|
616
|
+
)
|
|
617
|
+
if _lumo_idx >= 0:
|
|
618
|
+
_stream.write(
|
|
619
|
+
f" LUMO (MO #{_lumo_idx}): {_e_ev_1d[_lumo_idx]:.4f} eV\n"
|
|
620
|
+
)
|
|
621
|
+
if _homo_idx >= 0 and _lumo_idx >= 0:
|
|
622
|
+
_stream.write(
|
|
623
|
+
f" HOMO-LUMO gap: {_e_ev_1d[_lumo_idx] - _e_ev_1d[_homo_idx]:.4f} eV\n"
|
|
624
|
+
)
|
|
625
|
+
_stream.write(
|
|
626
|
+
f" All MO energies (eV): {' '.join(f'{e:.3f}' for e in _e_ev_1d)}\n"
|
|
627
|
+
)
|
|
628
|
+
except Exception: # noqa: BLE001 — cleanup (stream may be closed)
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
logger.info(
|
|
632
|
+
"Geometry optimization: %s %s/%s steps=%d converged=%s "
|
|
633
|
+
"E_final=%.8f Ha RMSD~%.4f Å",
|
|
634
|
+
formula,
|
|
635
|
+
method,
|
|
636
|
+
basis,
|
|
637
|
+
n_steps,
|
|
638
|
+
converged,
|
|
639
|
+
energies_hartree[-1] if energies_hartree else float("nan"),
|
|
640
|
+
_rmsd(molecule, trajectory[-1]) if len(trajectory) > 1 else 0.0,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return OptimizationResult(
|
|
644
|
+
molecule=trajectory[-1],
|
|
645
|
+
trajectory=trajectory,
|
|
646
|
+
energies_hartree=energies_hartree,
|
|
647
|
+
converged=converged,
|
|
648
|
+
n_steps=n_steps,
|
|
649
|
+
method=method,
|
|
650
|
+
basis=basis,
|
|
651
|
+
formula=formula,
|
|
652
|
+
mo_energy_hartree=_opt_mo_energy,
|
|
653
|
+
mo_occ=_opt_mo_occ,
|
|
654
|
+
mo_coeff=_opt_mo_coeff,
|
|
655
|
+
pyscf_mol_atom=_opt_mol_atom,
|
|
656
|
+
pyscf_mol_basis=_opt_mol_basis,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _rmsd(mol_a: Molecule, mol_b: Molecule) -> float:
|
|
661
|
+
"""Compute RMSD (Å) between two same-sized molecules (no alignment, pure Python)."""
|
|
662
|
+
a = mol_a.coordinates
|
|
663
|
+
b = mol_b.coordinates
|
|
664
|
+
if len(a) != len(b) or not a:
|
|
665
|
+
return float("nan")
|
|
666
|
+
total = sum(
|
|
667
|
+
(bx - ax) ** 2 + (by - ay) ** 2 + (bz - az) ** 2
|
|
668
|
+
for (ax, ay, az), (bx, by, bz) in zip(a, b)
|
|
669
|
+
)
|
|
670
|
+
return math.sqrt(total / len(a))
|