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,1102 @@
1
+ """
2
+ Orbital energy-level diagram and cube-file isosurface visualization.
3
+
4
+ Two capabilities, each with progressively heavier dependencies:
5
+
6
+ 1. **Orbital energy diagram** (matplotlib only) — works everywhere.
7
+ Draws a horizontal‐line energy‐level diagram with HOMO/LUMO labels,
8
+ colour-coded by occupation. Input is a NumPy array of MO energies
9
+ (from ``results.npz`` or a live ``SessionResult``).
10
+
11
+ 2. **Cube-file isosurface** (plotly + PySCF ``cubegen``) — Linux only.
12
+ Generates a volumetric cube file for a selected MO, then renders an
13
+ isosurface in 3-D using ``plotly.graph_objects.Isosurface``. This
14
+ requires PySCF at *generation* time; the viewer works on any platform
15
+ once the cube data is saved.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import List, Optional, Tuple
24
+
25
+ import numpy as np
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Conversion factor — PySCF stores MO energies in Hartree
30
+ HARTREE_TO_EV: float = 27.211386245988
31
+ BOHR_PER_ANGSTROM: float = 1.8897261254578281
32
+
33
+ # Light-weight chemistry tables for drawing atom/bond overlays on cube plots.
34
+ _COVALENT_RADII_ANGSTROM = {
35
+ 1: 0.31,
36
+ 5: 0.84,
37
+ 6: 0.76,
38
+ 7: 0.71,
39
+ 8: 0.66,
40
+ 9: 0.57,
41
+ 14: 1.11,
42
+ 15: 1.07,
43
+ 16: 1.05,
44
+ 17: 1.02,
45
+ 35: 1.20,
46
+ 53: 1.39,
47
+ }
48
+ _CPK_COLORS = {
49
+ 1: "#f8fafc", # H
50
+ 5: "#f59e0b", # B
51
+ 6: "#374151", # C
52
+ 7: "#2563eb", # N
53
+ 8: "#dc2626", # O
54
+ 9: "#22c55e", # F
55
+ 14: "#f59e0b", # Si
56
+ 15: "#f97316", # P
57
+ 16: "#facc15", # S
58
+ 17: "#16a34a", # Cl
59
+ 35: "#b45309", # Br
60
+ 53: "#7c3aed", # I
61
+ }
62
+ _ATOMIC_SYMBOLS = {
63
+ 1: "H",
64
+ 5: "B",
65
+ 6: "C",
66
+ 7: "N",
67
+ 8: "O",
68
+ 9: "F",
69
+ 14: "Si",
70
+ 15: "P",
71
+ 16: "S",
72
+ 17: "Cl",
73
+ 35: "Br",
74
+ 53: "I",
75
+ }
76
+
77
+
78
+ # ============================================================================
79
+ # Data container
80
+ # ============================================================================
81
+
82
+
83
+ @dataclass
84
+ class OrbitalInfo:
85
+ """Lightweight container extracted from a PySCF results file."""
86
+
87
+ mo_energies_ev: np.ndarray # shape (n_mo,)
88
+ n_occupied: int
89
+ homo_energy_ev: float
90
+ lumo_energy_ev: float
91
+ homo_lumo_gap_ev: float
92
+ formula: str # for chart title
93
+
94
+ @property
95
+ def n_virtual(self) -> int:
96
+ return len(self.mo_energies_ev) - self.n_occupied
97
+
98
+
99
+ def load_orbital_info(
100
+ results_path: Path,
101
+ *,
102
+ formula: str = "",
103
+ mo_occ: Optional[np.ndarray] = None,
104
+ ) -> OrbitalInfo:
105
+ """
106
+ Load orbital energies from a ``results.npz`` file.
107
+
108
+ Parameters
109
+ ----------
110
+ results_path : Path
111
+ Path to the ``.npz`` file saved by the PySCF calculation script.
112
+ Must contain at least ``mo_energy``; optionally ``mo_occ``.
113
+ formula : str
114
+ Molecule formula (used in chart title). If empty, uses the
115
+ stem of *results_path*.
116
+ mo_occ : ndarray, optional
117
+ Occupation numbers. If *None*, they are read from the file or
118
+ inferred by assuming all orbitals with energy below the midpoint
119
+ between the two lowest-energy unoccupied orbitals are filled.
120
+
121
+ Returns
122
+ -------
123
+ OrbitalInfo
124
+ """
125
+ data = np.load(results_path, allow_pickle=False)
126
+ mo_energy_ha: np.ndarray = data["mo_energy"]
127
+
128
+ # Handle UHF (2, n_mo) — use alpha spin
129
+ if mo_energy_ha.ndim == 2:
130
+ mo_energy_ha = mo_energy_ha[0]
131
+
132
+ mo_energy_ev = mo_energy_ha * HARTREE_TO_EV
133
+
134
+ # Determine occupation
135
+ if mo_occ is not None:
136
+ occ = np.asarray(mo_occ)
137
+ elif "mo_occ" in data:
138
+ occ = data["mo_occ"]
139
+ if occ.ndim == 2:
140
+ occ = occ[0]
141
+ else:
142
+ # Fallback: assume first n orbitals with energy < 0 are occupied
143
+ occ = (mo_energy_ha < 0).astype(float)
144
+
145
+ n_occ = int((occ > 0).sum())
146
+ if n_occ == 0 or n_occ >= len(mo_energy_ev):
147
+ raise ValueError(
148
+ f"Cannot determine HOMO/LUMO: n_occupied={n_occ}, n_total={len(mo_energy_ev)}"
149
+ )
150
+
151
+ homo_ev = float(mo_energy_ev[n_occ - 1])
152
+ lumo_ev = float(mo_energy_ev[n_occ])
153
+ gap_ev = lumo_ev - homo_ev
154
+
155
+ return OrbitalInfo(
156
+ mo_energies_ev=mo_energy_ev,
157
+ n_occupied=n_occ,
158
+ homo_energy_ev=homo_ev,
159
+ lumo_energy_ev=lumo_ev,
160
+ homo_lumo_gap_ev=gap_ev,
161
+ formula=formula or results_path.stem,
162
+ )
163
+
164
+
165
+ def orbital_info_from_arrays(
166
+ mo_energy: np.ndarray,
167
+ mo_occ: np.ndarray,
168
+ formula: str = "",
169
+ ) -> OrbitalInfo:
170
+ """
171
+ Build an :class:`OrbitalInfo` directly from NumPy arrays.
172
+
173
+ Useful when working with a live ``SessionResult`` where the data is
174
+ already in memory (no ``.npz`` on disk).
175
+ """
176
+ mo_energy = np.asarray(mo_energy)
177
+ mo_occ = np.asarray(mo_occ)
178
+
179
+ if mo_energy.ndim == 2:
180
+ mo_energy = mo_energy[0]
181
+ if mo_occ.ndim == 2:
182
+ mo_occ = mo_occ[0]
183
+
184
+ mo_ev = mo_energy * HARTREE_TO_EV
185
+ n_occ = int((mo_occ > 0).sum())
186
+
187
+ if n_occ == 0 or n_occ >= len(mo_ev):
188
+ raise ValueError(
189
+ f"Cannot determine HOMO/LUMO: n_occupied={n_occ}, n_total={len(mo_ev)}"
190
+ )
191
+
192
+ return OrbitalInfo(
193
+ mo_energies_ev=mo_ev,
194
+ n_occupied=n_occ,
195
+ homo_energy_ev=float(mo_ev[n_occ - 1]),
196
+ lumo_energy_ev=float(mo_ev[n_occ]),
197
+ homo_lumo_gap_ev=float(mo_ev[n_occ] - mo_ev[n_occ - 1]),
198
+ formula=formula,
199
+ )
200
+
201
+
202
+ # ============================================================================
203
+ # Matplotlib energy-level diagram
204
+ # ============================================================================
205
+
206
+
207
+ def plot_orbital_diagram(
208
+ info: OrbitalInfo,
209
+ *,
210
+ max_orbitals: int = 20,
211
+ figsize: Tuple[float, float] = (6, 8),
212
+ title: Optional[str] = None,
213
+ ):
214
+ """
215
+ Draw a horizontal-line orbital energy-level diagram using matplotlib.
216
+
217
+ Occupied orbitals are drawn in blue, virtual in grey. HOMO and LUMO
218
+ are highlighted and labelled. An arrow annotates the gap.
219
+
220
+ Parameters
221
+ ----------
222
+ info : OrbitalInfo
223
+ Orbital data to plot.
224
+ max_orbitals : int
225
+ Show at most this many orbitals centred on the HOMO–LUMO region.
226
+ Keeps the diagram readable for large basis sets.
227
+ figsize : tuple
228
+ Matplotlib figure size ``(width, height)`` in inches.
229
+ title : str, optional
230
+ Custom title; defaults to ``"Orbital Energy Levels — {formula}"``.
231
+
232
+ Returns
233
+ -------
234
+ matplotlib.figure.Figure
235
+ """
236
+ import matplotlib.patches as mpatches
237
+ from matplotlib.figure import Figure
238
+
239
+ energies = info.mo_energies_ev
240
+ n_occ = info.n_occupied
241
+ n_total = len(energies)
242
+
243
+ # Window around HOMO/LUMO
244
+ half = max_orbitals // 2
245
+ start = max(0, n_occ - half)
246
+ end = min(n_total, n_occ + half)
247
+ subset = energies[start:end]
248
+ subset_occ = np.arange(start, end) < n_occ
249
+
250
+ # Use Figure directly (not plt.subplots) to avoid triggering the IPython
251
+ # GUI event loop in interactive / test environments.
252
+ fig = Figure(figsize=figsize)
253
+ ax = fig.add_subplot(111)
254
+
255
+ # Draw energy levels
256
+ line_half_width = 0.3
257
+ for i, (e, occ) in enumerate(zip(subset, subset_occ)):
258
+ color = "#2171b5" if occ else "#bdbdbd"
259
+ lw = 2.5 if (start + i == n_occ - 1 or start + i == n_occ) else 1.5
260
+ ax.plot(
261
+ [-line_half_width, line_half_width],
262
+ [e, e],
263
+ color=color,
264
+ linewidth=lw,
265
+ solid_capstyle="round",
266
+ )
267
+
268
+ # HOMO / LUMO labels
269
+ homo_idx_in_subset = n_occ - 1 - start
270
+ lumo_idx_in_subset = n_occ - start
271
+
272
+ if 0 <= homo_idx_in_subset < len(subset):
273
+ ax.annotate(
274
+ "HOMO",
275
+ xy=(line_half_width + 0.05, subset[homo_idx_in_subset]),
276
+ fontsize=10,
277
+ fontweight="bold",
278
+ color="#2171b5",
279
+ va="center",
280
+ )
281
+
282
+ if 0 <= lumo_idx_in_subset < len(subset):
283
+ ax.annotate(
284
+ "LUMO",
285
+ xy=(line_half_width + 0.05, subset[lumo_idx_in_subset]),
286
+ fontsize=10,
287
+ fontweight="bold",
288
+ color="#e6550d",
289
+ va="center",
290
+ )
291
+
292
+ # Gap arrow
293
+ if 0 <= homo_idx_in_subset < len(subset) and 0 <= lumo_idx_in_subset < len(subset):
294
+ mid_x = -line_half_width - 0.15
295
+ ax.annotate(
296
+ "",
297
+ xy=(mid_x, info.lumo_energy_ev),
298
+ xytext=(mid_x, info.homo_energy_ev),
299
+ arrowprops=dict(arrowstyle="<->", color="#e6550d", lw=1.5),
300
+ )
301
+ gap_mid = (info.homo_energy_ev + info.lumo_energy_ev) / 2.0
302
+ ax.text(
303
+ mid_x - 0.05,
304
+ gap_mid,
305
+ f"{info.homo_lumo_gap_ev:.2f} eV",
306
+ fontsize=9,
307
+ color="#e6550d",
308
+ ha="right",
309
+ va="center",
310
+ fontweight="bold",
311
+ )
312
+
313
+ # Axis labels and styling
314
+ ax.set_ylabel("Energy (eV)", fontsize=12)
315
+ ax.set_xlim(-0.9, 1.0)
316
+ ax.set_xticks([])
317
+ ax.spines["top"].set_visible(False)
318
+ ax.spines["right"].set_visible(False)
319
+ ax.spines["bottom"].set_visible(False)
320
+ ax.set_title(
321
+ title or f"Orbital Energy Levels — {info.formula}",
322
+ fontsize=13,
323
+ fontweight="bold",
324
+ pad=12,
325
+ )
326
+
327
+ # Legend
328
+ occ_patch = mpatches.Patch(color="#2171b5", label="Occupied")
329
+ virt_patch = mpatches.Patch(color="#bdbdbd", label="Virtual")
330
+ ax.legend(handles=[occ_patch, virt_patch], loc="lower right", fontsize=9)
331
+
332
+ fig.tight_layout()
333
+ return fig
334
+
335
+
336
+ # ============================================================================
337
+ # Plotly interactive energy-level diagram
338
+ # ============================================================================
339
+
340
+
341
+ def plot_orbital_diagram_plotly(
342
+ info: OrbitalInfo,
343
+ *,
344
+ max_orbitals: int = 20,
345
+ yrange: Optional[Tuple[float, float]] = None,
346
+ title: Optional[str] = None,
347
+ width: int = 380,
348
+ height: int = 460,
349
+ ):
350
+ """Interactive Plotly orbital energy-level diagram.
351
+
352
+ Returns a ``plotly.graph_objects.Figure`` suitable for embedding in a
353
+ ``go.FigureWidget``. Each MO is drawn as a short horizontal line;
354
+ hover shows the MO index and energy in eV. HOMO/LUMO are highlighted
355
+ with labels and a gap annotation.
356
+
357
+ Parameters
358
+ ----------
359
+ info:
360
+ Orbital data.
361
+ max_orbitals:
362
+ Maximum number of MOs to display, centred on the HOMO–LUMO gap.
363
+ yrange:
364
+ Explicit ``(y_min, y_max)`` in eV; auto-computed when ``None``.
365
+ title:
366
+ Custom plot title; defaults to ``"Orbital Energy Levels — {formula}"``.
367
+ width, height:
368
+ Figure dimensions in pixels.
369
+
370
+ Returns
371
+ -------
372
+ plotly.graph_objects.Figure
373
+ """
374
+ import plotly.graph_objects as go
375
+
376
+ energies = info.mo_energies_ev
377
+ n_occ = info.n_occupied
378
+ n_total = len(energies)
379
+
380
+ half = max_orbitals // 2
381
+ start = max(0, n_occ - half)
382
+ end = min(n_total, n_occ + half)
383
+
384
+ LHW = 0.3 # half-width of each horizontal line in x
385
+
386
+ traces = []
387
+ for idx in range(start, end):
388
+ e = float(energies[idx])
389
+ is_homo = idx == n_occ - 1
390
+ is_lumo = idx == n_occ
391
+ is_occ = idx < n_occ
392
+
393
+ if is_homo:
394
+ color, lw = "#2171b5", 3.0
395
+ hover = f"MO #{idx + 1} — HOMO<br>{e:+.4f} eV"
396
+ elif is_lumo:
397
+ color, lw = "#e6550d", 3.0
398
+ hover = f"MO #{idx + 1} — LUMO<br>{e:+.4f} eV"
399
+ elif is_occ:
400
+ color, lw = "#2171b5", 1.5
401
+ hover = f"MO #{idx + 1} (occupied)<br>{e:+.4f} eV"
402
+ else:
403
+ color, lw = "#9e9e9e", 1.5
404
+ hover = f"MO #{idx + 1} (virtual)<br>{e:+.4f} eV"
405
+
406
+ traces.append(
407
+ go.Scatter(
408
+ x=[-LHW, LHW],
409
+ y=[e, e],
410
+ mode="lines",
411
+ line=dict(color=color, width=lw),
412
+ hovertemplate=hover + "<extra></extra>",
413
+ showlegend=False,
414
+ name="",
415
+ )
416
+ )
417
+
418
+ homo_e = info.homo_energy_ev
419
+ lumo_e = info.lumo_energy_ev
420
+ gap = info.homo_lumo_gap_ev
421
+ bracket_x = -LHW - 0.15
422
+
423
+ annotations = [
424
+ dict(
425
+ x=LHW + 0.04,
426
+ y=homo_e,
427
+ xref="x",
428
+ yref="y",
429
+ text="<b>HOMO</b>",
430
+ showarrow=False,
431
+ font=dict(size=11, color="#2171b5"),
432
+ xanchor="left",
433
+ yanchor="middle",
434
+ ),
435
+ dict(
436
+ x=LHW + 0.04,
437
+ y=lumo_e,
438
+ xref="x",
439
+ yref="y",
440
+ text="<b>LUMO</b>",
441
+ showarrow=False,
442
+ font=dict(size=11, color="#e6550d"),
443
+ xanchor="left",
444
+ yanchor="middle",
445
+ ),
446
+ dict(
447
+ x=bracket_x,
448
+ y=homo_e,
449
+ ax=bracket_x,
450
+ ay=lumo_e,
451
+ xref="x",
452
+ yref="y",
453
+ axref="x",
454
+ ayref="y",
455
+ text=f"<b>{gap:.2f} eV</b>",
456
+ font=dict(size=10, color="#e6550d"),
457
+ arrowhead=2,
458
+ arrowwidth=1.5,
459
+ arrowcolor="#e6550d",
460
+ xanchor="right",
461
+ ),
462
+ ]
463
+
464
+ subset = energies[start:end]
465
+ span = float(subset.max()) - float(subset.min())
466
+ margin = max(0.5, span * 0.08 + 0.5)
467
+ if yrange is None:
468
+ y_min = float(subset.min()) - margin
469
+ y_max = float(subset.max()) + margin
470
+ else:
471
+ y_min, y_max = yrange
472
+
473
+ fig = go.Figure(data=traces)
474
+ fig.update_layout(
475
+ width=width,
476
+ height=height,
477
+ margin=dict(l=60, r=110, t=50, b=30),
478
+ title=dict(
479
+ text=title or f"Orbital Energy Levels — {info.formula}",
480
+ font=dict(size=13, family="Arial"),
481
+ ),
482
+ xaxis=dict(
483
+ range=[-0.9, 0.9],
484
+ showticklabels=False,
485
+ showgrid=False,
486
+ zeroline=False,
487
+ fixedrange=True,
488
+ ),
489
+ yaxis=dict(
490
+ title="Energy (eV)",
491
+ range=[y_min, y_max],
492
+ showgrid=True,
493
+ gridcolor="#e5e7eb",
494
+ tickformat=".1f",
495
+ ),
496
+ plot_bgcolor="white",
497
+ paper_bgcolor="white",
498
+ annotations=annotations,
499
+ hovermode="closest",
500
+ )
501
+ return fig
502
+
503
+
504
+ # ============================================================================
505
+ # Summary HTML (for notebooks)
506
+ # ============================================================================
507
+
508
+
509
+ def orbital_summary_html(info: OrbitalInfo) -> str:
510
+ """
511
+ Return an HTML card summarising orbital energies.
512
+
513
+ Designed for ``IPython.display.HTML`` inside a Jupyter cell.
514
+ """
515
+ return (
516
+ '<div style="background:#f8f9fa; padding:12px; border-radius:6px; '
517
+ 'border-left:4px solid #2171b5; margin:8px 0; font-family:monospace;">'
518
+ f"<b>Orbital Summary — {info.formula}</b><br>"
519
+ f"Occupied MOs: {info.n_occupied} &nbsp;|&nbsp; "
520
+ f"Virtual MOs: {info.n_virtual} &nbsp;|&nbsp; "
521
+ f"Total: {len(info.mo_energies_ev)}<br>"
522
+ f"HOMO energy: {info.homo_energy_ev:+.4f} eV &nbsp;|&nbsp; "
523
+ f"LUMO energy: {info.lumo_energy_ev:+.4f} eV<br>"
524
+ f"<b>HOMO–LUMO gap: {info.homo_lumo_gap_ev:.4f} eV</b>"
525
+ "</div>"
526
+ )
527
+
528
+
529
+ # ============================================================================
530
+ # Cube-file generation (PySCF — Linux only)
531
+ # ============================================================================
532
+
533
+
534
+ def infer_charge_and_spin(mol_atom: list, mo_occ: np.ndarray | list) -> Tuple[int, int]:
535
+ """Infer ``(charge, spin)`` for a ``gto.Mole`` from atoms + MO occupations.
536
+
537
+ Cube/isosurface generation from saved MO data does not have direct access
538
+ to the original ``Molecule.charge`` / ``Molecule.multiplicity`` — only the
539
+ atom list and the MO coefficients/occupations that came out of the SCF.
540
+ PySCF's ``Mole.build()`` requires ``charge``/``spin`` consistent with the
541
+ actual electron count, so passing the default ``charge=0, spin=0`` fails
542
+ to build for any charged or open-shell (odd-electron) molecule.
543
+
544
+ This reconstructs both from data that's always available:
545
+
546
+ - ``spin`` (PySCF's ``2S = n_alpha - n_beta``) is 0 when ``mo_occ`` is
547
+ 1-D (closed-shell RHF/RKS — including the MP2/CCSD/CCSD(T) paths, which
548
+ always run on an RHF reference), or ``n_alpha - n_beta`` when ``mo_occ``
549
+ is 2-D (UHF/UKS, shape ``(2, n_mo)``).
550
+ - ``charge`` is the nuclear charge (sum of atomic numbers in ``mol_atom``)
551
+ minus the total electron count (``sum(mo_occ)`` over all spin channels).
552
+
553
+ Returns ``(0, 0)`` if ``mol_atom`` or ``mo_occ`` is falsy/``None`` so
554
+ callers can pass through directly without a separate None-check.
555
+ """
556
+ if not mol_atom or mo_occ is None:
557
+ return 0, 0
558
+ from .molecule import ATOMIC_NUMBERS
559
+
560
+ occ = np.asarray(mo_occ, dtype=float)
561
+ if occ.ndim == 2:
562
+ n_alpha = float(occ[0].sum())
563
+ n_beta = float(occ[1].sum())
564
+ spin = int(round(n_alpha - n_beta))
565
+ n_electrons = n_alpha + n_beta
566
+ else:
567
+ spin = 0
568
+ n_electrons = float(occ.sum())
569
+
570
+ nuclear_charge = sum(ATOMIC_NUMBERS.get(sym, 0) for sym, _ in mol_atom)
571
+ charge = int(round(nuclear_charge - n_electrons))
572
+ return charge, spin
573
+
574
+
575
+ def generate_cube_file(
576
+ results_path: Path,
577
+ orbital_index: int,
578
+ output_path: Path,
579
+ *,
580
+ nx: int = 60,
581
+ ny: int = 60,
582
+ nz: int = 60,
583
+ margin: float = 5.0,
584
+ ) -> Path:
585
+ """
586
+ Generate a Gaussian cube file for a molecular orbital.
587
+
588
+ Requires PySCF and the original ``mol`` object data. This function
589
+ is Linux/WSL only.
590
+
591
+ Parameters
592
+ ----------
593
+ results_path : Path
594
+ Path to ``results.npz`` (must also contain ``mol_atom`` and
595
+ ``mol_basis`` keys, added by an extended script template).
596
+ orbital_index : int
597
+ 0-based MO index to visualise.
598
+ output_path : Path
599
+ Where to write the ``.cube`` file.
600
+ nx, ny, nz : int
601
+ Grid resolution along each axis.
602
+ margin : float
603
+ Extra space (Bohr) beyond atomic extents.
604
+
605
+ Returns
606
+ -------
607
+ Path
608
+ The written cube file path.
609
+
610
+ Raises
611
+ ------
612
+ ImportError
613
+ If PySCF is not available.
614
+ """
615
+ try:
616
+ from pyscf import gto
617
+ from pyscf.tools import cubegen
618
+ except ImportError as exc:
619
+ raise ImportError(
620
+ "PySCF is required for cube file generation (Linux/WSL only).\n"
621
+ " conda install -c conda-forge pyscf"
622
+ ) from exc
623
+
624
+ data = np.load(results_path, allow_pickle=True)
625
+ mo_coeff = data["mo_coeff"]
626
+ mo_occ = data["mo_occ"] if "mo_occ" in data else None
627
+ if mo_coeff.ndim == 3:
628
+ mo_coeff = mo_coeff[0]
629
+
630
+ atom_str = str(data["mol_atom"]) if "mol_atom" in data else None
631
+ basis_str = str(data["mol_basis"]) if "mol_basis" in data else None
632
+
633
+ if atom_str is None or basis_str is None:
634
+ raise ValueError(
635
+ "results.npz does not contain 'mol_atom'/'mol_basis' keys. "
636
+ "Re-run the calculation with the updated script template."
637
+ )
638
+
639
+ # Charge/spin aren't stored in results.npz — infer them from the MO
640
+ # occupations (when present) so charged/open-shell molecules don't fail
641
+ # to build. mol_atom here is a PySCF-format string, not the (symbol,
642
+ # coords) tuple list infer_charge_and_spin expects, so parse it first.
643
+ charge, spin = 0, 0
644
+ if mo_occ is not None:
645
+ parsed_atoms = [
646
+ (tok.split()[0], [0.0, 0.0, 0.0])
647
+ for tok in atom_str.replace(";", "\n").splitlines()
648
+ if tok.strip()
649
+ ]
650
+ charge, spin = infer_charge_and_spin(parsed_atoms, mo_occ)
651
+
652
+ mol = gto.M(
653
+ atom=atom_str, basis=basis_str, unit="Angstrom", charge=charge, spin=spin
654
+ )
655
+
656
+ output_path = Path(output_path)
657
+ output_path.parent.mkdir(parents=True, exist_ok=True)
658
+
659
+ cubegen.orbital(
660
+ mol,
661
+ str(output_path),
662
+ mo_coeff[:, orbital_index],
663
+ nx=nx,
664
+ ny=ny,
665
+ nz=nz,
666
+ margin=margin,
667
+ )
668
+ logger.info("Wrote cube file: %s", output_path)
669
+ return output_path
670
+
671
+
672
+ def generate_cube_from_arrays(
673
+ mol_atom: list,
674
+ mol_basis: str,
675
+ mo_coeff: np.ndarray,
676
+ orbital_index: int,
677
+ output_path: Path,
678
+ *,
679
+ nx: int = 60,
680
+ ny: int = 60,
681
+ nz: int = 60,
682
+ margin: float = 5.0,
683
+ charge: int = 0,
684
+ spin: int = 0,
685
+ ) -> Path:
686
+ """
687
+ Generate a cube file from in-session MO data (no ``.npz`` file required).
688
+
689
+ Unlike :func:`generate_cube_file`, this function takes the atom list
690
+ and MO coefficient array directly, as stored in :class:`SessionResult`
691
+ or :class:`OptimizationResult`.
692
+
693
+ Parameters
694
+ ----------
695
+ mol_atom : list
696
+ Atom list in PySCF format — list of ``(symbol, [x, y, z])`` tuples
697
+ with coordinates in Angstrom.
698
+ mol_basis : str
699
+ Basis set string (e.g. ``'6-31G*'``).
700
+ mo_coeff : ndarray
701
+ MO coefficient matrix, shape ``(n_ao, n_mo)`` for RHF or
702
+ ``(2, n_ao, n_mo)`` for UHF. Alpha-spin coefficients are used for UHF.
703
+ orbital_index : int
704
+ 0-based MO index to visualise.
705
+ output_path : Path
706
+ Where to write the ``.cube`` file.
707
+ nx, ny, nz : int
708
+ Grid resolution along each axis.
709
+ margin : float
710
+ Extra space (Bohr) beyond atomic extents.
711
+ charge : int
712
+ Total molecular charge. Required for charged species (e.g. H3O+,
713
+ NH4+, OH-) — without it PySCF's electron-count check fails at
714
+ ``mol.build()``. Default 0 (neutral).
715
+ spin : int
716
+ PySCF's ``2S = n_alpha - n_beta``. Required for open-shell
717
+ (odd-electron) molecules — default 0 assumes closed-shell.
718
+
719
+ Returns
720
+ -------
721
+ Path
722
+ The written cube file path.
723
+
724
+ Raises
725
+ ------
726
+ ImportError
727
+ If PySCF is not available.
728
+ """
729
+ try:
730
+ from pyscf import gto
731
+ from pyscf.tools import cubegen
732
+ except ImportError as exc:
733
+ raise ImportError(
734
+ "PySCF is required for cube file generation (Linux/WSL only).\n"
735
+ " conda install -c conda-forge pyscf"
736
+ ) from exc
737
+
738
+ mol = gto.M(
739
+ atom=mol_atom, basis=mol_basis, unit="Angstrom", charge=charge, spin=spin
740
+ )
741
+
742
+ coeff = np.asarray(mo_coeff)
743
+ if coeff.ndim == 3:
744
+ coeff = coeff[0] # UHF: use alpha spin
745
+
746
+ output_path = Path(output_path)
747
+ output_path.parent.mkdir(parents=True, exist_ok=True)
748
+
749
+ cubegen.orbital(
750
+ mol,
751
+ str(output_path),
752
+ coeff[:, orbital_index],
753
+ nx=nx,
754
+ ny=ny,
755
+ nz=nz,
756
+ margin=margin,
757
+ )
758
+ logger.info("Wrote cube file: %s", output_path)
759
+ return output_path
760
+
761
+
762
+ # ============================================================================
763
+ # Cube-file isosurface viewer (plotly — works anywhere)
764
+ # ============================================================================
765
+
766
+ # Max grid points handed to one go.Isosurface trace. The cube grid is a fixed
767
+ # resolution regardless of molecule size, so the volume is strided down to this
768
+ # cap at render time to keep the figure payload bounded; the saved .cube keeps
769
+ # full resolution.
770
+ _MAX_ISOSURFACE_POINTS = 48_000
771
+
772
+
773
+ def parse_cube_file(cube_path: Path) -> dict:
774
+ """
775
+ Parse a Gaussian cube file into a dict of NumPy arrays.
776
+
777
+ Returns
778
+ -------
779
+ dict with keys:
780
+ atoms : list of (Z, x, y, z)
781
+ origin : ndarray (3,)
782
+ axes : ndarray (3, 3) — row i is the step vector for axis i
783
+ nx, ny, nz : int
784
+ data : ndarray (nx, ny, nz) — volumetric data
785
+ """
786
+ with open(cube_path) as fh:
787
+ # First two lines are comments
788
+ fh.readline()
789
+ fh.readline()
790
+
791
+ parts = fh.readline().split()
792
+ n_atoms = abs(int(parts[0]))
793
+ origin = np.array([float(x) for x in parts[1:4]])
794
+
795
+ axes = np.zeros((3, 3))
796
+ dims = []
797
+ for i in range(3):
798
+ parts = fh.readline().split()
799
+ dims.append(int(parts[0]))
800
+ axes[i] = [float(x) for x in parts[1:4]]
801
+
802
+ nx, ny, nz = dims
803
+
804
+ atoms = []
805
+ for _ in range(n_atoms):
806
+ parts = fh.readline().split()
807
+ z = int(parts[0])
808
+ x, y, zz = float(parts[2]), float(parts[3]), float(parts[4])
809
+ atoms.append((z, x, y, zz))
810
+
811
+ # Volumetric data
812
+ vals: List[float] = []
813
+ for line in fh:
814
+ vals.extend(float(v) for v in line.split())
815
+
816
+ data = np.array(vals).reshape((nx, ny, nz))
817
+
818
+ return {
819
+ "atoms": atoms,
820
+ "origin": origin,
821
+ "axes": axes,
822
+ "nx": nx,
823
+ "ny": ny,
824
+ "nz": nz,
825
+ "data": data,
826
+ }
827
+
828
+
829
+ def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) -> dict:
830
+ """Build marker and bond segments from cube atom records."""
831
+ atom_x: List[float] = []
832
+ atom_y: List[float] = []
833
+ atom_z: List[float] = []
834
+ atom_colors: List[str] = []
835
+ atom_sizes: List[float] = []
836
+ atom_labels: List[str] = []
837
+
838
+ for z_num, x, y, z in atoms:
839
+ atom_x.append(x)
840
+ atom_y.append(y)
841
+ atom_z.append(z)
842
+ atom_colors.append(_CPK_COLORS.get(z_num, "#9ca3af"))
843
+ atom_sizes.append(max(6.0, 15.0 * _COVALENT_RADII_ANGSTROM.get(z_num, 0.75)))
844
+ atom_labels.append(_ATOMIC_SYMBOLS.get(z_num, str(z_num)))
845
+
846
+ bond_x: List[float] = []
847
+ bond_y: List[float] = []
848
+ bond_z: List[float] = []
849
+ for i, (zi, xi, yi, zi_pos) in enumerate(atoms):
850
+ for zj, xj, yj, zj_pos in atoms[i + 1 :]:
851
+ ri = _COVALENT_RADII_ANGSTROM.get(zi, 0.75)
852
+ rj = _COVALENT_RADII_ANGSTROM.get(zj, 0.75)
853
+ cutoff = (ri + rj) * 1.25 * BOHR_PER_ANGSTROM
854
+ dist = float(
855
+ np.sqrt((xi - xj) ** 2 + (yi - yj) ** 2 + (zi_pos - zj_pos) ** 2)
856
+ )
857
+ if dist <= cutoff:
858
+ bond_x.extend([xi, xj, None])
859
+ bond_y.extend([yi, yj, None])
860
+ bond_z.extend([zi_pos, zj_pos, None])
861
+
862
+ return {
863
+ "atom_x": atom_x,
864
+ "atom_y": atom_y,
865
+ "atom_z": atom_z,
866
+ "atom_colors": atom_colors,
867
+ "atom_sizes": atom_sizes,
868
+ "atom_labels": atom_labels,
869
+ "bond_x": bond_x,
870
+ "bond_y": bond_y,
871
+ "bond_z": bond_z,
872
+ }
873
+
874
+
875
+ def render_orbital_isosurface_py3dmol(
876
+ cube_path: Path,
877
+ *,
878
+ isovalue: float = 0.02,
879
+ opacity: float = 0.85,
880
+ width: int = 760,
881
+ height: int = 620,
882
+ pos_color: str = "blue",
883
+ neg_color: str = "red",
884
+ bgcolor: str = "white",
885
+ style: str = "stick",
886
+ ) -> str:
887
+ """Render an orbital isosurface from a cube file via py3Dmol.
888
+
889
+ Unlike :func:`plot_cube_isosurface` (Plotly), py3Dmol isosurfaces the cube
890
+ *in the browser* at full resolution, so there is no Python-side volume
891
+ downsample and the payload is just the cube text. Both lobes are drawn:
892
+ ``+isovalue`` (``pos_color``) and ``-isovalue`` (``neg_color``).
893
+
894
+ Returns HTML via py3Dmol's ``_make_html``. The viewer is built through
895
+ :func:`quantui.viz_assets.make_view`, so it loads 3Dmol.js from the
896
+ vendored bundle (the page bootstrap) rather than the CDN — see
897
+ ``viz_assets`` for why this matters offline.
898
+
899
+ Parameters
900
+ ----------
901
+ cube_path : Path
902
+ Path to a Gaussian ``.cube`` file (read at full resolution).
903
+ isovalue : float
904
+ Isosurface threshold; both ``+`` and ``-`` lobes are drawn.
905
+ opacity : float
906
+ Surface opacity (0-1).
907
+ width, height : int
908
+ Viewer size in pixels.
909
+ pos_color, neg_color : str
910
+ Lobe colors for the positive and negative isosurfaces.
911
+ bgcolor : str
912
+ Viewer background color.
913
+ style : str
914
+ py3Dmol style for the embedded atoms (e.g. ``"stick"``).
915
+
916
+ Returns
917
+ -------
918
+ str
919
+ Self-contained HTML for the interactive viewer.
920
+ """
921
+ from quantui.viz_assets import make_view
922
+
923
+ cube_text = Path(cube_path).read_text()
924
+ view = make_view(width=width, height=height)
925
+ view.addModel(cube_text, "cube")
926
+ view.setStyle({style: {}})
927
+ view.addVolumetricData(
928
+ cube_text,
929
+ "cube",
930
+ {"isoval": isovalue, "color": pos_color, "opacity": opacity},
931
+ )
932
+ view.addVolumetricData(
933
+ cube_text,
934
+ "cube",
935
+ {"isoval": -isovalue, "color": neg_color, "opacity": opacity},
936
+ )
937
+ view.setBackgroundColor(bgcolor)
938
+ view.zoomTo()
939
+ return view._make_html()
940
+
941
+
942
+ def plot_cube_isosurface(
943
+ cube_path: Path,
944
+ *,
945
+ isovalue: float = 0.02,
946
+ opacity: float = 0.4,
947
+ width: int = 760,
948
+ height: int = 620,
949
+ title: Optional[str] = None,
950
+ show_molecule: bool = False,
951
+ show_grid: bool = True,
952
+ scene_bgcolor: str = "white",
953
+ axis_color: str = "#111827",
954
+ title_color: Optional[str] = None,
955
+ bond_color: str = "#6b7280",
956
+ ):
957
+ """
958
+ Render an orbital isosurface from a cube file using Plotly.
959
+
960
+ Draws both positive and negative lobes (blue / red) of the MO at
961
+ the given *isovalue*.
962
+
963
+ Parameters
964
+ ----------
965
+ cube_path : Path
966
+ Path to a Gaussian ``.cube`` file.
967
+ isovalue : float
968
+ Isosurface threshold (e.g. 0.02 for orbitals).
969
+ opacity : float
970
+ Surface opacity (0–1).
971
+ width, height : int
972
+ Figure size in pixels.
973
+ title : str, optional
974
+ Figure title.
975
+
976
+ Returns
977
+ -------
978
+ plotly.graph_objects.Figure
979
+ """
980
+ import plotly.graph_objects as go
981
+
982
+ cube = parse_cube_file(cube_path)
983
+ nx, ny, nz = cube["nx"], cube["ny"], cube["nz"]
984
+ data = cube["data"]
985
+ origin = cube["origin"]
986
+ axes = cube["axes"]
987
+
988
+ # Downsample so the browser payload + plotly.js isosurfacing stay bounded.
989
+ # Stride each axis so the total point count stays under _MAX_ISOSURFACE_POINTS.
990
+ total = nx * ny * nz
991
+ stride = 1
992
+ if total > _MAX_ISOSURFACE_POINTS:
993
+ stride = int(np.ceil((total / _MAX_ISOSURFACE_POINTS) ** (1.0 / 3.0)))
994
+ data = data[::stride, ::stride, ::stride]
995
+
996
+ # Build coordinate grids (Bohr), strided to match the downsampled volume.
997
+ x = origin[0] + np.arange(nx)[::stride] * axes[0, 0]
998
+ y = origin[1] + np.arange(ny)[::stride] * axes[1, 1]
999
+ z = origin[2] + np.arange(nz)[::stride] * axes[2, 2]
1000
+ X, Y, Z = np.meshgrid(x, y, z, indexing="ij")
1001
+
1002
+ fig = go.Figure()
1003
+
1004
+ # Both lobes in a single trace (half the payload of two): surfaces at
1005
+ # -isovalue (red) and +isovalue (blue), via a step colorscale split at the
1006
+ # midpoint of [-isovalue, +isovalue].
1007
+ fig.add_trace(
1008
+ go.Isosurface(
1009
+ x=X.flatten(),
1010
+ y=Y.flatten(),
1011
+ z=Z.flatten(),
1012
+ value=data.flatten(),
1013
+ isomin=-isovalue,
1014
+ isomax=isovalue,
1015
+ surface_count=2,
1016
+ opacity=opacity,
1017
+ colorscale=[
1018
+ [0.0, "rgb(222,45,38)"],
1019
+ [0.5, "rgb(222,45,38)"],
1020
+ [0.5, "rgb(49,130,189)"],
1021
+ [1.0, "rgb(49,130,189)"],
1022
+ ],
1023
+ cmin=-isovalue,
1024
+ cmax=isovalue,
1025
+ showscale=False,
1026
+ name=f"±{isovalue}",
1027
+ caps=dict(x_show=False, y_show=False, z_show=False),
1028
+ )
1029
+ )
1030
+
1031
+ if show_molecule and cube["atoms"]:
1032
+ overlay = _build_molecule_overlay_data(cube["atoms"])
1033
+ if overlay["bond_x"]:
1034
+ fig.add_trace(
1035
+ go.Scatter3d(
1036
+ x=overlay["bond_x"],
1037
+ y=overlay["bond_y"],
1038
+ z=overlay["bond_z"],
1039
+ mode="lines",
1040
+ line=dict(color=bond_color, width=6),
1041
+ name="Bonds",
1042
+ showlegend=False,
1043
+ hoverinfo="skip",
1044
+ )
1045
+ )
1046
+ fig.add_trace(
1047
+ go.Scatter3d(
1048
+ x=overlay["atom_x"],
1049
+ y=overlay["atom_y"],
1050
+ z=overlay["atom_z"],
1051
+ mode="markers",
1052
+ marker=dict(
1053
+ size=overlay["atom_sizes"],
1054
+ color=overlay["atom_colors"],
1055
+ opacity=1.0,
1056
+ line=dict(color=bond_color, width=1),
1057
+ ),
1058
+ text=overlay["atom_labels"],
1059
+ hovertemplate="%{text}<extra></extra>",
1060
+ name="Atoms",
1061
+ showlegend=False,
1062
+ )
1063
+ )
1064
+
1065
+ fig.update_layout(
1066
+ width=width,
1067
+ height=height,
1068
+ title=dict(
1069
+ text=title or "Molecular Orbital Isosurface",
1070
+ font=dict(color=title_color or axis_color),
1071
+ ),
1072
+ paper_bgcolor="rgba(0,0,0,0)",
1073
+ margin=dict(l=0, r=0, t=48, b=0),
1074
+ font=dict(color=axis_color),
1075
+ scene=dict(
1076
+ xaxis=dict(
1077
+ title="X (Bohr)",
1078
+ showgrid=show_grid,
1079
+ showbackground=show_grid,
1080
+ zeroline=False,
1081
+ color=axis_color,
1082
+ ),
1083
+ yaxis=dict(
1084
+ title="Y (Bohr)",
1085
+ showgrid=show_grid,
1086
+ showbackground=show_grid,
1087
+ zeroline=False,
1088
+ color=axis_color,
1089
+ ),
1090
+ zaxis=dict(
1091
+ title="Z (Bohr)",
1092
+ showgrid=show_grid,
1093
+ showbackground=show_grid,
1094
+ zeroline=False,
1095
+ color=axis_color,
1096
+ ),
1097
+ bgcolor=scene_bgcolor,
1098
+ aspectmode="data",
1099
+ ),
1100
+ )
1101
+
1102
+ return fig