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,2620 @@
1
+ """Visualization and rendering helpers used by QuantUIApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import Any, List
10
+
11
+ import ipywidgets as widgets
12
+ from IPython.display import HTML, display
13
+
14
+
15
+ @contextmanager
16
+ def _viz_render_event(app: Any, task: Any, backend: Any, **extras: Any):
17
+ """Lifecycle telemetry context manager for one render-path execution.
18
+
19
+ Emits ``viz_render_start`` on entry and ``viz_render_done`` /
20
+ ``viz_render_error`` on exit, each with ``elapsed_ms``. Wraps the
21
+ actual backend render work; the router decision itself is logged
22
+ separately by ``app._resolve_backend`` via ``viz_route_decision``.
23
+
24
+ Extra kwargs are appended as ``key=value`` pairs to the event body
25
+ (e.g. ``mode=3`` for vib renders, ``idx=12`` for trajectory frames).
26
+ All log writes are best-effort — failures never propagate.
27
+ """
28
+ from quantui import calc_log as _clog_evt
29
+
30
+ t0 = time.perf_counter()
31
+ pref = getattr(app, "_viz_backend_preference", "auto")
32
+ extras_str = " ".join(f"{k}={v}" for k, v in extras.items())
33
+ base = f"task={task} pref={pref} backend={backend}"
34
+ fields = f"{base} {extras_str}".strip()
35
+ try:
36
+ _clog_evt.log_event("viz_render_start", fields)
37
+ except Exception:
38
+ pass
39
+ try:
40
+ yield
41
+ except Exception as exc:
42
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
43
+ try:
44
+ _clog_evt.log_event(
45
+ "viz_render_error",
46
+ f"{fields} elapsed_ms={elapsed_ms} "
47
+ f"err={type(exc).__name__}:{exc}"[:300],
48
+ )
49
+ except Exception:
50
+ pass
51
+ raise
52
+ else:
53
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
54
+ try:
55
+ _clog_evt.log_event(
56
+ "viz_render_done",
57
+ f"{fields} elapsed_ms={elapsed_ms}",
58
+ )
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ def show_result_3d(
64
+ app: Any,
65
+ molecule: Any,
66
+ extra_output: Any = None,
67
+ *,
68
+ render_html_fn: Any,
69
+ ) -> None:
70
+ """Render molecule 3D structure in result and optional extra output panels.
71
+
72
+ Backend selection goes through ``app._resolve_backend(task)`` per-output:
73
+
74
+ - ``result_viz_output`` uses ``VizTask.STRUCTURE_VIEW_RESULTS``.
75
+ - ``extra_output == _analysis_mol_output`` uses ``ANALYSIS_STRUCTURE_VIEW``.
76
+ - Any other extra_output uses ``STRUCTURE_VIEW_RESULTS`` as a safe default.
77
+
78
+ ``render_html_fn`` must return self-contained HTML (e.g.
79
+ ``visualization_py3dmol.render_molecule_html``); the HTML is routed through
80
+ ``app._set_html_output`` so the viewer is replaced as a single atomic
81
+ ``Output.outputs`` swap. This avoids the nested-Output + ``display(viz)``
82
+ pattern that caused a trajectory regression and an Analysis-tab top
83
+ viewer rendering blank with 🙁 on history replay.
84
+ """
85
+ if render_html_fn is None or molecule is None:
86
+ return
87
+ from quantui.viz_backend_router import VizTask as _VT
88
+
89
+ is_analysis_output = extra_output is not None and extra_output is getattr(
90
+ app, "_analysis_mol_output", None
91
+ )
92
+
93
+ # Results-tab viewer.
94
+ if app.result_viz_output is not None:
95
+ chosen = app._resolve_backend(_VT.STRUCTURE_VIEW_RESULTS)
96
+ if chosen is not None:
97
+ with _viz_render_event(
98
+ app,
99
+ task="structure_view_results",
100
+ backend=str(chosen),
101
+ ):
102
+ html = render_html_fn(
103
+ molecule,
104
+ backend=str(chosen),
105
+ style=app._viz_style,
106
+ lighting=app._viz_lighting,
107
+ bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
108
+ )
109
+ app._set_html_output(app.result_viz_output, html)
110
+
111
+ # Optional second viewer (typically the Analysis tab).
112
+ if extra_output is not None:
113
+ task = (
114
+ _VT.ANALYSIS_STRUCTURE_VIEW
115
+ if is_analysis_output
116
+ else _VT.STRUCTURE_VIEW_RESULTS
117
+ )
118
+ chosen = app._resolve_backend(task)
119
+ if chosen is not None:
120
+ task_label = (
121
+ "analysis_structure_view"
122
+ if is_analysis_output
123
+ else "structure_view_results"
124
+ )
125
+ with _viz_render_event(app, task=task_label, backend=str(chosen)):
126
+ html = render_html_fn(
127
+ molecule,
128
+ backend=str(chosen),
129
+ style=app._viz_style,
130
+ lighting=app._viz_lighting,
131
+ bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
132
+ )
133
+ app._set_html_output(extra_output, html)
134
+ if is_analysis_output:
135
+ app._update_analysis_backend_label(chosen)
136
+
137
+ # Track the molecule currently shown in the Analysis-tab viewer so the
138
+ # preference-change re-render path can find it.
139
+ if is_analysis_output:
140
+ app._analysis_displayed_molecule = molecule
141
+
142
+
143
+ def on_traj_expand(app: Any, change: dict[str, Any]) -> None:
144
+ """Lazily generate trajectory animation when accordion first opens."""
145
+ if change["new"] != 0:
146
+ return
147
+ result = app._pending_traj_result
148
+
149
+ # Safety net: if _pending_traj_result was already consumed by a prior
150
+ # auto-select render but traj_output is now empty, recover by rendering
151
+ # from the cached _last_traj_result.
152
+ # NOTE: traj_output is now a widgets.VBox — check `children` (not
153
+ # `outputs`) for the populated-state heuristic.
154
+ recovery_used = False
155
+ if result is None:
156
+ last = getattr(app, "_last_traj_result", None)
157
+ children = getattr(app.traj_output, "children", ())
158
+ if last is not None and len(children) == 0:
159
+ result = last
160
+ recovery_used = True
161
+ try:
162
+ from quantui import calc_log as _clog_recovery
163
+
164
+ _clog_recovery.log_event(
165
+ "traj_render_recovery",
166
+ "children=0, rendering from _last_traj_result",
167
+ )
168
+ except Exception:
169
+ pass
170
+
171
+ try:
172
+ from quantui import calc_log as _clog_te
173
+
174
+ _clog_te.log_event(
175
+ "traj_expand",
176
+ f"pending={app._pending_traj_result is not None} "
177
+ f"recovery={recovery_used} "
178
+ f"children_n={len(getattr(app.traj_output, 'children', ()))}",
179
+ )
180
+ except Exception:
181
+ pass
182
+ if result is None:
183
+ return
184
+ app._pending_traj_result = None
185
+ app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1
186
+ render_token = app._traj_render_token
187
+
188
+ # Placeholder: replace traj_output's children with a Loading message.
189
+ app.traj_output.children = (
190
+ widgets.HTML(
191
+ value=(
192
+ '<p style="color:#555;font-style:italic;padding:8px">'
193
+ "Loading trajectory viewer…</p>"
194
+ )
195
+ ),
196
+ )
197
+
198
+ try:
199
+ app._show_opt_trajectory(result, render_token=render_token)
200
+ except Exception as exc:
201
+ try:
202
+ from quantui import calc_log as _clog_te2
203
+
204
+ _clog_te2.log_event(
205
+ "traj_expand_error",
206
+ f"{type(exc).__name__}: {exc}"[:300],
207
+ )
208
+ except Exception:
209
+ pass
210
+ if render_token != int(getattr(app, "_traj_render_token", 0)):
211
+ return
212
+ app.traj_output.children = (
213
+ widgets.HTML(
214
+ value=(
215
+ f'<p style="color:#b91c1c;padding:8px">'
216
+ f"⚠ Trajectory rendering failed: {exc}</p>"
217
+ )
218
+ ),
219
+ )
220
+
221
+
222
+ def show_opt_trajectory(
223
+ app: Any,
224
+ opt_result: Any,
225
+ *,
226
+ layout_fn: Any,
227
+ render_token: int | None = None,
228
+ ) -> None:
229
+ """Build the trajectory viewer + energy chart in the trajectory panel.
230
+
231
+ All optimization steps are loaded once into ONE py3Dmol viewer
232
+ (``addModelsAsFrames``) and navigated client-side with ``setFrame`` via an
233
+ in-HTML stepper (prev/next, play/pause, scrub slider, start↔final flip,
234
+ per-step energy label). Because the viewer instance never changes, the
235
+ camera (rotation/zoom) stays put across steps and there is no per-frame HTML
236
+ rebuild — the previous carousel rebuilt a fresh viewer each frame, which
237
+ reset the camera and flickered. py3Dmol-only per the viz routing policy; the
238
+ energy-convergence chart and Export button are unchanged.
239
+ """
240
+
241
+ def _is_stale() -> bool:
242
+ return render_token is not None and render_token != int(
243
+ getattr(app, "_traj_render_token", 0)
244
+ )
245
+
246
+ # Support both OptimizationResult (.trajectory) and PESScanResult
247
+ # (.coordinates_list).
248
+ traj = getattr(opt_result, "trajectory", None) or getattr(
249
+ opt_result, "coordinates_list", []
250
+ )
251
+ energies = opt_result.energies_hartree
252
+ n = len(traj)
253
+ if n < 2:
254
+ app.traj_output.children = (
255
+ widgets.HTML(
256
+ value=(
257
+ '<p style="color:#666;padding:8px">'
258
+ "No trajectory data available (single-frame result).</p>"
259
+ )
260
+ ),
261
+ )
262
+ return
263
+
264
+ hartree_to_kcal = 627.5094740631
265
+ e0 = energies[0] if energies else 0.0
266
+ rel_e = [(e - e0) * hartree_to_kcal for e in energies] if energies else []
267
+
268
+ # --- Energy convergence chart ---
269
+ has_plotly = False
270
+ try:
271
+ import plotly.graph_objects as go
272
+
273
+ energy_fig = go.Figure(
274
+ go.Scatter(
275
+ x=list(range(n)),
276
+ y=rel_e,
277
+ mode="lines+markers",
278
+ name="ΔE",
279
+ line=dict(color="#2563eb", width=2),
280
+ marker=dict(size=6),
281
+ )
282
+ )
283
+ energy_fig.update_layout(
284
+ title="Energy Convergence",
285
+ xaxis_title="Step",
286
+ yaxis_title="ΔE (kcal/mol)",
287
+ height=220,
288
+ margin=dict(l=60, r=20, t=40, b=40),
289
+ )
290
+ has_plotly = True
291
+ except ImportError:
292
+ pass
293
+
294
+ # --- Pre-build XYZ blocks (reused by the viewer and the export) ---
295
+ charge = traj[0].charge
296
+ xyzblocks = [
297
+ f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj
298
+ ]
299
+ formula = traj[0].get_formula()
300
+ try:
301
+ bgcolor = app._plotly_theme_colors()["scene_bgcolor"]
302
+ except Exception:
303
+ bgcolor = "white"
304
+
305
+ if _is_stale():
306
+ return
307
+
308
+ # --- Single-viewer trajectory stepper (all frames preloaded) ---
309
+ viewer_output = widgets.Output(
310
+ layout=layout_fn(
311
+ height="410px", width="100%", max_width="500px", overflow="hidden"
312
+ )
313
+ )
314
+ try:
315
+ with _viz_render_event(app, task="trajectory", backend="py3dmol", n_frames=n):
316
+ html = build_trajectory_viewer_html(
317
+ xyzblocks,
318
+ formula=formula,
319
+ energies=list(energies) if energies else None,
320
+ rel_e=rel_e or None,
321
+ bgcolor=bgcolor,
322
+ )
323
+ app._set_html_output(viewer_output, html)
324
+ except Exception as exc: # noqa: BLE001 — surface inline, never crash the tab
325
+ viewer_output.outputs = (
326
+ {
327
+ "output_type": "display_data",
328
+ "data": {
329
+ "text/html": (
330
+ '<p style="color:#b91c1c;padding:8px">'
331
+ f"Trajectory viewer failed: {exc}</p>"
332
+ )
333
+ },
334
+ "metadata": {},
335
+ },
336
+ )
337
+
338
+ # --- Export button (standalone HTML animation; plotlymol3d) ---
339
+ export_btn = widgets.Button(
340
+ description="Export Animation",
341
+ icon="download",
342
+ layout=layout_fn(width="160px"),
343
+ tooltip="Generate a standalone HTML animation file (may take a minute)",
344
+ )
345
+ export_status = widgets.HTML()
346
+
347
+ def _on_export(_btn) -> None:
348
+ _btn.disabled = True
349
+ export_status.value = (
350
+ f'<span style="color:#555;font-style:italic">'
351
+ f"Generating {n}-frame animation, please wait…</span>"
352
+ )
353
+
354
+ def _do_export() -> None:
355
+ try:
356
+ from plotlymol3d import create_trajectory_animation
357
+
358
+ anim_fig = create_trajectory_animation(
359
+ xyzblocks=xyzblocks,
360
+ energies_hartree=energies if energies else None,
361
+ charge=charge,
362
+ mode="ball+stick",
363
+ resolution=12,
364
+ title=f"Geo Opt: {opt_result.formula}",
365
+ )
366
+ result_dir = getattr(app, "_last_result_dir", None)
367
+ out_path = (
368
+ result_dir / "trajectory_animation.html"
369
+ if result_dir is not None
370
+ else Path.home() / f"{opt_result.formula}_trajectory.html"
371
+ )
372
+ anim_fig.write_html(str(out_path))
373
+ app._queue_main_thread_callback(
374
+ setattr,
375
+ export_status,
376
+ "value",
377
+ (
378
+ f'<span style="color:#16a34a;font-size:12px">'
379
+ f"✓ Saved: {out_path}</span>"
380
+ ),
381
+ )
382
+ except Exception as exc:
383
+ app._queue_main_thread_callback(
384
+ setattr,
385
+ export_status,
386
+ "value",
387
+ f'<span style="color:#b91c1c">Export failed: {exc}</span>',
388
+ )
389
+ finally:
390
+ app._queue_main_thread_callback(setattr, _btn, "disabled", False)
391
+
392
+ threading.Thread(target=_do_export, daemon=True).start()
393
+
394
+ export_btn.on_click(_on_export)
395
+
396
+ # --- Assemble: energy chart (HTML in an Output so RequireJS runs) +
397
+ # viewer + export row, set atomically as traj_output's children. ---
398
+ if _is_stale():
399
+ return
400
+ new_children: list[Any] = []
401
+ if has_plotly and rel_e:
402
+ import plotly.io as _pio_e
403
+
404
+ energy_html = _pio_e.to_html(
405
+ energy_fig,
406
+ full_html=False,
407
+ include_plotlyjs="require",
408
+ config={"responsive": True},
409
+ )
410
+ energy_holder = widgets.Output()
411
+ energy_holder.outputs = (
412
+ {
413
+ "output_type": "display_data",
414
+ "data": {"text/html": energy_html},
415
+ "metadata": {},
416
+ },
417
+ )
418
+ new_children.append(energy_holder)
419
+ new_children.append(viewer_output)
420
+ new_children.append(
421
+ widgets.HBox(
422
+ [export_btn, export_status],
423
+ layout=layout_fn(align_items="center", margin="4px 0"),
424
+ )
425
+ )
426
+ app.traj_output.children = tuple(new_children)
427
+
428
+ try:
429
+ from quantui import calc_log as _clog_sp
430
+
431
+ _clog_sp.log_event("traj_show_panel", f"n={n} single_viewer=1")
432
+ except Exception:
433
+ pass
434
+
435
+
436
+ def traj_step_html(
437
+ app: Any, step: int, traj: list[Any], energies: list[Any], rel_e: list[Any]
438
+ ) -> str:
439
+ """One-line info label for a trajectory step index."""
440
+ n = len(traj)
441
+ mol = traj[step]
442
+ e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—"
443
+ delta = (
444
+ f" &nbsp;·&nbsp; ΔE = {rel_e[step]:+.3f} kcal/mol"
445
+ if rel_e and step < len(rel_e)
446
+ else ""
447
+ )
448
+ return (
449
+ f'<span style="font-size:12px;color:#666">'
450
+ f"Step {step} / {n - 1} &nbsp;·&nbsp; {mol.get_formula()}"
451
+ f" &nbsp;·&nbsp; E = {e_abs}{delta}</span>"
452
+ )
453
+
454
+
455
+ def render_traj_frame(app: Any, molecule: Any, output_widget: Any) -> None:
456
+ """Render one trajectory frame into output widget."""
457
+ try:
458
+ from quantui.visualization_py3dmol import visualize_molecule_plotlymol
459
+
460
+ fig = visualize_molecule_plotlymol(
461
+ molecule, mode="ball+stick", resolution=8, width=460, height=340
462
+ )
463
+ scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
464
+ fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg))
465
+ output_widget.clear_output()
466
+ with output_widget:
467
+ display(fig)
468
+ return
469
+ except ImportError:
470
+ pass
471
+
472
+ # Fallback: py3Dmol
473
+ try:
474
+ from quantui.viz_assets import make_view
475
+
476
+ xyz = (
477
+ f"{len(molecule.atoms)}\n"
478
+ f"{molecule.get_formula()}\n"
479
+ f"{molecule.to_xyz_string()}"
480
+ )
481
+ view = make_view(width=460, height=340)
482
+ view.addModel(xyz, "xyz")
483
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
484
+ view.setBackgroundColor("white")
485
+ view.zoomTo()
486
+ output_widget.clear_output()
487
+ with output_widget:
488
+ display(view)
489
+ except Exception as exc:
490
+ output_widget.clear_output()
491
+ with output_widget:
492
+ display(
493
+ HTML(
494
+ f'<p style="color:#b91c1c;padding:8px">Frame render failed: {exc}</p>'
495
+ )
496
+ )
497
+
498
+
499
+ def build_vib_data_from_freq_result(app: Any, freq_result: Any, molecule: Any) -> Any:
500
+ """Construct plotlymol3d VibrationalData from a frequency result."""
501
+ try:
502
+ import numpy as np
503
+ from plotlymol3d import VibrationalData, VibrationalMode
504
+ except ImportError:
505
+ return None
506
+
507
+ try:
508
+ return app._build_vib_data_inner(
509
+ freq_result, molecule, np, VibrationalData, VibrationalMode
510
+ )
511
+ except Exception as exc:
512
+ try:
513
+ from quantui import calc_log as _clog
514
+
515
+ _clog.log_event("vib_data_error", f"{type(exc).__name__}: {exc}"[:300])
516
+ except Exception:
517
+ pass
518
+ return None
519
+
520
+
521
+ def build_vib_data_inner(
522
+ app: Any,
523
+ freq_result: Any,
524
+ molecule: Any,
525
+ np: Any,
526
+ VibrationalData: Any,
527
+ VibrationalMode: Any,
528
+ ) -> Any:
529
+ """Internal constructor for VibrationalData with dependency injection."""
530
+ displacements = getattr(freq_result, "displacements", None)
531
+ if displacements is None:
532
+ return None
533
+
534
+ freqs = freq_result.frequencies_cm1
535
+ intensities = freq_result.ir_intensities
536
+ n_modes = len(freqs)
537
+
538
+ coords = np.array(molecule.coordinates, dtype=float)
539
+
540
+ # Map element symbols to atomic numbers using a common-elements table.
541
+ z_map = {
542
+ "H": 1,
543
+ "He": 2,
544
+ "Li": 3,
545
+ "Be": 4,
546
+ "B": 5,
547
+ "C": 6,
548
+ "N": 7,
549
+ "O": 8,
550
+ "F": 9,
551
+ "Ne": 10,
552
+ "Na": 11,
553
+ "Mg": 12,
554
+ "Al": 13,
555
+ "Si": 14,
556
+ "P": 15,
557
+ "S": 16,
558
+ "Cl": 17,
559
+ "Ar": 18,
560
+ "K": 19,
561
+ "Ca": 20,
562
+ "Br": 35,
563
+ "I": 53,
564
+ }
565
+ atomic_numbers: List[int] = [z_map.get(sym, 0) for sym in molecule.atoms]
566
+
567
+ modes = []
568
+ for i in range(n_modes):
569
+ freq = freqs[i]
570
+ ir_inten = intensities[i] if i < len(intensities) else None
571
+ displ = np.array(displacements[i], dtype=float)
572
+ modes.append(
573
+ VibrationalMode(
574
+ mode_number=i + 1,
575
+ frequency=float(freq),
576
+ ir_intensity=ir_inten,
577
+ displacement_vectors=displ,
578
+ is_imaginary=freq < 0,
579
+ )
580
+ )
581
+
582
+ return VibrationalData(
583
+ coordinates=coords,
584
+ atomic_numbers=atomic_numbers,
585
+ modes=modes,
586
+ source_file="quantui_freq_calc",
587
+ program="pyscf",
588
+ )
589
+
590
+
591
+ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool:
592
+ """Populate vibrational animation accordion after a Frequency result.
593
+
594
+ Dropdown options are built from raw ``freq_result.frequencies_cm1`` so the
595
+ panel populates regardless of plotlymol3d availability. The plotlymol3d
596
+ `VibrationalData` wrapper is built optionally — required only when the
597
+ plotlymol render path is selected; the py3Dmol render path reads
598
+ displacements directly from ``freq_result``.
599
+ """
600
+ freqs = freq_result.frequencies_cm1
601
+ if not freqs:
602
+ return False
603
+
604
+ # Optional plotlymol3d data — may be None if plotlymol3d isn't installed.
605
+ # The py3Dmol render path doesn't need this; only the plotlymol path does.
606
+ vib_data = app._build_vib_data_from_freq_result(freq_result, molecule)
607
+
608
+ # Build dropdown options from raw freq_result; skip near-zero translation
609
+ # / rotation modes. Mode numbers are 1-indexed positions in
610
+ # frequencies_cm1.
611
+ options = []
612
+ for i, freq_val in enumerate(freqs, start=1):
613
+ if abs(freq_val) < 10:
614
+ continue
615
+ label = (
616
+ f"Mode {i}: {freq_val:.1f} cm⁻¹"
617
+ if freq_val >= 0
618
+ else f"Mode {i}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)"
619
+ )
620
+ options.append((label, i))
621
+
622
+ if not options:
623
+ return False
624
+
625
+ app._last_vib_data = vib_data # may be None — plotlymol3d optional
626
+ app._last_vib_molecule = molecule
627
+ app._last_vib_freq_result = freq_result
628
+
629
+ first_label, first_mode = options[0]
630
+
631
+ # Decide the render path BEFORE assigning vib_mode_dd.value (which fires
632
+ # on_vib_mode_changed): set the single-viewer flag first so that observer
633
+ # takes the client-side-switch branch instead of spawning a redundant
634
+ # legacy per-mode render. The preferred path is ONE persistent py3Dmol
635
+ # viewer holding every mode, with client-side mode switching, so the camera
636
+ # (rotation/zoom) is preserved across modes (matches pre-opt/trajectory).
637
+ # Falls back to the legacy renderer when py3Dmol isn't selected or
638
+ # displacements are unavailable (e.g. some history replays).
639
+ use_single = _vib_single_viewer_supported(app, freq_result)
640
+ app._vib_single_viewer_active = use_single
641
+
642
+ app.vib_mode_dd.options = options
643
+ app.vib_mode_dd.value = first_mode # fires on_vib_mode_changed
644
+
645
+ if use_single:
646
+ if _render_vib_single_viewer(
647
+ app, freq_result, molecule, first_mode, [m for _, m in options]
648
+ ):
649
+ return True
650
+ app._vib_single_viewer_active = False # build failed → legacy fallback
651
+
652
+ # Cache-hit fast path: on history replay the cached HTML for the first
653
+ # mode is on disk, so swap it in synchronously without a placeholder.
654
+ # ``reset_camera=True`` clears any stale camera matrix from a previous
655
+ # freq result so the first mode opens at default zoom-to-fit.
656
+ if _try_vib_cache_hit_sync(app, first_mode, reset_camera=True):
657
+ return True
658
+
659
+ # Bump render token so any stale worker thread bails out before stomping
660
+ # this fresh render's output.
661
+ app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1
662
+ token = app._vib_render_token
663
+ _swap_vib_output(
664
+ app,
665
+ _VIB_CAMERA_RESET_JS + f'<p style="color:#555;font-style:italic;padding:8px">'
666
+ f"⏳ Rendering vibrational animation ({first_label})…</p>",
667
+ )
668
+ threading.Thread(
669
+ target=app._render_vib_mode,
670
+ args=(vib_data, molecule, first_mode),
671
+ kwargs={"render_token": token},
672
+ daemon=True,
673
+ ).start()
674
+
675
+ return True
676
+
677
+
678
+ def show_ir_spectrum(app: Any, freq_result: Any) -> bool:
679
+ """Populate IR Spectrum accordion after a Frequency result."""
680
+ freqs = list(freq_result.frequencies_cm1 or [])
681
+ ints = list(getattr(freq_result, "ir_intensities", None) or [])
682
+ if not freqs:
683
+ return False
684
+
685
+ app._ir_intensities_real = bool(ints)
686
+ if not ints:
687
+ ints = [1.0] * len(freqs)
688
+ app._ir_accordion.set_title(
689
+ 0,
690
+ (
691
+ "IR Spectrum"
692
+ if app._ir_intensities_real
693
+ else "IR Spectrum (positions only — intensities unavailable)"
694
+ ),
695
+ )
696
+
697
+ app._last_ir_freqs = freqs
698
+ app._last_ir_ints = ints
699
+
700
+ app._update_ir_figure("Stick", 20.0)
701
+
702
+ # _show_ir_spectrum may run from _do_run background thread.
703
+ app._queue_main_thread_callback(app._wire_ir_controls)
704
+
705
+ return True
706
+
707
+
708
+ def wire_ir_controls(app: Any) -> None:
709
+ """Rebind IR controls and reset defaults on the main thread."""
710
+ # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all()
711
+ # here because it can remove unrelated trait observers in some frontends.
712
+ app._ir_mode_toggle.value = "Stick"
713
+ app._ir_fwhm_slider.value = 20.0
714
+ app._ir_fwhm_slider.layout.display = "none"
715
+
716
+
717
+ def on_ir_mode_changed(app: Any, change: dict[str, Any]) -> None:
718
+ """Handle Stick/Broadened mode changes for IR panel."""
719
+ mode = change["new"]
720
+ try:
721
+ import quantui.calc_log as _calc_log
722
+
723
+ _calc_log.log_event(
724
+ "ir_mode_change",
725
+ mode,
726
+ mode=mode,
727
+ session_id=app._session_id,
728
+ )
729
+ except Exception:
730
+ pass
731
+ app._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none"
732
+ app._update_ir_figure(mode, app._ir_fwhm_slider.value)
733
+
734
+
735
+ def on_ir_fwhm_changed(app: Any, change: dict[str, Any]) -> None:
736
+ """Re-render broadened IR trace when line width slider changes."""
737
+ if app._ir_mode_toggle.value == "Broadened":
738
+ app._update_ir_figure("Broadened", change["new"])
739
+
740
+
741
+ def update_ir_figure(app: Any, mode: str, fwhm: float) -> None:
742
+ """Re-render IR spectrum chart for mode and FWHM settings."""
743
+ try:
744
+ import plotly.io as _pio
745
+
746
+ from quantui.ir_plot import plot_ir_spectrum
747
+
748
+ y_title = (
749
+ "IR Intensity (km/mol)"
750
+ if getattr(app, "_ir_intensities_real", True)
751
+ else "Relative intensity (a.u.)"
752
+ )
753
+ fig = plot_ir_spectrum(
754
+ app._last_ir_freqs,
755
+ app._last_ir_ints,
756
+ mode=mode.lower(),
757
+ fwhm=fwhm,
758
+ yaxis_title=y_title,
759
+ )
760
+ app._apply_plotly_theme(fig)
761
+ app._last_ir_fig = fig
762
+ app._set_html_output(
763
+ app._ir_fig,
764
+ _pio.to_html(
765
+ fig,
766
+ include_plotlyjs="require",
767
+ full_html=False,
768
+ config={"responsive": True},
769
+ ),
770
+ )
771
+ except Exception as exc:
772
+ app._last_ir_fig = None
773
+ try:
774
+ from quantui import calc_log as _clog
775
+
776
+ _clog.log_event("ir_fig_error", f"{type(exc).__name__}: {exc}"[:300])
777
+ except Exception:
778
+ pass
779
+
780
+
781
+ def show_uv_vis_spectrum(
782
+ app: Any,
783
+ energies_ev: List[float],
784
+ oscillator_strengths: List[float],
785
+ wavelengths_nm: List[float],
786
+ ) -> bool:
787
+ """Populate UV-Vis spectrum data and render the default stick plot."""
788
+ wl = list(wavelengths_nm or [])
789
+ if not wl:
790
+ wl = [1240.0 / e for e in energies_ev if e and e > 0]
791
+
792
+ peaks: list[tuple[float, float]] = []
793
+ for x0, amp in zip(wl, oscillator_strengths):
794
+ try:
795
+ x_val = float(x0)
796
+ a_val = float(amp)
797
+ except Exception:
798
+ continue
799
+ if x_val <= 0:
800
+ continue
801
+ peaks.append((x_val, max(a_val, 0.0)))
802
+
803
+ if not peaks:
804
+ return False
805
+
806
+ peaks.sort(key=lambda p: p[0])
807
+ app._last_uv_wavelengths_nm = [p[0] for p in peaks]
808
+ app._last_uv_oscillator_strengths = [p[1] for p in peaks]
809
+
810
+ app._update_uv_vis_figure("Stick", 20.0)
811
+
812
+ # _show_uv_vis_spectrum may run from _do_run background thread.
813
+ app._queue_main_thread_callback(app._wire_uv_controls)
814
+ return True
815
+
816
+
817
+ def wire_uv_controls(app: Any) -> None:
818
+ """Rebind UV-Vis controls and reset defaults on the main thread."""
819
+ # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all()
820
+ # here because it can remove unrelated trait observers in some frontends.
821
+ app._uv_mode_toggle.value = "Stick"
822
+ app._uv_fwhm_slider.value = 20.0
823
+ app._uv_fwhm_slider.layout.display = "none"
824
+
825
+
826
+ def on_uv_mode_changed(app: Any, change: dict[str, Any]) -> None:
827
+ """Handle Stick/Broadened mode changes for UV-Vis panel."""
828
+ mode = change["new"]
829
+ app._uv_fwhm_slider.layout.display = "" if mode == "Broadened" else "none"
830
+ app._update_uv_vis_figure(mode, app._uv_fwhm_slider.value)
831
+
832
+
833
+ def on_uv_fwhm_changed(app: Any, change: dict[str, Any]) -> None:
834
+ """Re-render broadened UV-Vis trace when line width slider changes."""
835
+ if app._uv_mode_toggle.value == "Broadened":
836
+ app._update_uv_vis_figure("Broadened", change["new"])
837
+
838
+
839
+ def update_uv_vis_figure(app: Any, mode: str, fwhm: float) -> None:
840
+ """Re-render UV-Vis spectrum chart for mode and FWHM settings."""
841
+ wl = list(getattr(app, "_last_uv_wavelengths_nm", []) or [])
842
+ osc = list(getattr(app, "_last_uv_oscillator_strengths", []) or [])
843
+ if not wl or not osc:
844
+ return
845
+
846
+ try:
847
+ import numpy as _np
848
+ import plotly.graph_objects as _go
849
+ import plotly.io as _pio
850
+
851
+ mode_name = str(mode or "Stick")
852
+ mode_norm = mode_name.strip().lower()
853
+ fig = _go.Figure()
854
+
855
+ # Use one stable x-range across modes so toggling Stick/Broadened
856
+ # doesn't visibly shift the axis. The Broadened wings need ~3*gamma
857
+ # of headroom to show the full Lorentzian tail; padding by the same
858
+ # amount in Stick keeps the layout identical.
859
+ gamma = max(float(fwhm), 1.0) / 2.0
860
+ pad = max(80.0, 3.0 * gamma)
861
+ x_min = max(100.0, min(wl) - pad)
862
+ x_max = max(wl) + pad
863
+
864
+ if mode_norm == "broadened":
865
+ n_points = max(600, int((x_max - x_min) * 2.0))
866
+ x_grid = _np.linspace(x_min, x_max, n_points)
867
+ y_grid = _np.zeros_like(x_grid)
868
+ for x0, amp in zip(wl, osc):
869
+ y_grid += amp * (gamma**2 / ((x_grid - x0) ** 2 + gamma**2))
870
+ fig.add_trace(
871
+ _go.Scatter(
872
+ x=x_grid.tolist(),
873
+ y=y_grid.tolist(),
874
+ mode="lines",
875
+ line=dict(color="#2563eb", width=2),
876
+ name="Broadened",
877
+ )
878
+ )
879
+ else:
880
+ stick_x: list[float | None] = []
881
+ stick_y: list[float | None] = []
882
+ for x0, amp in zip(wl, osc):
883
+ stick_x.extend([x0, x0, None])
884
+ stick_y.extend([0.0, amp, None])
885
+ fig.add_trace(
886
+ _go.Scatter(
887
+ x=stick_x,
888
+ y=stick_y,
889
+ mode="lines",
890
+ line=dict(color="#2563eb", width=2),
891
+ name="Stick",
892
+ )
893
+ )
894
+ fig.add_trace(
895
+ _go.Scatter(
896
+ x=wl,
897
+ y=osc,
898
+ mode="markers",
899
+ marker=dict(color="#1d4ed8", size=6),
900
+ showlegend=False,
901
+ hovertemplate=(
902
+ "Wavelength: %{x:.2f} nm"
903
+ "<br>Oscillator strength: %{y:.3f}<extra></extra>"
904
+ ),
905
+ )
906
+ )
907
+
908
+ tc = app._plotly_theme_colors()
909
+ fig.update_layout(
910
+ xaxis_title="Wavelength (nm)",
911
+ yaxis_title="Oscillator strength",
912
+ height=320,
913
+ margin=dict(l=60, r=20, t=30, b=50),
914
+ showlegend=False,
915
+ plot_bgcolor=tc["plot_bgcolor"],
916
+ paper_bgcolor=tc["paper_bgcolor"],
917
+ font=dict(color=tc["font_color"]),
918
+ )
919
+ fig.update_xaxes(
920
+ showgrid=True,
921
+ gridcolor=tc["grid_color"],
922
+ zeroline=False,
923
+ range=[x_min, x_max],
924
+ )
925
+ fig.update_yaxes(
926
+ showgrid=True,
927
+ gridcolor=tc["grid_color"],
928
+ rangemode="tozero",
929
+ )
930
+
931
+ app._apply_plotly_theme(fig)
932
+ app._last_uv_fig = fig
933
+ app._set_html_output(
934
+ app._tddft_fig,
935
+ _pio.to_html(
936
+ fig,
937
+ include_plotlyjs="require",
938
+ full_html=False,
939
+ config={"responsive": True},
940
+ ),
941
+ )
942
+ except Exception as exc:
943
+ app._last_uv_fig = None
944
+ try:
945
+ from quantui import calc_log as _clog
946
+
947
+ _clog.log_event("uv_fig_error", f"{type(exc).__name__}: {exc}"[:300])
948
+ except Exception:
949
+ pass
950
+
951
+
952
+ def show_orbital_diagram(app: Any, result: Any) -> bool:
953
+ """Build and reveal interactive orbital diagram accordion."""
954
+ mo_energy = getattr(result, "mo_energy_hartree", None)
955
+ mo_occ = getattr(result, "mo_occ", None)
956
+ if mo_energy is None or mo_occ is None:
957
+ return False
958
+
959
+ try:
960
+ from quantui.orbital_visualization import orbital_info_from_arrays
961
+
962
+ info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula)
963
+ except Exception:
964
+ return False
965
+
966
+ app._last_orb_info = info
967
+ app._last_orb_mo_coeff = getattr(result, "mo_coeff", None)
968
+ app._last_orb_mo_occ = mo_occ
969
+ app._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None)
970
+ app._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None)
971
+
972
+ plotly_rendered = False
973
+ try:
974
+ import plotly.io as _pio
975
+
976
+ from quantui.orbital_visualization import plot_orbital_diagram_plotly
977
+
978
+ fig = plot_orbital_diagram_plotly(info, max_orbitals=app._orb_n_orb_input.value)
979
+ yr = fig.layout.yaxis.range
980
+ if yr is not None:
981
+ app._orb_ymin_input.value = round(float(yr[0]), 2)
982
+ app._orb_ymax_input.value = round(float(yr[1]), 2)
983
+ app._apply_plotly_theme(fig)
984
+ app._last_orb_fig = fig
985
+ html_str = _pio.to_html(
986
+ fig,
987
+ include_plotlyjs="require",
988
+ full_html=False,
989
+ config={"responsive": True},
990
+ )
991
+ app._set_html_output(app._orb_diagram_html, html_str)
992
+ plotly_rendered = True
993
+ except Exception:
994
+ pass
995
+
996
+ if not plotly_rendered:
997
+ app._last_orb_fig = None
998
+ import base64
999
+ import io as _io
1000
+
1001
+ try:
1002
+ from matplotlib.backends.backend_agg import (
1003
+ FigureCanvasAgg as _AggCanvas,
1004
+ )
1005
+
1006
+ from quantui.orbital_visualization import plot_orbital_diagram
1007
+
1008
+ mpl_fig = plot_orbital_diagram(info)
1009
+ _AggCanvas(mpl_fig)
1010
+ buf = _io.BytesIO()
1011
+ mpl_fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
1012
+ buf.seek(0)
1013
+ img_b64 = base64.b64encode(buf.read()).decode()
1014
+ app._set_html_output(
1015
+ app._orb_diagram_html,
1016
+ (
1017
+ f'<img src="data:image/png;base64,{img_b64}" '
1018
+ 'style="max-width:100%;height:auto" />'
1019
+ ),
1020
+ )
1021
+ except Exception:
1022
+ pass
1023
+
1024
+ if (
1025
+ app._last_orb_mo_coeff is not None
1026
+ and app._last_orb_mol_atom is not None
1027
+ and app._last_orb_mol_basis is not None
1028
+ ):
1029
+ app._orb_iso_output.clear_output()
1030
+ app._orb_toggle.value = "HOMO"
1031
+ app._orb_iso_controls.layout.display = ""
1032
+ app._iso_generate_btn.disabled = False
1033
+ else:
1034
+ app._orb_iso_controls.layout.display = "none"
1035
+ app._iso_generate_btn.disabled = True
1036
+
1037
+ return True
1038
+
1039
+
1040
+ def on_iso_generate(app: Any, btn: Any) -> None:
1041
+ """Generate orbital isosurface for currently selected orbital."""
1042
+ orbital_label = app._orb_toggle.value
1043
+ # "By index" mode renders an arbitrary 0-based MO index. Encode it
1044
+ # into the label as "MO <n>"; render_orbital_isosurface parses it back.
1045
+ if orbital_label == "By index":
1046
+ orbital_label = f"MO {int(app._orb_index_input.value)}"
1047
+ app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1
1048
+ render_token = app._iso_render_token
1049
+ btn.disabled = True
1050
+ btn.description = "Generating…"
1051
+ # Reveal the inline spinner + light the toolbar activity indicator
1052
+ # so the (slow) cube generation reads as busy, not hung.
1053
+ _spinner = getattr(app, "_iso_spinner", None)
1054
+ if _spinner is not None:
1055
+ _spinner.layout.display = ""
1056
+ try:
1057
+ app._activity_begin("Generating orbital isosurface…", kind="compute")
1058
+ except Exception:
1059
+ pass
1060
+ try:
1061
+ from quantui import calc_log as _clog
1062
+
1063
+ _clog.log_event("iso_render_start", orbital_label)
1064
+ except Exception:
1065
+ pass
1066
+ app._orb_iso_output.clear_output()
1067
+ with app._orb_iso_output:
1068
+ display(
1069
+ HTML(
1070
+ f'<p style="color:#555;font-style:italic;padding:4px 0">'
1071
+ f"⏳ Generating {orbital_label} cube file and rendering isosurface"
1072
+ f" — this may take 15–30 s…</p>"
1073
+ )
1074
+ )
1075
+
1076
+ done = threading.Event()
1077
+ # Balance the single _activity_begin above exactly once, across
1078
+ # both the normal-completion and timeout paths (idempotent).
1079
+ _finished = threading.Event()
1080
+
1081
+ def _finish_activity() -> None:
1082
+ if _finished.is_set():
1083
+ return
1084
+ _finished.set()
1085
+ try:
1086
+ app._activity_end(kind="compute")
1087
+ except Exception:
1088
+ pass
1089
+ # Only hide the spinner if no newer generation superseded this one
1090
+ # (a newer render still wants the spinner visible).
1091
+ if render_token == int(getattr(app, "_iso_render_token", 0)):
1092
+ _sp = getattr(app, "_iso_spinner", None)
1093
+ if _sp is not None:
1094
+ _sp.layout.display = "none"
1095
+
1096
+ def _reset_button() -> None:
1097
+ _finish_activity()
1098
+ if render_token != int(getattr(app, "_iso_render_token", 0)):
1099
+ return
1100
+ btn.disabled = False
1101
+ btn.description = "Generate Isosurface"
1102
+
1103
+ def _run() -> None:
1104
+ try:
1105
+ app._render_orbital_isosurface(orbital_label, render_token=render_token)
1106
+ finally:
1107
+ done.set()
1108
+ app._queue_main_thread_callback(_reset_button)
1109
+
1110
+ def _watchdog() -> None:
1111
+ if done.wait(timeout=180):
1112
+ return
1113
+
1114
+ def _show_timeout() -> None:
1115
+ _finish_activity()
1116
+ if render_token != int(getattr(app, "_iso_render_token", 0)):
1117
+ return
1118
+ try:
1119
+ from quantui import calc_log as _clog
1120
+
1121
+ _clog.log_event("iso_render_timeout", orbital_label)
1122
+ except Exception:
1123
+ pass
1124
+ btn.disabled = False
1125
+ btn.description = "Generate Isosurface"
1126
+ app._orb_iso_output.clear_output()
1127
+ with app._orb_iso_output:
1128
+ display(
1129
+ HTML(
1130
+ '<p style="color:#b91c1c;padding:8px">'
1131
+ "⚠ Orbital isosurface timed out after 180 s. "
1132
+ "Try a smaller basis set or a smaller molecule.</p>"
1133
+ )
1134
+ )
1135
+
1136
+ app._queue_main_thread_callback(_show_timeout)
1137
+
1138
+ threading.Thread(target=_run, daemon=True).start()
1139
+ threading.Thread(target=_watchdog, daemon=True).start()
1140
+
1141
+
1142
+ def on_orb_range_changed(app: Any, _change: Any = None) -> None:
1143
+ """Live-update orbital diagram for axis limits or orbital count changes."""
1144
+ info = getattr(app, "_last_orb_info", None)
1145
+ if info is None:
1146
+ return
1147
+ ymin = app._orb_ymin_input.value
1148
+ ymax = app._orb_ymax_input.value
1149
+ if ymin >= ymax:
1150
+ return
1151
+ try:
1152
+ import plotly.io as _pio
1153
+
1154
+ from quantui.orbital_visualization import plot_orbital_diagram_plotly
1155
+
1156
+ fig = plot_orbital_diagram_plotly(
1157
+ info,
1158
+ max_orbitals=app._orb_n_orb_input.value,
1159
+ yrange=(ymin, ymax),
1160
+ )
1161
+ app._apply_plotly_theme(fig)
1162
+ app._last_orb_fig = fig
1163
+ app._set_html_output(
1164
+ app._orb_diagram_html,
1165
+ _pio.to_html(
1166
+ fig,
1167
+ include_plotlyjs="require",
1168
+ full_html=False,
1169
+ config={"responsive": True},
1170
+ ),
1171
+ )
1172
+ except Exception:
1173
+ app._last_orb_fig = None
1174
+ pass
1175
+
1176
+
1177
+ def render_orbital_isosurface(
1178
+ app: Any, orbital_label: str, render_token: int | None = None
1179
+ ) -> None:
1180
+ """Generate cube file and render orbital isosurface (Linux/WSL only)."""
1181
+ import re as _re
1182
+ from datetime import datetime as _dt
1183
+
1184
+ def _is_stale() -> bool:
1185
+ return render_token is not None and render_token != int(
1186
+ getattr(app, "_iso_render_token", 0)
1187
+ )
1188
+
1189
+ orb_info = getattr(app, "_last_orb_info", None)
1190
+ if orb_info is None:
1191
+ return
1192
+
1193
+ n_occ = orb_info.n_occupied
1194
+ n_total = len(orb_info.mo_energies_ev)
1195
+ idx_map = {
1196
+ "HOMO-1": n_occ - 2,
1197
+ "HOMO": n_occ - 1,
1198
+ "LUMO": n_occ,
1199
+ "LUMO+1": n_occ + 1,
1200
+ }
1201
+ orb_idx = idx_map.get(orbital_label)
1202
+ # "MO <n>" labels carry an explicit 0-based index from By-index mode.
1203
+ if orb_idx is None:
1204
+ _m = _re.match(r"MO\s+(\d+)$", orbital_label)
1205
+ if _m:
1206
+ orb_idx = int(_m.group(1))
1207
+ if orb_idx is None or orb_idx < 0 or orb_idx >= n_total:
1208
+ # Out-of-range (now user-reachable via free index entry) — surface it
1209
+ # instead of silently leaving the "Generating…" placeholder in place.
1210
+ def _show_range_err() -> None:
1211
+ if _is_stale():
1212
+ return
1213
+ app._orb_iso_output.clear_output()
1214
+ with app._orb_iso_output:
1215
+ display(
1216
+ HTML(
1217
+ '<p style="color:#b91c1c;padding:8px">'
1218
+ f"⚠ Orbital index out of range. This calculation has "
1219
+ f"{n_total} molecular orbitals (valid indices 0–"
1220
+ f"{n_total - 1}; HOMO = {n_occ - 1}).</p>"
1221
+ )
1222
+ )
1223
+
1224
+ app._queue_main_thread_callback(_show_range_err)
1225
+ return
1226
+
1227
+ mo_coeff = getattr(app, "_last_orb_mo_coeff", None)
1228
+ mol_atom = getattr(app, "_last_orb_mol_atom", None)
1229
+ mol_basis = getattr(app, "_last_orb_mol_basis", None)
1230
+ mo_occ_for_charge = getattr(app, "_last_orb_mo_occ", None)
1231
+ if mo_coeff is None or mol_atom is None or mol_basis is None:
1232
+ return
1233
+
1234
+ try:
1235
+ import plotly.io as _pio
1236
+
1237
+ from quantui.orbital_visualization import (
1238
+ generate_cube_from_arrays,
1239
+ infer_charge_and_spin,
1240
+ plot_cube_isosurface,
1241
+ render_orbital_isosurface_py3dmol,
1242
+ )
1243
+ from quantui.viz_backend_router import VizTask as _VT
1244
+
1245
+ result_dir = getattr(app, "_last_result_dir", None)
1246
+ if not isinstance(result_dir, Path):
1247
+ try:
1248
+ result_dir = app._get_results_dir()
1249
+ except Exception:
1250
+ result_dir = Path.cwd()
1251
+
1252
+ cube_dir = Path(result_dir) / "isosurfaces"
1253
+ cube_dir.mkdir(parents=True, exist_ok=True)
1254
+
1255
+ formula = str(getattr(orb_info, "formula", "") or "molecule")
1256
+ safe_formula = _re.sub(r"[^A-Za-z0-9_.-]+", "_", formula).strip("._")
1257
+ if not safe_formula:
1258
+ safe_formula = "molecule"
1259
+ safe_orb = _re.sub(r"[^A-Za-z0-9_.-]+", "_", orbital_label).strip("._")
1260
+ if not safe_orb:
1261
+ safe_orb = "orbital"
1262
+ ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S-%f")
1263
+ cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube"
1264
+
1265
+ # Charge/spin aren't carried on the app's orbital-state attributes —
1266
+ # infer them from the MO occupations so charged/open-shell molecules
1267
+ # (H3O+, OH-, radicals, ...) don't fail to build in PySCF.
1268
+ _charge, _spin = infer_charge_and_spin(mol_atom, mo_occ_for_charge)
1269
+ generate_cube_from_arrays(
1270
+ mol_atom,
1271
+ mol_basis,
1272
+ mo_coeff,
1273
+ orb_idx,
1274
+ cube_path,
1275
+ charge=_charge,
1276
+ spin=_spin,
1277
+ )
1278
+ scene_bgcolor = app._plotly_theme_colors()["scene_bgcolor"]
1279
+
1280
+ # Route the render: py3Dmol does native, full-resolution in-browser
1281
+ # isosurfacing (primary); the Plotly path is the fallback (downsampled).
1282
+ # Both consume the same full-resolution cube on disk. Plotly is the
1283
+ # universal fallback whenever py3Dmol is not the chosen backend.
1284
+ chosen = app._resolve_backend(_VT.ORBITAL_ISOSURFACE)
1285
+ use_py3dmol = str(chosen) == "py3dmol"
1286
+ backend_label = "py3dmol" if use_py3dmol else "plotlymol"
1287
+
1288
+ with _viz_render_event(app, task=_VT.ORBITAL_ISOSURFACE, backend=backend_label):
1289
+ if use_py3dmol:
1290
+ html_str = render_orbital_isosurface_py3dmol(
1291
+ cube_path,
1292
+ bgcolor=scene_bgcolor,
1293
+ )
1294
+ else:
1295
+ is_dark = app.theme_btn.value == "Dark"
1296
+ axis_color = "#dbeafe" if is_dark else "#1f2937"
1297
+ bond_color = "#cbd5e1" if is_dark else "#4b5563"
1298
+ title_color = app._plotly_theme_colors()["font_color"]
1299
+ fig = plot_cube_isosurface(
1300
+ cube_path,
1301
+ title=f"{orbital_label} Isosurface",
1302
+ show_molecule=True,
1303
+ show_grid=False,
1304
+ scene_bgcolor=scene_bgcolor,
1305
+ axis_color=axis_color,
1306
+ title_color=title_color,
1307
+ bond_color=bond_color,
1308
+ )
1309
+ html_str = _pio.to_html(
1310
+ fig,
1311
+ include_plotlyjs="require",
1312
+ full_html=False,
1313
+ config={"responsive": True},
1314
+ )
1315
+ except Exception as exc:
1316
+ if _is_stale():
1317
+ return
1318
+ err_msg = f"{type(exc).__name__}: {exc}"
1319
+ try:
1320
+ from quantui import calc_log as _clog
1321
+
1322
+ _clog.log_event(
1323
+ "iso_render_error",
1324
+ f"{orbital_label}: {err_msg}"[:300],
1325
+ )
1326
+ except Exception:
1327
+ pass
1328
+
1329
+ def _show_err(msg: str = err_msg) -> None:
1330
+ app._orb_iso_output.clear_output()
1331
+ with app._orb_iso_output:
1332
+ display(
1333
+ HTML(
1334
+ f'<p style="color:#b91c1c;padding:8px">'
1335
+ f"⚠ Orbital isosurface failed: {msg}</p>"
1336
+ )
1337
+ )
1338
+
1339
+ app._queue_main_thread_callback(_show_err)
1340
+ return
1341
+ if _is_stale():
1342
+ return
1343
+ # Track the last-generated cube + its orbital
1344
+ # label so the "Export cube" button can copy it to the top-level
1345
+ # result dir with a friendly name without re-deriving the path.
1346
+ app._last_cube_path = cube_path
1347
+ app._last_cube_orbital = orbital_label
1348
+ try:
1349
+ from quantui import calc_log as _clog
1350
+
1351
+ _clog.log_event(
1352
+ "iso_cube_saved",
1353
+ cube_path.name,
1354
+ cube_path=str(cube_path),
1355
+ orbital=orbital_label,
1356
+ session_id=app._session_id,
1357
+ )
1358
+ _clog.log_event("iso_render_done", orbital_label)
1359
+ except Exception:
1360
+ pass
1361
+
1362
+ app._queue_main_thread_callback(
1363
+ app._set_html_output,
1364
+ app._orb_iso_output,
1365
+ html_str,
1366
+ )
1367
+
1368
+ # Now that ``_last_cube_path`` is populated, the
1369
+ # "Export cube" button has something to copy. Enable it on the main
1370
+ # thread alongside the iso render swap.
1371
+ def _enable_cube_btn() -> None:
1372
+ try:
1373
+ app._iso_export_cube_btn.disabled = False
1374
+ except Exception:
1375
+ pass
1376
+
1377
+ app._queue_main_thread_callback(_enable_cube_btn)
1378
+
1379
+
1380
+ def _swap_vib_output(app: Any, html_str: str) -> None:
1381
+ """Atomically replace ``app.vib_output``'s content with one HTML payload.
1382
+
1383
+ Single widget-state assignment → single browser update message → no
1384
+ transient empty state → no layout reflow → no page-scroll jump on
1385
+ every mode switch. Matches the atomic-swap pattern proven for
1386
+ ``frame_out`` in the trajectory carousel.
1387
+ """
1388
+ app.vib_output.outputs = (
1389
+ {
1390
+ "output_type": "display_data",
1391
+ "data": {"text/html": html_str},
1392
+ "metadata": {},
1393
+ },
1394
+ )
1395
+
1396
+
1397
+ def _vib_err(app: Any, msg: str) -> None:
1398
+ """Show an error message in the vibrational animation output panel."""
1399
+ _swap_vib_output(app, f'<p style="color:#b91c1c;padding:8px">⚠ {msg}</p>')
1400
+
1401
+
1402
+ # JS snippet appended to every py3Dmol vib HTML payload. Patches
1403
+ # ``$3Dmol.createViewer`` once per page so each new viewer:
1404
+ # 1. Hijacks its own ``zoomTo`` — if ``window._quantuiVibCamera`` is set
1405
+ # (from a previous mode's pan/rotate state) apply it via ``setView``
1406
+ # instead of recomputing the default fit. Falls back to the original
1407
+ # zoomTo when no camera is saved.
1408
+ # 2. Starts a periodic interval that writes ``viewer.getView()`` into
1409
+ # ``window._quantuiVibCamera``, so the user's interactive pan/zoom
1410
+ # survives mode switches.
1411
+ # The script is idempotent (``_quantuiVibCameraHookInstalled`` guard), so
1412
+ # it can ship inside every cached HTML blob without doubling the hook.
1413
+ _VIB_CAMERA_PERSISTENCE_JS = """
1414
+ <script>
1415
+ (function() {
1416
+ if (window._quantuiVibCameraHookInstalled) return;
1417
+ function install() {
1418
+ if (!window.$3Dmol || !window.$3Dmol.createViewer) {
1419
+ setTimeout(install, 50);
1420
+ return;
1421
+ }
1422
+ if (window._quantuiVibCameraHookInstalled) return;
1423
+ window._quantuiVibCameraHookInstalled = true;
1424
+ var origCreate = window.$3Dmol.createViewer;
1425
+ window.$3Dmol.createViewer = function(element, config) {
1426
+ var v = origCreate.call(this, element, config);
1427
+ try {
1428
+ var origZoomTo = v.zoomTo ? v.zoomTo.bind(v) : null;
1429
+ v.zoomTo = function() {
1430
+ if (window._quantuiVibCamera) {
1431
+ try { return v.setView(window._quantuiVibCamera); }
1432
+ catch (e) {}
1433
+ }
1434
+ if (origZoomTo) return origZoomTo.apply(this, arguments);
1435
+ };
1436
+ var iv = setInterval(function() {
1437
+ try {
1438
+ var el = (element && (element[0] || element));
1439
+ if (el && !document.body.contains(el)) {
1440
+ clearInterval(iv);
1441
+ return;
1442
+ }
1443
+ var view = v.getView();
1444
+ if (view) window._quantuiVibCamera = view;
1445
+ } catch (e) {}
1446
+ }, 350);
1447
+ } catch (e) {}
1448
+ return v;
1449
+ };
1450
+ }
1451
+ install();
1452
+ })();
1453
+ </script>
1454
+ """
1455
+
1456
+ # Tiny one-shot reset injected when a NEW freq result begins, so the
1457
+ # first mode of a new molecule opens at default zoom-to-fit instead of a
1458
+ # stale camera from a previous molecule. Mode switches WITHIN the same
1459
+ # freq result do not reset.
1460
+ _VIB_CAMERA_RESET_JS = "<script>window._quantuiVibCamera = null;</script>"
1461
+
1462
+
1463
+ def _ensure_vib_camera_hook(html: str) -> str:
1464
+ """Prepend ``_VIB_CAMERA_PERSISTENCE_JS`` to ``html`` only when not
1465
+ already present. Lets us serve both new cache entries (which embed
1466
+ the hook) and old cache entries (written before this feature) with
1467
+ a single code path — avoids redundant script tags and keeps cache
1468
+ HTML byte-stable across miss/hit cycles."""
1469
+ if "_quantuiVibCameraHookInstalled" in html:
1470
+ return html
1471
+ return _VIB_CAMERA_PERSISTENCE_JS + html
1472
+
1473
+
1474
+ def _is_vib_stale(app: Any, render_token: int | None) -> bool:
1475
+ """True when a newer vib render has started; used to bail out of an
1476
+ older background render thread before it stomps the newer one's
1477
+ output. Returns False when no token was supplied (call-site opt-out)."""
1478
+ if render_token is None:
1479
+ return False
1480
+ return render_token != int(getattr(app, "_vib_render_token", 0))
1481
+
1482
+
1483
+ def _try_vib_cache_hit_sync(
1484
+ app: Any, mode_number: int, *, reset_camera: bool = False
1485
+ ) -> bool:
1486
+ """Synchronous cache lookup + swap. Returns True iff a cached HTML blob
1487
+ matching the current render params was found and injected directly
1488
+ into ``app.vib_output``.
1489
+
1490
+ Why: without this, every mode switch sets a "Rendering…" placeholder,
1491
+ spawns a thread, the thread checks the cache, then writes the cached
1492
+ HTML — visible as a brief placeholder flash even when the result is
1493
+ on disk. Doing the cache check on the main thread before the
1494
+ placeholder avoids the flash entirely for cache hits.
1495
+
1496
+ When ``reset_camera`` is True a tiny inline script clears
1497
+ ``window._quantuiVibCamera`` before the cached HTML runs, so the
1498
+ first mode of a new molecule opens at default zoom rather than at a
1499
+ stale camera from a previous molecule.
1500
+
1501
+ Bumps ``_vib_render_token`` so any in-flight render thread bails out
1502
+ before stomping the swapped cached output.
1503
+ """
1504
+ result_dir = getattr(app, "_last_result_dir", None)
1505
+ if result_dir is None:
1506
+ return False
1507
+
1508
+ try:
1509
+ from quantui.viz_backend_router import VizBackend as _VB
1510
+ from quantui.viz_backend_router import VizTask as _VT
1511
+
1512
+ chosen = app._resolve_backend(_VT.VIB_INTERACTIVE)
1513
+ if chosen != _VB.PY3DMOL:
1514
+ # Plotlymol path doesn't write to the disk cache.
1515
+ return False
1516
+
1517
+ viz_settings = getattr(getattr(app, "_user_settings", None), "viz", None)
1518
+ fps = int(getattr(viz_settings, "vib_framerate_fps", 10))
1519
+ fps = max(1, fps)
1520
+
1521
+ from quantui import vib_cache
1522
+
1523
+ cached_html = vib_cache.get_cached_html(
1524
+ Path(result_dir),
1525
+ mode_number,
1526
+ n_frames=24,
1527
+ amplitude=0.4,
1528
+ renderer="py3dmol",
1529
+ fps=fps,
1530
+ )
1531
+ except Exception:
1532
+ return False
1533
+
1534
+ if cached_html is None:
1535
+ return False
1536
+
1537
+ payload = _ensure_vib_camera_hook(cached_html)
1538
+ if reset_camera:
1539
+ payload = _VIB_CAMERA_RESET_JS + payload
1540
+
1541
+ app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1
1542
+ with _viz_render_event(
1543
+ app,
1544
+ task="vib_interactive",
1545
+ backend="py3dmol",
1546
+ mode=mode_number,
1547
+ source="cache_sync",
1548
+ ):
1549
+ _swap_vib_output(app, payload)
1550
+ try:
1551
+ from quantui import calc_log as _clog_sync_hit
1552
+
1553
+ _clog_sync_hit.log_event(
1554
+ "vib_cache_hit",
1555
+ f"mode {mode_number} backend=py3dmol fps={fps} path=sync",
1556
+ )
1557
+ except Exception:
1558
+ pass
1559
+ return True
1560
+
1561
+
1562
+ def _render_vib_mode_py3dmol(
1563
+ app: Any,
1564
+ molecule: Any,
1565
+ mode_number: int,
1566
+ *,
1567
+ n_frames: int = 24,
1568
+ amplitude: float = 0.4,
1569
+ fps: int | None = None,
1570
+ render_token: int | None = None,
1571
+ ) -> None:
1572
+ """Render vibrational animation via py3Dmol multi-frame XYZ.
1573
+
1574
+ Pure-numpy frame generation (no plotlymol3d
1575
+ dependency); 24 sinusoidal-phase frames over one full oscillation;
1576
+ py3Dmol view with ``addModelsAsFrames`` + ``animate``; serialized to
1577
+ HTML and atomically swapped into ``app.vib_output``.
1578
+
1579
+ ``fps`` controls the playback rate of ``view.animate`` (interval =
1580
+ 1000 / fps ms). When ``None``, reads from
1581
+ ``app._user_settings.viz.vib_framerate_fps``.
1582
+
1583
+ ``render_token`` is checked at every output-write site so a stale
1584
+ background render thread can bail out before stomping a newer
1585
+ render's output. See ``_is_vib_stale``.
1586
+ """
1587
+ import numpy as np
1588
+
1589
+ if fps is None:
1590
+ fps = int(
1591
+ getattr(
1592
+ getattr(app, "_user_settings", None) and app._user_settings.viz,
1593
+ "vib_framerate_fps",
1594
+ 10,
1595
+ )
1596
+ )
1597
+ fps = max(1, int(fps))
1598
+
1599
+ try:
1600
+ import py3Dmol # noqa: F401 — probe for a friendly error; make_view imports it
1601
+ except ImportError as exc:
1602
+ if not _is_vib_stale(app, render_token):
1603
+ _vib_err(app, f"py3Dmol unavailable: {exc}")
1604
+ return
1605
+
1606
+ freq_result = getattr(app, "_last_vib_freq_result", None)
1607
+ if freq_result is None:
1608
+ if not _is_vib_stale(app, render_token):
1609
+ _vib_err(app, "No frequency result cached for vibrational animation.")
1610
+ return
1611
+
1612
+ # Cache hit short-circuit. The cache key now includes ``fps``
1613
+ # so a user who changes the framerate will rebuild rather than play back
1614
+ # a mismatched-interval HTML blob.
1615
+ result_dir = getattr(app, "_last_result_dir", None)
1616
+ if result_dir is not None:
1617
+ try:
1618
+ from quantui import vib_cache
1619
+
1620
+ cached_html = vib_cache.get_cached_html(
1621
+ Path(result_dir),
1622
+ mode_number,
1623
+ n_frames=n_frames,
1624
+ amplitude=amplitude,
1625
+ renderer="py3dmol",
1626
+ fps=fps,
1627
+ )
1628
+ except Exception:
1629
+ cached_html = None
1630
+ if cached_html is not None:
1631
+ if _is_vib_stale(app, render_token):
1632
+ return
1633
+ _swap_vib_output(app, _ensure_vib_camera_hook(cached_html))
1634
+ try:
1635
+ from quantui import calc_log as _clog_cache_hit
1636
+
1637
+ _clog_cache_hit.log_event(
1638
+ "vib_cache_hit",
1639
+ f"mode {mode_number} backend=py3dmol fps={fps}",
1640
+ )
1641
+ except Exception:
1642
+ pass
1643
+ return
1644
+
1645
+ try:
1646
+ from quantui import calc_log as _clog_cache_miss
1647
+
1648
+ _clog_cache_miss.log_event(
1649
+ "vib_cache_miss",
1650
+ f"mode {mode_number} backend=py3dmol fps={fps}",
1651
+ )
1652
+ except Exception:
1653
+ pass
1654
+
1655
+ try:
1656
+ displ = np.array(freq_result.displacements[mode_number - 1], dtype=float)
1657
+ except (AttributeError, IndexError, ValueError, TypeError) as exc:
1658
+ if not _is_vib_stale(app, render_token):
1659
+ _vib_err(
1660
+ app,
1661
+ f"Could not read displacements for mode {mode_number}: {exc}",
1662
+ )
1663
+ return
1664
+
1665
+ atoms = list(molecule.atoms)
1666
+ base_coords = np.array(molecule.coordinates, dtype=float)
1667
+ if base_coords.shape != displ.shape:
1668
+ if not _is_vib_stale(app, render_token):
1669
+ _vib_err(
1670
+ app,
1671
+ f"Shape mismatch: base coords {base_coords.shape} vs "
1672
+ f"displacements {displ.shape}",
1673
+ )
1674
+ return
1675
+
1676
+ # One full oscillation: n_frames evenly-spaced phases over [0, 2π).
1677
+ phases = np.sin(np.linspace(0, 2 * np.pi, n_frames, endpoint=False))
1678
+ n_atoms = len(atoms)
1679
+ xyz_lines: list[str] = []
1680
+ for phase in phases:
1681
+ coords = base_coords + amplitude * float(phase) * displ
1682
+ xyz_lines.append(f"{n_atoms}")
1683
+ xyz_lines.append(f"mode {mode_number} phase {float(phase):+.3f}")
1684
+ for sym, xyz in zip(atoms, coords):
1685
+ xyz_lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
1686
+ xyz_string = "\n".join(xyz_lines) + "\n"
1687
+
1688
+ try:
1689
+ from quantui.viz_assets import make_view
1690
+
1691
+ interval_ms = max(1, int(round(1000.0 / fps)))
1692
+ view = make_view(width=460, height=420)
1693
+ view.addModelsAsFrames(xyz_string, "xyz")
1694
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
1695
+ bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e"
1696
+ view.setBackgroundColor(bg)
1697
+ view.zoomTo()
1698
+ view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
1699
+ # Prepend the camera-persistence hook so the user's interactive
1700
+ # pan/rotate state survives mode switches. The hook is idempotent
1701
+ # (guarded by ``_quantuiVibCameraHookInstalled``) and ships inside
1702
+ # the cached HTML too — so disk-cache hits also persist the camera.
1703
+ html_str = _VIB_CAMERA_PERSISTENCE_JS + view._make_html()
1704
+ except Exception as exc:
1705
+ if not _is_vib_stale(app, render_token):
1706
+ _vib_err(app, f"Vibrational animation render failed: {exc}")
1707
+ raise
1708
+
1709
+ if _is_vib_stale(app, render_token):
1710
+ # A newer render has superseded this one; do NOT write to vib_output
1711
+ # (would stomp the newer render's content).
1712
+ return
1713
+
1714
+ _swap_vib_output(app, html_str)
1715
+
1716
+ # Persist to disk cache so future visits and history replay can hit
1717
+ # this mode instantly. Non-fatal on failure — render still
1718
+ # succeeded, cache is purely an optimization.
1719
+ if result_dir is not None:
1720
+ try:
1721
+ freqs = getattr(freq_result, "frequencies_cm1", None) or []
1722
+ freq_cm1 = (
1723
+ float(freqs[mode_number - 1]) if 0 < mode_number <= len(freqs) else None
1724
+ )
1725
+ from quantui import vib_cache
1726
+
1727
+ vib_cache.save_cached_html(
1728
+ Path(result_dir),
1729
+ mode_number,
1730
+ html_str,
1731
+ freq_cm1=freq_cm1,
1732
+ n_frames=n_frames,
1733
+ amplitude=amplitude,
1734
+ renderer="py3dmol",
1735
+ fps=fps,
1736
+ )
1737
+ except Exception as exc:
1738
+ try:
1739
+ from quantui import calc_log as _clog_cache_err
1740
+
1741
+ _clog_cache_err.log_event(
1742
+ "vib_cache_write_error",
1743
+ f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300],
1744
+ )
1745
+ except Exception:
1746
+ pass
1747
+
1748
+
1749
+ def _render_vib_mode_plotlymol(
1750
+ app: Any,
1751
+ vib_data: Any,
1752
+ molecule: Any,
1753
+ mode_number: int,
1754
+ *,
1755
+ render_token: int | None = None,
1756
+ ) -> None:
1757
+ """Render vibrational animation via the plotlymol3d path (PlotlyMol +
1758
+ RDKit bond perception + Plotly figure). Used when user explicitly
1759
+ prefers plotlymol or when py3Dmol is unavailable."""
1760
+ if vib_data is None:
1761
+ if not _is_vib_stale(app, render_token):
1762
+ _vib_err(
1763
+ app,
1764
+ "PlotlyMol vibrational animation requires plotlymol3d, "
1765
+ "which is not installed.",
1766
+ )
1767
+ return
1768
+
1769
+ try:
1770
+ from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol
1771
+ except ImportError as exc:
1772
+ if not _is_vib_stale(app, render_token):
1773
+ _vib_err(
1774
+ app,
1775
+ f"PlotlyMol vibrational animation requires plotlymol3d "
1776
+ f"(<code>pip install plotlymol3d</code>): {exc}",
1777
+ )
1778
+ return
1779
+
1780
+ xyzblock = (
1781
+ f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
1782
+ f"{molecule.to_xyz_string()}"
1783
+ )
1784
+ try:
1785
+ rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
1786
+ except Exception as exc:
1787
+ if not _is_vib_stale(app, render_token):
1788
+ _vib_err(app, f"Could not parse molecule for bond connectivity: {exc}")
1789
+ return
1790
+
1791
+ try:
1792
+ anim_fig = create_vibration_animation(
1793
+ vib_data=vib_data,
1794
+ mode_number=mode_number,
1795
+ mol=rdmol,
1796
+ amplitude=0.4,
1797
+ n_frames=20,
1798
+ mode="ball+stick",
1799
+ resolution=12,
1800
+ )
1801
+ anim_fig.update_layout(height=420)
1802
+ except Exception as exc:
1803
+ if not _is_vib_stale(app, render_token):
1804
+ _vib_err(app, f"Animation generation failed: {exc}")
1805
+ raise
1806
+
1807
+ import plotly.io as _pio
1808
+
1809
+ anim_html = _pio.to_html(
1810
+ anim_fig,
1811
+ full_html=False,
1812
+ include_plotlyjs="require",
1813
+ config={"responsive": True},
1814
+ )
1815
+ if _is_vib_stale(app, render_token):
1816
+ return
1817
+ _swap_vib_output(app, anim_html)
1818
+
1819
+
1820
+ def render_vib_mode(
1821
+ app: Any,
1822
+ vib_data: Any,
1823
+ molecule: Any,
1824
+ mode_number: int,
1825
+ *,
1826
+ render_token: int | None = None,
1827
+ ) -> None:
1828
+ """Render vibrational animation for mode_number into ``app.vib_output``.
1829
+
1830
+ Backend dispatch goes through the router (``VizTask.VIB_INTERACTIVE``):
1831
+ py3Dmol primary, plotlymol3d fallback. The py3Dmol path is preferred for
1832
+ speed and doesn't require plotlymol3d to be installed.
1833
+
1834
+ ``render_token`` lets a caller (e.g. ``on_vib_mode_changed``) bump
1835
+ ``app._vib_render_token`` before spawning a worker thread, so any
1836
+ stale worker can bail out before stomping the newer render's output.
1837
+ """
1838
+ from quantui.viz_backend_router import VizBackend as _VB
1839
+ from quantui.viz_backend_router import VizTask as _VT
1840
+
1841
+ chosen = app._resolve_backend(_VT.VIB_INTERACTIVE)
1842
+ if chosen == _VB.PY3DMOL:
1843
+ try:
1844
+ with _viz_render_event(
1845
+ app, task="vib_interactive", backend="py3dmol", mode=mode_number
1846
+ ):
1847
+ _render_vib_mode_py3dmol(
1848
+ app, molecule, mode_number, render_token=render_token
1849
+ )
1850
+ except Exception:
1851
+ # viz_render_error was already logged by the context manager;
1852
+ # swallow here so a worker-thread render failure doesn't crash
1853
+ # the thread. The inner function already wrote a user-facing
1854
+ # error message via ``_vib_err``.
1855
+ pass
1856
+ elif chosen == _VB.PLOTLYMOL:
1857
+ try:
1858
+ with _viz_render_event(
1859
+ app, task="vib_interactive", backend="plotlymol", mode=mode_number
1860
+ ):
1861
+ _render_vib_mode_plotlymol(
1862
+ app, vib_data, molecule, mode_number, render_token=render_token
1863
+ )
1864
+ except Exception:
1865
+ pass
1866
+ else:
1867
+ if not _is_vib_stale(app, render_token):
1868
+ _vib_err(
1869
+ app,
1870
+ "No vibrational animation backend available "
1871
+ "(neither py3Dmol nor plotlymol3d installed).",
1872
+ )
1873
+
1874
+
1875
+ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None:
1876
+ """Re-render vibrational animation when mode dropdown changes."""
1877
+ mode_number = change["new"]
1878
+ vib_data = getattr(app, "_last_vib_data", None)
1879
+ molecule = getattr(app, "_last_vib_molecule", None)
1880
+ freq_result = getattr(app, "_last_vib_freq_result", None)
1881
+ # vib_data may be None when plotlymol3d is unavailable — the py3Dmol
1882
+ # render path doesn't need it. Bail only if we can't render at all.
1883
+ if molecule is None or freq_result is None:
1884
+ return
1885
+
1886
+ # Single-viewer path: switch modes client-side on the one persistent viewer
1887
+ # (camera preserved, no rebuild). Export + prev/next still drive vib_mode_dd,
1888
+ # so they keep working unchanged.
1889
+ if getattr(app, "_vib_single_viewer_active", False):
1890
+ _vib_bridge_set_mode(app, mode_number)
1891
+ return
1892
+
1893
+ # Cache-hit fast path: swap cached HTML synchronously, no placeholder,
1894
+ # no thread. Bumps the render token internally to invalidate any
1895
+ # in-flight render.
1896
+ if _try_vib_cache_hit_sync(app, mode_number):
1897
+ return
1898
+
1899
+ label = next(
1900
+ (lbl for lbl, num in app.vib_mode_dd.options if num == mode_number),
1901
+ f"mode {mode_number}",
1902
+ )
1903
+ # Bump render token so older in-flight render threads bail before they
1904
+ # stomp the newer render's output. Eliminates the intermittent
1905
+ # missing-render symptom from rapid mode switching.
1906
+ app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1
1907
+ token = app._vib_render_token
1908
+ _swap_vib_output(
1909
+ app,
1910
+ f'<p style="color:#555;font-style:italic;padding:8px">'
1911
+ f"⏳ Rendering vibrational animation ({label})…</p>",
1912
+ )
1913
+ threading.Thread(
1914
+ target=app._render_vib_mode,
1915
+ args=(vib_data, molecule, mode_number),
1916
+ kwargs={"render_token": token},
1917
+ daemon=True,
1918
+ ).start()
1919
+
1920
+
1921
+ _STEPPER_BTN_STYLE = (
1922
+ "padding:2px 9px;border:1px solid #cbd5e1;border-radius:4px;"
1923
+ "background:#f8fafc;color:#334155;cursor:pointer;font-size:13px;line-height:1.4;"
1924
+ )
1925
+
1926
+ # Shared single-viewer stepper logic. Drives ``viewer.setFrame()`` on a viewer
1927
+ # whose frames are ALL already loaded client-side via ``addModelsAsFrames`` — so
1928
+ # navigation never rebuilds the viewer, the camera (rotation/zoom) stays put
1929
+ # across frames, and there is no per-frame HTML/network round-trip. Play/pause is
1930
+ # a self-managed setInterval (not 3Dmol's animate(), so manual stepping and play
1931
+ # never fight). Tokens are substituted in :func:`_frame_stepper_controls`.
1932
+ _STEPPER_JS = """
1933
+ (function(){
1934
+ var UID="__UID__", N=__N__, IV=__IV__, LOOP=__LOOP__;
1935
+ var AB_START=__AB_START__, AB_OTHER=__AB_OTHER__;
1936
+ __EXTRA__
1937
+ function g(p){return document.getElementById(p+UID);}
1938
+ var slider=g("st_slider_"), lbl=g("st_lbl_"), prevB=g("st_prev_"),
1939
+ nextB=g("st_next_"), playB=g("st_play_"), abB=g("st_ab_");
1940
+ var cur=__START__, timer=null;
1941
+ function vw(){return window["viewer_"+UID];}
1942
+ function label(i){ __LABEL_BODY__ }
1943
+ function draw(i){
1944
+ i=Math.max(0,Math.min(N-1,i)); cur=i;
1945
+ var v=vw();
1946
+ if(v){ try{ var p=v.setFrame(i);
1947
+ if(p&&p.then){ p.then(function(){v.render();}); } else { v.render(); }
1948
+ }catch(e){ try{ v.render(); }catch(_){} } }
1949
+ if(slider) slider.value=i;
1950
+ if(lbl) lbl.innerHTML=label(i);
1951
+ if(prevB) prevB.disabled=(i<=0);
1952
+ if(nextB) nextB.disabled=(i>=N-1);
1953
+ if(abB) abB.innerHTML=(i===0)?AB_START:AB_OTHER;
1954
+ }
1955
+ function stop(){ if(timer){clearInterval(timer);timer=null;}
1956
+ if(playB) playB.innerHTML="\\u25b6 Play"; }
1957
+ function play(){ if(N<=1) return;
1958
+ if(cur>=N-1) draw(0); // at the end → replay from the first frame
1959
+ if(playB) playB.innerHTML="\\u23f8 Pause";
1960
+ timer=setInterval(function(){
1961
+ if(cur>=N-1){ if(LOOP){ draw(0); return; } stop(); return; }
1962
+ draw(cur+1);
1963
+ }, IV); }
1964
+ if(prevB) prevB.onclick=function(){stop();draw(cur-1);};
1965
+ if(nextB) nextB.onclick=function(){stop();draw(cur+1);};
1966
+ if(playB) playB.onclick=function(){ timer?stop():play(); };
1967
+ if(abB) abB.onclick =function(){ stop();draw(cur===0?N-1:0); };
1968
+ if(slider)slider.oninput=function(){ stop();draw(parseInt(slider.value,10)); };
1969
+ var t=0, poll=setInterval(function(){ t++;
1970
+ if(vw()){ clearInterval(poll); draw(cur); }
1971
+ else if(t>200){ clearInterval(poll);
1972
+ if(lbl) lbl.innerHTML="3D viewer failed to load"; }
1973
+ },50);
1974
+ })();
1975
+ """
1976
+
1977
+
1978
+ def _frame_stepper_controls(
1979
+ uid: str,
1980
+ n: int,
1981
+ interval_ms: int,
1982
+ *,
1983
+ label_js: str,
1984
+ initial_label: str,
1985
+ loop: bool,
1986
+ ab_at_start: str | None = None,
1987
+ ab_other: str | None = None,
1988
+ scrub_title: str = "Scrub frames",
1989
+ start_index: int | None = None,
1990
+ extra_decls: str = "",
1991
+ ) -> str:
1992
+ """Build in-HTML stepper controls (prev/play/next, scrub slider, optional
1993
+ A/B flip, live label) for a single multi-frame py3Dmol viewer.
1994
+
1995
+ Element ids are namespaced with the viewer's ``uid`` so multiple viewers on
1996
+ one page never collide. The script polls for the global ``viewer_<uid>``
1997
+ (py3Dmol creates it after the async 3Dmol.js load resolves) before wiring up.
1998
+
1999
+ ``label_js`` is the JS body of ``function label(i){…}`` returning the label
2000
+ HTML for frame ``i``; ``extra_decls`` is JS injected at the top of the IIFE
2001
+ (e.g. per-frame energy arrays). ``loop`` makes Play cycle forever, else it
2002
+ runs once and stops on the last frame.
2003
+ """
2004
+ import json
2005
+
2006
+ btn = _STEPPER_BTN_STYLE
2007
+ start = (n - 1) if start_index is None else start_index
2008
+ ab_html = ""
2009
+ if ab_at_start is not None:
2010
+ ab_html = (
2011
+ f'<button id="st_ab_{uid}" type="button" '
2012
+ 'title="Jump between the first and last frame" '
2013
+ # start index is the last frame, so the button initially offers the
2014
+ # "other" (first-frame) action.
2015
+ f'style="{btn}">{ab_other}</button>'
2016
+ )
2017
+ bar = (
2018
+ '<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;'
2019
+ 'margin:4px 0 2px;font-size:13px;">'
2020
+ f'<button id="st_prev_{uid}" type="button" title="Previous frame" '
2021
+ f'style="{btn}">&#9664;</button>'
2022
+ f'<button id="st_play_{uid}" type="button" '
2023
+ f'style="{btn}">&#9654; Play</button>'
2024
+ f'<button id="st_next_{uid}" type="button" title="Next frame" '
2025
+ f'style="{btn}">&#9654;</button>'
2026
+ f'<input id="st_slider_{uid}" type="range" min="0" max="{n - 1}" '
2027
+ f'value="{start}" step="1" title="{scrub_title}" '
2028
+ 'style="flex:1;min-width:110px;vertical-align:middle;">'
2029
+ f"{ab_html}"
2030
+ "</div>"
2031
+ f'<div id="st_lbl_{uid}" '
2032
+ 'style="font-size:12px;color:#64748b;margin:0 0 4px 2px;">'
2033
+ f"{initial_label}</div>"
2034
+ )
2035
+ js = (
2036
+ _STEPPER_JS.replace("__UID__", uid)
2037
+ .replace("__N__", str(n))
2038
+ .replace("__IV__", str(interval_ms))
2039
+ .replace("__LOOP__", "1" if loop else "0")
2040
+ .replace("__START__", str(start))
2041
+ .replace("__AB_START__", json.dumps(ab_at_start or ""))
2042
+ .replace("__AB_OTHER__", json.dumps(ab_other or ""))
2043
+ .replace("__EXTRA__", extra_decls)
2044
+ .replace("__LABEL_BODY__", label_js)
2045
+ )
2046
+ return bar + f"<script>{js}</script>"
2047
+
2048
+
2049
+ def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
2050
+ """Stepper controls for the pre-opt preview (input → relaxed)."""
2051
+ label_js = (
2052
+ 'if(i===0) return "Frame 1/"+N+" \\u2022 Input (your geometry)";'
2053
+ 'if(i===N-1) return "Frame "+N+"/"+N+" \\u2022 Relaxed (final)";'
2054
+ 'return "Frame "+(i+1)+"/"+N+" \\u2022 relaxing\\u2026";'
2055
+ )
2056
+ return _frame_stepper_controls(
2057
+ uid,
2058
+ n,
2059
+ interval_ms,
2060
+ label_js=label_js,
2061
+ initial_label=f"Frame {n}/{n} &bull; Relaxed (final)",
2062
+ loop=False, # one-shot: stop on the relaxed frame (no lingering "relaxing…")
2063
+ ab_at_start="⇄ Show relaxed",
2064
+ ab_other="⇄ Show input",
2065
+ scrub_title="Scrub the relaxation",
2066
+ )
2067
+
2068
+
2069
+ def build_preopt_preview_html(
2070
+ atoms: list[str],
2071
+ frames: list[list[list[float]]],
2072
+ *,
2073
+ bgcolor: str = "white",
2074
+ fps: int = 8,
2075
+ ) -> str:
2076
+ """Build an interactive py3Dmol view of a classical pre-opt relaxation.
2077
+
2078
+ ``frames`` is a list of per-iteration coordinate snapshots (from
2079
+ ``preopt.preoptimize_with_trajectory``); ``atoms`` is the element list.
2080
+ Returns self-contained, offline-safe HTML (3Dmol.js loaded from the vendored
2081
+ bundle via ``make_view``). All frames are loaded client-side via
2082
+ ``addModelsAsFrames``, and a stepper UI (prev/next, play/pause, scrub
2083
+ slider, and an input&hairsp;&#8644;&hairsp;relaxed A/B flip) drives
2084
+ ``viewer.setFrame`` so the user can compare geometries without re-rendering.
2085
+ A single-frame trajectory (no relaxation / FF fallback) renders as a static
2086
+ structure with no controls. Used by the interactive "Preview
2087
+ pre-optimization" flow.
2088
+ """
2089
+ import re
2090
+
2091
+ from quantui.viz_assets import make_view
2092
+
2093
+ n = len(atoms)
2094
+ lines: list[str] = []
2095
+ for coords in frames:
2096
+ lines.append(str(n))
2097
+ lines.append("preopt")
2098
+ for sym, xyz in zip(atoms, coords):
2099
+ lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
2100
+ xyz_string = "\n".join(lines) + "\n"
2101
+
2102
+ view = make_view(width=460, height=290)
2103
+ view.addModelsAsFrames(xyz_string, "xyz")
2104
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
2105
+ view.setBackgroundColor(bgcolor)
2106
+ view.zoomTo()
2107
+ view_html = view._make_html()
2108
+
2109
+ n_frames = len(frames)
2110
+ # Single frame (FF no-op / RDKit absent): nothing to step through.
2111
+ if n_frames <= 1:
2112
+ return view_html
2113
+
2114
+ m = re.search(r"3dmolviewer_(\w+)", view_html)
2115
+ if m is None:
2116
+ # Couldn't find the viewer id to wire controls to — fall back to a
2117
+ # plain auto-loop animation so the relaxation is still visible.
2118
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2119
+ view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
2120
+ return view._make_html()
2121
+
2122
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2123
+ controls = _preopt_controls_html(m.group(1), n_frames, interval_ms)
2124
+ return f'<div style="max-width:480px">{view_html}{controls}</div>'
2125
+
2126
+
2127
+ def build_trajectory_viewer_html(
2128
+ xyzblocks: list[str],
2129
+ *,
2130
+ formula: str = "",
2131
+ energies: list[float] | None = None,
2132
+ rel_e: list[float] | None = None,
2133
+ bgcolor: str = "white",
2134
+ width: int = 460,
2135
+ height: int = 340,
2136
+ fps: int = 8,
2137
+ ) -> str:
2138
+ """Build an interactive py3Dmol view of a geometry-optimization trajectory.
2139
+
2140
+ Loads every step as a frame of ONE viewer (``addModelsAsFrames``) and wires
2141
+ an in-HTML stepper (prev/next, play/pause, scrub slider, start↔final flip,
2142
+ per-step energy label) that navigates with ``viewer.setFrame`` — so the
2143
+ camera stays put across steps and there is no per-frame HTML rebuild
2144
+ (the previous carousel rebuilt a fresh viewer each step, resetting the
2145
+ rotation/zoom and flickering). Offline-safe via the vendored 3Dmol loader
2146
+ (``make_view``). ``energies`` (Hartree) and ``rel_e`` (kcal/mol) are optional
2147
+ per-step annotations; a <2-frame trajectory renders as a static structure.
2148
+ """
2149
+ import json
2150
+ import re
2151
+
2152
+ from quantui.viz_assets import make_view
2153
+
2154
+ n = len(xyzblocks)
2155
+ xyz_string = "\n".join(b.rstrip("\n") for b in xyzblocks) + "\n"
2156
+
2157
+ view = make_view(width=width, height=height)
2158
+ view.addModelsAsFrames(xyz_string, "xyz")
2159
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
2160
+ view.setBackgroundColor(bgcolor)
2161
+ view.zoomTo()
2162
+ view_html = view._make_html()
2163
+
2164
+ if n <= 1:
2165
+ return view_html
2166
+
2167
+ m = re.search(r"3dmolviewer_(\w+)", view_html)
2168
+ if m is None:
2169
+ return view_html # can't wire controls without the viewer id
2170
+
2171
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2172
+ eabs = json.dumps([float(e) for e in energies]) if energies else "null"
2173
+ erel = json.dumps([float(e) for e in rel_e]) if rel_e else "null"
2174
+ fjs = json.dumps(formula or "")
2175
+ label_js = (
2176
+ 'var s="Step "+i+" / "+(N-1);'
2177
+ f'if({fjs}) s+=" \\u00b7 "+{fjs};'
2178
+ 'if(EABS&&EABS[i]!=null) s+=" \\u00b7 E = "+EABS[i].toFixed(8)+" Ha";'
2179
+ "if(EREL&&EREL[i]!=null) s+="
2180
+ '" \\u00b7 \\u0394E = "+(EREL[i]>=0?"+":"")+EREL[i].toFixed(3)+" kcal/mol";'
2181
+ "return s;"
2182
+ )
2183
+ controls = _frame_stepper_controls(
2184
+ m.group(1),
2185
+ n,
2186
+ interval_ms,
2187
+ label_js=label_js,
2188
+ initial_label=f"Step {n - 1} / {n - 1}",
2189
+ loop=True, # optimization animation: loop continuously
2190
+ ab_at_start="⇄ Final geometry",
2191
+ ab_other="⇄ First step (input)",
2192
+ scrub_title="Scrub the optimization steps",
2193
+ extra_decls=f"var EABS={eabs}; var EREL={erel};",
2194
+ )
2195
+ return f'<div style="max-width:{width + 20}px">{view_html}{controls}</div>'
2196
+
2197
+
2198
+ # Single-viewer vibrational animation. ONE py3Dmol viewer holds every mode; the
2199
+ # per-mode oscillation frames are computed client-side from the embedded
2200
+ # displacement vectors (tiny: n_atoms×3 per mode) on demand, and a mode switch
2201
+ # calls ``window.__quantuiVibSetMode`` to swap frames on the SAME viewer instance
2202
+ # — so the camera (rotation/zoom) is preserved exactly across modes, with no
2203
+ # rebuild/flash. ``fit`` is true only for the initial mode (zoom-to-fit); switches
2204
+ # never re-fit. Replaces the old per-mode rebuild + fragile getView/setView hook.
2205
+ _VIB_VIEWER_JS = """
2206
+ (function(){
2207
+ var UID="__UID__";
2208
+ var SYM=__SYM__, BASE=__BASE__, DISPL=__DISPL__;
2209
+ var NAT=__NAT__, NF=__NF__, AMP=__AMP__, IV=__IV__, BG=__BG__, INIT=__INIT__;
2210
+ function vw(){ return window["viewer_"+UID]; }
2211
+ function frames(m){
2212
+ var d=DISPL[m]; if(!d) return null;
2213
+ var out="";
2214
+ for(var f=0; f<NF; f++){
2215
+ var ph=Math.sin(2*Math.PI*f/NF);
2216
+ out += NAT+"\\nmode "+m+"\\n";
2217
+ for(var a=0; a<NAT; a++){
2218
+ out += SYM[a]+" "+(BASE[a][0]+AMP*ph*d[a][0]).toFixed(5)+" "+
2219
+ (BASE[a][1]+AMP*ph*d[a][1]).toFixed(5)+" "+
2220
+ (BASE[a][2]+AMP*ph*d[a][2]).toFixed(5)+"\\n";
2221
+ }
2222
+ }
2223
+ return out;
2224
+ }
2225
+ window.__quantuiVibSetMode=function(m, fit){
2226
+ var v=vw(); if(!v) return false;
2227
+ var xyz=frames(m); if(xyz===null) return false;
2228
+ try{
2229
+ // stopAnimate FIRST: setMode may be called more than once for the same
2230
+ // viewer (initial render + the dropdown observer + each mode switch).
2231
+ // Without stopping the running loop, animate() stacks additional loops,
2232
+ // advancing frames several times per tick — glitchy, too-fast playback
2233
+ // that ignores the fps interval. Stopping guarantees exactly one loop.
2234
+ if(v.stopAnimate) v.stopAnimate();
2235
+ v.removeAllModels();
2236
+ v.addModelsAsFrames(xyz,"xyz");
2237
+ v.setStyle({"stick":{},"sphere":{"scale":0.3}});
2238
+ v.setBackgroundColor(BG);
2239
+ if(fit) v.zoomTo(); // fit only on first mode; switches keep the camera
2240
+ v.animate({"loop":"forward","interval":IV,"reps":0});
2241
+ v.render();
2242
+ }catch(e){ return false; }
2243
+ return true;
2244
+ };
2245
+ // Live framerate change (custom fps setting): update the interval and restart
2246
+ // the loop on the current frames — no rebuild, so the camera is preserved.
2247
+ window.__quantuiVibSetFps=function(fps){
2248
+ IV=Math.max(1,Math.round(1000/Math.max(1,fps)));
2249
+ var v=vw(); if(!v) return;
2250
+ try{ if(v.stopAnimate) v.stopAnimate();
2251
+ v.animate({"loop":"forward","interval":IV,"reps":0}); v.render();
2252
+ }catch(e){}
2253
+ };
2254
+ var t=0, poll=setInterval(function(){ t++;
2255
+ if(vw()){ clearInterval(poll); window.__quantuiVibSetMode(INIT, true); }
2256
+ else if(t>200){ clearInterval(poll); }
2257
+ },50);
2258
+ })();
2259
+ """
2260
+
2261
+
2262
+ def build_vib_viewer_html(
2263
+ molecule: Any,
2264
+ freq_result: Any,
2265
+ mode_numbers: list[int],
2266
+ initial_mode: int,
2267
+ *,
2268
+ amplitude: float = 0.4,
2269
+ n_frames: int = 24,
2270
+ fps: int = 10,
2271
+ bgcolor: str = "white",
2272
+ width: int = 460,
2273
+ height: int = 420,
2274
+ ) -> str:
2275
+ """Build a single py3Dmol viewer that holds every vibrational mode.
2276
+
2277
+ All modes share ONE viewer instance; oscillation frames are generated
2278
+ client-side from the embedded per-mode displacement vectors, and switching
2279
+ modes (``window.__quantuiVibSetMode``) swaps frames on that same viewer so
2280
+ the camera is preserved across modes. Offline-safe via the vendored 3Dmol
2281
+ loader (``make_view``). Raises if displacements are missing/misshaped so the
2282
+ caller can fall back to the legacy per-mode renderer.
2283
+ """
2284
+ import json
2285
+ import re
2286
+
2287
+ import numpy as np
2288
+
2289
+ from quantui.viz_assets import make_view
2290
+
2291
+ displacements = getattr(freq_result, "displacements", None)
2292
+ if not displacements:
2293
+ raise ValueError("freq_result has no displacements for single-viewer vib")
2294
+
2295
+ atoms = list(molecule.atoms)
2296
+ base = np.asarray(molecule.coordinates, dtype=float)
2297
+ n_atoms = len(atoms)
2298
+ displ_map: dict[int, list] = {}
2299
+ for m in mode_numbers:
2300
+ d = np.asarray(displacements[m - 1], dtype=float)
2301
+ if d.shape != base.shape:
2302
+ raise ValueError(
2303
+ f"mode {m} displacement shape {d.shape} != coords {base.shape}"
2304
+ )
2305
+ displ_map[int(m)] = d.tolist()
2306
+
2307
+ view = make_view(width=width, height=height)
2308
+ view.setBackgroundColor(bgcolor)
2309
+ view_html = view._make_html() # empty viewer; JS populates the initial mode
2310
+ m_uid = re.search(r"3dmolviewer_(\w+)", view_html)
2311
+ if m_uid is None:
2312
+ raise ValueError("could not find py3Dmol viewer id")
2313
+
2314
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
2315
+ js = (
2316
+ _VIB_VIEWER_JS.replace("__UID__", m_uid.group(1))
2317
+ .replace("__SYM__", json.dumps(atoms))
2318
+ .replace("__BASE__", json.dumps(base.tolist()))
2319
+ .replace("__DISPL__", json.dumps(displ_map))
2320
+ .replace("__NAT__", str(n_atoms))
2321
+ .replace("__NF__", str(int(n_frames)))
2322
+ .replace("__AMP__", repr(float(amplitude)))
2323
+ .replace("__IV__", str(interval_ms))
2324
+ .replace("__BG__", json.dumps(bgcolor))
2325
+ .replace("__INIT__", str(int(initial_mode)))
2326
+ )
2327
+ return (
2328
+ f'<div style="max-width:{width + 20}px">{view_html}<script>{js}</script></div>'
2329
+ )
2330
+
2331
+
2332
+ def _vib_single_viewer_supported(app: Any, freq_result: Any) -> bool:
2333
+ """True when the single-persistent-viewer vib path applies: py3Dmol backend
2334
+ is selected and the result carries per-mode displacement vectors."""
2335
+ try:
2336
+ from quantui.viz_backend_router import VizBackend as _VB
2337
+ from quantui.viz_backend_router import VizTask as _VT
2338
+
2339
+ if app._resolve_backend(_VT.VIB_INTERACTIVE) != _VB.PY3DMOL:
2340
+ return False
2341
+ return bool(getattr(freq_result, "displacements", None))
2342
+ except Exception:
2343
+ return False
2344
+
2345
+
2346
+ def _render_vib_single_viewer(
2347
+ app: Any,
2348
+ freq_result: Any,
2349
+ molecule: Any,
2350
+ initial_mode: int,
2351
+ mode_numbers: list[int],
2352
+ ) -> bool:
2353
+ """Build + swap in the single-viewer vib animation. Returns True on success
2354
+ (sets ``app._vib_single_viewer_active``); False to fall back to legacy."""
2355
+ try:
2356
+ viz_settings = getattr(getattr(app, "_user_settings", None), "viz", None)
2357
+ fps = max(1, int(getattr(viz_settings, "vib_framerate_fps", 10)))
2358
+ bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e"
2359
+ with _viz_render_event(
2360
+ app, task="vib_interactive", backend="py3dmol", source="single_viewer"
2361
+ ):
2362
+ html = build_vib_viewer_html(
2363
+ molecule, freq_result, mode_numbers, initial_mode, fps=fps, bgcolor=bg
2364
+ )
2365
+ except Exception as exc: # noqa: BLE001 — fall back to the legacy renderer
2366
+ try:
2367
+ from quantui import calc_log as _clog_sv
2368
+
2369
+ _clog_sv.log_event(
2370
+ "vib_single_viewer_fallback", f"{type(exc).__name__}: {exc}"[:200]
2371
+ )
2372
+ except Exception:
2373
+ pass
2374
+ app._vib_single_viewer_active = False
2375
+ return False
2376
+ _swap_vib_output(app, html)
2377
+ app._vib_single_viewer_active = True
2378
+ return True
2379
+
2380
+
2381
+ def _vib_bridge_set_mode(app: Any, mode_number: int) -> None:
2382
+ """Switch the live single-viewer to ``mode_number`` client-side (camera kept).
2383
+
2384
+ Emits a one-shot JS call to ``window.__quantuiVibSetMode`` via a hidden
2385
+ bridge Output; retries briefly in case the viewer is still loading."""
2386
+ bridge = getattr(app, "_vib_js_bridge", None)
2387
+ if bridge is None:
2388
+ return
2389
+ from IPython.display import Javascript, display
2390
+
2391
+ # %-formatting is deliberate here: the payload is JavaScript, which is dense
2392
+ # with braces, so an f-string or .format() would require doubling every one.
2393
+ js = (
2394
+ "(function(){var n=0;function go(){n++;" # noqa: UP031 — see above
2395
+ "if(window.__quantuiVibSetMode){window.__quantuiVibSetMode(%d,false);}"
2396
+ "else if(n<40){setTimeout(go,50);}}go();})();" % int(mode_number)
2397
+ )
2398
+ try:
2399
+ bridge.clear_output(wait=True)
2400
+ with bridge:
2401
+ display(Javascript(js))
2402
+ except Exception:
2403
+ pass
2404
+
2405
+
2406
+ def _vib_bridge_set_fps(app: Any, fps: int) -> None:
2407
+ """Update the live single-viewer's animation framerate client-side (no
2408
+ rebuild, camera preserved) via ``window.__quantuiVibSetFps``."""
2409
+ bridge = getattr(app, "_vib_js_bridge", None)
2410
+ if bridge is None:
2411
+ return
2412
+ from IPython.display import Javascript, display
2413
+
2414
+ # %-formatting is deliberate here — same JavaScript brace-density reason as
2415
+ # ``_vib_bridge_set_mode`` above.
2416
+ js = (
2417
+ "(function(){var n=0;function go(){n++;" # noqa: UP031 — see above
2418
+ "if(window.__quantuiVibSetFps){window.__quantuiVibSetFps(%d);}"
2419
+ "else if(n<40){setTimeout(go,50);}}go();})();" % int(fps)
2420
+ )
2421
+ try:
2422
+ bridge.clear_output(wait=True)
2423
+ with bridge:
2424
+ display(Javascript(js))
2425
+ except Exception:
2426
+ pass
2427
+
2428
+
2429
+ def show_pes_scan_result(app: Any, result: Any) -> bool:
2430
+ """Render PES energy profile chart and stash latest PES result."""
2431
+ app._last_pes_result = result
2432
+ try:
2433
+ import plotly.graph_objects as go
2434
+ import plotly.io as pio
2435
+
2436
+ e_rel = result.energies_relative_kcal
2437
+ x_vals = result.scan_parameter_values
2438
+
2439
+ hover_text = [
2440
+ f"{result.scan_coordinate_label}: {x:.4f}<br>"
2441
+ f"ΔE = {de:.3f} kcal/mol<br>"
2442
+ f"E = {e:.8f} Ha"
2443
+ for x, de, e in zip(x_vals, e_rel, result.energies_hartree)
2444
+ ]
2445
+
2446
+ fig = go.Figure(
2447
+ go.Scatter(
2448
+ x=x_vals,
2449
+ y=e_rel,
2450
+ mode="lines+markers",
2451
+ line=dict(color="#2563eb", width=2),
2452
+ marker=dict(size=8, color="#2563eb"),
2453
+ hovertext=hover_text,
2454
+ hoverinfo="text",
2455
+ )
2456
+ )
2457
+ tc = app._plotly_theme_colors()
2458
+ fig.update_layout(
2459
+ xaxis_title=result.scan_coordinate_label,
2460
+ yaxis_title="Relative energy / kcal mol⁻¹",
2461
+ height=380,
2462
+ margin=dict(l=60, r=20, t=30, b=50),
2463
+ plot_bgcolor=tc["plot_bgcolor"],
2464
+ paper_bgcolor=tc["paper_bgcolor"],
2465
+ font=dict(color=tc["font_color"]),
2466
+ xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
2467
+ yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]),
2468
+ hovermode="closest",
2469
+ )
2470
+ app._last_pes_fig = fig
2471
+ app._set_html_output(
2472
+ app._pes_plot_html,
2473
+ pio.to_html(
2474
+ fig,
2475
+ include_plotlyjs="require",
2476
+ full_html=False,
2477
+ config={"responsive": True},
2478
+ ),
2479
+ )
2480
+ except Exception:
2481
+ app._last_pes_fig = None
2482
+ pass
2483
+
2484
+ return True
2485
+
2486
+
2487
+ def build_vib_export_html(app: Any, mode_number: int) -> tuple[str, str]:
2488
+ """Build a self-contained HTML string for the given vibrational mode.
2489
+
2490
+ Backend resolution is preference-independent (decoupled from the user's
2491
+ live-render default backend): plotlymol3d is preferred because it produces
2492
+ a self-contained Plotly animation with embedded playback controls — the
2493
+ canonical "export quality" output. py3Dmol is used as a fallback only when
2494
+ plotlymol3d is unavailable; the resulting HTML embeds the multi-frame
2495
+ py3Dmol viewer with its built-in animate() loop.
2496
+
2497
+ Returns ``(backend_name, html_string)``.
2498
+
2499
+ Raises ``ValueError`` when vib state is missing or no backend is available.
2500
+ """
2501
+ freq_result = getattr(app, "_last_vib_freq_result", None)
2502
+ molecule = getattr(app, "_last_vib_molecule", None)
2503
+ if freq_result is None or molecule is None:
2504
+ raise ValueError(
2505
+ "No vibrational data available — run a Frequency calculation "
2506
+ "and open the Vibrational panel first."
2507
+ )
2508
+
2509
+ availability = getattr(app, "_viz_availability", None)
2510
+ if availability is None:
2511
+ raise ValueError("Visualization availability not initialised.")
2512
+
2513
+ # Plotlymol3d path — preferred for export.
2514
+ if availability.plotlymol:
2515
+ vib_data = getattr(app, "_last_vib_data", None)
2516
+ if vib_data is None:
2517
+ # Plotlymol3d installed but the per-result wrapper wasn't built.
2518
+ # Try once more from the cached freq_result + molecule.
2519
+ try:
2520
+ vib_data = app._build_vib_data_from_freq_result(freq_result, molecule)
2521
+ except Exception:
2522
+ vib_data = None
2523
+ if vib_data is not None:
2524
+ try:
2525
+ import plotly.io as _pio
2526
+ from plotlymol3d import (
2527
+ create_vibration_animation,
2528
+ xyzblock_to_rdkitmol,
2529
+ )
2530
+ except ImportError:
2531
+ pass
2532
+ else:
2533
+ xyzblock = (
2534
+ f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
2535
+ f"{molecule.to_xyz_string()}"
2536
+ )
2537
+ rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
2538
+ anim_fig = create_vibration_animation(
2539
+ vib_data=vib_data,
2540
+ mode_number=mode_number,
2541
+ mol=rdmol,
2542
+ amplitude=0.4,
2543
+ n_frames=20,
2544
+ mode="ball+stick",
2545
+ resolution=12,
2546
+ )
2547
+ anim_fig.update_layout(height=420)
2548
+ html_str = _pio.to_html(
2549
+ anim_fig,
2550
+ full_html=True,
2551
+ include_plotlyjs=True,
2552
+ config={"responsive": True},
2553
+ )
2554
+ return ("plotlymol", html_str)
2555
+
2556
+ # py3Dmol fallback — preference-independent fallback when plotlymol is
2557
+ # unavailable or its build path fails. Mirrors _render_vib_mode_py3dmol's
2558
+ # frame construction but emits stand-alone HTML rather than swapping into
2559
+ # vib_output.
2560
+ if availability.py3dmol:
2561
+ try:
2562
+ import numpy as np
2563
+ import py3Dmol # noqa: F401 — probe; make_view imports it for the export
2564
+ except ImportError as exc:
2565
+ raise ValueError(f"py3Dmol unavailable for fallback export: {exc}") from exc
2566
+
2567
+ try:
2568
+ displ = np.array(freq_result.displacements[mode_number - 1], dtype=float)
2569
+ except (AttributeError, IndexError, ValueError, TypeError) as exc:
2570
+ raise ValueError(
2571
+ f"Could not read displacements for mode {mode_number}: {exc}"
2572
+ ) from exc
2573
+
2574
+ atoms = list(molecule.atoms)
2575
+ base_coords = np.array(molecule.coordinates, dtype=float)
2576
+ if base_coords.shape != displ.shape:
2577
+ raise ValueError(
2578
+ f"Shape mismatch: base coords {base_coords.shape} vs "
2579
+ f"displacements {displ.shape}"
2580
+ )
2581
+
2582
+ n_frames = 24
2583
+ amplitude = 0.4
2584
+ fps = int(
2585
+ getattr(
2586
+ getattr(app, "_user_settings", None) and app._user_settings.viz,
2587
+ "vib_framerate_fps",
2588
+ 10,
2589
+ )
2590
+ )
2591
+ fps = max(1, fps)
2592
+ interval_ms = max(1, int(round(1000.0 / fps)))
2593
+
2594
+ phases = np.sin(np.linspace(0, 2 * np.pi, n_frames, endpoint=False))
2595
+ n_atoms = len(atoms)
2596
+ xyz_lines: list[str] = []
2597
+ for phase in phases:
2598
+ coords = base_coords + amplitude * float(phase) * displ
2599
+ xyz_lines.append(f"{n_atoms}")
2600
+ xyz_lines.append(f"mode {mode_number} phase {float(phase):+.3f}")
2601
+ for sym, xyz in zip(atoms, coords):
2602
+ xyz_lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
2603
+ xyz_string = "\n".join(xyz_lines) + "\n"
2604
+
2605
+ from quantui.viz_assets import make_view, standalone_html
2606
+
2607
+ view = make_view(width=640, height=520)
2608
+ view.addModelsAsFrames(xyz_string, "xyz")
2609
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
2610
+ view.setBackgroundColor("white")
2611
+ view.zoomTo()
2612
+ view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
2613
+ # Exported HTML is opened outside the app (no page bootstrap), so embed
2614
+ # the offline 3Dmol.js loader inline to make the file self-contained.
2615
+ return ("py3dmol", standalone_html(view._make_html()))
2616
+
2617
+ raise ValueError(
2618
+ "No visualization backend available to export the vibrational "
2619
+ "animation. Install plotlymol3d (preferred) or py3dmol."
2620
+ )