From 52d39700620a492b5eda82d3bfe3186f350fa746 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Fri, 12 Jun 2026 17:34:00 -0400
Subject: [PATCH 01/19] Add py3Dmol backend for orbital isosurfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce a py3Dmol-based renderer and route selection for orbital isosurfaces.
- Add render_orbital_isosurface_py3dmol(...) in quantui/orbital_visualization.py to load the full-resolution .cube, create ± isosurfaces in-browser, and return self-contained HTML via py3Dmol._make_html().
- Update quantui/app_visualization.py to resolve the visualization backend for ORBITAL_ISOSURFACE, save the cube to disk, and dispatch rendering to either the new py3Dmol path or the existing Plotly fallback (wrapped in a viz render event).
- Update quantui/viz_backend_router.py policy to prefer PY3DMOL for VizTask.ORBITAL_ISOSURFACE with Plotly as fallback.
- Tests updated: added py3Dmol-only tests for the new renderer, added a test ensuring the py3Dmol path is used and the cube file is saved, and adjusted existing isosurface test to pin the Plotly fallback.
This change enables full-resolution, in-browser isosurfacing via py3Dmol while retaining Plotly as a universal fallback.
---
quantui/app_visualization.py | 59 +++++++++++++++++---------
quantui/orbital_visualization.py | 65 +++++++++++++++++++++++++++++
quantui/viz_backend_router.py | 9 ++--
tests/test_app.py | 47 +++++++++++++++++++++
tests/test_orbital_visualization.py | 47 +++++++++++++++++++++
tests/test_viz_backend_router.py | 4 +-
6 files changed, 207 insertions(+), 24 deletions(-)
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 1d0844a..fb2136f 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -1472,7 +1472,9 @@ def _is_stale() -> bool:
from quantui.orbital_visualization import (
generate_cube_from_arrays,
plot_cube_isosurface,
+ render_orbital_isosurface_py3dmol,
)
+ from quantui.viz_backend_router import VizTask as _VT
result_dir = getattr(app, "_last_result_dir", None)
if not isinstance(result_dir, Path):
@@ -1495,26 +1497,43 @@ def _is_stale() -> bool:
cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube"
generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path)
- is_dark = app.theme_btn.value == "Dark"
- axis_color = "#dbeafe" if is_dark else "#1f2937"
- bond_color = "#cbd5e1" if is_dark else "#4b5563"
- title_color = app._plotly_theme_colors()["font_color"]
- fig = plot_cube_isosurface(
- cube_path,
- title=f"{orbital_label} Isosurface",
- show_molecule=True,
- show_grid=False,
- scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"],
- axis_color=axis_color,
- title_color=title_color,
- bond_color=bond_color,
- )
- html_str = _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- )
+ scene_bgcolor = app._plotly_theme_colors()["scene_bgcolor"]
+
+ # Route the render: py3Dmol does native, full-resolution in-browser
+ # isosurfacing (primary); the Plotly path is the fallback (downsampled).
+ # Both consume the same full-resolution cube on disk. Plotly is the
+ # universal fallback whenever py3Dmol is not the chosen backend.
+ chosen = app._resolve_backend(_VT.ORBITAL_ISOSURFACE)
+ use_py3dmol = str(chosen) == "py3dmol"
+ backend_label = "py3dmol" if use_py3dmol else "plotlymol"
+
+ with _viz_render_event(app, task=_VT.ORBITAL_ISOSURFACE, backend=backend_label):
+ if use_py3dmol:
+ html_str = render_orbital_isosurface_py3dmol(
+ cube_path,
+ bgcolor=scene_bgcolor,
+ )
+ else:
+ is_dark = app.theme_btn.value == "Dark"
+ axis_color = "#dbeafe" if is_dark else "#1f2937"
+ bond_color = "#cbd5e1" if is_dark else "#4b5563"
+ title_color = app._plotly_theme_colors()["font_color"]
+ fig = plot_cube_isosurface(
+ cube_path,
+ title=f"{orbital_label} Isosurface",
+ show_molecule=True,
+ show_grid=False,
+ scene_bgcolor=scene_bgcolor,
+ axis_color=axis_color,
+ title_color=title_color,
+ bond_color=bond_color,
+ )
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs="require",
+ full_html=False,
+ config={"responsive": True},
+ )
except Exception as exc:
if _is_stale():
return
diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py
index 546a03b..ee58af6 100644
--- a/quantui/orbital_visualization.py
+++ b/quantui/orbital_visualization.py
@@ -805,6 +805,71 @@ def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) -
}
+def render_orbital_isosurface_py3dmol(
+ cube_path: Path,
+ *,
+ isovalue: float = 0.02,
+ opacity: float = 0.85,
+ width: int = 760,
+ height: int = 620,
+ pos_color: str = "blue",
+ neg_color: str = "red",
+ bgcolor: str = "white",
+ style: str = "stick",
+) -> str:
+ """Render an orbital isosurface from a cube file via py3Dmol.
+
+ Unlike :func:`plot_cube_isosurface` (Plotly), py3Dmol isosurfaces the cube
+ *in the browser* at full resolution, so there is no Python-side volume
+ downsample and the payload is just the cube text. Both lobes are drawn:
+ ``+isovalue`` (``pos_color``) and ``-isovalue`` (``neg_color``).
+
+ Returns self-contained HTML via py3Dmol's ``_make_html`` — the same
+ offline-safe embedding the molecule viewer uses (never a CDN).
+
+ Parameters
+ ----------
+ cube_path : Path
+ Path to a Gaussian ``.cube`` file (read at full resolution).
+ isovalue : float
+ Isosurface threshold; both ``+`` and ``-`` lobes are drawn.
+ opacity : float
+ Surface opacity (0-1).
+ width, height : int
+ Viewer size in pixels.
+ pos_color, neg_color : str
+ Lobe colors for the positive and negative isosurfaces.
+ bgcolor : str
+ Viewer background color.
+ style : str
+ py3Dmol style for the embedded atoms (e.g. ``"stick"``).
+
+ Returns
+ -------
+ str
+ Self-contained HTML for the interactive viewer.
+ """
+ import py3Dmol
+
+ cube_text = Path(cube_path).read_text()
+ view = py3Dmol.view(width=width, height=height)
+ view.addModel(cube_text, "cube")
+ view.setStyle({style: {}})
+ view.addVolumetricData(
+ cube_text,
+ "cube",
+ {"isoval": isovalue, "color": pos_color, "opacity": opacity},
+ )
+ view.addVolumetricData(
+ cube_text,
+ "cube",
+ {"isoval": -isovalue, "color": neg_color, "opacity": opacity},
+ )
+ view.setBackgroundColor(bgcolor)
+ view.zoomTo()
+ return view._make_html()
+
+
def plot_cube_isosurface(
cube_path: Path,
*,
diff --git a/quantui/viz_backend_router.py b/quantui/viz_backend_router.py
index 4995438..326883c 100644
--- a/quantui/viz_backend_router.py
+++ b/quantui/viz_backend_router.py
@@ -134,8 +134,11 @@ class Decision:
# is the only viable real-time trajectory backend in this app.
# - TRAJECTORY_EXPORT / VIB_EXPORT: plotlymol produces self-contained HTML
# animations with embedded controls, which is the export contract.
-# - ORBITAL_ISOSURFACE: existing Plotly cube-isosurface path; orthogonal
-# to the molecule backend policy.
+# ORBITAL_ISOSURFACE is dual-backend: py3Dmol does native, full-resolution
+# in-browser cube isosurfacing (primary); the Plotly cube-isosurface path is the
+# fallback (downsampled). "plotlymol" here is the umbrella for that Plotly path,
+# which only needs plotly itself — the dispatch site treats Plotly as the
+# universal fallback when py3Dmol is not chosen.
_TASK_POLICY: dict[VizTask, tuple[VizBackend, VizBackend | None]] = {
VizTask.MOLECULE_PREVIEW: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL),
VizTask.STRUCTURE_VIEW_RESULTS: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL),
@@ -145,7 +148,7 @@ class Decision:
VizTask.TRAJECTORY_EXPORT: (VizBackend.PLOTLYMOL, None),
VizTask.VIB_INTERACTIVE: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL),
VizTask.VIB_EXPORT: (VizBackend.PLOTLYMOL, None),
- VizTask.ORBITAL_ISOSURFACE: (VizBackend.PLOTLYMOL, None),
+ VizTask.ORBITAL_ISOSURFACE: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL),
}
diff --git a/tests/test_app.py b/tests/test_app.py
index 2a770df..9dac227 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2423,6 +2423,10 @@ def test_render_orbital_isosurface_saves_cube_to_disk(self, tmp_path):
app._last_orb_mo_coeff = [[1.0, 0.0], [0.0, 1.0]]
app._last_orb_mol_atom = [["H", [0.0, 0.0, 0.0]]]
app._last_orb_mol_basis = "sto-3g"
+ # Force the Plotly fallback path (M-ORBVIZ: the default routes to
+ # py3Dmol when available). Backend is pinned via _resolve_backend so the
+ # test is independent of which backends are installed.
+ app._resolve_backend = lambda task: "plotlymol"
captured: dict[str, object] = {}
@@ -2455,6 +2459,49 @@ def _fake_generate(_atom, _basis, _coeff, _idx, out_path):
mock_gen.assert_called_once()
mock_plot.assert_called_once()
+ def test_render_orbital_isosurface_py3dmol_path(self, tmp_path):
+ # When the backend resolves to py3Dmol, the renderer is the py3Dmol
+ # cube path (not Plotly), and the cube is still saved to disk.
+ app = QuantUIApp()
+ app._last_result_dir = tmp_path
+ app._last_orb_info = MagicMock()
+ app._last_orb_info.n_occupied = 1
+ app._last_orb_info.mo_energies_ev = [-10.0, 2.0]
+ app._last_orb_info.formula = "H2O"
+ app._last_orb_mo_coeff = [[1.0, 0.0], [0.0, 1.0]]
+ app._last_orb_mol_atom = [["H", [0.0, 0.0, 0.0]]]
+ app._last_orb_mol_basis = "sto-3g"
+ app._resolve_backend = lambda task: "py3dmol"
+
+ captured: dict[str, object] = {}
+
+ def _fake_generate(_atom, _basis, _coeff, _idx, out_path):
+ captured["path"] = out_path
+ out_path.write_text("cube", encoding="utf-8")
+ return out_path
+
+ with (
+ patch(
+ "quantui.orbital_visualization.generate_cube_from_arrays",
+ side_effect=_fake_generate,
+ ) as mock_gen,
+ patch(
+ "quantui.orbital_visualization.render_orbital_isosurface_py3dmol",
+ return_value="
py3dmol iso
",
+ ) as mock_py3dmol,
+ patch(
+ "quantui.orbital_visualization.plot_cube_isosurface",
+ return_value=MagicMock(),
+ ) as mock_plot,
+ ):
+ app._render_orbital_isosurface("HOMO")
+
+ saved_path = captured.get("path")
+ assert saved_path is not None and saved_path.exists()
+ mock_gen.assert_called_once()
+ mock_py3dmol.assert_called_once()
+ mock_plot.assert_not_called()
+
# ---------------------------------------------------------------------------
# M-UI — Results tab widgets (M-UI.8)
diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py
index 697fd16..03d437b 100644
--- a/tests/test_orbital_visualization.py
+++ b/tests/test_orbital_visualization.py
@@ -19,6 +19,7 @@
parse_cube_file,
plot_cube_isosurface,
plot_orbital_diagram,
+ render_orbital_isosurface_py3dmol,
)
try:
@@ -30,6 +31,17 @@
_pyscf_only = pytest.mark.skipif(not _PYSCF_AVAILABLE, reason="PySCF not available")
+try:
+ import py3Dmol # noqa: F401
+
+ _PY3DMOL_AVAILABLE = True
+except ImportError:
+ _PY3DMOL_AVAILABLE = False
+
+_py3dmol_only = pytest.mark.skipif(
+ not _PY3DMOL_AVAILABLE, reason="py3Dmol not available"
+)
+
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -362,6 +374,41 @@ def test_title_color_override(self, minimal_cube_file):
assert fig.layout.title.font.color == "#123456"
+@_py3dmol_only
+class TestRenderOrbitalIsosurfacePy3Dmol:
+ """py3Dmol orbital isosurface renderer (M-ORBVIZ).
+
+ Renders both ± lobes via in-browser cube isosurfacing and returns
+ self-contained HTML through py3Dmol's _make_html — the same offline-safe
+ embedding the molecule viewer uses (no CDN).
+ """
+
+ def test_returns_html_string(self, minimal_cube_file):
+ html = render_orbital_isosurface_py3dmol(minimal_cube_file)
+ assert isinstance(html, str)
+ assert len(html) > 0
+
+ def test_adds_two_volumetric_lobes(self, minimal_cube_file):
+ # Both the positive and negative isosurface calls must be embedded.
+ html = render_orbital_isosurface_py3dmol(minimal_cube_file, isovalue=0.02)
+ assert html.count("addVolumetricData") == 2
+ assert "0.02" in html # +isovalue
+ assert "-0.02" in html # -isovalue
+
+ def test_embeds_offline_no_cdn(self, minimal_cube_file):
+ # py3Dmol's _make_html must not pull 3Dmol.js from a remote CDN —
+ # offline Voilà would silently fail to render otherwise.
+ html = render_orbital_isosurface_py3dmol(minimal_cube_file)
+ assert 'src="http' not in html
+ assert "src='http" not in html
+
+ def test_full_resolution_no_downsample(self, minimal_cube_file):
+ # py3Dmol surfaces the cube client-side, so the full cube text is
+ # handed over verbatim (no Python-side stride/downsample).
+ html = render_orbital_isosurface_py3dmol(minimal_cube_file)
+ assert "addModel" in html
+
+
# ---------------------------------------------------------------------------
# generate_cube_from_arrays — M6.2 acceptance criteria
# ---------------------------------------------------------------------------
diff --git a/tests/test_viz_backend_router.py b/tests/test_viz_backend_router.py
index 375fbf8..9fff0be 100644
--- a/tests/test_viz_backend_router.py
+++ b/tests/test_viz_backend_router.py
@@ -33,13 +33,15 @@
VizTask.ANALYSIS_STRUCTURE_VIEW,
VizTask.HISTORY_STRUCTURE_REPLAY,
VizTask.VIB_INTERACTIVE,
+ # py3Dmol does native in-browser isosurfacing (primary); the Plotly
+ # cube-isosurface path is the fallback.
+ VizTask.ORBITAL_ISOSURFACE,
]
# Tasks that require plotlymol3d regardless of preference.
_PLOTLYMOL_ONLY_TASKS = [
VizTask.TRAJECTORY_EXPORT,
VizTask.VIB_EXPORT,
- VizTask.ORBITAL_ISOSURFACE,
]
# Tasks that require py3Dmol regardless of preference.
From 83254aad434ce9a87e7ade07a07d55b4cea195ed Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Mon, 15 Jun 2026 22:16:52 -0400
Subject: [PATCH 02/19] Vendor 3Dmol.js; add offline bootstrap
Bundle vendored 3Dmol.js and related provenance/license files and include them in the wheel (pyproject.toml). Add quantui.viz_assets to provide an offline-safe bootstrap and helper make_view/standalone_html utilities. Update app and visualization modules to inject the offline 3Dmol loader on display, use make_view for all py3Dmol views, and embed the loader into exported standalone HTML. Add tests for offline viz and other small refactors. The bootstrap is guarded with a try/except and logged on failure so app rendering is not blocked.
---
pyproject.toml | 9 +-
quantui/app.py | 17 ++-
quantui/app_visualization.py | 24 ++--
quantui/data/js/3Dmol-min.js | 2 +
quantui/data/js/3Dmol-min.js.LICENSE.txt | 5 +
quantui/data/js/PROVENANCE.md | 18 +++
quantui/orbital_visualization.py | 10 +-
quantui/visualization_py3dmol.py | 9 +-
quantui/viz_assets.py | 141 +++++++++++++++++++++++
tests/test_code_quality.py | 20 ++++
tests/test_viz_offline.py | 109 ++++++++++++++++++
11 files changed, 345 insertions(+), 19 deletions(-)
create mode 100644 quantui/data/js/3Dmol-min.js
create mode 100644 quantui/data/js/3Dmol-min.js.LICENSE.txt
create mode 100644 quantui/data/js/PROVENANCE.md
create mode 100644 quantui/viz_assets.py
create mode 100644 tests/test_viz_offline.py
diff --git a/pyproject.toml b/pyproject.toml
index 3ee417e..dc2bf62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,14 @@ packages = ["quantui"]
# Bundled molecule library (M-STRUCT): the indexed SQLite store + the
# human-readable JSON manifests it is built from. Both ship in the wheel so
# offline structure lookup works on a fresh install.
-quantui = ["data/library/*.sqlite", "data/manifests/*.json"]
+# Vendored 3Dmol.js: py3Dmol fetches 3Dmol.js from a CDN by default, which
+# fails offline; we ship the bundle so all 3D views render with no network.
+quantui = [
+ "data/library/*.sqlite",
+ "data/manifests/*.json",
+ "data/js/*.js",
+ "data/js/*.txt",
+]
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
diff --git a/quantui/app.py b/quantui/app.py
index e06a10a..0d355e9 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -1006,6 +1006,19 @@ def __init__(self) -> None:
def display(self) -> None:
"""Inject global CSS and render the application widget."""
display(HTML(_APP_CSS))
+ # Load 3Dmol.js from the vendored bundle (offline-safe) BEFORE any
+ # py3Dmol viewer renders. py3Dmol otherwise fetches 3Dmol.js from a CDN
+ # per view, which blanks every 3D view offline. This one-time
+"""
+
+
+@lru_cache(maxsize=1)
+def offline_bootstrap_html() -> str:
+ """One-time ``
-"""
+ """Return the vendored 3Dmol.js as a base64 ``data:`` URI (cached).
-
-@lru_cache(maxsize=1)
-def offline_bootstrap_html() -> str:
- """One-time ``"
+ )
+
+
+# Stepper logic. Drives viewer.setFrame() on the already-loaded multi-frame
+# view; play/pause is a self-managed setInterval (not 3Dmol's animate(), so
+# manual stepping and play never fight). Tokens substituted in _preopt_controls_html.
+_PREOPT_CONTROLS_JS = """
+(function(){
+ var UID="__UID__", N=__N__, IV=__IV__;
+ function g(p){return document.getElementById(p+UID);}
+ var slider=g("po_slider_"), lbl=g("po_lbl_"), prevB=g("po_prev_"),
+ nextB=g("po_next_"), playB=g("po_play_"), abB=g("po_ab_");
+ var cur=N-1, timer=null;
+ function vw(){return window["viewer_"+UID];}
+ function label(i){
+ if(i===0) return "Frame 1/"+N+" \\u2022 Input (your geometry)";
+ if(i===N-1) return "Frame "+N+"/"+N+" \\u2022 Relaxed (final)";
+ return "Frame "+(i+1)+"/"+N+" \\u2022 relaxing\\u2026";
+ }
+ function draw(i){
+ i=Math.max(0,Math.min(N-1,i)); cur=i;
+ var v=vw();
+ if(v){ try{ var p=v.setFrame(i);
+ if(p&&p.then){ p.then(function(){v.render();}); } else { v.render(); }
+ }catch(e){ try{ v.render(); }catch(_){} } }
+ if(slider) slider.value=i;
+ if(lbl) lbl.textContent=label(i);
+ if(prevB) prevB.disabled=(i<=0);
+ if(nextB) nextB.disabled=(i>=N-1);
+ if(abB) abB.innerHTML=(i===0)?"\\u21c4 Show relaxed":"\\u21c4 Show input";
+ }
+ function stop(){ if(timer){clearInterval(timer);timer=null;}
+ if(playB) playB.innerHTML="\\u25b6 Play"; }
+ function play(){ if(N<=1) return; if(playB) playB.innerHTML="\\u23f8 Pause";
+ timer=setInterval(function(){ draw(cur>=N-1?0:cur+1); }, IV); }
+ if(prevB) prevB.onclick=function(){stop();draw(cur-1);};
+ if(nextB) nextB.onclick=function(){stop();draw(cur+1);};
+ if(playB) playB.onclick=function(){ timer?stop():play(); };
+ if(abB) abB.onclick =function(){ stop();draw(cur===0?N-1:0); };
+ if(slider)slider.oninput=function(){ stop();draw(parseInt(slider.value,10)); };
+ var t=0, poll=setInterval(function(){ t++;
+ if(vw()){ clearInterval(poll); draw(cur); }
+ else if(t>200){ clearInterval(poll);
+ if(lbl) lbl.textContent="3D viewer failed to load"; }
+ },50);
+})();
+"""
+
+
def build_preopt_preview_html(
atoms: list[str],
frames: list[list[list[float]]],
@@ -2140,15 +2232,21 @@ def build_preopt_preview_html(
bgcolor: str = "white",
fps: int = 8,
) -> str:
- """Build an animated py3Dmol view of a classical pre-opt relaxation.
+ """Build an interactive py3Dmol view of a classical pre-opt relaxation.
``frames`` is a list of per-iteration coordinate snapshots (from
``preopt.preoptimize_with_trajectory``); ``atoms`` is the element list.
Returns self-contained, offline-safe HTML (3Dmol.js loaded from the vendored
- bundle via ``make_view``). A single-frame trajectory (no relaxation / FF
- fallback) renders as a static structure. Used by the interactive
- "Preview pre-optimization" flow (M-PREOPT PREOPT.2).
+ bundle via ``make_view``). All frames are loaded client-side via
+ ``addModelsAsFrames``, and a stepper UI (prev/next, play/pause, scrub
+ slider, and an input ⇄ relaxed A/B flip) drives
+ ``viewer.setFrame`` so the user can compare geometries without re-rendering.
+ A single-frame trajectory (no relaxation / FF fallback) renders as a static
+ structure with no controls. Used by the interactive "Preview
+ pre-optimization" flow (M-PREOPT PREOPT.2).
"""
+ import re
+
from quantui.viz_assets import make_view
n = len(atoms)
@@ -2160,14 +2258,29 @@ def build_preopt_preview_html(
lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
xyz_string = "\n".join(lines) + "\n"
- interval_ms = max(1, int(round(1000.0 / max(1, fps))))
- view = make_view(width=460, height=300)
+ view = make_view(width=460, height=290)
view.addModelsAsFrames(xyz_string, "xyz")
view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
view.setBackgroundColor(bgcolor)
view.zoomTo()
- view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
- return view._make_html()
+ view_html = view._make_html()
+
+ n_frames = len(frames)
+ # Single frame (FF no-op / RDKit absent): nothing to step through.
+ if n_frames <= 1:
+ return view_html
+
+ m = re.search(r"3dmolviewer_(\w+)", view_html)
+ if m is None:
+ # Couldn't find the viewer id to wire controls to — fall back to a
+ # plain auto-loop animation so the relaxation is still visible.
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
+ view.animate({"loop": "forward", "interval": interval_ms, "reps": 0})
+ return view._make_html()
+
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
+ controls = _preopt_controls_html(m.group(1), n_frames, interval_ms)
+ return f'
{view_html}{controls}
'
def show_pes_scan_result(app: Any, result: Any) -> bool:
diff --git a/quantui/help_content.py b/quantui/help_content.py
index 649fc80..cafef34 100644
--- a/quantui/help_content.py
+++ b/quantui/help_content.py
@@ -337,8 +337,11 @@
"point and run a geometry optimization for accurate results.
"
"
Quick clean-up: beside the Classical pre-optimize "
"checkbox, click Preview to relax the geometry with a fast "
- "force field (MMFF94/UFF) and watch it animate in place — then "
- "Keep this geometry to adopt it or Revert to discard. "
+ "force field (MMFF94/UFF). Use the controls under the viewer to "
+ "play the relaxation, scrub to any step, or click ⇄ to "
+ "flip between your input and the relaxed result for a direct "
+ "comparison — then Keep this geometry to adopt it or "
+ "Revert to discard. "
"(Leaving the checkbox ticked without previewing just runs the same "
"pre-opt silently before your calculation.)
"
),
diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py
index 5e08785..2c13967 100644
--- a/tests/test_preopt_preview.py
+++ b/tests/test_preopt_preview.py
@@ -82,14 +82,35 @@ def test_html_is_offline_and_multi_frame(self):
html = build_preopt_preview_html(atoms, frames)
assert "cdn.jsdelivr.net" not in html # offline-safe (vendored 3Dmol)
assert "addModelsAsFrames" in html
- assert "animate" in html
- def test_single_frame_renders(self):
+ def test_multi_frame_has_interactive_stepper(self):
+ pytest.importorskip("py3Dmol")
+ from quantui.app_visualization import build_preopt_preview_html
+
+ atoms = ["O", "H", "H"]
+ # Three frames → real relaxation → stepper controls are wired.
+ frames = [
+ [[0, 0, 0], [1.0, 0, 0], [0, 1.0, 0]],
+ [[0, 0, 0], [0.98, 0, 0], [0, 0.98, 0]],
+ [[0, 0, 0], [0.96, 0, 0], [0, 0.96, 0]],
+ ]
+ html = build_preopt_preview_html(atoms, frames)
+ # Frame navigation is driven client-side via setFrame on the already-
+ # loaded multi-frame view (no per-frame HTML rebuild).
+ assert "setFrame" in html
+ assert 'type="range"' in html # scrub slider
+ assert "Show input" in html # input <-> relaxed A/B flip
+ # Slider spans all frames (0 .. n-1).
+ assert 'max="2"' in html
+
+ def test_single_frame_renders_without_controls(self):
pytest.importorskip("py3Dmol")
from quantui.app_visualization import build_preopt_preview_html
html = build_preopt_preview_html(["H"], [[[0, 0, 0]]])
assert "cdn.jsdelivr.net" not in html
+ # Nothing to step through → no stepper controls.
+ assert "setFrame" not in html
# ── Handlers: preview / keep / revert ───────────────────────────────────────
From a21cee3229156eff65d067fafc2440e55c67cb3d Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 00:02:37 -0400
Subject: [PATCH 12/19] Improve preopt preview capture and playback
Replace the old per-iteration Minimize(maxIts=1) snapshotting with a more robust preview pipeline: capture fresh minimizations at increasing iteration budgets, limit work with a wall-clock time budget, and select displayed frames at (approximately) even RMSD spacing. Introduce _PREVIEW_FRAMES, _PREVIEW_TIME_BUDGET_S, _preview_iter_grid and _select_even_rmsd_frames and update _rdkit_ff_relax to return the final geometry plus the selected frames. Tweak playback JS to replay from the input geometry when starting at the end and to stop on the relaxed frame rather than looping. Add tests (including an RDKit embed helper) to verify final-frame consistency and that motion is distributed across frames and that the even-RMSD selector behaves correctly.
---
quantui/app_visualization.py | 9 ++-
quantui/preopt.py | 147 +++++++++++++++++++++++++++--------
tests/test_preopt_preview.py | 102 ++++++++++++++++++++++++
3 files changed, 222 insertions(+), 36 deletions(-)
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 4270b21..4437812 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -2209,8 +2209,13 @@ def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
}
function stop(){ if(timer){clearInterval(timer);timer=null;}
if(playB) playB.innerHTML="\\u25b6 Play"; }
- function play(){ if(N<=1) return; if(playB) playB.innerHTML="\\u23f8 Pause";
- timer=setInterval(function(){ draw(cur>=N-1?0:cur+1); }, IV); }
+ function play(){ if(N<=1) return;
+ if(cur>=N-1) draw(0); // at the end → replay from the input geometry
+ if(playB) playB.innerHTML="\\u23f8 Pause";
+ timer=setInterval(function(){
+ if(cur>=N-1){ stop(); return; } // stop on the relaxed frame, stay there
+ draw(cur+1);
+ }, IV); }
if(prevB) prevB.onclick=function(){stop();draw(cur-1);};
if(nextB) nextB.onclick=function(){stop();draw(cur+1);};
if(playB) playB.onclick=function(){ timer?stop():play(); };
diff --git a/quantui/preopt.py b/quantui/preopt.py
index a0a27c6..81f077a 100644
--- a/quantui/preopt.py
+++ b/quantui/preopt.py
@@ -66,9 +66,76 @@ def _copy_molecule(molecule: Molecule) -> Molecule:
)
-# Cap on captured preview frames so the relaxation animation stays light
-# (single-iteration steps; reasonable geometries converge well under this).
-_PREVIEW_FRAME_CAP = 40
+# Interactive-preview animation tuning (preoptimize_with_trajectory). The
+# trajectory is captured as fresh minimizations from the input at increasing
+# iteration budgets (see _rdkit_ff_relax). _PREVIEW_FRAMES is how many are shown
+# (selected at even RMSD spacing); _PREVIEW_TIME_BUDGET_S is a wall-clock safety
+# valve so a large molecule can't stall the preview thread building waypoints.
+_PREVIEW_FRAMES = 20
+_PREVIEW_TIME_BUDGET_S = 6.0
+
+
+def _preview_iter_grid(steps: int) -> List[int]:
+ """Iteration budgets to snapshot for the preview animation.
+
+ Fine early, coarser later: small stiff molecules (e.g. water) relax within a
+ handful of iterations, while large molecules' BFGS barely moves for the first
+ iterations then accelerates over tens-to-hundreds. A single fixed spacing
+ serves one regime and misses the other (a coarse step skips a tiny molecule's
+ whole relaxation; a fine step is wasteful for a large one). This grid samples
+ the active region for both without an excessive number of fresh minimizations
+ (budgets past convergence are nearly free — RDKit's Minimize returns early).
+ """
+ grid: List[int] = []
+ k = 0
+ while k < steps:
+ grid.append(k)
+ if k < 16:
+ k += 1
+ elif k < 64:
+ k += 4
+ else:
+ k += 8
+ return grid
+
+
+def _select_even_rmsd_frames(
+ waypoints: List[List[List[float]]], n_frames: int
+) -> List[List[List[float]]]:
+ """Pick ~``n_frames`` waypoints spaced at even RMSD from the final geometry.
+
+ ``waypoints`` is an ordered list of geometries (input first, relaxed last).
+ Returns a sublist (input first, relaxed last) chosen so consecutive frames
+ are roughly equidistant in RMSD. Without this the animation looks weighted
+ to wherever the optimizer took its largest steps (RDKit's BFGS barely moves
+ for the first iterations, then accelerates), playing back as a long static
+ stretch followed by a rush.
+ """
+ import numpy as np
+
+ if len(waypoints) <= 2:
+ return list(waypoints)
+ final = np.asarray(waypoints[-1], dtype=float)
+ to_final = [
+ float(
+ np.sqrt(np.mean(np.sum((np.asarray(w, dtype=float) - final) ** 2, axis=1)))
+ )
+ for w in waypoints
+ ]
+ total = to_final[0]
+ if total < 1e-3:
+ return [waypoints[-1]] # no meaningful motion → single static frame
+ targets = np.linspace(total, 0.0, max(2, n_frames))
+ chosen: List[int] = []
+ j = 0
+ for t in targets:
+ # to_final decreases as the molecule relaxes; advance to the first
+ # waypoint at or below this RMSD target.
+ while j < len(waypoints) - 1 and to_final[j] > t:
+ j += 1
+ if not chosen or chosen[-1] != j:
+ chosen.append(j)
+ return [waypoints[i] for i in chosen]
def _conf_coords(conf, n_atoms: int) -> List[List[float]]:
@@ -133,38 +200,50 @@ def _rdkit_ff_relax(
raise ValueError("atom count changed during FF relaxation")
return coords, ff_name, None
- # Frame-capturing path: build the force field and step it one iteration at a
- # time, snapshotting the geometry, so we can animate the real relaxation.
- if ff_name == "MMFF94":
- ff = AllChem.MMFFGetMoleculeForceField(
- rdmol, AllChem.MMFFGetMoleculeProperties(rdmol)
- )
- else:
- ff = AllChem.UFFGetMoleculeForceField(rdmol)
- if ff is None:
- raise ValueError("could not build force field for frame capture")
- ff.Initialize()
-
- frames: List[List[List[float]]] = [_conf_coords(conf, n)]
- converged = False
- for _ in range(int(steps)):
- more = ff.Minimize(maxIts=1) # 0 == converged, 1 == needs more
- frames.append(_conf_coords(conf, n))
- if more == 0:
- converged = True
- break
- if len(frames) >= _PREVIEW_FRAME_CAP:
- break
- if not converged:
- # Finish the relaxation in bulk (don't snapshot each remaining step);
- # append the final geometry so the animation ends at the real minimum.
- ff.Minimize(maxIts=int(steps))
- frames.append(_conf_coords(conf, n))
-
- coords = _conf_coords(conf, n)
- if len(coords) != len(molecule.atoms):
+ # Frame-capturing path. RDKit exposes no per-iteration callback, and calling
+ # Minimize(maxIts=1) repeatedly *restarts* its BFGS optimizer each call (the
+ # inverse-Hessian estimate resets to the identity), so single-step snapshots
+ # barely move while one bulk minimize does ~all the work — an animation that
+ # looks static then snaps on the last frame. Instead, snapshot a set of
+ # fresh minimizations from the input at increasing iteration budgets. BFGS is
+ # deterministic, so minimizing for k iterations is a true waypoint on the
+ # path to minimizing for 2k, and the budget==steps point is identical to the
+ # silent preoptimize() result (so Preview and a silent run agree). Frames are
+ # then selected at even RMSD spacing for a smooth playback.
+ import time as _time
+
+ def _relax_to(max_its: int) -> List[List[float]]:
+ rd = Chem.Mol(rdmol) # fresh copy at the input geometry
+ cf = rd.GetConformer()
+ if ff_name == "MMFF94":
+ ff = AllChem.MMFFGetMoleculeForceField(
+ rd, AllChem.MMFFGetMoleculeProperties(rd)
+ )
+ else:
+ ff = AllChem.UFFGetMoleculeForceField(rd)
+ if ff is None:
+ raise ValueError("could not build force field for frame capture")
+ ff.Initialize()
+ if max_its > 0:
+ ff.Minimize(maxIts=max_its)
+ return _conf_coords(cf, n)
+
+ final_coords = _relax_to(int(steps)) # == silent preoptimize() geometry
+ if len(final_coords) != len(molecule.atoms):
raise ValueError("atom count changed during FF relaxation")
- return coords, ff_name, frames
+
+ # Waypoints at increasing iteration budgets (fresh from input each time;
+ # budgets past convergence cost almost nothing as Minimize returns early).
+ waypoints: List[List[List[float]]] = []
+ t0 = _time.monotonic()
+ for k in _preview_iter_grid(int(steps)):
+ waypoints.append(_relax_to(k))
+ if _time.monotonic() - t0 > _PREVIEW_TIME_BUDGET_S:
+ break
+ waypoints.append(final_coords)
+
+ frames = _select_even_rmsd_frames(waypoints, _PREVIEW_FRAMES)
+ return final_coords, ff_name, frames
def preoptimize(
diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py
index 2c13967..b93bc87 100644
--- a/tests/test_preopt_preview.py
+++ b/tests/test_preopt_preview.py
@@ -26,6 +26,30 @@ def _stretched_water() -> Molecule:
)
+def _embed_smiles(smiles: str):
+ """RDKit-embed a SMILES into a Molecule, or None if RDKit/embedding fails."""
+ try:
+ from rdkit import Chem
+ from rdkit.Chem import AllChem
+ except ImportError:
+ return None
+ m = Chem.AddHs(Chem.MolFromSmiles(smiles))
+ if AllChem.EmbedMolecule(m, randomSeed=1) != 0:
+ return None
+ conf = m.GetConformer()
+ return Molecule(
+ atoms=[a.GetSymbol() for a in m.GetAtoms()],
+ coordinates=[
+ [
+ conf.GetAtomPosition(i).x,
+ conf.GetAtomPosition(i).y,
+ conf.GetAtomPosition(i).z,
+ ]
+ for i in range(m.GetNumAtoms())
+ ],
+ )
+
+
# ── Backend: preoptimize_with_trajectory ────────────────────────────────────
@@ -55,6 +79,47 @@ def test_distorted_input_produces_multi_frame_relaxation(self):
assert rmsd > 0.0
assert len(frames) >= 2 # actually relaxed over multiple steps
+ def test_trajectory_ends_at_kept_geometry(self):
+ # The last frame must equal the geometry "Keep" adopts (the returned
+ # molecule) — otherwise the animation lies about what you're keeping.
+ from quantui.preopt import _RDKIT_AVAILABLE, preoptimize_with_trajectory
+
+ if not _RDKIT_AVAILABLE:
+ pytest.skip("rdkit not installed")
+ import numpy as np
+
+ mol, rmsd, frames = preoptimize_with_trajectory(_stretched_water())
+ assert np.allclose(frames[-1], mol.coordinates, atol=1e-6)
+
+ def test_relaxation_is_gradual_no_dominating_jump(self):
+ # Regression: capturing single Minimize(maxIts=1) steps restarted RDKit's
+ # BFGS each call, so ~80% of the motion landed in one final bulk frame —
+ # the animation looked static then snapped. The fresh-from-input even-
+ # RMSD capture must spread the motion across frames instead. Uses a
+ # medium flexible molecule (a tiny one like water genuinely relaxes in a
+ # couple of iterations, so its motion can't be subdivided).
+ import numpy as np
+
+ from quantui.preopt import _RDKIT_AVAILABLE, preoptimize_with_trajectory
+
+ if not _RDKIT_AVAILABLE:
+ pytest.skip("rdkit not installed")
+ mol = _embed_smiles("CC(C)Cc1ccc(cc1)C(C)C(=O)O") # ibuprofen
+ if mol is None:
+ pytest.skip("could not embed test molecule")
+ _, rmsd, frames = preoptimize_with_trajectory(mol)
+ if rmsd < 0.05 or len(frames) < 4:
+ pytest.skip("too little motion to assess smoothness")
+ fr = [np.asarray(f, dtype=float) for f in frames]
+ steps = [
+ float(np.sqrt(np.mean(np.sum((fr[i + 1] - fr[i]) ** 2, axis=1))))
+ for i in range(len(fr) - 1)
+ ]
+ total = float(np.sqrt(np.mean(np.sum((fr[-1] - fr[0]) ** 2, axis=1))))
+ # No single inter-frame step may carry more than half the total motion
+ # (the regressed capture put ~80% in one frame).
+ assert max(steps) <= 0.5 * total
+
def test_rdkit_absent_is_non_destructive_single_frame(self, monkeypatch):
import quantui.preopt as preopt_mod
@@ -66,6 +131,43 @@ def test_rdkit_absent_is_non_destructive_single_frame(self, monkeypatch):
assert len(frames) == 1 # just the input geometry
+# ── Even-RMSD frame selection (platform-independent) ────────────────────────
+
+
+class TestEvenRmsdSelection:
+ def test_back_loaded_path_selects_evenly(self):
+ # A 1-atom "trajectory" that barely moves early then rushes late (exactly
+ # the RDKit BFGS profile). Even-RMSD selection should sample the active
+ # late region densely so playback steps are roughly equal.
+
+ from quantui.preopt import _select_even_rmsd_frames
+
+ n = 120
+ xs = [10.0 * (1.0 - (i / (n - 1)) ** 3) for i in range(n)] # late motion
+ waypoints = [[[x, 0.0, 0.0]] for x in xs]
+ frames = _select_even_rmsd_frames(waypoints, 11)
+
+ assert frames[0][0] == pytest.approx([10.0, 0.0, 0.0]) # input first
+ assert frames[-1][0] == pytest.approx([0.0, 0.0, 0.0]) # relaxed last
+ gaps = [
+ abs(frames[i + 1][0][0] - frames[i][0][0]) for i in range(len(frames) - 1)
+ ]
+ mean_gap = sum(gaps) / len(gaps)
+ # Even spacing: no gap is wildly larger than the mean (vs the raw
+ # iteration-spaced sampling, which would crowd the static early region).
+ assert max(gaps) <= 1.8 * mean_gap
+ # Strictly monotone toward the final geometry.
+ assert all(gaps[i] > 0 for i in range(len(gaps)))
+
+ def test_no_motion_collapses_to_single_frame(self):
+ from quantui.preopt import _select_even_rmsd_frames
+
+ same = [[0.0, 0.0, 0.0]]
+ waypoints = [list(map(list, same)) for _ in range(10)]
+ frames = _select_even_rmsd_frames(waypoints, 20)
+ assert len(frames) == 1
+
+
# ── Renderer: build_preopt_preview_html ─────────────────────────────────────
From 773ee0b1c85164d3dcade2ba0bc7250729a03744 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 12:16:29 -0400
Subject: [PATCH 13/19] Clear stale run status and fix cancel cleanup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clear lingering run_status when loading/reverting molecules and avoid reraise during cancel cleanup.
- quantui/app.py: When setting a new molecule or reverting a preview, clear run_status.value unless a calculation is running (guarded on _calc_running) so a previous "Pre-optimized geometry accepted." doesn't persist or wipe a live "Pre-optimizing…" status. In the cancel handler, clear the cancel event before writing the cancellation footer to the shared log to prevent _CalcCancelled from being re-raised during the cleanup write.
- quantui/app_runflow.py: Ensure on_preopt_reset also drops stale run_status when no calculation is running.
- tests: Add tests to verify the cancel cleanup write does not re-raise and to cover clearing/retaining run_status in the various molecule load/reset scenarios.
These changes prevent a stuck "Cancelling…" state and stale acceptance messages after switching molecules or reverting previews.
---
quantui/app.py | 15 +
quantui/app_runflow.py | 3 +
quantui/app_visualization.py | 671 ++++++++++-----------------
tests/test_cancel_and_clear_guard.py | 18 +
tests/test_preopt_preview.py | 25 +
tests/test_viz_offline.py | 34 ++
6 files changed, 336 insertions(+), 430 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 6be4178..ddfbbed 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -3503,6 +3503,14 @@ def _set_molecule(self, mol: Molecule, label: str = "") -> None:
self._preopt_relaxed_mol = None
self.preopt_preview_box.layout.display = "none"
+ # Loading a new molecule makes the previous run/preopt status stale
+ # ("Pre-optimized geometry accepted.", "Cancelled.", "Done in …") —
+ # clear it. Guarded on _calc_running so the mid-run pre-opt call to
+ # _set_molecule_threadsafe doesn't wipe the live "Pre-optimizing…"
+ # status. (Accept sets its own status right after this returns.)
+ if not self._calc_running:
+ self.run_status.value = ""
+
# Advance step indicator
if self.step_progress._states[2] != "active":
if self.step_progress._states[2] in ("done", "fail"):
@@ -4673,6 +4681,13 @@ def _run_required_final_single_point(target_mol, reason: str):
except _CalcCancelled:
_elapsed = time.perf_counter() - _run_wall_t
+ # Clear the cancel flag FIRST: the next line writes through
+ # ``log`` (``_LogCapture.write``), which re-raises _CalcCancelled
+ # while the flag is still set — that would propagate out of this
+ # handler and skip the cancelled card + "Cancelled." status below
+ # (only ``finally`` would run, leaving the status stuck on
+ # "Cancelling…"). ``finally`` clears it again (idempotent).
+ self._cancel_event.clear()
log.write("\n── Calculation cancelled by user ──\n")
self.result_output.append_display_data(
HTML(
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index 8ec419e..e26945d 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -492,6 +492,9 @@ def on_preopt_reset(app: Any, btn: Any = None) -> None:
app.preopt_preview_box.layout.display = "none"
app.preopt_preview_output.clear_output()
app.preopt_preview_status.value = ""
+ # Drop any stale "Pre-optimized geometry accepted." left from a prior accept.
+ if not app._calc_running:
+ app.run_status.value = ""
def on_accumulate(app: Any, btn: Any) -> None:
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 4437812..f88ecfc 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -226,41 +226,25 @@ def show_opt_trajectory(
layout_fn: Any,
render_token: int | None = None,
) -> None:
- """Build trajectory carousel and energy chart in trajectory panel."""
- import concurrent.futures
+ """Build the trajectory viewer + energy chart in the trajectory panel.
+
+ All optimization steps are loaded once into ONE py3Dmol viewer
+ (``addModelsAsFrames``) and navigated client-side with ``setFrame`` via an
+ in-HTML stepper (prev/next, play/pause, scrub slider, start↔final flip,
+ per-step energy label). Because the viewer instance never changes, the
+ camera (rotation/zoom) stays put across steps and there is no per-frame HTML
+ rebuild — the previous carousel rebuilt a fresh viewer each frame, which
+ reset the camera and flickered. py3Dmol-only per the viz routing policy; the
+ energy-convergence chart and Export button are unchanged.
+ """
def _is_stale() -> bool:
return render_token is not None and render_token != int(
getattr(app, "_traj_render_token", 0)
)
- def _set_cache_label(value: str) -> None:
- if _is_stale():
- return
- cache_label.value = value
-
- def _swap_frame_out(html_str: str) -> None:
- """Atomically replace frame_out's content in a single widget-state
- update so the browser never sees an intermediate empty state.
- Combined with the fixed `height` on frame_out, this prevents the
- layout-flash that otherwise happens between clear+append on every
- frame switch (visible as a page-scroll jump in the previous build)."""
- frame_out.outputs = (
- {
- "output_type": "display_data",
- "data": {"text/html": html_str},
- "metadata": {},
- },
- )
-
- def _show_frame_error(message: str) -> None:
- if _is_stale():
- return
- _swap_frame_out(
- f'
Frame render failed: {message}
'
- )
-
- # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list)
+ # Support both OptimizationResult (.trajectory) and PESScanResult
+ # (.coordinates_list).
traj = getattr(opt_result, "trajectory", None) or getattr(
opt_result, "coordinates_list", []
)
@@ -307,265 +291,55 @@ def _show_frame_error(message: str) -> None:
except ImportError:
pass
- # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) ---
+ # --- Pre-build XYZ blocks (reused by the viewer and the export) ---
charge = traj[0].charge
xyzblocks = [
f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj
]
- frame_w, frame_h, frame_res = 460, 340, 8
-
- # --- Attempt fast-path: bond perception once on frame 0 ---
- ref_mol = None
- plotlymol_fast = False
+ formula = traj[0].get_formula()
try:
- from plotlymol3d import (
- draw_3D_mol as _draw_3D_mol,
- )
- from plotlymol3d import (
- format_figure as _fmt_fig,
- )
- from plotlymol3d import (
- format_lighting as _fmt_light,
- )
- from plotlymol3d import (
- make_subplots as _make_subplots,
- )
- from plotlymol3d import (
- xyzblock_to_rdkitmol as _xyz_to_rdkit,
- )
- from rdkit import Chem as _Chem
-
- from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP
-
- ref_mol = _xyz_to_rdkit(xyzblocks[0], charge=charge)
- plotlymol_fast = ref_mol is not None
+ bgcolor = app._plotly_theme_colors()["scene_bgcolor"]
except Exception:
- pass
-
- def _build_fig_fast(idx: int):
- """Reuse frame-0 bond topology; only swap in new atom positions."""
- mol_xyz = _Chem.MolFromXYZBlock(xyzblocks[idx] + "\n")
- if mol_xyz is None:
- return None
- rw = _Chem.RWMol(ref_mol)
- conf_src = mol_xyz.GetConformer()
- conf_dst = rw.GetConformer()
- for atom_idx in range(rw.GetNumAtoms()):
- conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx))
- fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]])
- _draw_3D_mol(fig, rw.GetMol(), frame_res, "ball+stick")
- fig = _fmt_fig(fig)
- fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"]))
- scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
- fig.update_layout(
- width=frame_w,
- height=frame_h,
- paper_bgcolor="white",
- scene=dict(bgcolor=scene_bg),
- margin=dict(l=0, r=0, t=0, b=0),
- )
- return fig
-
- def _try_py3dmol(idx: int):
- """Build frame idx with py3Dmol. Returns (kind, obj) or None."""
- try:
- from quantui.viz_assets import make_view
-
- view = make_view(width=frame_w, height=frame_h)
- view.addModel(xyzblocks[idx], "xyz")
- view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
- view.setBackgroundColor(
- "white" if app.theme_btn.value == "Light" else "#1e1e1e"
- )
- view.zoomTo()
- return ("py3dmol", view)
- except Exception:
- return None
+ bgcolor = "white"
- def _try_plotlymol(idx: int):
- """Build frame idx with plotlymol3d. Tries fast bond-cached path
- first, falls back to slow path. Returns (kind, obj) or None."""
- if plotlymol_fast:
- try:
- fig = _build_fig_fast(idx)
- if fig is not None:
- return ("plotly", fig)
- except Exception:
- pass
- try:
- from quantui.visualization_py3dmol import visualize_molecule_plotlymol
-
- fig = visualize_molecule_plotlymol(
- traj[idx],
- mode="ball+stick",
- resolution=frame_res,
- width=frame_w,
- height=frame_h,
- )
- scene_bg = app._plotly_theme_colors()["scene_bgcolor"]
- fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg))
- return ("plotly", fig)
- except ImportError:
- return None
-
- def _build_fig(idx: int):
- """Return (kind, obj) for frame idx. Trajectory frame rendering is
- py3Dmol-only per the routing policy: plotlymol is blocked from
- real-time trajectory use to avoid its RequireJS flicker pattern.
- If py3Dmol is unavailable on this host, returns an error frame
- rather than silently falling back to plotlymol."""
- from quantui.viz_backend_router import VizBackend as _VB
- from quantui.viz_backend_router import VizTask as _VT
+ if _is_stale():
+ return
- chosen = app._resolve_backend(_VT.TRAJECTORY_FRAME)
- if chosen != _VB.PY3DMOL:
- return (
- "error",
- "Trajectory rendering requires py3Dmol (plotlymol blocked "
- "for real-time use to avoid flicker). py3Dmol is unavailable.",
- )
- with _viz_render_event(
- app, task="trajectory_frame", backend="py3dmol", idx=idx
- ):
- result = _try_py3dmol(idx)
- if result is not None:
- return result
- return ("error", "py3Dmol failed to build trajectory frame")
-
- frame_cache: dict[int, Any] = {}
-
- # --- Carousel controls ---
- step_slider = widgets.IntSlider(
- value=0,
- min=0,
- max=n - 1,
- description="Step:",
- continuous_update=False,
- style={"description_width": "40px"},
- layout=layout_fn(width="360px"),
- )
- step_info = widgets.HTML(value=app._traj_step_html(0, traj, energies, rel_e))
- # Fixed height (not just min_height) so the container box never resizes
- # between frame swaps — eliminates the layout flash / page-scroll jump
- # the user reported on each arrow/slider click.
- frame_out = widgets.Output(
- layout=layout_fn(height=f"{frame_h}px", width=f"{frame_w}px")
- )
- cache_label = widgets.HTML(
- value=f''
- f"Pre-rendering frames… 0 / {n}"
+ # --- Single-viewer trajectory stepper (all frames preloaded) ---
+ viewer_output = widgets.Output(
+ layout=layout_fn(
+ height="410px", width="100%", max_width="500px", overflow="hidden"
+ )
)
-
- def _display_frame(idx: int) -> None:
- if _is_stale():
- return
- kind, obj = frame_cache[idx]
- try:
- from quantui import calc_log as _clog_df
-
- _clog_df.log_event("traj_frame_display", f"idx={idx} kind={kind}")
- except Exception:
- pass
- if kind == "error":
- _swap_frame_out(
- f'
'
- f"Frame render failed: {obj}
"
- )
- return
- if kind == "plotly":
- # Render via Plotly HTML serialization. The atomic outputs swap
- # avoids the brief empty state between clear+append, eliminating
- # the layout-flash visible on rapid frame switches.
- import plotly.io as _pio
-
- _swap_frame_out(
- _pio.to_html(
- obj,
- full_html=False,
- include_plotlyjs="require",
- config={"responsive": True},
- )
+ try:
+ with _viz_render_event(app, task="trajectory", backend="py3dmol", n_frames=n):
+ html = build_trajectory_viewer_html(
+ xyzblocks,
+ formula=formula,
+ energies=list(energies) if energies else None,
+ rel_e=rel_e or None,
+ bgcolor=bgcolor,
)
- return
- # py3Dmol view object — convert to its HTML repr and atomic-swap.
- make_html = getattr(obj, "_make_html", None)
- if callable(make_html):
- try:
- _swap_frame_out(obj._make_html())
- return
- except Exception as exc:
- _swap_frame_out(
- f'
"
+ )
+ },
+ "metadata": {},
+ },
)
- def _on_demand() -> None:
- try:
- frame_cache[idx] = _build_fig(idx)
- app._queue_main_thread_callback(_display_frame, idx)
- except Exception as exc:
- if _is_stale():
- return
- app._queue_main_thread_callback(_show_frame_error, str(exc))
-
- threading.Thread(target=_on_demand, daemon=True).start()
-
- step_slider.observe(app._safe_cb(_update_frame), names="value")
-
- # --- Prev/next arrow buttons for one-step navigation ---
- prev_btn = widgets.Button(
- icon="arrow-left",
- tooltip="Previous frame",
- layout=layout_fn(width="40px", margin="0 4px 0 0"),
- disabled=True, # starts at frame 0
- )
- next_btn = widgets.Button(
- icon="arrow-right",
- tooltip="Next frame",
- layout=layout_fn(width="40px", margin="0 8px 0 4px"),
- disabled=(n <= 1),
- )
-
- def _on_prev_clicked(_btn) -> None:
- if step_slider.value > 0:
- step_slider.value -= 1
-
- def _on_next_clicked(_btn) -> None:
- if step_slider.value < n - 1:
- step_slider.value += 1
-
- prev_btn.on_click(_on_prev_clicked)
- next_btn.on_click(_on_next_clicked)
-
- def _update_nav_buttons(change: dict[str, Any]) -> None:
- idx = change["new"]
- prev_btn.disabled = idx <= 0
- next_btn.disabled = idx >= n - 1
-
- step_slider.observe(app._safe_cb(_update_nav_buttons), names="value")
-
- # --- Export button ---
+ # --- Export button (standalone HTML animation; plotlymol3d) ---
export_btn = widgets.Button(
description="Export Animation",
icon="download",
- layout=layout_fn(width="160px", margin="0 0 0 12px"),
+ layout=layout_fn(width="160px"),
tooltip="Generate a standalone HTML animation file (may take a minute)",
)
export_status = widgets.HTML()
@@ -619,48 +393,11 @@ def _do_export() -> None:
export_btn.on_click(_on_export)
- # --- Assemble layout ---
- header = widgets.HBox(
- [prev_btn, step_slider, next_btn, export_btn],
- layout=layout_fn(align_items="center", margin="4px 0"),
- )
- panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status])
-
- # Build and render frame 0 SYNCHRONOUSLY on the main thread before
- # displaying the panel, so the Output widget arrives at the browser with
- # frame 0 already in its outputs list. This avoids the io_loop-callback
- # latency that left frame 0 invisible until the first slider click.
- if _is_stale():
- return
- try:
- frame_cache[0] = _build_fig(0)
- _display_frame(0)
- sync_frame0_ok = True
- except Exception as _f0_exc:
- sync_frame0_ok = False
- try:
- from quantui import calc_log as _clog_f0
-
- _clog_f0.log_event(
- "traj_frame0_sync_error",
- f"{type(_f0_exc).__name__}: {_f0_exc}"[:300],
- )
- except Exception:
- pass
- _swap_frame_out(
- '
'
- "Rendering frame 0…
"
- )
-
- # Display panel.
+ # --- Assemble: energy chart (HTML in an Output so RequireJS runs) +
+ # viewer + export row, set atomically as traj_output's children. ---
if _is_stale():
return
- # Build the energy figure as HTML inside an Output widget so RequireJS
- # / Plotly scripts execute, and put the panel widget directly as a
- # sibling child of traj_output. Setting traj_output.children atomically
- # avoids the deferred-display-via-Output issue that was emptying the
- # accordion in BUG-FRESH-TRAJ.
- new_children = []
+ new_children: list[Any] = []
if has_plotly and rel_e:
import plotly.io as _pio_e
@@ -679,70 +416,22 @@ def _do_export() -> None:
},
)
new_children.append(energy_holder)
- new_children.append(panel)
+ new_children.append(viewer_output)
+ new_children.append(
+ widgets.HBox(
+ [export_btn, export_status],
+ layout=layout_fn(align_items="center", margin="4px 0"),
+ )
+ )
app.traj_output.children = tuple(new_children)
+
try:
from quantui import calc_log as _clog_sp
- _clog_sp.log_event(
- "traj_show_panel",
- f"n={n} plotlymol_fast={plotlymol_fast} "
- f"sync_frame0_ok={sync_frame0_ok} "
- f"traj_children_n={len(getattr(app.traj_output, 'children', ()))}",
- )
+ _clog_sp.log_event("traj_show_panel", f"n={n} single_viewer=1")
except Exception:
pass
- def _prerender_all() -> None:
- """Render remaining frames in a background thread (frame 0 already
- built+displayed synchronously above when sync_frame0_ok)."""
- if _is_stale():
- return
- try:
- if 0 not in frame_cache:
- frame_cache[0] = _build_fig(0)
- app._queue_main_thread_callback(_display_frame, 0)
- app._queue_main_thread_callback(
- _set_cache_label,
- f''
- f"Pre-rendering frames… 1 / {n}",
- )
- if n > 1:
- with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
- futures = {pool.submit(_build_fig, i): i for i in range(1, n)}
- done = 1
- for fut in concurrent.futures.as_completed(futures):
- if _is_stale():
- return
- i = futures[fut]
- try:
- frame_cache[i] = fut.result()
- except Exception:
- pass
- done += 1
- app._queue_main_thread_callback(
- _set_cache_label,
- f''
- f"Pre-rendering frames… {done} / {n}",
- )
- except Exception:
- pass
- app._queue_main_thread_callback(
- _set_cache_label,
- f''
- f"✓ All {n} frames ready",
- )
- try:
- from quantui import calc_log as _clog_pre
-
- _clog_pre.log_event(
- "traj_prerender_complete", f"n={n} cached={len(frame_cache)}"
- )
- except Exception:
- pass
-
- threading.Thread(target=_prerender_all, daemon=True).start()
-
def traj_step_html(
app: Any, step: int, traj: list[Any], energies: list[Any], rel_e: list[Any]
@@ -2133,68 +1822,28 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None:
).start()
-_PREOPT_BTN_STYLE = (
+_STEPPER_BTN_STYLE = (
"padding:2px 9px;border:1px solid #cbd5e1;border-radius:4px;"
"background:#f8fafc;color:#334155;cursor:pointer;font-size:13px;line-height:1.4;"
)
-
-def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
- """Build the in-HTML stepper controls for the pre-opt preview.
-
- All ``n`` relaxation frames are already loaded client-side in the py3Dmol
- viewer (``addModelsAsFrames``), so navigation is pure ``viewer.setFrame(i)``
- — instant, offline, no per-frame HTML rebuild. Element ids are namespaced
- with the viewer's ``uid`` so multiple previews never collide. The script
- polls for the global ``viewer_`` (py3Dmol creates it after the async
- 3Dmol.js load resolves) before wiring up.
- """
- js = _PREOPT_CONTROLS_JS # token-substituted template (avoids f-string {} hell)
- js = (
- js.replace("__UID__", uid)
- .replace("__N__", str(n))
- .replace("__IV__", str(interval_ms))
- )
- btn = _PREOPT_BTN_STYLE
- return (
- '
'
- f''
- f''
- f''
- f''
- f''
- "
"
- f'
'
- f"Frame {n}/{n} • Relaxed (final)
"
- f""
- )
-
-
-# Stepper logic. Drives viewer.setFrame() on the already-loaded multi-frame
-# view; play/pause is a self-managed setInterval (not 3Dmol's animate(), so
-# manual stepping and play never fight). Tokens substituted in _preopt_controls_html.
-_PREOPT_CONTROLS_JS = """
+# Shared single-viewer stepper logic. Drives ``viewer.setFrame()`` on a viewer
+# whose frames are ALL already loaded client-side via ``addModelsAsFrames`` — so
+# navigation never rebuilds the viewer, the camera (rotation/zoom) stays put
+# across frames, and there is no per-frame HTML/network round-trip. Play/pause is
+# a self-managed setInterval (not 3Dmol's animate(), so manual stepping and play
+# never fight). Tokens are substituted in :func:`_frame_stepper_controls`.
+_STEPPER_JS = """
(function(){
- var UID="__UID__", N=__N__, IV=__IV__;
+ var UID="__UID__", N=__N__, IV=__IV__, LOOP=__LOOP__;
+ var AB_START=__AB_START__, AB_OTHER=__AB_OTHER__;
+ __EXTRA__
function g(p){return document.getElementById(p+UID);}
- var slider=g("po_slider_"), lbl=g("po_lbl_"), prevB=g("po_prev_"),
- nextB=g("po_next_"), playB=g("po_play_"), abB=g("po_ab_");
- var cur=N-1, timer=null;
+ var slider=g("st_slider_"), lbl=g("st_lbl_"), prevB=g("st_prev_"),
+ nextB=g("st_next_"), playB=g("st_play_"), abB=g("st_ab_");
+ var cur=__START__, timer=null;
function vw(){return window["viewer_"+UID];}
- function label(i){
- if(i===0) return "Frame 1/"+N+" \\u2022 Input (your geometry)";
- if(i===N-1) return "Frame "+N+"/"+N+" \\u2022 Relaxed (final)";
- return "Frame "+(i+1)+"/"+N+" \\u2022 relaxing\\u2026";
- }
+ function label(i){ __LABEL_BODY__ }
function draw(i){
i=Math.max(0,Math.min(N-1,i)); cur=i;
var v=vw();
@@ -2202,18 +1851,18 @@ def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
if(p&&p.then){ p.then(function(){v.render();}); } else { v.render(); }
}catch(e){ try{ v.render(); }catch(_){} } }
if(slider) slider.value=i;
- if(lbl) lbl.textContent=label(i);
+ if(lbl) lbl.innerHTML=label(i);
if(prevB) prevB.disabled=(i<=0);
if(nextB) nextB.disabled=(i>=N-1);
- if(abB) abB.innerHTML=(i===0)?"\\u21c4 Show relaxed":"\\u21c4 Show input";
+ if(abB) abB.innerHTML=(i===0)?AB_START:AB_OTHER;
}
function stop(){ if(timer){clearInterval(timer);timer=null;}
if(playB) playB.innerHTML="\\u25b6 Play"; }
function play(){ if(N<=1) return;
- if(cur>=N-1) draw(0); // at the end → replay from the input geometry
+ if(cur>=N-1) draw(0); // at the end → replay from the first frame
if(playB) playB.innerHTML="\\u23f8 Pause";
timer=setInterval(function(){
- if(cur>=N-1){ stop(); return; } // stop on the relaxed frame, stay there
+ if(cur>=N-1){ if(LOOP){ draw(0); return; } stop(); return; }
draw(cur+1);
}, IV); }
if(prevB) prevB.onclick=function(){stop();draw(cur-1);};
@@ -2224,12 +1873,103 @@ def _preopt_controls_html(uid: str, n: int, interval_ms: int) -> str:
var t=0, poll=setInterval(function(){ t++;
if(vw()){ clearInterval(poll); draw(cur); }
else if(t>200){ clearInterval(poll);
- if(lbl) lbl.textContent="3D viewer failed to load"; }
+ if(lbl) lbl.innerHTML="3D viewer failed to load"; }
},50);
})();
"""
+def _frame_stepper_controls(
+ uid: str,
+ n: int,
+ interval_ms: int,
+ *,
+ label_js: str,
+ initial_label: str,
+ loop: bool,
+ ab_at_start: str | None = None,
+ ab_other: str | None = None,
+ scrub_title: str = "Scrub frames",
+ start_index: int | None = None,
+ extra_decls: str = "",
+) -> str:
+ """Build in-HTML stepper controls (prev/play/next, scrub slider, optional
+ A/B flip, live label) for a single multi-frame py3Dmol viewer.
+
+ Element ids are namespaced with the viewer's ``uid`` so multiple viewers on
+ one page never collide. The script polls for the global ``viewer_``
+ (py3Dmol creates it after the async 3Dmol.js load resolves) before wiring up.
+
+ ``label_js`` is the JS body of ``function label(i){…}`` returning the label
+ HTML for frame ``i``; ``extra_decls`` is JS injected at the top of the IIFE
+ (e.g. per-frame energy arrays). ``loop`` makes Play cycle forever, else it
+ runs once and stops on the last frame.
+ """
+ import json
+
+ btn = _STEPPER_BTN_STYLE
+ start = (n - 1) if start_index is None else start_index
+ ab_html = ""
+ if ab_at_start is not None:
+ ab_html = (
+ f''
+ )
+ bar = (
+ '
'
+def build_trajectory_viewer_html(
+ xyzblocks: list[str],
+ *,
+ formula: str = "",
+ energies: list[float] | None = None,
+ rel_e: list[float] | None = None,
+ bgcolor: str = "white",
+ width: int = 460,
+ height: int = 340,
+ fps: int = 8,
+) -> str:
+ """Build an interactive py3Dmol view of a geometry-optimization trajectory.
+
+ Loads every step as a frame of ONE viewer (``addModelsAsFrames``) and wires
+ an in-HTML stepper (prev/next, play/pause, scrub slider, start↔final flip,
+ per-step energy label) that navigates with ``viewer.setFrame`` — so the
+ camera stays put across steps and there is no per-frame HTML rebuild
+ (the previous carousel rebuilt a fresh viewer each step, resetting the
+ rotation/zoom and flickering). Offline-safe via the vendored 3Dmol loader
+ (``make_view``). ``energies`` (Hartree) and ``rel_e`` (kcal/mol) are optional
+ per-step annotations; a <2-frame trajectory renders as a static structure.
+ """
+ import json
+ import re
+
+ from quantui.viz_assets import make_view
+
+ n = len(xyzblocks)
+ xyz_string = "\n".join(b.rstrip("\n") for b in xyzblocks) + "\n"
+
+ view = make_view(width=width, height=height)
+ view.addModelsAsFrames(xyz_string, "xyz")
+ view.setStyle({"stick": {}, "sphere": {"scale": 0.3}})
+ view.setBackgroundColor(bgcolor)
+ view.zoomTo()
+ view_html = view._make_html()
+
+ if n <= 1:
+ return view_html
+
+ m = re.search(r"3dmolviewer_(\w+)", view_html)
+ if m is None:
+ return view_html # can't wire controls without the viewer id
+
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
+ eabs = json.dumps([float(e) for e in energies]) if energies else "null"
+ erel = json.dumps([float(e) for e in rel_e]) if rel_e else "null"
+ fjs = json.dumps(formula or "")
+ label_js = (
+ 'var s="Step "+i+" / "+(N-1);'
+ f'if({fjs}) s+=" \\u00b7 "+{fjs};'
+ 'if(EABS&&EABS[i]!=null) s+=" \\u00b7 E = "+EABS[i].toFixed(8)+" Ha";'
+ "if(EREL&&EREL[i]!=null) s+="
+ '" \\u00b7 \\u0394E = "+(EREL[i]>=0?"+":"")+EREL[i].toFixed(3)+" kcal/mol";'
+ "return s;"
+ )
+ controls = _frame_stepper_controls(
+ m.group(1),
+ n,
+ interval_ms,
+ label_js=label_js,
+ initial_label="Step %d / %d" % (n - 1, n - 1),
+ loop=True, # optimization animation: loop continuously
+ ab_at_start="⇄ Final geometry",
+ ab_other="⇄ First step (input)",
+ scrub_title="Scrub the optimization steps",
+ extra_decls=f"var EABS={eabs}; var EREL={erel};",
+ )
+ return f'
{view_html}{controls}
'
+
+
def show_pes_scan_result(app: Any, result: Any) -> bool:
"""Render PES energy profile chart and stash latest PES result."""
app._last_pes_result = result
diff --git a/tests/test_cancel_and_clear_guard.py b/tests/test_cancel_and_clear_guard.py
index a1a7d8b..6bb7ce3 100644
--- a/tests/test_cancel_and_clear_guard.py
+++ b/tests/test_cancel_and_clear_guard.py
@@ -90,6 +90,24 @@ def test_cancel_button_disabled_by_default(self, app):
# Nothing running at construction → Cancel is inert.
assert app.cancel_btn.disabled is True
+ def test_cancel_cleanup_write_does_not_reraise(self, app):
+ # Regression: _do_run's ``except _CalcCancelled`` writes a footer line
+ # through the same _LogCapture. With the cancel flag STILL set, that
+ # write re-raises _CalcCancelled, propagates out of the handler, and
+ # skips the cancelled card + ``run_status = "Cancelled."`` (only
+ # ``finally`` runs → status stuck on "Cancelling…"). The fix clears the
+ # flag before writing; lock in that order.
+ cap = _LogCapture(
+ app.run_output, app.run_status, cancel_check=app._cancel_event.is_set
+ )
+ app._cancel_event.set()
+ # Buggy order (write while still set) would raise — that was the bug.
+ with pytest.raises(_CalcCancelled):
+ cap.write("step\n")
+ # The fix: clear first, then the cleanup write must NOT raise.
+ app._cancel_event.clear()
+ cap.write("\n── Calculation cancelled by user ──\n") # no exception
+
class TestClearGuard:
"""Spy on ``clear_output`` rather than asserting an emptied ``.outputs`` —
diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py
index b93bc87..023d0ac 100644
--- a/tests/test_preopt_preview.py
+++ b/tests/test_preopt_preview.py
@@ -271,3 +271,28 @@ def test_revert_discards_without_changing_molecule(self, app):
assert app._molecule is original # unchanged
assert app._preopt_relaxed_mol is None
assert app.preopt_preview_box.layout.display == "none"
+
+
+class TestStaleRunStatus:
+ """Regression: 'Pre-optimized geometry accepted.' lingered next to Run
+ after switching molecules / reverting a later preview."""
+
+ def test_loading_molecule_clears_stale_run_status(self, app):
+ app._calc_running = False
+ app.run_status.value = "Pre-optimized geometry accepted."
+ app._set_molecule(_water(), "new mol")
+ assert app.run_status.value == ""
+
+ def test_loading_molecule_midrun_keeps_run_status(self, app):
+ # The mid-run pre-opt sets the molecule via _set_molecule; it must NOT
+ # wipe the live "Pre-optimizing…" status.
+ app._calc_running = True
+ app.run_status.value = "Pre-optimizing..."
+ app._set_molecule(_water(), "preopt mid-run")
+ assert app.run_status.value == "Pre-optimizing..."
+
+ def test_revert_clears_stale_accepted_status(self, app):
+ app._set_molecule(_water(), "orig")
+ app.run_status.value = "Pre-optimized geometry accepted."
+ app._on_preopt_reset()
+ assert app.run_status.value == ""
diff --git a/tests/test_viz_offline.py b/tests/test_viz_offline.py
index d443fbb..ca05e1f 100644
--- a/tests/test_viz_offline.py
+++ b/tests/test_viz_offline.py
@@ -101,3 +101,37 @@ def test_orbital_isosurface_renderer_is_cdn_free(tmp_path):
# Two lobes (M-ORBVIZ contract) + loads vendored 3Dmol offline.
assert html.count("addVolumetricData") == 2
assert "data:text/javascript;base64," in html
+
+
+def test_trajectory_viewer_is_single_viewer_stepper():
+ # The trajectory viewer loads ALL steps into ONE viewer (addModelsAsFrames)
+ # and navigates client-side via setFrame, so the camera persists across
+ # steps (vs the old per-frame rebuild). Offline-safe + energy-annotated.
+ from quantui.app_visualization import build_trajectory_viewer_html
+
+ xyzblocks = [
+ "2\nH2\nH 0 0 0\nH 0 0 0.74",
+ "2\nH2\nH 0 0 0\nH 0 0 0.77",
+ "2\nH2\nH 0 0 0\nH 0 0 0.80",
+ ]
+ html = build_trajectory_viewer_html(
+ xyzblocks,
+ formula="H2",
+ energies=[-1.10, -1.13, -1.12],
+ rel_e=[0.0, -18.8, -12.5],
+ bgcolor="white",
+ )
+ assert _CDN not in html # offline-safe
+ assert "addModelsAsFrames" in html # one viewer, all frames preloaded
+ assert "setFrame" in html # client-side navigation
+ assert 'type="range"' in html and 'max="2"' in html # scrub slider spans frames
+ assert "Final geometry" in html # start <-> final A/B flip
+ assert "EABS" in html and "EREL" in html # per-step energy label data
+
+
+def test_trajectory_viewer_single_frame_is_static():
+ from quantui.app_visualization import build_trajectory_viewer_html
+
+ html = build_trajectory_viewer_html(["1\nH\nH 0 0 0"])
+ assert _CDN not in html
+ assert "setFrame" not in html # nothing to step through
From 94ff5bc1b5ba6c20d3aba383e320f1d6d7dd5268 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 12:52:31 -0400
Subject: [PATCH 14/19] Add single-viewer vibrational animation
Introduce a single persistent py3Dmol viewer for vibrational animations that holds all modes and switches frames client-side (window.__quantuiVibSetMode) so the camera is preserved across mode changes. Adds embedded JS (_VIB_VIEWER_JS) and helpers (build_vib_viewer_html, _vib_single_viewer_supported, _render_vib_single_viewer, _vib_bridge_set_mode) to build offline-safe HTML, detect support, and swap the viewer; falls back to the legacy per-mode renderer on failure. Wire the new client-side bridge into the UI by adding a hidden Output widget in the results accordion and update show_vib_animation/on_vib_mode_changed to prefer the single-viewer path. Tests added to validate the single-viewer behaviour and displacement requirements; small test formatting tweak for textwrap.dedent.
---
quantui/app_builders.py | 8 +-
quantui/app_visualization.py | 223 ++++++++++++++++++++++++++++++++++-
tests/test_viz_offline.py | 45 +++++++
3 files changed, 272 insertions(+), 4 deletions(-)
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 1283694..294d271 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -1429,10 +1429,16 @@ def _plot_export_row(prefix: str) -> widgets.HBox:
layout=layout_fn(align_items="center", margin="6px 0 0 0"),
)
+ # Hidden sink for Python→JS calls that switch the single-viewer's mode
+ # client-side (window.__quantuiVibSetMode). Kept in the DOM (not display:none)
+ # so the injected Javascript executes; empty/cleared between calls so it
+ # takes no visible space. See app_visualization._vib_bridge_set_mode.
+ app._vib_js_bridge = widgets.Output(layout=layout_fn(margin="0", padding="0"))
+
app.vib_accordion = widgets.Accordion(
children=[
widgets.VBox(
- [vib_mode_row, app.vib_output, vib_export_row],
+ [vib_mode_row, app.vib_output, vib_export_row, app._vib_js_bridge],
layout=layout_fn(padding="8px"),
)
],
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index f88ecfc..2a02eff 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -622,14 +622,33 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool:
if not options:
return False
- app.vib_mode_dd.options = options
- app.vib_mode_dd.value = options[0][1]
-
app._last_vib_data = vib_data # may be None — plotlymol3d optional
app._last_vib_molecule = molecule
app._last_vib_freq_result = freq_result
first_label, first_mode = options[0]
+
+ # Decide the render path BEFORE assigning vib_mode_dd.value (which fires
+ # on_vib_mode_changed): set the single-viewer flag first so that observer
+ # takes the client-side-switch branch instead of spawning a redundant
+ # legacy per-mode render. The preferred path is ONE persistent py3Dmol
+ # viewer holding every mode, with client-side mode switching, so the camera
+ # (rotation/zoom) is preserved across modes (matches pre-opt/trajectory).
+ # Falls back to the legacy renderer when py3Dmol isn't selected or
+ # displacements are unavailable (e.g. some history replays).
+ use_single = _vib_single_viewer_supported(app, freq_result)
+ app._vib_single_viewer_active = use_single
+
+ app.vib_mode_dd.options = options
+ app.vib_mode_dd.value = first_mode # fires on_vib_mode_changed
+
+ if use_single:
+ if _render_vib_single_viewer(
+ app, freq_result, molecule, first_mode, [m for _, m in options]
+ ):
+ return True
+ app._vib_single_viewer_active = False # build failed → legacy fallback
+
# Cache-hit fast path: on history replay the cached HTML for the first
# mode is on disk, so swap it in synchronously without a placeholder.
# ``reset_camera=True`` clears any stale camera matrix from a previous
@@ -1794,6 +1813,13 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None:
if molecule is None or freq_result is None:
return
+ # Single-viewer path: switch modes client-side on the one persistent viewer
+ # (camera preserved, no rebuild). Export + prev/next still drive vib_mode_dd,
+ # so they keep working unchanged.
+ if getattr(app, "_vib_single_viewer_active", False):
+ _vib_bridge_set_mode(app, mode_number)
+ return
+
# Cache-hit fast path: swap cached HTML synchronously, no placeholder,
# no thread. Bumps the render token internally to invalidate any
# in-flight render.
@@ -2099,6 +2125,197 @@ def build_trajectory_viewer_html(
return f'
{view_html}{controls}
'
+# Single-viewer vibrational animation. ONE py3Dmol viewer holds every mode; the
+# per-mode oscillation frames are computed client-side from the embedded
+# displacement vectors (tiny: n_atoms×3 per mode) on demand, and a mode switch
+# calls ``window.__quantuiVibSetMode`` to swap frames on the SAME viewer instance
+# — so the camera (rotation/zoom) is preserved exactly across modes, with no
+# rebuild/flash. ``fit`` is true only for the initial mode (zoom-to-fit); switches
+# never re-fit. Replaces the old per-mode rebuild + fragile getView/setView hook.
+_VIB_VIEWER_JS = """
+(function(){
+ var UID="__UID__";
+ var SYM=__SYM__, BASE=__BASE__, DISPL=__DISPL__;
+ var NAT=__NAT__, NF=__NF__, AMP=__AMP__, IV=__IV__, BG=__BG__, INIT=__INIT__;
+ function vw(){ return window["viewer_"+UID]; }
+ function frames(m){
+ var d=DISPL[m]; if(!d) return null;
+ var out="";
+ for(var f=0; f200){ clearInterval(poll); }
+ },50);
+})();
+"""
+
+
+def build_vib_viewer_html(
+ molecule: Any,
+ freq_result: Any,
+ mode_numbers: list[int],
+ initial_mode: int,
+ *,
+ amplitude: float = 0.4,
+ n_frames: int = 24,
+ fps: int = 10,
+ bgcolor: str = "white",
+ width: int = 460,
+ height: int = 420,
+) -> str:
+ """Build a single py3Dmol viewer that holds every vibrational mode.
+
+ All modes share ONE viewer instance; oscillation frames are generated
+ client-side from the embedded per-mode displacement vectors, and switching
+ modes (``window.__quantuiVibSetMode``) swaps frames on that same viewer so
+ the camera is preserved across modes. Offline-safe via the vendored 3Dmol
+ loader (``make_view``). Raises if displacements are missing/misshaped so the
+ caller can fall back to the legacy per-mode renderer.
+ """
+ import json
+ import re
+
+ import numpy as np
+
+ from quantui.viz_assets import make_view
+
+ displacements = getattr(freq_result, "displacements", None)
+ if not displacements:
+ raise ValueError("freq_result has no displacements for single-viewer vib")
+
+ atoms = list(molecule.atoms)
+ base = np.asarray(molecule.coordinates, dtype=float)
+ n_atoms = len(atoms)
+ displ_map: dict[int, list] = {}
+ for m in mode_numbers:
+ d = np.asarray(displacements[m - 1], dtype=float)
+ if d.shape != base.shape:
+ raise ValueError(
+ f"mode {m} displacement shape {d.shape} != coords {base.shape}"
+ )
+ displ_map[int(m)] = d.tolist()
+
+ view = make_view(width=width, height=height)
+ view.setBackgroundColor(bgcolor)
+ view_html = view._make_html() # empty viewer; JS populates the initial mode
+ m_uid = re.search(r"3dmolviewer_(\w+)", view_html)
+ if m_uid is None:
+ raise ValueError("could not find py3Dmol viewer id")
+
+ interval_ms = max(1, int(round(1000.0 / max(1, fps))))
+ js = (
+ _VIB_VIEWER_JS.replace("__UID__", m_uid.group(1))
+ .replace("__SYM__", json.dumps(atoms))
+ .replace("__BASE__", json.dumps(base.tolist()))
+ .replace("__DISPL__", json.dumps(displ_map))
+ .replace("__NAT__", str(n_atoms))
+ .replace("__NF__", str(int(n_frames)))
+ .replace("__AMP__", repr(float(amplitude)))
+ .replace("__IV__", str(interval_ms))
+ .replace("__BG__", json.dumps(bgcolor))
+ .replace("__INIT__", str(int(initial_mode)))
+ )
+ return (
+ f'
{view_html}
'
+ )
+
+
+def _vib_single_viewer_supported(app: Any, freq_result: Any) -> bool:
+ """True when the single-persistent-viewer vib path applies: py3Dmol backend
+ is selected and the result carries per-mode displacement vectors."""
+ try:
+ from quantui.viz_backend_router import VizBackend as _VB
+ from quantui.viz_backend_router import VizTask as _VT
+
+ if app._resolve_backend(_VT.VIB_INTERACTIVE) != _VB.PY3DMOL:
+ return False
+ return bool(getattr(freq_result, "displacements", None))
+ except Exception:
+ return False
+
+
+def _render_vib_single_viewer(
+ app: Any,
+ freq_result: Any,
+ molecule: Any,
+ initial_mode: int,
+ mode_numbers: list[int],
+) -> bool:
+ """Build + swap in the single-viewer vib animation. Returns True on success
+ (sets ``app._vib_single_viewer_active``); False to fall back to legacy."""
+ try:
+ viz_settings = getattr(getattr(app, "_user_settings", None), "viz", None)
+ fps = max(1, int(getattr(viz_settings, "vib_framerate_fps", 10)))
+ bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e"
+ with _viz_render_event(
+ app, task="vib_interactive", backend="py3dmol", source="single_viewer"
+ ):
+ html = build_vib_viewer_html(
+ molecule, freq_result, mode_numbers, initial_mode, fps=fps, bgcolor=bg
+ )
+ except Exception as exc: # noqa: BLE001 — fall back to the legacy renderer
+ try:
+ from quantui import calc_log as _clog_sv
+
+ _clog_sv.log_event(
+ "vib_single_viewer_fallback", f"{type(exc).__name__}: {exc}"[:200]
+ )
+ except Exception:
+ pass
+ app._vib_single_viewer_active = False
+ return False
+ _swap_vib_output(app, html)
+ app._vib_single_viewer_active = True
+ return True
+
+
+def _vib_bridge_set_mode(app: Any, mode_number: int) -> None:
+ """Switch the live single-viewer to ``mode_number`` client-side (camera kept).
+
+ Emits a one-shot JS call to ``window.__quantuiVibSetMode`` via a hidden
+ bridge Output; retries briefly in case the viewer is still loading."""
+ bridge = getattr(app, "_vib_js_bridge", None)
+ if bridge is None:
+ return
+ from IPython.display import Javascript, display
+
+ js = (
+ "(function(){var n=0;function go(){n++;"
+ "if(window.__quantuiVibSetMode){window.__quantuiVibSetMode(%d,false);}"
+ "else if(n<40){setTimeout(go,50);}}go();})();" % int(mode_number)
+ )
+ try:
+ bridge.clear_output(wait=True)
+ with bridge:
+ display(Javascript(js))
+ except Exception:
+ pass
+
+
def show_pes_scan_result(app: Any, result: Any) -> bool:
"""Render PES energy profile chart and stash latest PES result."""
app._last_pes_result = result
diff --git a/tests/test_viz_offline.py b/tests/test_viz_offline.py
index ca05e1f..61b23da 100644
--- a/tests/test_viz_offline.py
+++ b/tests/test_viz_offline.py
@@ -135,3 +135,48 @@ def test_trajectory_viewer_single_frame_is_static():
html = build_trajectory_viewer_html(["1\nH\nH 0 0 0"])
assert _CDN not in html
assert "setFrame" not in html # nothing to step through
+
+
+def test_vib_viewer_is_single_viewer_all_modes():
+ # All vibrational modes share ONE viewer; modes switch client-side via
+ # window.__quantuiVibSetMode on the same instance, so the camera persists
+ # across modes (vs the old per-mode rebuild). Offline-safe.
+ from types import SimpleNamespace
+
+ from quantui.app_visualization import build_vib_viewer_html
+ from quantui.molecule import Molecule
+
+ mol = Molecule(
+ atoms=["O", "H", "H"],
+ coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]],
+ )
+ displ = [
+ [[0, 0, 0.1], [0, 0, -0.4], [0, 0, -0.4]],
+ [[0.1, 0, 0], [-0.4, 0.2, 0], [-0.4, -0.2, 0]],
+ [[0, 0.1, 0], [0, -0.4, 0], [0, -0.4, 0]],
+ ]
+ freq = SimpleNamespace(
+ displacements=displ, frequencies_cm1=[1600.0, 3700.0, 3800.0]
+ )
+ html = build_vib_viewer_html(mol, freq, [1, 2, 3], 1, fps=10)
+
+ assert _CDN not in html # offline-safe
+ assert "window.__quantuiVibSetMode" in html # client-side mode switch fn
+ assert "removeAllModels" in html # swaps frames on the SAME viewer instance
+ assert "addModelsAsFrames" in html
+ # All three modes' displacement vectors are embedded for client-side frames.
+ assert '"1":' in html and '"2":' in html and '"3":' in html
+
+
+def test_vib_viewer_requires_displacements():
+ from types import SimpleNamespace
+
+ import pytest
+
+ from quantui.app_visualization import build_vib_viewer_html
+ from quantui.molecule import Molecule
+
+ mol = Molecule(atoms=["H", "H"], coordinates=[[0, 0, 0], [0, 0, 0.74]])
+ freq = SimpleNamespace(displacements=None, frequencies_cm1=[4400.0])
+ with pytest.raises(ValueError):
+ build_vib_viewer_html(mol, freq, [1], 1)
From a09c4ddb5a73ec0419f31be23c0207bc58fe0c0c Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 15:06:44 -0400
Subject: [PATCH 15/19] Defer startup tasks (GPU, history) and tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move slow startup work off the synchronous construction path to improve perceived startup time. GPU detection now runs on a daemon thread (named quantui-gpu-detect) and the Status panel is rendered via a closure that accepts a gpu_state so the UI can show a "checking…" placeholder and be re-rendered when detection completes. History and Compare population are deferred onto the kernel I/O loop (or run inline if no loop) and show loading placeholders until populated. Also hardens result-label formatting in runflow to use safe .get() defaults for formula/method/basis to avoid KeyErrors. Adds tests (tests/test_startup_deferral.py) that assert placeholders, scheduling of deferred callbacks, and status rendering behavior.
---
quantui/app.py | 54 +++++++++++++++++
quantui/app_builders.py | 106 ++++++++++++++++++---------------
quantui/app_runflow.py | 6 +-
tests/test_startup_deferral.py | 59 ++++++++++++++++++
4 files changed, 176 insertions(+), 49 deletions(-)
create mode 100644 tests/test_startup_deferral.py
diff --git a/quantui/app.py b/quantui/app.py
index ddfbbed..622e6b2 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -1070,6 +1070,60 @@ def __init__(self) -> None:
except OSError:
pass
+ # Kick off slow startup work (GPU detection, History/Compare loading)
+ # off the synchronous construction path so the UI paints fast.
+ self._start_deferred_startup_tasks()
+
+ def _start_deferred_startup_tasks(self) -> None:
+ """Run slow startup work AFTER widget construction so it doesn't block
+ first paint.
+
+ - **GPU detection** imports gpu4pyscf + cupy and queries CUDA (~7 s); it
+ runs on a daemon thread, then re-renders the Status badge on the kernel
+ loop. ``is_gpu_available`` is lru-cached, so the run dispatcher reuses
+ the result with no extra cost.
+ - **History + Compare** population loads every saved result; deferring it
+ onto the kernel io loop lets it run right after the cell returns (UI
+ already painted), and the summary sidecar keeps it fast. Falls back to
+ inline when there is no kernel loop (tests / plain scripts).
+ """
+
+ def _detect_gpu() -> None:
+ try:
+ from quantui.gpu_offload import is_gpu_available
+
+ state = is_gpu_available()
+ except Exception: # noqa: BLE001 — treat any failure as "no GPU"
+ state = (False, None)
+ render = getattr(self, "_render_status_html", None)
+ html_widget = getattr(self, "_status_html", None)
+ if render is None or html_widget is None:
+ return
+
+ def _apply() -> None:
+ try:
+ html_widget.value = render(state)
+ except Exception:
+ pass
+
+ loop = self._get_kernel_io_loop()
+ if loop is not None:
+ loop.add_callback(_apply)
+ else:
+ _apply()
+
+ threading.Thread(
+ target=_detect_gpu, daemon=True, name="quantui-gpu-detect"
+ ).start()
+
+ loop = self._get_kernel_io_loop()
+ if loop is not None:
+ loop.add_callback(self._refresh_results_browser)
+ loop.add_callback(self._populate_compare_list)
+ else:
+ self._refresh_results_browser()
+ self._populate_compare_list()
+
def display(self) -> None:
"""Inject global CSS and render the application widget."""
display(HTML(_APP_CSS))
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 294d271..0582992 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -82,40 +82,6 @@ def _ok(flag: bool, extra: str = "") -> str:
cross = '✗'
return (tick if flag else cross) + (" " + extra if extra else "")
- # GPU offload indicator (M-GPU / GPU.2). Reuses the runtime detection
- # helper so this status line tracks the EXACT same logic the dispatcher
- # uses — no risk of drift between "what the user sees in Status" and
- # "what actually happens when they click Run".
- from .gpu_offload import is_gpu_available as _gpu_available_fn
-
- _gpu_avail, _gpu_name = _gpu_available_fn()
- if _gpu_avail:
- _gpu_msg = f"— {_gpu_name}"
- _gpu_flag = True
- else:
- _gpu_msg = "— gpu4pyscf not installed or no CUDA device"
- _gpu_flag = False
-
- items = [
- (
- "PySCF (calculations)",
- _ok(
- pyscf_available,
- "" if pyscf_available else "— Linux / macOS / WSL required",
- ),
- ),
- ("ASE (structure I/O, opt.)", _ok(ase_available)),
- ("PubChem search", _ok(pubchem_available)),
- ("3D viewer (py3Dmol)", _ok(visualization_available)),
- ("GPU offload (gpu4pyscf)", _ok(_gpu_flag, _gpu_msg)),
- ("CPU cores / Memory", f"{cores} cores / {mem}"),
- ]
- rows = "".join(
- f'
{k}
'
- f'
{v}
'
- for k, v in items
- )
-
env_badge = (
f' {env}'
@@ -130,17 +96,58 @@ def _ok(flag: bool, extra: str = "") -> str:
"Timing calibration: not yet run — use the Calibrate panel in History"
)
- app._status_html = widgets.HTML(
- f'
"
- )
+ # GPU offload indicator (M-GPU / GPU.2). Detecting GPUs imports gpu4pyscf +
+ # cupy and queries CUDA, which costs ~7 s — far too slow to block startup.
+ # So the status HTML is rendered via this closure with the GPU row as a
+ # "checking…" placeholder; the app re-renders it (via app._render_status_html)
+ # once a background thread resolves is_gpu_available(). The detection helper
+ # is the same one the run dispatcher uses, so the badge can't drift from
+ # actual behavior.
+ def _gpu_cell(gpu_state: Any) -> str:
+ if gpu_state is None:
+ return '⌛ checking…'
+ avail, name = gpu_state
+ if avail:
+ return _ok(True, f"— {name}")
+ return _ok(
+ False, "— gpu4pyscf not installed or no CUDA device"
+ )
+
+ def _render_status(gpu_state: Any) -> str:
+ items = [
+ (
+ "PySCF (calculations)",
+ _ok(
+ pyscf_available,
+ "" if pyscf_available else "— Linux / macOS / WSL required",
+ ),
+ ),
+ ("ASE (structure I/O, opt.)", _ok(ase_available)),
+ ("PubChem search", _ok(pubchem_available)),
+ ("3D viewer (py3Dmol)", _ok(visualization_available)),
+ ("GPU offload (gpu4pyscf)", _gpu_cell(gpu_state)),
+ ("CPU cores / Memory", f"{cores} cores / {mem}"),
+ ]
+ rows = "".join(
+ f'
"
+ )
+
+ # Exposed so the app's background GPU-detection task can refresh the badge.
+ app._render_status_html = _render_status
+ app._status_html = widgets.HTML(_render_status(None))
# ── Settings section ──────────────────────────────────────────────────
# "Default 3D backend" — user preference persisted via UserSettings.
@@ -458,7 +465,10 @@ def build_history_section(
app._perf_accordion,
)
- app._refresh_results_browser()
+ # History population (loads every saved result) is deferred to a background
+ # task after the UI paints — see app._start_deferred_startup_tasks. Show a
+ # placeholder until then.
+ app.past_dd.options = [("⏳ loading saved results…", "")]
app._refresh_perf_stats()
@@ -1940,7 +1950,9 @@ def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) ->
app.advanced_accordion.set_title(0, "Export")
app.advanced_accordion.selected_index = None
- app._populate_compare_list()
+ # Deferred to a background startup task (loads every saved result) — see
+ # app._start_deferred_startup_tasks. Placeholder until then.
+ app.compare_select.options = [("⏳ loading saved results…", "")]
def build_output_tab(app: Any, *, layout_fn: Any) -> None:
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index e26945d..e473626 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -1188,7 +1188,8 @@ def refresh_results_browser(app: Any) -> None:
calib_marker = "🔧 " if data.get("calibration_run_id") else ""
label = (
f"{ts} · [{calc_badge}] "
- f"{calib_marker}{data['formula']} {data['method']}/{data['basis']}"
+ f"{calib_marker}{data.get('formula', '?')} "
+ f"{data.get('method', '?')}/{data.get('basis', '?')}"
)
options.append((label, str(d)))
except Exception:
@@ -1239,7 +1240,8 @@ def populate_compare_list(app: Any) -> None:
calc_badge = _calc_type_badge(data.get("calc_type", ""))
label = (
f"{ts} [{calc_badge}] "
- f"{data['formula']} {data['method']}/{data['basis']}"
+ f"{data.get('formula', '?')} "
+ f"{data.get('method', '?')}/{data.get('basis', '?')}"
)
options.append((label, str(d)))
except Exception:
diff --git a/tests/test_startup_deferral.py b/tests/test_startup_deferral.py
new file mode 100644
index 0000000..1e3f1b3
--- /dev/null
+++ b/tests/test_startup_deferral.py
@@ -0,0 +1,59 @@
+"""Startup-speed deferrals (perceived-startup optimization).
+
+Construction used to block ~15 s on (1) GPU detection (imports gpu4pyscf/cupy +
+CUDA query) and (2) loading every saved result for the History and Compare
+dropdowns. Both are now deferred off the synchronous construction path so the UI
+paints fast: GPU detection runs on a daemon thread and re-renders the Status
+badge; History/Compare population is scheduled on the kernel io loop.
+
+Platform-independent (no PySCF / GPU). ``QUANTUI_DISABLE_GPU=1`` keeps the
+background GPU thread from importing gpu4pyscf during the test.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture
+def app(tmp_path, monkeypatch):
+ monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(tmp_path / "settings.json"))
+ monkeypatch.setenv("QUANTUI_DISABLE_GPU", "1")
+ from quantui.app import QuantUIApp
+
+ captured: list = []
+
+ class _FakeLoop:
+ def add_callback(self, cb, *a, **k):
+ captured.append((cb, a, k))
+
+ # A fake kernel loop captures deferred callbacks instead of running them —
+ # exactly how Voilà defers them until after the cell returns / first paint.
+ monkeypatch.setattr(QuantUIApp, "_get_kernel_io_loop", lambda self: _FakeLoop())
+ instance = QuantUIApp()
+ instance._captured_callbacks = captured
+ return instance
+
+
+class TestStartupDeferral:
+ def test_history_dropdown_shows_loading_placeholder(self, app):
+ # The real load (every saved result) is deferred, so construction leaves
+ # a placeholder rather than the populated list.
+ labels = [lbl for lbl, _val in app.past_dd.options]
+ assert any("loading" in lbl.lower() for lbl in labels)
+
+ def test_compare_dropdown_shows_loading_placeholder(self, app):
+ labels = [lbl for lbl, _val in app.compare_select.options]
+ assert any("loading" in lbl.lower() for lbl in labels)
+
+ def test_history_and_compare_scheduled_on_loop(self, app):
+ # At least the History + Compare population were scheduled (the GPU
+ # apply may add a third once the daemon thread resolves).
+ assert len(app._captured_callbacks) >= 2
+
+ def test_status_badge_renders_each_gpu_state(self, app):
+ # The Status panel is rendered via a closure so the background GPU
+ # detector can refresh just the GPU row.
+ assert "checking" in app._render_status_html(None).lower()
+ assert "MyGPU" in app._render_status_html((True, "MyGPU"))
+ assert "not installed" in app._render_status_html((False, None)).lower()
From 737c6f040fa3365862d380e895d8f61572de3475 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 15:53:24 -0400
Subject: [PATCH 16/19] Make classical pre-opt Preview-only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Remove the silent classical pre-optimization checkbox and make MMFF/UFF pre-optimizations an explicit Preview → Keep/Revert workflow. Updates include: probing preopt availability in app.py (no automatic invocation), replacing the checkbox with a descriptive label and Preview button in app_builders.py, stopping automatic pre-opt in the run path in app.py/app_runflow.py, updating help text to describe the Preview flow, and adapting tests to the new behaviour. This avoids invisible geometry changes and the confusing dual path of preview+silent re-opt.
---
quantui/app.py | 19 +++++++++---------
quantui/app_builders.py | 37 ++++++++++++++++++------------------
quantui/app_runflow.py | 5 ++---
quantui/help_content.py | 13 ++++++-------
tests/test_preopt_preview.py | 7 ++++---
5 files changed, 40 insertions(+), 41 deletions(-)
diff --git a/quantui/app.py b/quantui/app.py
index 622e6b2..47e8a8c 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -522,7 +522,10 @@
_PYSCF_AVAILABLE = False
try:
- from quantui.preopt import preoptimize
+ # Availability probe only — the classical pre-opt is invoked via the
+ # interactive Preview flow (app_runflow uses preoptimize_with_trajectory),
+ # not from app.py. ``_PREOPT_AVAILABLE`` gates the Preview button.
+ import quantui.preopt # noqa: F401
_PREOPT_AVAILABLE = True
except (ImportError, AttributeError):
@@ -946,7 +949,7 @@ class QuantUIApp:
nstates_si: Any
perf_estimate_html: Any
post_calc_panel: Any
- preopt_cb: Any
+ preopt_preview_label: Any
preopt_preview_btn: Any
preopt_accept_btn: Any
preopt_reset_btn: Any
@@ -4023,14 +4026,12 @@ def _run_required_final_single_point(target_mol, reason: str):
pass
try:
+ # Classical pre-optimization is now an explicit Preview → Keep tool
+ # (it mutates the active molecule before the run when the user keeps
+ # it), not a silent step here. So the run uses the active geometry
+ # as-is. (The QM "Geometry optimization before calculation" path is
+ # separate and handled below per calc type.)
calc_mol = mol
- if self.preopt_cb.value and _PREOPT_AVAILABLE:
- self.run_status.value = "Pre-optimizing..."
- calc_mol, _rmsd = preoptimize(mol)
- self._set_molecule_threadsafe(
- calc_mol,
- f"Geometry pre-optimized (MMFF94/UFF, RMSD={_rmsd:.3f} Å)",
- )
ct = self.calc_type_dd.value
result: Any = None
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 0582992..9e61409 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -606,23 +606,20 @@ def build_shared_widgets(
style={"description_width": "100px"},
layout=layout_fn(width="190px"),
)
- # POLISH.10 (M-POLISH, 2026-05-25): ``style={"description_width":
- # "initial"}`` removes the default left-side description gutter that
- # ipywidgets reserves on Checkbox, which was producing both the
- # indent the user noticed AND the horizontal scrollbar (description
- # gutter + ``width="100%"`` exceeded the container width). Letting
- # the checkbox size to its content also drops the scrollbar.
- app.preopt_cb = widgets.Checkbox(
- value=False,
- description="Classical pre-optimize geometry (fast, crude starting point)",
- disabled=not preopt_available,
- style={"description_width": "initial"},
- indent=False,
+ # Classical (MMFF/UFF) pre-optimization is an explicit, transparent tool —
+ # Preview → Keep/Revert — NOT a silent checkbox baked into the run. This
+ # avoids the confusing dual path (accepting a previewed geometry while a
+ # checkbox would re-run the same step) and means nothing pre-optimizes
+ # invisibly. (Distinct from the QM "Geometry optimization before
+ # calculation" checkbox below, which is a full DFT/HF opt.)
+ app.preopt_preview_label = widgets.HTML(
+ ''
+ "Classical pre-optimize geometry"
+ ' — fast MMFF/UFF '
+ "cleanup of a rough structure"
)
-
# Interactive pre-opt (M-PREOPT PREOPT.2/.3): run the bonded-FF pre-opt on
- # demand, watch it relax in-place, then keep or revert — instead of it being
- # a silent step buried inside the run.
+ # demand, watch it relax in-place, then keep or revert.
app.preopt_preview_btn = widgets.Button(
description="Preview",
icon="eye",
@@ -630,7 +627,8 @@ def build_shared_widgets(
disabled=not preopt_available,
layout=layout_fn(width="110px", height="28px"),
tooltip="Watch the classical pre-optimization relax this geometry, "
- "then keep or revert it",
+ "then keep or revert it"
+ + ("" if preopt_available else " (requires RDKit — not installed)"),
)
app.preopt_accept_btn = widgets.Button(
description="Keep this geometry",
@@ -672,8 +670,9 @@ def build_shared_widgets(
from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS
- # POLISH.10: same fix as preopt_cb above — drop the gutter +
- # explicit width that produced the indent + scrollbar.
+ # POLISH.10: drop the default Checkbox description gutter + explicit width
+ # (style description_width "initial" + indent=False) that produced the
+ # indent + horizontal scrollbar.
app.solvent_cb = widgets.Checkbox(
value=False,
description="Implicit solvent (PCM)",
@@ -1263,7 +1262,7 @@ def build_calc_setup(app: Any, *, layout_fn: Any) -> None:
app.calc_type_dd,
app.calc_extra_opts,
widgets.HBox(
- [app.preopt_cb, app.preopt_preview_btn],
+ [app.preopt_preview_label, app.preopt_preview_btn],
layout=layout_fn(align_items="center", gap="10px"),
),
app.preopt_preview_box,
diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py
index e473626..2a1ef07 100644
--- a/quantui/app_runflow.py
+++ b/quantui/app_runflow.py
@@ -476,9 +476,8 @@ def on_preopt_accept(app: Any, btn: Any = None) -> None:
if relaxed is None:
return
app._set_molecule(relaxed, "Pre-optimized (MMFF94/UFF — accepted from preview)")
- # The active geometry is now pre-optimized, so turn OFF the auto pre-opt
- # checkbox to avoid redundantly re-optimizing it inside the run.
- app.preopt_cb.value = False
+ # The active geometry IS the relaxed one now; the run uses it as-is (there is
+ # no silent pre-opt step to disable — classical pre-opt is Preview-only).
app._preopt_relaxed_mol = None
app.preopt_preview_box.layout.display = "none"
app.preopt_preview_output.clear_output()
diff --git a/quantui/help_content.py b/quantui/help_content.py
index cafef34..e6431d7 100644
--- a/quantui/help_content.py
+++ b/quantui/help_content.py
@@ -335,15 +335,14 @@
"you know whether coordinates are experimental, DFT-optimized, or "
"force-field-embedded. Either way, treat them as a starting "
"point and run a geometry optimization for accurate results."
- "
Quick clean-up: beside the Classical pre-optimize "
- "checkbox, click Preview to relax the geometry with a fast "
- "force field (MMFF94/UFF). Use the controls under the viewer to "
+ "
Quick clean-up: next to Classical pre-optimize "
+ "geometry, click Preview to relax the structure with a "
+ "fast force field (MMFF94/UFF). Use the controls under the viewer to "
"play the relaxation, scrub to any step, or click ⇄ to "
"flip between your input and the relaxed result for a direct "
- "comparison — then Keep this geometry to adopt it or "
- "Revert to discard. "
- "(Leaving the checkbox ticked without previewing just runs the same "
- "pre-opt silently before your calculation.)
"
+ "comparison — then Keep this geometry to adopt it (it "
+ "becomes the active structure your calculation runs on) or "
+ "Revert to discard. Nothing is changed unless you Keep it."
),
},
}
diff --git a/tests/test_preopt_preview.py b/tests/test_preopt_preview.py
index 023d0ac..d4787ac 100644
--- a/tests/test_preopt_preview.py
+++ b/tests/test_preopt_preview.py
@@ -246,16 +246,17 @@ def test_preview_done_reveals_keep_revert(self, app):
assert app.preopt_reset_btn.disabled is False
assert "0.123" in app.preopt_preview_status.value
- def test_accept_sets_molecule_and_unchecks_autopreopt(self, app):
+ def test_accept_sets_molecule_and_hides_preview(self, app):
+ # Pre-opt is Preview-only: Keep makes the relaxed geometry the active
+ # molecule (which the run then uses as-is). There is no checkbox.
relaxed = _water()
app._preopt_relaxed_mol = relaxed
- app.preopt_cb.value = True
app.preopt_preview_box.layout.display = ""
app._on_preopt_accept()
assert app._molecule is relaxed
- assert app.preopt_cb.value is False # decoupled: no redundant re-opt
+ assert not hasattr(app, "preopt_cb") # classical-preopt checkbox removed
assert app._preopt_relaxed_mol is None
assert app.preopt_preview_box.layout.display == "none"
From 25be6515627569050020c576f4876d518777c0e2 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 16:05:26 -0400
Subject: [PATCH 17/19] Prevent stacked vib animations; add live FPS update
Stop accumulating animation loops by calling stopAnimate() before each animate() in the vibrational viewer JS. Add a client-side window.__quantuiVibSetFps(fps) function to update the animation interval and restart the loop without rebuilding (camera preserved). Expose a Python bridge _vib_bridge_set_fps that sends the FPS change to the frontend and wire app.py to call it for the single-viewer path. Update tests to check for the new stopAnimate and fps-change hooks and tidy a dedent call in an unrelated test.
---
quantui/app.py | 7 +++++++
quantui/app_visualization.py | 36 ++++++++++++++++++++++++++++++++++++
tests/test_viz_offline.py | 5 +++++
3 files changed, 48 insertions(+)
diff --git a/quantui/app.py b/quantui/app.py
index 47e8a8c..cc1a81c 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -2645,6 +2645,13 @@ def _on_vib_framerate_changed(self, change) -> None:
_calc_log.log_event("vib_framerate_changed", f"fps={new_fps}")
except OSError:
pass
+ # Single-viewer path: update the running animation's interval in place
+ # (no rebuild, camera preserved). The legacy per-mode path re-renders.
+ if getattr(self, "_vib_single_viewer_active", False):
+ from quantui.app_visualization import _vib_bridge_set_fps
+
+ _vib_bridge_set_fps(self, new_fps)
+ return
# If a vibrational result is currently loaded, re-render the current
# mode through the new fps so the change is visible immediately.
if (
diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py
index 2a02eff..3569165 100644
--- a/quantui/app_visualization.py
+++ b/quantui/app_visualization.py
@@ -2156,6 +2156,12 @@ def build_trajectory_viewer_html(
var v=vw(); if(!v) return false;
var xyz=frames(m); if(xyz===null) return false;
try{
+ // stopAnimate FIRST: setMode may be called more than once for the same
+ // viewer (initial render + the dropdown observer + each mode switch).
+ // Without stopping the running loop, animate() stacks additional loops,
+ // advancing frames several times per tick — glitchy, too-fast playback
+ // that ignores the fps interval. Stopping guarantees exactly one loop.
+ if(v.stopAnimate) v.stopAnimate();
v.removeAllModels();
v.addModelsAsFrames(xyz,"xyz");
v.setStyle({"stick":{},"sphere":{"scale":0.3}});
@@ -2166,6 +2172,15 @@ def build_trajectory_viewer_html(
}catch(e){ return false; }
return true;
};
+ // Live framerate change (custom fps setting): update the interval and restart
+ // the loop on the current frames — no rebuild, so the camera is preserved.
+ window.__quantuiVibSetFps=function(fps){
+ IV=Math.max(1,Math.round(1000/Math.max(1,fps)));
+ var v=vw(); if(!v) return;
+ try{ if(v.stopAnimate) v.stopAnimate();
+ v.animate({"loop":"forward","interval":IV,"reps":0}); v.render();
+ }catch(e){}
+ };
var t=0, poll=setInterval(function(){ t++;
if(vw()){ clearInterval(poll); window.__quantuiVibSetMode(INIT, true); }
else if(t>200){ clearInterval(poll); }
@@ -2316,6 +2331,27 @@ def _vib_bridge_set_mode(app: Any, mode_number: int) -> None:
pass
+def _vib_bridge_set_fps(app: Any, fps: int) -> None:
+ """Update the live single-viewer's animation framerate client-side (no
+ rebuild, camera preserved) via ``window.__quantuiVibSetFps``."""
+ bridge = getattr(app, "_vib_js_bridge", None)
+ if bridge is None:
+ return
+ from IPython.display import Javascript, display
+
+ js = (
+ "(function(){var n=0;function go(){n++;"
+ "if(window.__quantuiVibSetFps){window.__quantuiVibSetFps(%d);}"
+ "else if(n<40){setTimeout(go,50);}}go();})();" % int(fps)
+ )
+ try:
+ bridge.clear_output(wait=True)
+ with bridge:
+ display(Javascript(js))
+ except Exception:
+ pass
+
+
def show_pes_scan_result(app: Any, result: Any) -> bool:
"""Render PES energy profile chart and stash latest PES result."""
app._last_pes_result = result
diff --git a/tests/test_viz_offline.py b/tests/test_viz_offline.py
index 61b23da..23a737c 100644
--- a/tests/test_viz_offline.py
+++ b/tests/test_viz_offline.py
@@ -164,6 +164,11 @@ def test_vib_viewer_is_single_viewer_all_modes():
assert "window.__quantuiVibSetMode" in html # client-side mode switch fn
assert "removeAllModels" in html # swaps frames on the SAME viewer instance
assert "addModelsAsFrames" in html
+ # stopAnimate before each animate() prevents stacked animation loops — the
+ # glitchy / too-fast playback after repeated setMode calls.
+ assert "stopAnimate" in html
+ # Live framerate change without a rebuild (camera preserved).
+ assert "window.__quantuiVibSetFps" in html
# All three modes' displacement vectors are embedded for client-side frames.
assert '"1":' in html and '"2":' in html and '"3":' in html
From e507b96084eefc5a7aca705dae230b42d2e1bf81 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 16:21:30 -0400
Subject: [PATCH 18/19] Handle CuPy GPU arrays in session calc
Add a helper _to_numpy_array to safely convert arrays (including CuPy GPU arrays) to NumPy on the host and use it throughout session_calc. Use the helper for early extraction of mf.mo_energy / mf.mo_occ (including UHF handling), Mulliken charges and dipole moment. Fall back to mf.to_cpu() when population analysis is not implemented on the GPU object. Removes a duplicate helper and addresses a silent bug where GPU-offloaded CuPy arrays caused MO, charge and dipole fields to be lost.
---
quantui/session_calc.py | 85 +++++++++++++++++++++++------------------
1 file changed, 48 insertions(+), 37 deletions(-)
diff --git a/quantui/session_calc.py b/quantui/session_calc.py
index 8a3a307..8a8825a 100644
--- a/quantui/session_calc.py
+++ b/quantui/session_calc.py
@@ -441,15 +441,35 @@ def _run_session_calc_body(
converged = bool(getattr(mf, "converged", False))
n_iterations = int(getattr(mf, "cycles", -1))
+ import numpy as _np
+
+ def _to_numpy_array(arr: Any) -> Any:
+ """Convert ``arr`` to a NumPy array, transferring from GPU if needed.
+
+ gpu4pyscf returns CuPy arrays (``mf.mo_occ`` / ``mo_energy`` / ``mo_coeff``
+ on a GPU-offloaded run). ``numpy.array(cupy_array)`` raises (NumPy refuses
+ implicit device→host transfers), so probe for CuPy's ``.get()`` host copy
+ first. Returns ``None`` unchanged.
+ """
+ if arr is None:
+ return None
+ # CuPy arrays have a ``.get()`` method (synchronous device→host copy).
+ # Probe for it rather than importing cupy, so the CPU-only path doesn't
+ # pull cupy onto the import graph.
+ get = getattr(arr, "get", None)
+ if callable(get) and type(arr).__module__.startswith("cupy"):
+ return _np.asarray(get())
+ return _np.asarray(arr)
+
homo_lumo_gap_ev: Optional[float] = None
try:
- mo_occ = mf.mo_occ
- mo_energy = mf.mo_energy
- import numpy as _np
-
- if isinstance(mo_energy, (list, _np.ndarray)) and hasattr(
- mo_energy[0], "__len__"
- ):
+ # Route through _to_numpy_array: on a GPU-offloaded run mf.mo_occ /
+ # mo_energy are CuPy arrays, and the old ``_np.array(mo_occ_ref)`` here
+ # raised (silently → gap None). Same CuPy fix the MO-array extraction
+ # below already had; this block was overlooked.
+ mo_energy = _to_numpy_array(mf.mo_energy)
+ mo_occ = _to_numpy_array(mf.mo_occ)
+ if mo_energy.ndim == 2:
# UHF: mo_energy is (2, n_mo) — use alpha spin for the gap estimate
mo_energy_ref = mo_energy[0]
mo_occ_ref = mo_occ[0]
@@ -457,7 +477,7 @@ def _run_session_calc_body(
mo_energy_ref = mo_energy
mo_occ_ref = mo_occ
- n_occ = int((_np.array(mo_occ_ref) > 0).sum())
+ n_occ = int((mo_occ_ref > 0).sum())
if 0 < n_occ < len(mo_energy_ref):
homo_lumo_gap_ev = float(
(mo_energy_ref[n_occ] - mo_energy_ref[n_occ - 1]) * HARTREE_TO_EV
@@ -469,48 +489,39 @@ def _run_session_calc_body(
dipole_moment_debye: Optional[float] = None
if method_upper != "UHF":
try:
- _, chg = mf.mulliken_pop(verbose=0)
- mulliken_charges = [float(c) for c in chg]
+ # gpu4pyscf doesn't implement population analysis on the GPU object
+ # (``mf.mulliken_pop`` is NotImplemented), so on a GPU-offloaded run
+ # fall back to the host (CPU) object via ``to_cpu()``. ``chg`` is
+ # then host NumPy; _to_numpy_array also covers the CuPy case.
+ mf_pop = mf
+ if not callable(getattr(mf, "mulliken_pop", None)) and callable(
+ getattr(mf, "to_cpu", None)
+ ):
+ mf_pop = mf.to_cpu()
+ _, chg = mf_pop.mulliken_pop(verbose=0)
+ mulliken_charges = [float(c) for c in _to_numpy_array(chg)]
except Exception as exc:
logger.debug("Mulliken population extraction failed: %s", exc)
try:
- import numpy as _np2
-
- dip = mf.dip_moment(verbose=0)
- dipole_moment_debye = float(_np2.linalg.norm(dip))
+ dip = _to_numpy_array(mf.dip_moment(verbose=0))
+ dipole_moment_debye = float(_np.linalg.norm(dip))
except Exception as exc:
logger.debug("Dipole moment extraction failed: %s", exc)
# MO arrays for orbital visualization (non-fatal if extraction fails).
- #
- # GPU-offload note (BUG fix, 2026-05-25): when ``gpu4pyscf`` migrated
- # ``mf`` to the GPU, ``mf.mo_energy`` / ``mo_coeff`` / ``mo_occ`` are
- # CuPy arrays. ``numpy.array(cupy_array)`` raises ``TypeError`` (numpy
- # refuses implicit device transfers), so the bare ``except`` swallowed
- # it and we silently shipped a ``SessionResult`` with all MO fields
- # ``None``. That in turn made ``save_orbitals`` no-op (it short-
- # circuits when both ``mo_e`` and ``mo_occ`` are None), and history
- # replay of GPU-run geo-opts / single-points showed "Not available"
- # in the Energies + Isosurface panels. ``_to_numpy_array`` below
- # detects CuPy arrays and copies them to host via ``cupy.asnumpy``.
+ # Uses the same ``_to_numpy_array`` CuPy→host helper defined above
+ # (GPU-offload note, BUG fix 2026-05-25):
+ # when gpu4pyscf migrated ``mf`` to the GPU, ``mf.mo_energy`` / ``mo_coeff``
+ # / ``mo_occ`` are CuPy arrays. ``numpy.array(cupy_array)`` raises (numpy
+ # refuses implicit device transfers), which silently shipped a
+ # ``SessionResult`` with all MO fields ``None`` → ``save_orbitals`` no-op
+ # and "Not available" in the Energies + Isosurface panels on replay.
_mo_energy_ha_arr: Optional[Any] = None
_mo_occ_arr: Optional[Any] = None
_mo_coeff_arr: Optional[Any] = None
_pyscf_mol_atom: Optional[Any] = None
_pyscf_mol_basis: Optional[str] = None
- def _to_numpy_array(arr: Any) -> Any:
- """Convert ``arr`` to a NumPy array, transferring from GPU if needed."""
- if arr is None:
- return None
- # CuPy arrays have a ``.get()`` method (synchronous device→host copy).
- # Probe for it rather than importing cupy, so the CPU-only path
- # doesn't pull cupy onto the import graph.
- get = getattr(arr, "get", None)
- if callable(get) and type(arr).__module__.startswith("cupy"):
- return _np.asarray(get())
- return _np.asarray(arr)
-
try:
_mo_energy_ha_arr = _to_numpy_array(mf.mo_energy)
_mo_occ_arr = _to_numpy_array(mf.mo_occ)
From 57636faa0b5f14a7ed1b53d23d5e145707d6cd0e Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Thu, 18 Jun 2026 16:25:33 -0400
Subject: [PATCH 19/19] Bump version to 0.4.0 and add changelog
Prepare and document the 0.4.0 release: update package version in pyproject.toml and quantui.__init__.py, update the Apptainer image label, and add a detailed 0.4.0 entry to CHANGELOG.md. The changelog highlights interactive MO isosurfaces (py3Dmol), an interactive classical pre-optimization preview workflow, single persistent 3D viewers with preserved camera state, offline-first 3D rendering, faster startup, a cooperative Cancel button, and several bug fixes (GPU result extraction, vibrational animation, cancel/stale status handling, and provenance persistence).
---
CHANGELOG.md | 58 +++++++++++++++++++++++++++++++++++++++++++
apptainer/quantui.def | 2 +-
pyproject.toml | 2 +-
quantui/__init__.py | 2 +-
4 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68f34ec..fba6c3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,64 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
## [Unreleased]
+## [0.4.0] - 2026-06-18
+
+Interactive-visualization and offline-readiness release. Adds molecular-orbital
+isosurfaces and an interactive pre-optimization preview, reworks the 3D viewers
+to preserve camera orientation across frames, makes all 3D rendering work
+offline, and substantially speeds up startup.
+
+### Added
+
+- **Molecular-orbital isosurfaces (py3Dmol)** — interactive HOMO / LUMO / MO
+ isosurface viewer rendered with py3Dmol; the downsampled Plotly path remains a
+ fallback.
+- **Interactive classical pre-optimization** — a **Preview** button relaxes the
+ geometry with a fast bonded force field (RDKit MMFF94 → UFF) and animates the
+ relaxation in place; **Keep this geometry** adopts it as the active structure
+ or **Revert** discards it. Stepper controls (play/pause, prev/next, scrub
+ slider, and an input ⇄ relaxed flip) let you compare geometries, and the
+ captured trajectory is sampled at even RMSD spacing for smooth playback.
+- **Cancel button** — stop a running calculation cooperatively at the next SCF
+ cycle / optimization step.
+- **Live vibrational-animation framerate** — the Vib fps setting updates the
+ running animation immediately.
+
+### Changed
+
+- **Single persistent 3D viewers** — the trajectory and vibrational-mode viewers
+ now load all frames into one py3Dmol viewer and switch frames/modes
+ client-side, so the camera (rotation/zoom) is preserved across steps and modes
+ and there is no per-frame rebuild or flicker.
+- **Pre-optimization is Preview-only** — the silent "classical pre-optimize"
+ checkbox is gone; pre-optimization happens only through the transparent
+ Preview → Keep/Revert flow, so nothing relaxes the geometry invisibly. (The
+ separate QM "geometry optimization before calculation" option is unchanged.)
+- **Offline-first 3D rendering** — 3Dmol.js is vendored and loaded per-view from
+ a local `data:` URI instead of a CDN, so every 3D view works with no network
+ (the build fails if the vendored asset is missing). Native launchers tolerate
+ offline `pip install`.
+- **Faster startup** — GPU detection and History/Compare population are deferred
+ off the synchronous construction path, so the UI paints in ~1 s instead of
+ ~15 s; the GPU status badge and dropdowns fill in shortly after.
+- **Clear** of the live calculation log is disabled while a calculation runs.
+
+### Fixed
+
+- **GPU-offloaded result extraction** — HOMO–LUMO gap, dipole moment, and
+ Mulliken charges are now reported for GPU runs. CuPy arrays are copied to host
+ before extraction, and Mulliken falls back to the CPU object (gpu4pyscf does
+ not implement population analysis on the GPU).
+- **Vibrational animation glitchiness** — stacked animation loops (a new loop
+ started on every mode switch) made playback jittery and too fast and ignored
+ the framerate setting; exactly one loop now runs.
+- **Cancel status** no longer sticks on "Cancelling…" after a calculation is
+ cancelled.
+- **Stale run status** — "Pre-optimized geometry accepted." is cleared when a new
+ molecule is loaded or a preview is reverted.
+- Structure provenance is reported and the input viewer is persisted across
+ reloads.
+
## [0.3.0] - 2026-06-11
Structure-sourcing release. Repairs the external-database structure search and
diff --git a/apptainer/quantui.def b/apptainer/quantui.def
index 1b387e1..b67cc96 100644
--- a/apptainer/quantui.def
+++ b/apptainer/quantui.def
@@ -21,7 +21,7 @@ FROM: continuumio/miniconda3:latest
%labels
Maintainer "Jonathan Schultz"
Purpose "Local teaching interface for quantum chemistry calculations"
- Version "0.3.0"
+ Version "0.4.0"
%environment
export PATH="/opt/conda/bin:${PATH}"
diff --git a/pyproject.toml b/pyproject.toml
index e0e88d1..d96ddf9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "quantui"
-version = "0.3.0"
+version = "0.4.0"
description = "An open-source frontend for DFT and post-HF quantum chemistry with PySCF"
readme = "README.md"
requires-python = ">=3.9"
diff --git a/quantui/__init__.py b/quantui/__init__.py
index f9648d2..22bcf42 100644
--- a/quantui/__init__.py
+++ b/quantui/__init__.py
@@ -7,7 +7,7 @@
PySCF requires Linux/macOS/WSL. Windows users should use the Apptainer container.
"""
-__version__ = "0.3.0"
+__version__ = "0.4.0"
import logging