quantui 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
|
@@ -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" · Δ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} · {mol.get_formula()}"
|
|
451
|
+
f" · 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}">◀</button>'
|
|
2022
|
+
f'<button id="st_play_{uid}" type="button" '
|
|
2023
|
+
f'style="{btn}">▶ Play</button>'
|
|
2024
|
+
f'<button id="st_next_{uid}" type="button" title="Next frame" '
|
|
2025
|
+
f'style="{btn}">▶</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} • 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 ⇄ 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
|
+
)
|