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
@@ -0,0 +1,435 @@
1
+ """
2
+ QuantUI Reorganization Energy Module
3
+
4
+ Computes the internal (inner-sphere) reorganization energy ``λ`` from Marcus
5
+ theory using the standard **4-point scheme**.
6
+
7
+ For a charge-transfer event between a neutral molecule and its ion, the
8
+ reorganization energy is the energy penalty for relaxing each charge state
9
+ from the *other* state's equilibrium geometry to its own:
10
+
11
+ λ = λ₁ + λ₂
12
+ λ₁ = E_ion(R_neutral) − E_ion(R_ion) # ion relaxation
13
+ λ₂ = E_neutral(R_ion) − E_neutral(R_neutral) # neutral relaxation
14
+
15
+ where ``R_x`` is the fully relaxed (optimized) geometry of charge state ``x``.
16
+ The "4 points" are the four single-point energies that appear above:
17
+
18
+ E_neutral(R_neutral), E_ion(R_ion) — the two optimized minima
19
+ E_ion(R_neutral), E_neutral(R_ion) — the two cross evaluations
20
+
21
+ Two channels are supported:
22
+
23
+ * **hole** (charge +1) — relevant for hole/p-type charge transport,
24
+ * **electron** (charge −1) — relevant for electron/n-type transport.
25
+
26
+ Running ``mode="both"`` computes both channels while sharing the single
27
+ neutral geometry optimization, so it costs three optimizations rather than
28
+ four.
29
+
30
+ The heavy lifting (SCF + gradients) is delegated to the same code paths as
31
+ the rest of QuantUI: :func:`quantui.optimizer.optimize_geometry` for the
32
+ relaxations and :func:`quantui.session_calc.run_in_session` for the
33
+ single-point cross evaluations.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+ from typing import IO, List, Optional
41
+
42
+ from .molecule import Molecule
43
+ from .optimizer import DEFAULT_FMAX, DEFAULT_OPT_STEPS, optimize_geometry
44
+ from .session_calc import HARTREE_TO_EV, run_in_session
45
+
46
+ # 1 Hartree in kcal/mol (CODATA-consistent with HARTREE_TO_EV).
47
+ HARTREE_TO_KCAL: float = 627.509474
48
+
49
+ VALID_MODES = ("hole", "electron", "both")
50
+
51
+
52
+ # ============================================================================
53
+ # Result dataclasses
54
+ # ============================================================================
55
+
56
+
57
+ @dataclass
58
+ class ReorgChannelResult:
59
+ """Reorganization energy for a single charge-transfer channel.
60
+
61
+ Attributes:
62
+ kind: ``"hole"`` (cation, +1) or ``"electron"`` (anion, −1).
63
+ ion_charge: Total charge of the ion for this channel.
64
+ ion_multiplicity: Spin multiplicity used for the ion.
65
+ e_neutral_at_neutral: E_neutral(R_neutral) in Hartree (shared point).
66
+ e_ion_at_ion: E_ion(R_ion) in Hartree.
67
+ e_ion_at_neutral: E_ion(R_neutral) in Hartree.
68
+ e_neutral_at_ion: E_neutral(R_ion) in Hartree.
69
+ lambda1_hartree: Ion relaxation energy λ₁ (Ha).
70
+ lambda2_hartree: Neutral relaxation energy λ₂ (Ha).
71
+ lambda_hartree: Total reorganization energy λ = λ₁ + λ₂ (Ha).
72
+ converged: True if every SCF/opt feeding this channel converged.
73
+ """
74
+
75
+ kind: str
76
+ ion_charge: int
77
+ ion_multiplicity: int
78
+ e_neutral_at_neutral: float
79
+ e_ion_at_ion: float
80
+ e_ion_at_neutral: float
81
+ e_neutral_at_ion: float
82
+ lambda1_hartree: float
83
+ lambda2_hartree: float
84
+ lambda_hartree: float
85
+ converged: bool
86
+
87
+ @property
88
+ def lambda_ev(self) -> float:
89
+ """Total reorganization energy in electronvolts."""
90
+ return self.lambda_hartree * HARTREE_TO_EV
91
+
92
+ @property
93
+ def lambda_mev(self) -> float:
94
+ """Total reorganization energy in millielectronvolts."""
95
+ return self.lambda_ev * 1000.0
96
+
97
+ @property
98
+ def lambda_kcal(self) -> float:
99
+ """Total reorganization energy in kcal/mol."""
100
+ return self.lambda_hartree * HARTREE_TO_KCAL
101
+
102
+ @property
103
+ def label(self) -> str:
104
+ """Human-readable channel label."""
105
+ return "Hole (cation)" if self.kind == "hole" else "Electron (anion)"
106
+
107
+
108
+ @dataclass
109
+ class ReorganizationEnergyResult:
110
+ """Structured output from a 4-point reorganization energy calculation.
111
+
112
+ Exposes ``formula``/``method``/``basis``/``energy_hartree``/``converged``
113
+ so it can be persisted by :func:`quantui.results_storage.save_result`
114
+ like every other result type. ``energy_hartree`` reports the optimized
115
+ neutral SCF energy (the physical reference for the run).
116
+ """
117
+
118
+ formula: str
119
+ method: str
120
+ basis: str
121
+ mode: str
122
+ molecule: Molecule # optimized neutral geometry (used for 3D display)
123
+ neutral_charge: int
124
+ neutral_multiplicity: int
125
+ neutral_energy_hartree: float
126
+ channels: List[ReorgChannelResult] = field(default_factory=list)
127
+ converged: bool = True
128
+ n_total_opt_steps: int = 0
129
+
130
+ @property
131
+ def energy_hartree(self) -> float:
132
+ """Optimized neutral SCF energy (Ha) — the run's reference energy."""
133
+ return self.neutral_energy_hartree
134
+
135
+ @property
136
+ def energy_ev(self) -> float:
137
+ """Optimized neutral SCF energy in electronvolts."""
138
+ return self.neutral_energy_hartree * HARTREE_TO_EV
139
+
140
+ def channel(self, kind: str) -> Optional[ReorgChannelResult]:
141
+ """Return the channel result for ``"hole"``/``"electron"`` or None."""
142
+ for ch in self.channels:
143
+ if ch.kind == kind:
144
+ return ch
145
+ return None
146
+
147
+ def to_spectra(self) -> dict:
148
+ """Serialisable payload stored under result.json ``spectra`` key."""
149
+ return {
150
+ "reorganization_energy": {
151
+ "mode": self.mode,
152
+ "neutral_charge": self.neutral_charge,
153
+ "neutral_multiplicity": self.neutral_multiplicity,
154
+ "neutral_energy_hartree": self.neutral_energy_hartree,
155
+ "n_total_opt_steps": self.n_total_opt_steps,
156
+ "channels": [
157
+ {
158
+ "kind": ch.kind,
159
+ "ion_charge": ch.ion_charge,
160
+ "ion_multiplicity": ch.ion_multiplicity,
161
+ "e_neutral_at_neutral": ch.e_neutral_at_neutral,
162
+ "e_ion_at_ion": ch.e_ion_at_ion,
163
+ "e_ion_at_neutral": ch.e_ion_at_neutral,
164
+ "e_neutral_at_ion": ch.e_neutral_at_ion,
165
+ "lambda1_hartree": ch.lambda1_hartree,
166
+ "lambda2_hartree": ch.lambda2_hartree,
167
+ "lambda_hartree": ch.lambda_hartree,
168
+ "lambda_ev": ch.lambda_ev,
169
+ "lambda_kcal": ch.lambda_kcal,
170
+ "converged": ch.converged,
171
+ }
172
+ for ch in self.channels
173
+ ],
174
+ }
175
+ }
176
+
177
+ def summary(self) -> str:
178
+ """Return a multi-line human-readable result summary."""
179
+ lines = [
180
+ "=" * 60,
181
+ "Reorganization Energy (Marcus 4-point)",
182
+ "=" * 60,
183
+ f" Molecule : {self.formula}",
184
+ f" Method/Basis : {self.method}/{self.basis}",
185
+ f" Neutral state : charge {self.neutral_charge:+d}, "
186
+ f"mult {self.neutral_multiplicity}",
187
+ f" All converged : {'Yes' if self.converged else 'NO'}",
188
+ f" Total opt steps: {self.n_total_opt_steps}",
189
+ "-" * 60,
190
+ ]
191
+ for ch in self.channels:
192
+ lines.append(
193
+ f" {ch.label:<18}: λ = {ch.lambda_ev:.4f} eV "
194
+ f"({ch.lambda_kcal:.2f} kcal/mol)"
195
+ )
196
+ lines.append(
197
+ f" λ₁ (ion relax) = {ch.lambda1_hartree * HARTREE_TO_EV:.4f} eV,"
198
+ f" λ₂ (neutral relax) = {ch.lambda2_hartree * HARTREE_TO_EV:.4f} eV"
199
+ )
200
+ lines.append("=" * 60)
201
+ return "\n".join(lines)
202
+
203
+
204
+ # ============================================================================
205
+ # Helpers
206
+ # ============================================================================
207
+
208
+
209
+ def _promote_method(method: str, multiplicity: int) -> str:
210
+ """Return a method suitable for the given spin state.
211
+
212
+ PySCF's restricted RHF cannot treat an open-shell ion, and the QuantUI
213
+ optimizer only special-cases ``RHF``/``UHF`` for Hartree-Fock (DFT is
214
+ auto-restricted/unrestricted from the spin). So promote a closed-shell
215
+ HF request to UHF whenever the species is open-shell.
216
+ """
217
+ if multiplicity > 1 and method.upper() in ("RHF", "HF"):
218
+ return "UHF"
219
+ return method
220
+
221
+
222
+ def _ion_multiplicity(molecule: Molecule, ion_charge: int) -> int:
223
+ """Low-spin multiplicity for an ion at ``ion_charge``.
224
+
225
+ Removing/adding one electron flips the electron-count parity, so the
226
+ ground-state multiplicity is 1 (even electrons) or 2 (odd electrons).
227
+ This picks the minimal valid multiplicity; users wanting a high-spin ion
228
+ can build the calculation manually.
229
+ """
230
+ n_electrons = molecule.get_electron_count() - (ion_charge - molecule.charge)
231
+ return 1 if n_electrons % 2 == 0 else 2
232
+
233
+
234
+ def _emit(stream: IO[str], message: str) -> None:
235
+ try:
236
+ stream.write(message)
237
+ except Exception: # noqa: BLE001 — logging must never kill the run
238
+ pass
239
+
240
+
241
+ # ============================================================================
242
+ # Main entry point
243
+ # ============================================================================
244
+
245
+
246
+ def run_reorganization_energy(
247
+ molecule: Molecule,
248
+ mode: str = "both",
249
+ method: str = "B3LYP",
250
+ basis: str = "6-31G*",
251
+ fmax: float = DEFAULT_FMAX,
252
+ steps: int = DEFAULT_OPT_STEPS,
253
+ progress_stream: Optional[IO[str]] = None,
254
+ solvent: Optional[str] = None,
255
+ ) -> ReorganizationEnergyResult:
256
+ """Compute the 4-point Marcus reorganization energy for a molecule.
257
+
258
+ Args:
259
+ molecule: The **neutral** (reference) molecule. Its charge and
260
+ multiplicity define the reference state; ions are derived from it.
261
+ mode: ``"hole"``, ``"electron"``, or ``"both"``.
262
+ method: SCF method / DFT functional (e.g. ``"B3LYP"``). Automatically
263
+ promoted to UHF for open-shell HF cases.
264
+ basis: Basis set name recognised by PySCF.
265
+ fmax: Force convergence threshold (eV/Å) for the optimizations.
266
+ steps: Maximum optimizer steps per optimization.
267
+ progress_stream: Writable stream for live log output (Jupyter widget
268
+ stream in the app, ``sys.stdout`` otherwise).
269
+ solvent: Optional PCM solvent name for the single-point evaluations.
270
+
271
+ Returns:
272
+ :class:`ReorganizationEnergyResult`.
273
+
274
+ Raises:
275
+ ValueError: If ``mode`` is not one of :data:`VALID_MODES`.
276
+ RuntimeError: If a required single-point evaluation fails to converge.
277
+ """
278
+ mode = (mode or "both").lower()
279
+ if mode not in VALID_MODES:
280
+ raise ValueError(
281
+ f"Invalid mode '{mode}'. Choose one of {', '.join(VALID_MODES)}."
282
+ )
283
+
284
+ stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
285
+ base_charge = molecule.charge
286
+ base_mult = molecule.multiplicity
287
+ neutral_method = _promote_method(method, base_mult)
288
+
289
+ def _single_point(mol: Molecule, mth: str, tag: str) -> float:
290
+ """Run a single point and return its energy, asserting convergence."""
291
+ _emit(stream, f"\n── Single point: {tag} ──────────────────────────\n")
292
+ res = run_in_session(
293
+ molecule=mol,
294
+ method=mth,
295
+ basis=basis,
296
+ progress_stream=stream, # type: ignore[arg-type]
297
+ solvent=solvent,
298
+ )
299
+ if not bool(getattr(res, "converged", False)):
300
+ raise RuntimeError(f"Single point did not converge: {tag}")
301
+ return float(res.energy_hartree)
302
+
303
+ _emit(
304
+ stream,
305
+ "\n"
306
+ + "═" * 62
307
+ + f"\n Reorganization energy (4-point) — mode: {mode}\n"
308
+ + f" {molecule.get_formula()} @ {method}/{basis}\n"
309
+ + "═" * 62
310
+ + "\n",
311
+ )
312
+
313
+ # ── Step 1: optimize the neutral reference geometry ──────────────────────
314
+ _emit(stream, "\n── Optimizing neutral geometry (R_neutral) ──────────\n")
315
+ neutral_opt = optimize_geometry(
316
+ molecule=molecule,
317
+ method=neutral_method,
318
+ basis=basis,
319
+ fmax=fmax,
320
+ steps=steps,
321
+ progress_stream=stream, # type: ignore[arg-type]
322
+ status_label="Reorg: optimizing neutral geometry",
323
+ report_fraction=False, # Don't let sub-opt 0→1 resets oscillate ETA
324
+ )
325
+ neutral_mol = neutral_opt.molecule
326
+ n_total_steps = neutral_opt.n_steps
327
+ all_converged = bool(neutral_opt.converged)
328
+
329
+ # E_neutral(R_neutral) — shared "point 1" across both channels.
330
+ e_neutral_at_neutral = _single_point(
331
+ neutral_mol, neutral_method, "E_neutral(R_neutral)"
332
+ )
333
+
334
+ # ── Step 2: per-channel ion optimization + cross single points ───────────
335
+ targets: List[tuple[str, int]] = []
336
+ if mode in ("hole", "both"):
337
+ targets.append(("hole", base_charge + 1))
338
+ if mode in ("electron", "both"):
339
+ targets.append(("electron", base_charge - 1))
340
+
341
+ channels: List[ReorgChannelResult] = []
342
+ for kind, ion_charge in targets:
343
+ ion_mult = _ion_multiplicity(molecule, ion_charge)
344
+ ion_method = _promote_method(method, ion_mult)
345
+ _emit(
346
+ stream,
347
+ f"\n── {kind.capitalize()} channel: optimizing ion geometry "
348
+ f"(charge {ion_charge:+d}, mult {ion_mult}) ──\n",
349
+ )
350
+
351
+ # Seed the ion optimization from the relaxed neutral geometry — it is
352
+ # closer to the ion minimum than the raw input geometry.
353
+ ion_seed = Molecule(
354
+ atoms=list(molecule.atoms),
355
+ coordinates=[list(c) for c in neutral_mol.coordinates],
356
+ charge=ion_charge,
357
+ multiplicity=ion_mult,
358
+ )
359
+ ion_opt = optimize_geometry(
360
+ molecule=ion_seed,
361
+ method=ion_method,
362
+ basis=basis,
363
+ fmax=fmax,
364
+ steps=steps,
365
+ progress_stream=stream, # type: ignore[arg-type]
366
+ status_label=f"Reorg: optimizing {kind} ion geometry",
367
+ report_fraction=False, # See neutral-opt note above
368
+ )
369
+ ion_mol = ion_opt.molecule
370
+ n_total_steps += ion_opt.n_steps
371
+ all_converged = all_converged and bool(ion_opt.converged)
372
+
373
+ # The four energies (two already share R_neutral / R_ion optimizations).
374
+ e_ion_at_ion = _single_point(ion_mol, ion_method, f"E_{kind}(R_{kind})")
375
+ e_ion_at_neutral = _single_point(
376
+ Molecule(
377
+ atoms=list(molecule.atoms),
378
+ coordinates=[list(c) for c in neutral_mol.coordinates],
379
+ charge=ion_charge,
380
+ multiplicity=ion_mult,
381
+ ),
382
+ ion_method,
383
+ f"E_{kind}(R_neutral)",
384
+ )
385
+ e_neutral_at_ion = _single_point(
386
+ Molecule(
387
+ atoms=list(molecule.atoms),
388
+ coordinates=[list(c) for c in ion_mol.coordinates],
389
+ charge=base_charge,
390
+ multiplicity=base_mult,
391
+ ),
392
+ neutral_method,
393
+ f"E_neutral(R_{kind})",
394
+ )
395
+
396
+ lambda1 = e_ion_at_neutral - e_ion_at_ion # ion relaxation
397
+ lambda2 = e_neutral_at_ion - e_neutral_at_neutral # neutral relaxation
398
+ lambda_total = lambda1 + lambda2
399
+
400
+ channels.append(
401
+ ReorgChannelResult(
402
+ kind=kind,
403
+ ion_charge=ion_charge,
404
+ ion_multiplicity=ion_mult,
405
+ e_neutral_at_neutral=e_neutral_at_neutral,
406
+ e_ion_at_ion=e_ion_at_ion,
407
+ e_ion_at_neutral=e_ion_at_neutral,
408
+ e_neutral_at_ion=e_neutral_at_ion,
409
+ lambda1_hartree=lambda1,
410
+ lambda2_hartree=lambda2,
411
+ lambda_hartree=lambda_total,
412
+ converged=bool(ion_opt.converged),
413
+ )
414
+ )
415
+ _emit(
416
+ stream,
417
+ f"\n → λ_{kind} = {lambda_total * HARTREE_TO_EV:.4f} eV "
418
+ f"({lambda_total * HARTREE_TO_KCAL:.2f} kcal/mol)\n",
419
+ )
420
+
421
+ result = ReorganizationEnergyResult(
422
+ formula=molecule.get_formula(),
423
+ method=method,
424
+ basis=basis,
425
+ mode=mode,
426
+ molecule=neutral_mol,
427
+ neutral_charge=base_charge,
428
+ neutral_multiplicity=base_mult,
429
+ neutral_energy_hartree=e_neutral_at_neutral,
430
+ channels=channels,
431
+ converged=all_converged,
432
+ n_total_opt_steps=n_total_steps,
433
+ )
434
+ _emit(stream, "\n" + result.summary() + "\n")
435
+ return result