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