From 96671bf10c1300a01da2209dc940c81e81bdbc3f Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 21 May 2026 11:38:33 -0400 Subject: [PATCH 1/9] Add 3D viz backend router and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a pure-function router (quantui.viz_backend_router) that resolves which 3D rendering backend (py3Dmol or plotlymol3d) to use per visualization task based on user preference and runtime availability. Implements VizTask, VizPreference, VizBackend enums, BackendAvailability probe, and an immutable Decision result that includes chosen backend, optional fallback, and a human-readable reason. Encodes a task->(primary,fallback) policy (single-backend tasks ignore preference) and robust fallback logic. Adds exhaustive unit tests (tests/test_viz_backend_router.py) covering the full task × preference × availability matrix, immutability and reason-string requirements. --- quantui/viz_backend_router.py | 219 +++++++++++++++++++++++++++++++ tests/test_viz_backend_router.py | 214 ++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 quantui/viz_backend_router.py create mode 100644 tests/test_viz_backend_router.py diff --git a/quantui/viz_backend_router.py b/quantui/viz_backend_router.py new file mode 100644 index 0000000..8f65f41 --- /dev/null +++ b/quantui/viz_backend_router.py @@ -0,0 +1,219 @@ +""" +3D visualization backend router for QuantUI. + +Resolves which 3D rendering backend (py3Dmol or plotlymol3d) to use for a given +render task, taking into account user preference and installed-package +availability. Pure function — no I/O, no widget state, no app reference. + +The routing policy mirrors the Capability Routing Policy table in +`M-VIZ-BACKEND` roadmap (`22-m-viz-backend-routing-roadmap.md`). + +Typical usage +------------- +>>> from quantui.viz_backend_router import ( +... BackendAvailability, VizPreference, VizTask, select_backend, +... ) +>>> avail = BackendAvailability.from_environment() +>>> decision = select_backend( +... task=VizTask.TRAJECTORY_FRAME, +... preference=VizPreference.AUTO, +... availability=avail, +... ) +>>> if decision.chosen == "py3dmol": +... ... # render via py3Dmol +... elif decision.chosen == "plotlymol": +... ... # render via plotlymol3d +... else: +... ... # no renderer available +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class VizTask(StrEnum): + """Internal routing key for each visualization context.""" + + MOLECULE_PREVIEW = "molecule_preview" + STRUCTURE_VIEW_RESULTS = "structure_view_results" + ANALYSIS_STRUCTURE_VIEW = "analysis_structure_view" + HISTORY_STRUCTURE_REPLAY = "history_structure_replay" + TRAJECTORY_FRAME = "trajectory_frame" + TRAJECTORY_EXPORT = "trajectory_export" + VIB_INTERACTIVE = "vib_interactive" + VIB_EXPORT = "vib_export" + ORBITAL_ISOSURFACE = "orbital_isosurface" + + +class VizPreference(StrEnum): + """User-selectable backend preference. `AUTO` defers to the task's primary.""" + + AUTO = "auto" + PY3DMOL = "py3dmol" + PLOTLYMOL = "plotlymol" + + +class VizBackend(StrEnum): + """Concrete backend identifier returned by `select_backend`.""" + + PY3DMOL = "py3dmol" + PLOTLYMOL = "plotlymol" + + +@dataclass(frozen=True) +class BackendAvailability: + """Which backends are importable in the current Python environment.""" + + py3dmol: bool + plotlymol: bool + + @classmethod + def from_environment(cls) -> BackendAvailability: + """Probe imports to detect installed backends. Call once at app startup.""" + try: + import py3Dmol # noqa: F401 + + py3dmol_ok = True + except ImportError: + py3dmol_ok = False + try: + import plotlymol3d # noqa: F401 + + plotlymol_ok = True + except ImportError: + plotlymol_ok = False + return cls(py3dmol=py3dmol_ok, plotlymol=plotlymol_ok) + + def supports(self, backend: VizBackend) -> bool: + if backend == VizBackend.PY3DMOL: + return self.py3dmol + if backend == VizBackend.PLOTLYMOL: + return self.plotlymol + return False + + +@dataclass(frozen=True) +class Decision: + """Result of a routing decision. + + `chosen` is None when no available backend can serve the requested task — + callers should render a graceful unavailable-state message in that case. + `fallback` reports the secondary option available for that task at the time + of the decision (informational; not a promise to retry automatically). + """ + + chosen: VizBackend | None + fallback: VizBackend | None + reason: str + + +# Capability routing policy. +# +# Maps task -> (primary backend, optional fallback backend). +# Tasks whose fallback is None are single-backend — user preference is ignored +# for these (export-quality renders and the existing isosurface Plotly path). +_TASK_POLICY: dict[VizTask, tuple[VizBackend, VizBackend | None]] = { + VizTask.MOLECULE_PREVIEW: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + VizTask.STRUCTURE_VIEW_RESULTS: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + VizTask.ANALYSIS_STRUCTURE_VIEW: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + VizTask.HISTORY_STRUCTURE_REPLAY: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + VizTask.TRAJECTORY_FRAME: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + 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), +} + + +def select_backend( + task: VizTask, + preference: VizPreference, + availability: BackendAvailability, +) -> Decision: + """Resolve the backend to use for a given render task. + + Resolution order: + + 1. If the task is single-backend (export and isosurface paths), user + preference is ignored and the task's required backend is used if + available; otherwise `Decision.chosen` is None. + 2. Otherwise, `preference` selects between py3Dmol and plotlymol3d. + `AUTO` resolves to the task's primary backend. + 3. If the preferred backend is unavailable, fall back to the task's + fallback backend if it is available. + 4. If neither is available, `Decision.chosen` is None. + """ + primary, fallback_policy = _TASK_POLICY[task] + + # Single-backend tasks: preference is ignored. Used for export-quality + # renders (trajectory/vib export HTML) and the orbital isosurface path. + if fallback_policy is None: + if availability.supports(primary): + return Decision( + chosen=primary, + fallback=None, + reason=f"task '{task}' requires {primary}", + ) + return Decision( + chosen=None, + fallback=None, + reason=f"task '{task}' requires {primary} but it is unavailable", + ) + + # Multi-backend tasks: resolve preference -> preferred backend. + if preference == VizPreference.AUTO: + preferred = primary + reason_prefix = f"auto -> task primary ({primary})" + elif preference == VizPreference.PY3DMOL: + preferred = VizBackend.PY3DMOL + reason_prefix = "user preference (py3dmol)" + elif preference == VizPreference.PLOTLYMOL: + preferred = VizBackend.PLOTLYMOL + reason_prefix = "user preference (plotlymol)" + else: # defensive — should be unreachable given the StrEnum + preferred = primary + reason_prefix = f"unknown preference '{preference}' -> task primary ({primary})" + + if availability.supports(preferred): + # Report the other backend (if available) as fallback for transparency. + other = fallback_policy if preferred == primary else primary + reported_fallback = other if availability.supports(other) else None + return Decision( + chosen=preferred, + fallback=reported_fallback, + reason=reason_prefix, + ) + + # Preferred is unavailable — try the policy fallback if it differs. + if fallback_policy != preferred and availability.supports(fallback_policy): + return Decision( + chosen=fallback_policy, + fallback=None, + reason=( + f"preferred {preferred} unavailable -> " + f"fell back to {fallback_policy}" + ), + ) + + # Some installs may have the policy primary but not the requested + # preference's fallback. Try the primary as a last resort if it differs. + if preferred != primary and availability.supports(primary): + return Decision( + chosen=primary, + fallback=None, + reason=( + f"preferred {preferred} unavailable -> " + f"fell back to task primary ({primary})" + ), + ) + + return Decision( + chosen=None, + fallback=None, + reason=( + f"no available backend for task '{task}' " + f"(preferred={preferred}, policy fallback={fallback_policy})" + ), + ) diff --git a/tests/test_viz_backend_router.py b/tests/test_viz_backend_router.py new file mode 100644 index 0000000..9493acc --- /dev/null +++ b/tests/test_viz_backend_router.py @@ -0,0 +1,214 @@ +"""Unit tests for `quantui.viz_backend_router.select_backend`. + +The router is a pure function — no I/O, no widget state — so these tests are +exhaustive across the availability × preference × task matrix without any +mocking infrastructure. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from quantui.viz_backend_router import ( + BackendAvailability, + Decision, + VizBackend, + VizPreference, + VizTask, + select_backend, +) + +BOTH = BackendAvailability(py3dmol=True, plotlymol=True) +ONLY_PY3DMOL = BackendAvailability(py3dmol=True, plotlymol=False) +ONLY_PLOTLYMOL = BackendAvailability(py3dmol=False, plotlymol=True) +NEITHER = BackendAvailability(py3dmol=False, plotlymol=False) + + +# Tasks whose policy permits either backend (multi-backend tasks). +_DUAL_BACKEND_TASKS = [ + VizTask.MOLECULE_PREVIEW, + VizTask.STRUCTURE_VIEW_RESULTS, + VizTask.ANALYSIS_STRUCTURE_VIEW, + VizTask.HISTORY_STRUCTURE_REPLAY, + VizTask.TRAJECTORY_FRAME, + VizTask.VIB_INTERACTIVE, +] + +# Tasks that require plotlymol3d regardless of preference. +_PLOTLYMOL_ONLY_TASKS = [ + VizTask.TRAJECTORY_EXPORT, + VizTask.VIB_EXPORT, + VizTask.ORBITAL_ISOSURFACE, +] + + +class TestStrEnumBehavior: + """StrEnum members must act as str — verifies the choice made in VIZBACK.1 + so log events and JSON serialization don't need `.value` unwrapping.""" + + def test_viz_task_is_str(self): + assert VizTask.TRAJECTORY_FRAME == "trajectory_frame" + assert f"task={VizTask.TRAJECTORY_FRAME}" == "task=trajectory_frame" + + def test_viz_preference_is_str(self): + assert VizPreference.AUTO == "auto" + assert VizPreference.PY3DMOL == "py3dmol" + assert VizPreference.PLOTLYMOL == "plotlymol" + + def test_viz_backend_is_str(self): + assert VizBackend.PY3DMOL == "py3dmol" + assert VizBackend.PLOTLYMOL == "plotlymol" + + +class TestBackendAvailability: + def test_supports_py3dmol(self): + assert ONLY_PY3DMOL.supports(VizBackend.PY3DMOL) is True + assert ONLY_PY3DMOL.supports(VizBackend.PLOTLYMOL) is False + + def test_supports_plotlymol(self): + assert ONLY_PLOTLYMOL.supports(VizBackend.PY3DMOL) is False + assert ONLY_PLOTLYMOL.supports(VizBackend.PLOTLYMOL) is True + + def test_supports_neither(self): + assert NEITHER.supports(VizBackend.PY3DMOL) is False + assert NEITHER.supports(VizBackend.PLOTLYMOL) is False + + def test_from_environment_returns_bool_fields(self): + """from_environment should always return an instance (boolean fields + reflect the runtime environment — we don't assert on their values).""" + avail = BackendAvailability.from_environment() + assert isinstance(avail.py3dmol, bool) + assert isinstance(avail.plotlymol, bool) + + +class TestDualBackendTasksAuto: + """Auto preference should pick py3Dmol primary for all dual-backend tasks.""" + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_auto_with_both_available_picks_py3dmol(self, task): + decision = select_backend(task, VizPreference.AUTO, BOTH) + assert decision.chosen == VizBackend.PY3DMOL + assert decision.fallback == VizBackend.PLOTLYMOL + assert "auto" in decision.reason + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_auto_with_only_py3dmol(self, task): + decision = select_backend(task, VizPreference.AUTO, ONLY_PY3DMOL) + assert decision.chosen == VizBackend.PY3DMOL + assert decision.fallback is None + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_auto_with_only_plotlymol_falls_back(self, task): + decision = select_backend(task, VizPreference.AUTO, ONLY_PLOTLYMOL) + assert decision.chosen == VizBackend.PLOTLYMOL + assert decision.fallback is None + assert "fell back" in decision.reason + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_auto_with_neither_returns_none(self, task): + decision = select_backend(task, VizPreference.AUTO, NEITHER) + assert decision.chosen is None + assert decision.fallback is None + assert "no available backend" in decision.reason + + +class TestDualBackendTasksExplicitPreference: + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_explicit_py3dmol_with_both(self, task): + decision = select_backend(task, VizPreference.PY3DMOL, BOTH) + assert decision.chosen == VizBackend.PY3DMOL + assert decision.fallback == VizBackend.PLOTLYMOL + assert "user preference" in decision.reason + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_explicit_plotlymol_with_both(self, task): + decision = select_backend(task, VizPreference.PLOTLYMOL, BOTH) + assert decision.chosen == VizBackend.PLOTLYMOL + assert decision.fallback == VizBackend.PY3DMOL + assert "user preference" in decision.reason + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_explicit_py3dmol_when_only_plotlymol_available(self, task): + decision = select_backend(task, VizPreference.PY3DMOL, ONLY_PLOTLYMOL) + assert decision.chosen == VizBackend.PLOTLYMOL + assert decision.fallback is None + assert "unavailable" in decision.reason + + @pytest.mark.parametrize("task", _DUAL_BACKEND_TASKS) + def test_explicit_plotlymol_when_only_py3dmol_available(self, task): + decision = select_backend(task, VizPreference.PLOTLYMOL, ONLY_PY3DMOL) + assert decision.chosen == VizBackend.PY3DMOL + assert decision.fallback is None + assert "unavailable" in decision.reason + + +class TestSingleBackendTasksIgnorePreference: + """Export and isosurface tasks require plotlymol3d; preference must not + change that decision (only availability can).""" + + @pytest.mark.parametrize("task", _PLOTLYMOL_ONLY_TASKS) + @pytest.mark.parametrize( + "preference", + [VizPreference.AUTO, VizPreference.PY3DMOL, VizPreference.PLOTLYMOL], + ) + def test_picks_plotlymol_when_available(self, task, preference): + decision = select_backend(task, preference, BOTH) + assert decision.chosen == VizBackend.PLOTLYMOL + assert decision.fallback is None + assert "requires" in decision.reason + + @pytest.mark.parametrize("task", _PLOTLYMOL_ONLY_TASKS) + @pytest.mark.parametrize( + "preference", + [VizPreference.AUTO, VizPreference.PY3DMOL, VizPreference.PLOTLYMOL], + ) + def test_returns_none_when_plotlymol_unavailable(self, task, preference): + decision = select_backend(task, preference, ONLY_PY3DMOL) + assert decision.chosen is None + assert decision.fallback is None + assert "unavailable" in decision.reason + + +class TestDecisionShape: + def test_decision_is_immutable(self): + decision = select_backend(VizTask.MOLECULE_PREVIEW, VizPreference.AUTO, BOTH) + with pytest.raises(dataclasses.FrozenInstanceError): + decision.chosen = VizBackend.PLOTLYMOL # type: ignore[misc] + + def test_decision_reason_is_nonempty(self): + """Every decision must explain itself for log telemetry.""" + for task in VizTask: + for preference in VizPreference: + for avail in (BOTH, ONLY_PY3DMOL, ONLY_PLOTLYMOL, NEITHER): + decision = select_backend(task, preference, avail) + assert decision.reason, ( + f"empty reason for task={task} " + f"preference={preference} availability={avail}" + ) + + +class TestFullMatrix: + """Exhaustive matrix: every task × every preference × every availability + state should return a valid Decision with consistent fields.""" + + def test_every_combination_returns_decision(self): + for task in VizTask: + for preference in VizPreference: + for avail in (BOTH, ONLY_PY3DMOL, ONLY_PLOTLYMOL, NEITHER): + decision = select_backend(task, preference, avail) + assert isinstance(decision, Decision) + # chosen is either None or a real VizBackend + assert decision.chosen is None or isinstance( + decision.chosen, VizBackend + ) + # fallback is None or a real VizBackend that differs from chosen + if decision.fallback is not None: + assert isinstance(decision.fallback, VizBackend) + assert decision.fallback != decision.chosen + # If chosen is set, the corresponding availability is True + if decision.chosen == VizBackend.PY3DMOL: + assert avail.py3dmol + if decision.chosen == VizBackend.PLOTLYMOL: + assert avail.plotlymol From d7fe30d7216a42a43f6a45e00f333fdf94461ed3 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 21 May 2026 12:53:13 -0400 Subject: [PATCH 2/9] Add persistent user settings & default 3D backend Introduce a new UserSettings facility to persist user preferences to ~/.quantui/settings.json (overrideable via QUANTUI_SETTINGS_PATH). The settings module provides a small schema (viz section with default_backend), atomic writes, schema-versioning, and robust fallbacks for malformed/missing files. Integrate settings into the app: load settings at startup, snapshot viz backend availability, expose a "Default 3D backend" ToggleButtons control in the Status tab, and persist changes when the user updates the preference. Add comprehensive unit tests covering load/save roundtrips, fallback paths, atomic write semantics, and path-resolution behavior. --- quantui/app.py | 34 +++++ quantui/app_builders.py | 38 +++++- quantui/user_settings.py | 182 +++++++++++++++++++++++++++ tests/test_user_settings.py | 242 ++++++++++++++++++++++++++++++++++++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 quantui/user_settings.py create mode 100644 tests/test_user_settings.py diff --git a/quantui/app.py b/quantui/app.py index 6d176cd..8b23343 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -381,7 +381,9 @@ from quantui.help_content import HELP_TOPICS from quantui.molecule import Molecule, parse_xyz_input from quantui.progress import StepProgress +from quantui.user_settings import UserSettings from quantui.utils import get_session_resources +from quantui.viz_backend_router import BackendAvailability # ── Availability flags (computed once at import, not per-instantiation) ─────── try: @@ -754,6 +756,7 @@ class QuantUIApp: theme_btn: Any viz_backend_toggle: Any viz_controls_box: Any + viz_default_backend_dd: Any viz_lighting_dd: Any viz_output: Any viz_style_dd: Any @@ -897,6 +900,15 @@ def __init__(self) -> None: self._pyscf_available: bool = _PYSCF_AVAILABLE self._preopt_available: bool = _PREOPT_AVAILABLE + # User settings (persisted in ~/.quantui/settings.json) + viz + # backend availability snapshot. The router consumes these; render + # call sites will be migrated to the router in VIZBACK.4 ff. + self._user_settings: UserSettings = UserSettings.load() + self._viz_availability: BackendAvailability = ( + BackendAvailability.from_environment() + ) + self._viz_backend_preference: str = self._user_settings.viz.default_backend + # ── Build → wire → assemble ─────────────────────────────────────── self._build_widgets() self._wire_callbacks() @@ -1062,6 +1074,7 @@ def _build_status_panel(self) -> None: ase_available=ASE_AVAILABLE, pubchem_available=PUBCHEM_AVAILABLE, visualization_available=VISUALIZATION_AVAILABLE, + viz_default_backend=self._user_settings.viz.default_backend, ) # ── Welcome header ──────────────────────────────────────────────────── @@ -1338,6 +1351,10 @@ def _wire_callbacks(self) -> None: self.viz_backend_toggle.observe( self._safe_cb(self._on_viz_backend_changed), names="value" ) + # Settings → "Default 3D backend" preference (Status tab; persisted). + self.viz_default_backend_dd.observe( + self._safe_cb(self._on_viz_default_backend_changed), names="value" + ) # 3D viewer style and lighting controls if VISUALIZATION_AVAILABLE: self.viz_style_dd.observe( @@ -1971,6 +1988,23 @@ def _on_viz_backend_changed(self, change) -> None: bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) + def _on_viz_default_backend_changed(self, change) -> None: + """Update the persisted default-backend preference. Drives router + decisions for new render operations; existing widgets remain on + their current effective backend until the next render.""" + new_value = change["new"] + if new_value not in ("auto", "py3dmol", "plotlymol"): + return + self._viz_backend_preference = new_value + self._user_settings.viz.default_backend = new_value + self._user_settings.save() + try: + _calc_log.log_event( + "viz_default_backend_changed", f"preference={new_value}" + ) + except OSError: + pass + def _on_viz_style_changed(self, change) -> None: self._viz_style = change["new"] if self._molecule is not None and _display_molecule is not None: diff --git a/quantui/app_builders.py b/quantui/app_builders.py index f38dcec..59de1ff 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -23,6 +23,7 @@ def build_status_panel( ase_available: bool, pubchem_available: bool, visualization_available: bool, + viz_default_backend: str = "auto", ) -> None: """Build the Status tab panel.""" cores, mem_gb = get_session_resources_fn() @@ -104,8 +105,43 @@ def _ok(flag: bool, extra: str = "") -> str: f"" ) + # ── Settings section ────────────────────────────────────────────────── + # "Default 3D backend" — user preference persisted via UserSettings. + # Drives viz_backend_router resolution. Distinct from the Calculate-tab + # `viz_backend_toggle` (which selects the current effective backend for + # interactive use). + app.viz_default_backend_dd = widgets.ToggleButtons( + options=[ + ("Auto", "auto"), + ("py3Dmol", "py3dmol"), + ("plotlymol3d", "plotlymol"), + ], + value=viz_default_backend, + style={"button_width": "110px"}, + tooltips=[ + "Use the recommended backend per task (py3Dmol-first where supported).", + "Always prefer py3Dmol when available.", + "Always prefer plotlymol3d when available.", + ], + ) + settings_html = widgets.HTML( + '
' + '
Settings
' + '
' + "Default 3D backend " + '' + "(persists across launches)
" + "
" + ) + settings_box = widgets.VBox( + [settings_html, app.viz_default_backend_dd], + layout=layout_fn(margin="0 0 8px 0"), + ) + app._status_tab_panel = widgets.VBox( - [app._status_html, guide_html], + [app._status_html, guide_html, settings_box], layout=layout_fn(padding="8px 0"), ) diff --git a/quantui/user_settings.py b/quantui/user_settings.py new file mode 100644 index 0000000..9b67a51 --- /dev/null +++ b/quantui/user_settings.py @@ -0,0 +1,182 @@ +""" +User preference persistence for QuantUI. + +Settings are stored at ``~/.quantui/settings.json`` (override with the +``QUANTUI_SETTINGS_PATH`` environment variable for testing). The schema is +section-based for additive growth — new feature areas (theme, defaults, +etc.) add their own top-level sections without breaking existing readers. + +Robustness rules +---------------- +- **Atomic writes** — write to ``settings.json.tmp`` then rename, so a + crash mid-write cannot corrupt the file. +- **Graceful fallback** — missing file, malformed JSON, unknown schema + version, missing sections, or invalid field values all silently fall + back to defaults with a single warning log. Startup never crashes on + bad settings. +- **Additive-friendly** — new fields use defaults if absent in older + saved files. Unknown fields are tolerated on read (no crash). +- **Schema versioning** — ``_schema_version`` is bumped only for breaking + changes; additive changes keep the same version. Mismatched versions + fall back to defaults. + +Typical usage +------------- +>>> from quantui.user_settings import UserSettings +>>> settings = UserSettings.load() # at app startup +>>> settings.viz.default_backend = "py3dmol" +>>> settings.save() # on settings change +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + +_SCHEMA_VERSION = 1 +_LOG = logging.getLogger(__name__) + +# Valid values for VizSettings.default_backend. Kept in sync with +# quantui.viz_backend_router.VizPreference values; not imported here to keep +# this module zero-dependency for unit testing. +_VALID_VIZ_BACKENDS = ("auto", "py3dmol", "plotlymol") + +# Default settings path. The QUANTUI_SETTINGS_PATH env var overrides for tests. +DEFAULT_SETTINGS_PATH = Path.home() / ".quantui" / "settings.json" + + +@dataclass +class VizSettings: + """Visualization-related user preferences.""" + + default_backend: str = "auto" # one of _VALID_VIZ_BACKENDS + + +@dataclass +class UserSettings: + """Root user settings container — section-based for additive growth.""" + + viz: VizSettings = field(default_factory=VizSettings) + + @classmethod + def load(cls, path: Path | None = None) -> UserSettings: + """Load settings from disk, falling back to defaults on any failure. + + Missing file, malformed JSON, unknown schema version, malformed + sections, and invalid field values all return the default + ``UserSettings`` with one warning log per failure mode. + """ + resolved = cls._resolve_path(path) + if not resolved.exists(): + return cls() + try: + data = json.loads(resolved.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _LOG.warning( + "Failed to read settings at %s (%s); using defaults", + resolved, + exc, + ) + return cls() + return cls._from_dict(data) + + @classmethod + def _from_dict(cls, data: object) -> UserSettings: + """Parse a deserialized JSON object into a UserSettings instance.""" + if not isinstance(data, dict): + _LOG.warning( + "Settings root is not a JSON object (got %s); using defaults", + type(data).__name__, + ) + return cls() + + version = data.get("_schema_version") + if version != _SCHEMA_VERSION: + _LOG.warning( + "Settings schema version %r does not match current %r; " + "using defaults", + version, + _SCHEMA_VERSION, + ) + return cls() + + viz_section = data.get("viz", {}) + if not isinstance(viz_section, dict): + _LOG.warning( + "Settings 'viz' section is not an object (got %s); " + "using viz defaults", + type(viz_section).__name__, + ) + viz_section = {} + + viz = VizSettings() + candidate_backend = viz_section.get("default_backend", viz.default_backend) + if ( + isinstance(candidate_backend, str) + and candidate_backend in _VALID_VIZ_BACKENDS + ): + viz.default_backend = candidate_backend + else: + _LOG.warning( + "Invalid viz.default_backend %r; using %r", + candidate_backend, + viz.default_backend, + ) + + return cls(viz=viz) + + def to_dict(self) -> dict: + """Serialize to a dict for JSON storage with the current schema version.""" + return { + "_schema_version": _SCHEMA_VERSION, + "viz": asdict(self.viz), + } + + def save(self, path: Path | None = None) -> None: + """Write settings to disk atomically (write to .tmp then rename). + + Does not raise on filesystem failure — logs a warning and continues. + Callers should not assume the save succeeded on a hostile filesystem; + the next load will fall back to defaults if the file is missing. + """ + resolved = self._resolve_path(path) + try: + resolved.parent.mkdir(parents=True, exist_ok=True) + except OSError as exc: + _LOG.warning( + "Failed to create settings parent dir %s (%s); save aborted", + resolved.parent, + exc, + ) + return + + tmp = resolved.with_suffix(resolved.suffix + ".tmp") + try: + tmp.write_text( + json.dumps(self.to_dict(), indent=2) + "\n", + encoding="utf-8", + ) + os.replace(tmp, resolved) + except OSError as exc: + _LOG.warning( + "Failed to save settings to %s (%s)", + resolved, + exc, + ) + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + + @classmethod + def _resolve_path(cls, path: Path | None) -> Path: + if path is not None: + return Path(path) + env_override = os.environ.get("QUANTUI_SETTINGS_PATH") + if env_override: + return Path(env_override) + return DEFAULT_SETTINGS_PATH diff --git a/tests/test_user_settings.py b/tests/test_user_settings.py new file mode 100644 index 0000000..0338372 --- /dev/null +++ b/tests/test_user_settings.py @@ -0,0 +1,242 @@ +"""Unit tests for `quantui.user_settings.UserSettings`. + +Covers default values, load/save roundtrip, every fallback path documented in +the module docstring, atomic-write semantics, and path resolution precedence. +All file I/O uses pytest's `tmp_path` fixture so no test touches +``~/.quantui/settings.json``. +""" + +from __future__ import annotations + +import json +import os + +import pytest + +from quantui.user_settings import UserSettings, VizSettings + + +class TestDefaults: + def test_default_viz_backend_is_auto(self): + assert VizSettings().default_backend == "auto" + + def test_default_user_settings_has_default_viz(self): + settings = UserSettings() + assert settings.viz.default_backend == "auto" + + def test_to_dict_uses_current_schema_version(self): + data = UserSettings().to_dict() + assert data["_schema_version"] == 1 + assert data["viz"] == {"default_backend": "auto"} + + +class TestLoad: + def test_missing_file_returns_defaults(self, tmp_path): + path = tmp_path / "doesnt_exist.json" + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_valid_settings_load_correctly(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "py3dmol"}}) + ) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "py3dmol" + + def test_malformed_json_returns_defaults(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text("this is not json {{{") + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_wrong_schema_version_returns_defaults(self, tmp_path): + """Mismatched schema version must NOT silently consume the file's data.""" + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 99, "viz": {"default_backend": "py3dmol"}}) + ) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_missing_schema_version_returns_defaults(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text(json.dumps({"viz": {"default_backend": "py3dmol"}})) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_missing_viz_section_uses_default_viz(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text(json.dumps({"_schema_version": 1})) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_viz_section_wrong_type_uses_defaults(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text(json.dumps({"_schema_version": 1, "viz": "not a dict"})) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_invalid_backend_value_uses_default(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + {"_schema_version": 1, "viz": {"default_backend": "nonexistent"}} + ) + ) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_backend_value_wrong_type_uses_default(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": 42}}) + ) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_top_level_list_returns_defaults(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text(json.dumps(["not", "a", "dict"])) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "auto" + + def test_unknown_fields_are_tolerated(self, tmp_path): + """Future versions may add fields old code doesn't know about — old + code should still load successfully and ignore them.""" + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "_schema_version": 1, + "viz": { + "default_backend": "py3dmol", + "future_field": {"nested": True}, + }, + "future_section": {"key": "value"}, + } + ) + ) + settings = UserSettings.load(path) + assert settings.viz.default_backend == "py3dmol" + + +class TestSave: + def test_creates_file(self, tmp_path): + path = tmp_path / "settings.json" + UserSettings().save(path) + assert path.exists() + + def test_creates_parent_directory(self, tmp_path): + path = tmp_path / "nested" / "subdir" / "settings.json" + UserSettings().save(path) + assert path.exists() + + def test_writes_valid_json(self, tmp_path): + path = tmp_path / "settings.json" + settings = UserSettings() + settings.viz.default_backend = "plotlymol" + settings.save(path) + data = json.loads(path.read_text()) + assert data["_schema_version"] == 1 + assert data["viz"]["default_backend"] == "plotlymol" + + def test_no_tmp_leftover_on_success(self, tmp_path): + path = tmp_path / "settings.json" + UserSettings().save(path) + tmp_files = list(tmp_path.glob("*.tmp")) + assert tmp_files == [] + + def test_replaces_existing_file_atomically(self, tmp_path): + path = tmp_path / "settings.json" + path.write_text(json.dumps({"old_format": True})) + settings = UserSettings() + settings.viz.default_backend = "py3dmol" + settings.save(path) + data = json.loads(path.read_text()) + assert data["_schema_version"] == 1 + assert "old_format" not in data + assert data["viz"]["default_backend"] == "py3dmol" + + def test_save_failure_does_not_raise(self, tmp_path, monkeypatch): + """Filesystem errors during save should log, not crash startup.""" + path = tmp_path / "settings.json" + + def _boom(*_args, **_kwargs): + raise OSError("simulated disk full") + + # Replace write_text on Path so the atomic write fails. + monkeypatch.setattr("pathlib.Path.write_text", _boom) + UserSettings().save(path) # must NOT raise + + +class TestRoundtrip: + def test_save_then_load_preserves_values(self, tmp_path): + path = tmp_path / "settings.json" + original = UserSettings() + original.viz.default_backend = "py3dmol" + original.save(path) + loaded = UserSettings.load(path) + assert loaded.viz.default_backend == "py3dmol" + + @pytest.mark.parametrize("backend", ["auto", "py3dmol", "plotlymol"]) + def test_roundtrip_each_valid_backend(self, tmp_path, backend): + path = tmp_path / "settings.json" + original = UserSettings(viz=VizSettings(default_backend=backend)) + original.save(path) + loaded = UserSettings.load(path) + assert loaded.viz.default_backend == backend + + +class TestPathResolution: + def test_explicit_path_used_when_provided(self, tmp_path): + explicit = tmp_path / "explicit.json" + UserSettings().save(explicit) + assert explicit.exists() + + def test_env_var_overrides_default(self, tmp_path, monkeypatch): + env_path = tmp_path / "env.json" + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(env_path)) + UserSettings().save() + assert env_path.exists() + + def test_explicit_path_overrides_env_var(self, tmp_path, monkeypatch): + env_path = tmp_path / "env.json" + explicit_path = tmp_path / "explicit.json" + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(env_path)) + UserSettings().save(explicit_path) + assert explicit_path.exists() + assert not env_path.exists() + + def test_env_var_load_path(self, tmp_path, monkeypatch): + env_path = tmp_path / "env.json" + env_path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "plotlymol"}}) + ) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(env_path)) + settings = UserSettings.load() + assert settings.viz.default_backend == "plotlymol" + + def test_empty_env_var_falls_through_to_default(self, tmp_path, monkeypatch): + """Empty QUANTUI_SETTINGS_PATH should not be treated as a real path.""" + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", "") + # We don't want to actually write to ~/.quantui — only check resolution + # logic by ensuring the explicit path argument wins. + explicit = tmp_path / "explicit.json" + UserSettings().save(explicit) + assert explicit.exists() + + +class TestEnvironmentIsolation: + """Defensive — ensure QUANTUI_SETTINGS_PATH from a parent process does not + leak into tests that don't set it. pytest's monkeypatch handles this when + used; this test verifies the convention holds.""" + + def test_env_path_uses_default_when_unset(self, monkeypatch): + monkeypatch.delenv("QUANTUI_SETTINGS_PATH", raising=False) + # We can't safely assert the actual default path is what we expect + # (the default lives under ~/.quantui), so just check the env var + # does not affect resolution when unset. + resolved = UserSettings._resolve_path(None) + assert "settings.json" in str(resolved) + assert os.environ.get("QUANTUI_SETTINGS_PATH") is None From 613b48773f0dcb523a29196c1e2f2ad1e4264d69 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Thu, 21 May 2026 20:02:56 -0400 Subject: [PATCH 3/9] Improve viz backend routing, sync & trajectory UI Introduce per-task backend routing and sync logic for 3D visualization: resolve_backend/select_backend are used to pick backends per VizTask, and a new _set_viz_preference centralizes preference updates (with optional persistence). Add a sync lock (_viz_sync_in_progress) and an analysis-tab backend toggle + small "Rendering with:" label to keep Calculate/Analysis toggles in parity without observer echo loops. At startup the persisted preference is resolved and widgets are aligned via _initialize_viz_state_from_preference; _rerender_3d_views re-renders visible viewers through the router. Hard-wire trajectory frame rendering to py3Dmol (update task policy) to avoid Plotly/RequireJS flicker; trajectory rendering now uses router decisions and emits explicit error frames when py3Dmol is unavailable. Implement atomic HTML swaps for frame output to eliminate layout flashes, add prev/next arrow navigation, fix frame_out sizing, and improve py3Dmol/plotlymol fallbacks in trajectory playback. Refactor show_result_3d to call the router per output/task and track the analysis-displayed molecule so toggling backends can re-render the Analysis viewer. Update viz_backend_router policy (TRAJECTORY_FRAME -> py3Dmol-only) and extend tests: adjust existing router tests and add comprehensive tests (tests/test_viz_backend_sync.py) for toggle parity, sync behaviour, startup preference application, and router-driven rendering. --- quantui/app.py | 223 +++++++++++++++++++++--- quantui/app_builders.py | 59 ++++++- quantui/app_visualization.py | 260 ++++++++++++++++++++-------- quantui/viz_backend_router.py | 11 +- tests/test_viz_backend_router.py | 34 +++- tests/test_viz_backend_sync.py | 282 +++++++++++++++++++++++++++++++ 6 files changed, 759 insertions(+), 110 deletions(-) create mode 100644 tests/test_viz_backend_sync.py diff --git a/quantui/app.py b/quantui/app.py index 8b23343..4b1fab6 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -383,7 +383,13 @@ from quantui.progress import StepProgress from quantui.user_settings import UserSettings from quantui.utils import get_session_resources -from quantui.viz_backend_router import BackendAvailability +from quantui.viz_backend_router import ( + BackendAvailability, + VizBackend, + VizPreference, + VizTask, + select_backend, +) # ── Availability flags (computed once at import, not per-instantiation) ─────── try: @@ -754,7 +760,9 @@ class QuantUIApp: solvent_dd: Any step_progress: Any theme_btn: Any + viz_backend_label_ana: Any viz_backend_toggle: Any + viz_backend_toggle_ana: Any viz_controls_box: Any viz_default_backend_dd: Any viz_lighting_dd: Any @@ -909,8 +917,24 @@ def __init__(self) -> None: ) self._viz_backend_preference: str = self._user_settings.viz.default_backend + # Synchronization state for Calculate/Analysis backend toggles. + # When _set_viz_backend updates one toggle, it sets this flag so the + # other toggle's observer can short-circuit and avoid an echo loop. + self._viz_sync_in_progress: bool = False + # Molecule currently rendered into _analysis_mol_output. Updated by + # show_result_3d; consumed by _set_viz_backend to re-render the + # Analysis-tab viewer when the toggle changes. + self._analysis_displayed_molecule: Any = None + # ── Build → wire → assemble ─────────────────────────────────────── self._build_widgets() + + # Resolve the persisted preference through the router and align all + # three preference widgets + _viz_backend with the router decision. + # Observers are NOT yet wired so widget assignments don't trigger + # render side-effects — this is pure initial-state alignment. + self._initialize_viz_state_from_preference() + self._wire_callbacks() self._assemble_tabs() @@ -1351,6 +1375,11 @@ def _wire_callbacks(self) -> None: self.viz_backend_toggle.observe( self._safe_cb(self._on_viz_backend_changed), names="value" ) + # Analysis-tab backend toggle (only wired when both backends available). + if self.viz_backend_toggle_ana is not None: + self.viz_backend_toggle_ana.observe( + self._safe_cb(self._on_viz_backend_changed_ana), names="value" + ) # Settings → "Default 3D backend" preference (Status tab; persisted). self.viz_default_backend_dd.observe( self._safe_cb(self._on_viz_default_backend_changed), names="value" @@ -1969,41 +1998,179 @@ def _rerender_plotly_theme(self) -> None: bgcolor=self._plotly_theme_colors()["scene_bgcolor"], ) - def _on_viz_backend_changed(self, change) -> None: - self._viz_backend = change["new"] # type: ignore[assignment] - # Lighting only works with the PlotlyMol backend + def _initialize_viz_state_from_preference(self) -> None: + """Align _viz_backend and the three preference widgets with the + persisted preference. Called at startup before observers are wired.""" + resolved = self._resolve_backend(VizTask.MOLECULE_PREVIEW) + if resolved is not None: + self._viz_backend = str(resolved) # type: ignore[assignment] + if ( + self.viz_backend_toggle is not None + and self.viz_backend_toggle.value != str(resolved) + ): + self.viz_backend_toggle.value = str(resolved) + if ( + self.viz_backend_toggle_ana is not None + and self.viz_backend_toggle_ana.value != str(resolved) + ): + self.viz_backend_toggle_ana.value = str(resolved) + # The Settings widget was already built with viz_default_backend + # loaded from settings.json — no further alignment needed there. + + def _resolve_backend(self, task: VizTask) -> VizBackend | None: + """Convenience wrapper: resolve backend for a render task via the + router using current preference + availability. Returns None if no + backend is available for the task.""" + decision = select_backend( + task, + VizPreference(self._viz_backend_preference), + self._viz_availability, + ) + try: + _calc_log.log_event( + "viz_route_decision", + f"task={task} pref={self._viz_backend_preference} " + f"chosen={decision.chosen} reason={decision.reason}"[:300], + ) + except OSError: + pass + return decision.chosen + + def _set_viz_preference(self, new_pref: str, *, persist: bool) -> None: + """Single source-of-truth setter for the backend preference. + + ``new_pref`` must be one of "auto" | "py3dmol" | "plotlymol". The + Settings widget (Status tab) calls this with ``persist=True``; the + Calculate/Analysis effective toggles call it with ``persist=False`` + (session-only override — clicking either toggle is treated as an + explicit commit to a concrete preference, even if the prior + preference was "auto"). + + Updates ``self._viz_backend_preference``, resolves the effective + backend for general static-structure rendering, syncs all three + widgets under ``_viz_sync_in_progress`` (no echo loops), updates + lighting-control visibility, and re-renders all visible 3D views. + """ + if new_pref not in ("auto", "py3dmol", "plotlymol"): + return + if new_pref == self._viz_backend_preference: + return + self._viz_backend_preference = new_pref + + if persist: + self._user_settings.viz.default_backend = new_pref + self._user_settings.save() + try: + _calc_log.log_event( + "viz_default_backend_changed", f"preference={new_pref}" + ) + except OSError: + pass + + # Resolve the effective backend for general static-structure rendering. + # MOLECULE_PREVIEW is used as the canonical task for the + # Calculate/Analysis toggle display (all static-structure tasks + # currently resolve to the same backend per the routing policy). + resolved = self._resolve_backend(VizTask.MOLECULE_PREVIEW) + if resolved is not None: + self._viz_backend = str(resolved) # type: ignore[assignment] + + # Sync all three widgets under the lock. + self._viz_sync_in_progress = True + try: + if self.viz_default_backend_dd.value != new_pref: + self.viz_default_backend_dd.value = new_pref + # Effective toggles can only display concrete values. + if resolved is not None: + resolved_str = str(resolved) + if ( + self.viz_backend_toggle is not None + and self.viz_backend_toggle.value != resolved_str + ): + self.viz_backend_toggle.value = resolved_str + if ( + self.viz_backend_toggle_ana is not None + and self.viz_backend_toggle_ana.value != resolved_str + ): + self.viz_backend_toggle_ana.value = resolved_str + finally: + self._viz_sync_in_progress = False + + # Lighting only works with the PlotlyMol backend. _lighting_usable = _PLOTLYMOL_VIZ and self._viz_backend == "plotlymol" self.viz_lighting_dd.disabled = not _lighting_usable self.viz_lighting_dd.layout.visibility = ( "visible" if _lighting_usable else "hidden" ) - if self._molecule is not None and _display_molecule is not None: - self.viz_output.clear_output() - with self.viz_output: - _display_molecule( - self._molecule, - backend=self._viz_backend, - style=self._viz_style, - lighting=self._viz_lighting, - bgcolor=self._plotly_theme_colors()["scene_bgcolor"], - ) + + # Re-render all currently-visible 3D molecule viewers via the router. + self._rerender_3d_views() + + def _rerender_3d_views(self) -> None: + """Re-render visible 3D molecule viewers using the router to pick a + backend per task. Updates the "Rendering with: X" label widgets.""" + if _display_molecule is None: + return + + # Calculate-tab molecule preview (MOLECULE_PREVIEW task). + if self._molecule is not None: + chosen = self._resolve_backend(VizTask.MOLECULE_PREVIEW) + if chosen is not None: + self.viz_output.clear_output() + with self.viz_output: + _display_molecule( + self._molecule, + backend=str(chosen), + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + + # Analysis-tab molecule viewer (ANALYSIS_STRUCTURE_VIEW task). + if self._analysis_displayed_molecule is not None: + chosen = self._resolve_backend(VizTask.ANALYSIS_STRUCTURE_VIEW) + if chosen is not None: + self._analysis_mol_output.clear_output() + with self._analysis_mol_output: + _display_molecule( + self._analysis_displayed_molecule, + backend=str(chosen), + style=self._viz_style, + lighting=self._viz_lighting, + bgcolor=self._plotly_theme_colors()["scene_bgcolor"], + ) + self._update_analysis_backend_label(chosen) + + def _update_analysis_backend_label(self, chosen: VizBackend) -> None: + """Update the small 'Rendering with: X' label next to the Analysis + molecule viewer. No-op if the label widget does not exist (built only + when both backends are available).""" + label = getattr(self, "viz_backend_label_ana", None) + if label is None: + return + display_name = "py3Dmol" if chosen == VizBackend.PY3DMOL else "plotlymol3d" + label.value = ( + f'' + f"Rendering with: {display_name}" + ) + + def _on_viz_backend_changed(self, change) -> None: + """Calculate-tab toggle observer — explicit override of preference.""" + if self._viz_sync_in_progress: + return + self._set_viz_preference(change["new"], persist=False) + + def _on_viz_backend_changed_ana(self, change) -> None: + """Analysis-tab toggle observer — explicit override of preference.""" + if self._viz_sync_in_progress: + return + self._set_viz_preference(change["new"], persist=False) def _on_viz_default_backend_changed(self, change) -> None: - """Update the persisted default-backend preference. Drives router - decisions for new render operations; existing widgets remain on - their current effective backend until the next render.""" - new_value = change["new"] - if new_value not in ("auto", "py3dmol", "plotlymol"): + """Settings widget observer — persistent preference change.""" + if self._viz_sync_in_progress: return - self._viz_backend_preference = new_value - self._user_settings.viz.default_backend = new_value - self._user_settings.save() - try: - _calc_log.log_event( - "viz_default_backend_changed", f"preference={new_value}" - ) - except OSError: - pass + self._set_viz_preference(change["new"], persist=True) def _on_viz_style_changed(self, change) -> None: self._viz_style = change["new"] diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 59de1ff..68752fa 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -1316,6 +1316,49 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._analysis_mol_output = widgets.Output() + # Analysis-tab backend toggle — mirrors the Calculate-tab `viz_backend_toggle`. + # Created only when both backends are available (matches Calculate-tab + # convention). Synchronized with the Calculate-tab toggle via + # `_set_viz_preference` + `_viz_sync_in_progress` flag in app.py. + if app.viz_backend_toggle is not None: + app.viz_backend_toggle_ana = widgets.ToggleButtons( + options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")], + value=app._viz_backend, + tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"], + style={"button_width": "90px"}, + layout=layout_fn(margin="2px 0 4px 0"), + ) + # Small "Rendering with: X" label — updated by _update_analysis_backend_label + # after each render so the user can see what's actually rendering even + # when preference is "auto" (per-task routing may select different + # backends than the toggle suggests). + app.viz_backend_label_ana = widgets.HTML( + value=( + '' + "Rendering with: —" + ), + layout=layout_fn(margin="0 0 8px 0"), + ) + ana_backend_row = widgets.VBox( + [ + widgets.HBox( + [ + widgets.HTML( + 'Backend:' + ), + app.viz_backend_toggle_ana, + ], + layout=layout_fn(align_items="center"), + ), + app.viz_backend_label_ana, + ], + ) + else: + app.viz_backend_toggle_ana = None # type: ignore[assignment] + app.viz_backend_label_ana = None # type: ignore[assignment] + ana_backend_row = None + app._analysis_context_lbl = widgets.HTML( value=( '

' @@ -1333,10 +1376,15 @@ def _plot_export_row(prefix: str) -> widgets.HBox: ) app._ana_unavail_html = widgets.HTML(value="", layout=layout_fn(display="none")) app._build_ana_switcher() - app.analysis_tab_panel = widgets.VBox( + + ana_children = [ + app._analysis_context_lbl, + app._analysis_mol_output, + ] + if ana_backend_row is not None: + ana_children.append(ana_backend_row) + ana_children.extend( [ - app._analysis_context_lbl, - app._analysis_mol_output, app._analysis_empty_html, app._ana_unavail_html, app._orb_accordion, @@ -1347,7 +1395,10 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._iso_accordion, app._tddft_accordion, app._nmr_accordion, - ], + ] + ) + app.analysis_tab_panel = widgets.VBox( + ana_children, layout=layout_fn(padding="8px 0"), ) app.post_calc_panel = app.analysis_tab_panel diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index c839abc..d392899 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -17,21 +17,61 @@ def show_result_3d( *, display_molecule_fn: Any, ) -> None: - """Render molecule 3D structure in result and optional extra output panels.""" + """Render molecule 3D structure in result and optional extra output panels. + + Backend selection goes through ``app._resolve_backend(task)`` per-output: + + - ``result_viz_output`` uses ``VizTask.STRUCTURE_VIEW_RESULTS``. + - ``extra_output == _analysis_mol_output`` uses ``ANALYSIS_STRUCTURE_VIEW``. + - Any other extra_output uses ``STRUCTURE_VIEW_RESULTS`` as a safe default. + """ if display_molecule_fn is None or molecule is None: return - for out_widget in [app.result_viz_output, extra_output]: - if out_widget is None: - continue - out_widget.clear_output() - with out_widget: - display_molecule_fn( - molecule, - backend=app._viz_backend, - style=app._viz_style, - lighting=app._viz_lighting, - bgcolor=app._plotly_theme_colors()["scene_bgcolor"], - ) + from quantui.viz_backend_router import VizTask as _VT + + is_analysis_output = extra_output is not None and extra_output is getattr( + app, "_analysis_mol_output", None + ) + + # Results-tab viewer. + if app.result_viz_output is not None: + chosen = app._resolve_backend(_VT.STRUCTURE_VIEW_RESULTS) + if chosen is not None: + app.result_viz_output.clear_output() + with app.result_viz_output: + display_molecule_fn( + molecule, + backend=str(chosen), + style=app._viz_style, + lighting=app._viz_lighting, + bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + ) + + # Optional second viewer (typically the Analysis tab). + if extra_output is not None: + task = ( + _VT.ANALYSIS_STRUCTURE_VIEW + if is_analysis_output + else _VT.STRUCTURE_VIEW_RESULTS + ) + chosen = app._resolve_backend(task) + if chosen is not None: + extra_output.clear_output() + with extra_output: + display_molecule_fn( + molecule, + backend=str(chosen), + style=app._viz_style, + lighting=app._viz_lighting, + bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + ) + if is_analysis_output: + app._update_analysis_backend_label(chosen) + + # Track the molecule currently shown in the Analysis-tab viewer so the + # preference-change re-render path can find it. + if is_analysis_output: + app._analysis_displayed_molecule = molecule def on_traj_expand(app: Any, change: dict[str, Any]) -> None: @@ -110,16 +150,26 @@ def _set_cache_label(value: str) -> None: 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 - frame_out.clear_output() - with frame_out: - _ipy_display( - HTML( - f'

Frame render failed: {message}

' - ) - ) + _swap_frame_out( + f'

Frame render failed: {message}

' + ) # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list) traj = getattr(opt_result, "trajectory", None) or getattr( @@ -227,8 +277,25 @@ def _build_fig_fast(idx: int): ) return fig - def _build_fig(idx: int): - """Return (kind, obj) for frame idx; fast path when bonds are cached.""" + def _try_py3dmol(idx: int): + """Build frame idx with py3Dmol. Returns (kind, obj) or None.""" + try: + import py3Dmol as _p3d + + view = _p3d.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 + + 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) @@ -236,7 +303,6 @@ def _build_fig(idx: int): return ("plotly", fig) except Exception: pass - # Slow fallback: full plotlymol pipeline try: from quantui.visualization_py3dmol import visualize_molecule_plotlymol @@ -251,21 +317,28 @@ def _build_fig(idx: int): fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg)) return ("plotly", fig) except ImportError: - pass - # Last resort: py3Dmol - try: - import py3Dmol as _p3d + return None - view = _p3d.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" + 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 + + 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.", ) - view.zoomTo() - return ("py3dmol", view) - except Exception as exc: - return ("error", str(exc)) + result = _try_py3dmol(idx) + if result is not None: + return result + return ("error", "py3Dmol failed to build trajectory frame") frame_cache: dict[int, Any] = {} @@ -280,7 +353,12 @@ def _build_fig(idx: int): layout=layout_fn(width="360px"), ) step_info = widgets.HTML(value=app._traj_step_html(0, traj, energies, rel_e)) - frame_out = widgets.Output(layout=layout_fn(min_height="340px")) + # 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}" @@ -297,34 +375,42 @@ def _display_frame(idx: int) -> None: except Exception: pass if kind == "error": - frame_out.clear_output() - with frame_out: - _ipy_display( - HTML( - f'

Frame render failed: {obj}

' - ) - ) + _swap_frame_out( + f'

' + f"Frame render failed: {obj}

" + ) return if kind == "plotly": - # Render via to_html + append_display_data — same pattern proven to - # work in vib_output and other Plotly panels. The previous - # `with frame_out: display(fig)` path silently failed to update - # this *nested* Output widget after the parent VBox had already - # been displayed. See GOTCHAS: _render_vib_mode workaround. + # 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 - _html = _pio.to_html( - obj, - full_html=False, - include_plotlyjs="require", - config={"responsive": True}, + _swap_frame_out( + _pio.to_html( + obj, + full_html=False, + include_plotlyjs="require", + config={"responsive": True}, + ) ) - frame_out.clear_output() - frame_out.append_display_data(HTML(_html)) return - frame_out.clear_output() - with frame_out: - _ipy_display(obj) + # 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'

' + f"py3Dmol render failed: {exc}

" + ) + return + _swap_frame_out( + '

' + "Frame object missing HTML representation

" + ) def _update_frame(change: dict[str, Any]) -> None: if _is_stale(): @@ -334,13 +420,9 @@ def _update_frame(change: dict[str, Any]) -> None: if idx in frame_cache: _display_frame(idx) return - frame_out.clear_output() - with frame_out: - _ipy_display( - HTML( - '

Rendering…

' - ) - ) + _swap_frame_out( + '

Rendering…

' + ) def _on_demand() -> None: try: @@ -355,6 +437,38 @@ def _on_demand() -> None: 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_btn = widgets.Button( description="Export Animation", @@ -415,7 +529,7 @@ def _do_export() -> None: # --- Assemble layout --- header = widgets.HBox( - [step_slider, export_btn], + [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]) @@ -441,14 +555,10 @@ def _do_export() -> None: ) except Exception: pass - frame_out.clear_output() - with frame_out: - _ipy_display( - HTML( - '

' - "Rendering frame 0…

" - ) - ) + _swap_frame_out( + '

' + "Rendering frame 0…

" + ) # Display panel. if _is_stale(): diff --git a/quantui/viz_backend_router.py b/quantui/viz_backend_router.py index 8f65f41..9373160 100644 --- a/quantui/viz_backend_router.py +++ b/quantui/viz_backend_router.py @@ -113,13 +113,20 @@ class Decision: # # Maps task -> (primary backend, optional fallback backend). # Tasks whose fallback is None are single-backend — user preference is ignored -# for these (export-quality renders and the existing isosurface Plotly path). +# for these. Single-backend rationale by task: +# - TRAJECTORY_FRAME: py3Dmol-only. Plotlymol's RequireJS-driven re-render +# pattern causes flicker when frames swap rapidly; py3Dmol's WebGL path +# 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. _TASK_POLICY: dict[VizTask, tuple[VizBackend, VizBackend | None]] = { VizTask.MOLECULE_PREVIEW: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), VizTask.STRUCTURE_VIEW_RESULTS: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), VizTask.ANALYSIS_STRUCTURE_VIEW: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), VizTask.HISTORY_STRUCTURE_REPLAY: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), - VizTask.TRAJECTORY_FRAME: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), + VizTask.TRAJECTORY_FRAME: (VizBackend.PY3DMOL, None), VizTask.TRAJECTORY_EXPORT: (VizBackend.PLOTLYMOL, None), VizTask.VIB_INTERACTIVE: (VizBackend.PY3DMOL, VizBackend.PLOTLYMOL), VizTask.VIB_EXPORT: (VizBackend.PLOTLYMOL, None), diff --git a/tests/test_viz_backend_router.py b/tests/test_viz_backend_router.py index 9493acc..375fbf8 100644 --- a/tests/test_viz_backend_router.py +++ b/tests/test_viz_backend_router.py @@ -32,7 +32,6 @@ VizTask.STRUCTURE_VIEW_RESULTS, VizTask.ANALYSIS_STRUCTURE_VIEW, VizTask.HISTORY_STRUCTURE_REPLAY, - VizTask.TRAJECTORY_FRAME, VizTask.VIB_INTERACTIVE, ] @@ -43,6 +42,11 @@ VizTask.ORBITAL_ISOSURFACE, ] +# Tasks that require py3Dmol regardless of preference. +_PY3DMOL_ONLY_TASKS = [ + VizTask.TRAJECTORY_FRAME, +] + class TestStrEnumBehavior: """StrEnum members must act as str — verifies the choice made in VIZBACK.1 @@ -171,6 +175,34 @@ def test_returns_none_when_plotlymol_unavailable(self, task, preference): assert "unavailable" in decision.reason +class TestPy3DmolOnlyTasksIgnorePreference: + """Trajectory frame browsing requires py3Dmol regardless of preference — + plotlymol is blocked from real-time trajectory rendering to avoid the + RequireJS flicker issue.""" + + @pytest.mark.parametrize("task", _PY3DMOL_ONLY_TASKS) + @pytest.mark.parametrize( + "preference", + [VizPreference.AUTO, VizPreference.PY3DMOL, VizPreference.PLOTLYMOL], + ) + def test_picks_py3dmol_when_available(self, task, preference): + decision = select_backend(task, preference, BOTH) + assert decision.chosen == VizBackend.PY3DMOL + assert decision.fallback is None + assert "requires" in decision.reason + + @pytest.mark.parametrize("task", _PY3DMOL_ONLY_TASKS) + @pytest.mark.parametrize( + "preference", + [VizPreference.AUTO, VizPreference.PY3DMOL, VizPreference.PLOTLYMOL], + ) + def test_returns_none_when_py3dmol_unavailable(self, task, preference): + decision = select_backend(task, preference, ONLY_PLOTLYMOL) + assert decision.chosen is None + assert decision.fallback is None + assert "unavailable" in decision.reason + + class TestDecisionShape: def test_decision_is_immutable(self): decision = select_backend(VizTask.MOLECULE_PREVIEW, VizPreference.AUTO, BOTH) diff --git a/tests/test_viz_backend_sync.py b/tests/test_viz_backend_sync.py new file mode 100644 index 0000000..bea4b25 --- /dev/null +++ b/tests/test_viz_backend_sync.py @@ -0,0 +1,282 @@ +"""Tests for VIZBACK.3 — Analysis-tab backend toggle parity and sync. + +Verifies that the Calculate-tab and Analysis-tab backend toggles stay in +sync without observer feedback loops, that ``_set_viz_backend`` is a +single source of truth, and that ``_analysis_displayed_molecule`` tracking +allows the Analysis-tab viewer to re-render on toggle. +""" + +from __future__ import annotations + +import pytest + +from quantui.app import QuantUIApp + + +@pytest.fixture +def app(): + return QuantUIApp() + + +class TestToggleParityWhenBothBackendsAvailable: + """When both py3Dmol and plotlymol3d are installed, both toggles should + exist and be wired together.""" + + def test_analysis_toggle_exists_when_both_backends_available(self, app): + # If the Calculate-tab toggle exists, the Analysis-tab toggle must + # also exist (and vice versa). + if app.viz_backend_toggle is not None: + assert app.viz_backend_toggle_ana is not None + else: + assert app.viz_backend_toggle_ana is None + + def test_toggles_start_in_sync(self, app): + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + assert app.viz_backend_toggle.value == app.viz_backend_toggle_ana.value + assert app.viz_backend_toggle.value == app._viz_backend + + +class TestSyncBehavior: + """Changing one toggle should update the other without echo loops.""" + + def test_calculate_toggle_change_syncs_analysis(self, app): + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + # Pick a value different from the current one. + current = app.viz_backend_toggle.value + new_value = "py3dmol" if current != "py3dmol" else "plotlymol" + app.viz_backend_toggle.value = new_value + assert app._viz_backend == new_value + assert app.viz_backend_toggle_ana.value == new_value + + def test_analysis_toggle_change_syncs_calculate(self, app): + if app.viz_backend_toggle_ana is None: + pytest.skip("Single-backend environment; sync N/A") + current = app.viz_backend_toggle_ana.value + new_value = "py3dmol" if current != "py3dmol" else "plotlymol" + app.viz_backend_toggle_ana.value = new_value + assert app._viz_backend == new_value + assert app.viz_backend_toggle.value == new_value + + def test_no_observer_echo_loop(self, app): + """Repeated toggles must not cause unbounded recursion or duplicate + state updates — flag should always end in cleared state.""" + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + for _ in range(3): + app.viz_backend_toggle.value = "py3dmol" + assert app._viz_sync_in_progress is False + app.viz_backend_toggle.value = "plotlymol" + assert app._viz_sync_in_progress is False + app.viz_backend_toggle_ana.value = "py3dmol" + assert app._viz_sync_in_progress is False + + def test_setter_idempotent_on_same_value(self, app): + """Setting the preference to its current value should be a no-op.""" + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + before_pref = app._viz_backend_preference + before_backend = app._viz_backend + app._set_viz_preference(before_pref, persist=False) + assert app._viz_backend_preference == before_pref + assert app._viz_backend == before_backend + assert app._viz_sync_in_progress is False + + +class TestAnalysisDisplayedMoleculeTracking: + def test_initial_state_is_none(self, app): + assert app._analysis_displayed_molecule is None + + def test_show_result_3d_tracks_analysis_molecule(self, app): + """When show_result_3d renders into _analysis_mol_output, it should + cache the molecule so the toggle can re-render later.""" + # Skip if visualization isn't available on this platform. + try: + from quantui.molecule import Molecule + except ImportError: + pytest.skip("Molecule unavailable") + mol = Molecule( + atoms=["O", "H", "H"], + coordinates=[[0, 0, 0], [0.96, 0, 0], [-0.24, 0.93, 0]], + ) + # Direct invocation of the wrapper method. + app._show_result_3d(mol, extra_output=app._analysis_mol_output) + assert app._analysis_displayed_molecule is mol + + def test_show_result_3d_without_analysis_output_does_not_set_state(self, app): + try: + from quantui.molecule import Molecule + except ImportError: + pytest.skip("Molecule unavailable") + mol = Molecule( + atoms=["O", "H", "H"], + coordinates=[[0, 0, 0], [0.96, 0, 0], [-0.24, 0.93, 0]], + ) + app._show_result_3d(mol, extra_output=None) + assert app._analysis_displayed_molecule is None + + +class TestPersistedPreferenceAppliedAtStartup: + """The persisted preference should drive the runtime effective backend + at startup (bridge for VIZBACK.4). Concrete preferences are honoured + when the requested backend is available; "auto" leaves the runtime + default alone for later per-task routing.""" + + def test_concrete_py3dmol_preference_applied_at_init(self, tmp_path, monkeypatch): + # Write a settings file requesting py3dmol, then construct an app. + import json + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "py3dmol"}}) + ) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(path)) + from quantui.app import QuantUIApp + + a = QuantUIApp() + if not a._viz_availability.py3dmol: + pytest.skip("py3dmol not available in this environment") + assert a._viz_backend == "py3dmol" + assert a._viz_backend_preference == "py3dmol" + if a.viz_backend_toggle is not None: + assert a.viz_backend_toggle.value == "py3dmol" + if a.viz_backend_toggle_ana is not None: + assert a.viz_backend_toggle_ana.value == "py3dmol" + + def test_concrete_plotlymol_preference_applied_at_init(self, tmp_path, monkeypatch): + import json + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "plotlymol"}}) + ) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(path)) + from quantui.app import QuantUIApp + + a = QuantUIApp() + if not a._viz_availability.plotlymol: + pytest.skip("plotlymol not available in this environment") + assert a._viz_backend == "plotlymol" + assert a._viz_backend_preference == "plotlymol" + + def test_auto_preference_resolves_per_task_at_startup(self, tmp_path, monkeypatch): + """Post-VIZBACK.4: 'auto' preference is resolved through the router + at startup. _viz_backend reflects the router's MOLECULE_PREVIEW + resolution (py3Dmol when both backends are available).""" + import json + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "auto"}}) + ) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(path)) + from quantui.app import QuantUIApp + + a = QuantUIApp() + assert a._viz_backend_preference == "auto" + if a._viz_availability.py3dmol: + # Auto resolves to py3Dmol primary for MOLECULE_PREVIEW. + assert a._viz_backend == "py3dmol" + elif a._viz_availability.plotlymol: + assert a._viz_backend == "plotlymol" + + +class TestSettingsWidgetChangeAppliesRuntime: + """Toggling the Settings widget should immediately update the runtime + effective backend (when preference is concrete) and sync both toggles.""" + + def test_settings_change_to_concrete_updates_runtime(self, app): + if not (app._viz_availability.py3dmol and app._viz_availability.plotlymol): + pytest.skip("Both backends needed for this test") + # Pick a target different from current backend. + target = "py3dmol" if app._viz_backend != "py3dmol" else "plotlymol" + app.viz_default_backend_dd.value = target + assert app._viz_backend == target + assert app._viz_backend_preference == target + if app.viz_backend_toggle is not None: + assert app.viz_backend_toggle.value == target + if app.viz_backend_toggle_ana is not None: + assert app.viz_backend_toggle_ana.value == target + + def test_settings_change_to_auto_resolves_via_router(self, app): + """Post-VIZBACK.4: setting preference to 'auto' resolves _viz_backend + through the router. For MOLECULE_PREVIEW with both backends + available, that's py3Dmol.""" + if not (app._viz_availability.py3dmol and app._viz_availability.plotlymol): + pytest.skip("Both backends needed") + # Force preference to a concrete plotlymol first. + app._set_viz_preference("plotlymol", persist=False) + assert app._viz_backend == "plotlymol" + # Switch to auto. + app.viz_default_backend_dd.value = "auto" + assert app._viz_backend_preference == "auto" + # Router resolves MOLECULE_PREVIEW (auto, both available) -> py3Dmol. + assert app._viz_backend == "py3dmol" + + +class TestSyncLockState: + """The sync flag must always be cleared after a set, even on the + no-change short-circuit path.""" + + def test_flag_cleared_after_change(self, app): + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + new = "py3dmol" if app._viz_backend != "py3dmol" else "plotlymol" + app._set_viz_preference(new, persist=False) + assert app._viz_sync_in_progress is False + + def test_flag_cleared_after_no_op(self, app): + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + app._set_viz_preference(app._viz_backend_preference, persist=False) + assert app._viz_sync_in_progress is False + + +class TestRouterDrivenRendering: + """Render sites should call the router instead of using _viz_backend + directly. Verifies VIZBACK.4 (static) and VIZBACK.5 (trajectory).""" + + def test_auto_preference_routes_static_to_py3dmol(self, tmp_path, monkeypatch): + """With preference='auto' and both backends available, the router + should resolve STRUCTURE_VIEW_RESULTS / ANALYSIS_STRUCTURE_VIEW to + py3Dmol per the routing policy.""" + import json + + path = tmp_path / "settings.json" + path.write_text( + json.dumps({"_schema_version": 1, "viz": {"default_backend": "auto"}}) + ) + monkeypatch.setenv("QUANTUI_SETTINGS_PATH", str(path)) + from quantui.app import QuantUIApp + + a = QuantUIApp() + if not (a._viz_availability.py3dmol and a._viz_availability.plotlymol): + pytest.skip("Both backends needed") + # _viz_backend should be py3dmol after initialization (auto -> py3dmol + # primary for MOLECULE_PREVIEW per the policy). + assert a._viz_backend == "py3dmol" + # Settings widget still shows "auto" (preference, not effective backend). + assert a.viz_default_backend_dd.value == "auto" + # Effective toggles display the resolved value. + if a.viz_backend_toggle is not None: + assert a.viz_backend_toggle.value == "py3dmol" + if a.viz_backend_toggle_ana is not None: + assert a.viz_backend_toggle_ana.value == "py3dmol" + + def test_toggle_click_commits_concrete_preference(self, app): + """Clicking a Calculate/Analysis toggle should set preference to the + chosen concrete value (no longer "auto").""" + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment; sync N/A") + # Start by forcing preference to auto. + app._set_viz_preference("auto", persist=False) + # Click the toggle to plotlymol. + app.viz_backend_toggle.value = "plotlymol" + assert app._viz_backend_preference == "plotlymol" + assert app._viz_backend == "plotlymol" + + def test_analysis_label_widget_exists_when_both_backends_available(self, app): + if app.viz_backend_toggle is None: + pytest.skip("Single-backend environment") + assert getattr(app, "viz_backend_label_ana", None) is not None From 09df5c8c19d54dd7414f9560329b689711d12de5 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 22 May 2026 16:05:45 -0400 Subject: [PATCH 4/9] Fix trajectory viewer rendering and recovery Replace traj_output Output with a VBox and switch to manipulating children atomically to avoid deferred/asynchronous widget display that left the Trajectory accordion empty. Add a safety-net cache (_last_traj_result) set by trajectory-populators and cleared on context reset so on_traj_expand can recover when a prior auto-render consumed _pending_traj_result. Update on_traj_expand to detect empty children, restore from cache, show a loading placeholder via children, and log recovery/metrics. Update show_opt_trajectory to build Plotly HTML into an Output.outputs entry and set traj_output.children = (energy_holder, panel) to ensure scripts execute and avoid the stale-empty UI. Minor related updates: clear traj_output by resetting children and set _last_traj_result alongside _pending_traj_result in pop_* trajectory helpers. --- quantui/app.py | 5 ++ quantui/app_analysis.py | 12 +++- quantui/app_builders.py | 8 ++- quantui/app_visualization.py | 109 +++++++++++++++++++++++++---------- 4 files changed, 100 insertions(+), 34 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index 4b1fab6..7aadbe4 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -881,6 +881,11 @@ def __init__(self) -> None: self._last_calc_type: Optional[str] = None # e.g. "frequency", "single_point" self._results: List = [] self._pending_traj_result: Any = None + # Cached for the fresh-path safety net in on_traj_expand: if the + # initial render's outputs go missing before the user views the + # Analysis tab, on_traj_expand re-renders from this cache. Cleared + # by apply_analysis_context at every context reset. + self._last_traj_result: Any = None self._traj_render_token: int = 0 self._iso_render_token: int = 0 self._last_uv_wavelengths_nm: list[float] = [] diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py index 363ba7a..45464ea 100644 --- a/quantui/app_analysis.py +++ b/quantui/app_analysis.py @@ -159,10 +159,17 @@ def apply_analysis_context(app: Any, ctx: Any) -> None: app._deactivate_all_ana_panels() _reset_unavailable_messages_for_context(app, ctx) app._pending_traj_result = None + # Safety-net cache so on_traj_expand can recover if the initial render's + # outputs are missing from traj_output by the time the user views the + # accordion. Cleared here at context reset; re-set by each + # _pop_*_trajectory populate method. + app._last_traj_result = None app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1 app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1 app.traj_accordion.set_title(0, "Trajectory Viewer") - app.traj_output.clear_output() + # traj_output is a VBox (see app_builders.py traj_output construction); + # clear children instead of clear_output. + app.traj_output.children = () app._orb_iso_output.clear_output() first_auto_selected = False @@ -293,6 +300,7 @@ def pop_geo_trajectory(app: Any, ctx: Any) -> bool: formula=ctx.formula, ) app._pending_traj_result = stub + app._last_traj_result = stub return True @@ -356,6 +364,7 @@ def pop_preopt_trajectory(app: Any, ctx: Any) -> bool: formula=ctx.formula, ) app._pending_traj_result = stub + app._last_traj_result = stub app.traj_accordion.set_title(0, "Pre-optimization Trajectory") return True @@ -582,5 +591,6 @@ def pop_pes_trajectory(app: Any, ctx: Any) -> bool: formula=ctx.formula, ) app._pending_traj_result = stub + app._last_traj_result = stub app.traj_accordion.set_title(0, "Geometry at Each Scan Point") return True diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 68752fa..770d88a 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -1015,7 +1015,13 @@ def _plot_export_row(prefix: str) -> widgets.HBox: app._pes_scan_accordion.set_title(0, "PES Energy Profile") app._pes_scan_accordion.selected_index = None - app.traj_output = widgets.Output() + # traj_output is a VBox container (NOT widgets.Output) so widget content + # can be added as direct children via `traj_output.children = (...)`. + # Using `widgets.Output` here previously caused widget references inside + # `with output: display(widget)` to be deferred/asynchronous, leaving the + # accordion visibly empty even after _show_opt_trajectory logged success. + # See BUG-FRESH-TRAJ root-cause analysis in session 48. + app.traj_output = widgets.VBox(layout=layout_fn(margin="0")) app.traj_accordion = widgets.Accordion( children=[app.traj_output], layout=layout_fn(display="none", margin="8px 0"), diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index d392899..83a5815 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -79,10 +79,38 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None: if change["new"] != 0: return result = app._pending_traj_result + + # Safety net: if _pending_traj_result was already consumed by a prior + # auto-select render but traj_output is now empty, recover by rendering + # from the cached _last_traj_result. + # NOTE: traj_output is now a widgets.VBox — check `children` (not + # `outputs`) for the populated-state heuristic. + recovery_used = False + if result is None: + last = getattr(app, "_last_traj_result", None) + children = getattr(app.traj_output, "children", ()) + if last is not None and len(children) == 0: + result = last + recovery_used = True + try: + from quantui import calc_log as _clog_recovery + + _clog_recovery.log_event( + "traj_render_recovery", + "children=0, rendering from _last_traj_result", + ) + except Exception: + pass + try: from quantui import calc_log as _clog_te - _clog_te.log_event("traj_expand", f"pending={result is not None}") + _clog_te.log_event( + "traj_expand", + f"pending={app._pending_traj_result is not None} " + f"recovery={recovery_used} " + f"children_n={len(getattr(app.traj_output, 'children', ()))}", + ) except Exception: pass if result is None: @@ -91,16 +119,15 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None: app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1 render_token = app._traj_render_token - from IPython.display import HTML as _H - from IPython.display import display as _d - - app.traj_output.clear_output() - with app.traj_output: - _d( - _H( - '

Loading trajectory viewer…

' + # Placeholder: replace traj_output's children with a Loading message. + app.traj_output.children = ( + widgets.HTML( + value=( + '

' + "Loading trajectory viewer…

" ) - ) + ), + ) try: app._show_opt_trajectory(result, render_token=render_token) @@ -116,16 +143,14 @@ def on_traj_expand(app: Any, change: dict[str, Any]) -> None: pass if render_token != int(getattr(app, "_traj_render_token", 0)): return - from IPython.display import HTML as _H2 - from IPython.display import display as _d2 - - app.traj_output.clear_output() - with app.traj_output: - _d2( - _H2( - f'

⚠ Trajectory rendering failed: {exc}

' + app.traj_output.children = ( + widgets.HTML( + value=( + f'

' + f"⚠ Trajectory rendering failed: {exc}

" ) - ) + ), + ) def show_opt_trajectory( @@ -138,8 +163,6 @@ def show_opt_trajectory( """Build trajectory carousel and energy chart in trajectory panel.""" import concurrent.futures - from IPython.display import display as _ipy_display - def _is_stale() -> bool: return render_token is not None and render_token != int( getattr(app, "_traj_render_token", 0) @@ -178,14 +201,14 @@ def _show_frame_error(message: str) -> None: energies = opt_result.energies_hartree n = len(traj) if n < 2: - app.traj_output.clear_output() - with app.traj_output: - _ipy_display( - HTML( + app.traj_output.children = ( + widgets.HTML( + value=( '

' "No trajectory data available (single-frame result).

" ) - ) + ), + ) return hartree_to_kcal = 627.5094740631 @@ -563,18 +586,40 @@ def _do_export() -> None: # Display panel. if _is_stale(): return - app.traj_output.clear_output() - with app.traj_output: - if has_plotly and rel_e: - _ipy_display(energy_fig) - _ipy_display(panel) + # 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 = [] + if has_plotly and rel_e: + import plotly.io as _pio_e + + energy_html = _pio_e.to_html( + energy_fig, + full_html=False, + include_plotlyjs="require", + config={"responsive": True}, + ) + energy_holder = widgets.Output() + energy_holder.outputs = ( + { + "output_type": "display_data", + "data": {"text/html": energy_html}, + "metadata": {}, + }, + ) + new_children.append(energy_holder) + new_children.append(panel) + 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"sync_frame0_ok={sync_frame0_ok} " + f"traj_children_n={len(getattr(app.traj_output, 'children', ()))}", ) except Exception: pass From a966ecafb1ce404c64792594b3a968ba73654ca4 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 22 May 2026 16:29:56 -0400 Subject: [PATCH 5/9] Add py3Dmol vib renderer and tests Introduce a py3Dmol-based vibrational animation path and resilient backend dispatch while keeping plotlymol3d optional. show_vib_animation now builds dropdown options from raw frequencies (skipping near-zero modes), treats vib_data as optional, and caches the Frequency result in _last_vib_freq_result. Added _vib_err for consistent user-facing errors, _render_vib_mode_py3dmol to generate 24-frame sinusoidal multi-frame XYZ payloads and atomically swap HTML into app.vib_output, and _render_vib_mode_plotlymol for the existing plotlymol3d flow. render_vib_mode now dispatches via the viz backend router (py3Dmol preferred, plotlymol3d fallback) and logging events were enriched. on_vib_mode_changed updated to allow py3Dmol rendering when vib_data is None. Added comprehensive tests (tests/test_vib_py3dmol_render.py) covering py3Dmol rendering, frame count, amplitude scaling, error handling, and backend dispatch. --- quantui/app_visualization.py | 213 +++++++++++++++++++++++++++---- tests/test_vib_py3dmol_render.py | 193 ++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 tests/test_vib_py3dmol_render.py diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 83a5815..7599300 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -831,27 +831,35 @@ def build_vib_data_inner( def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: - """Populate vibrational animation accordion after a Frequency result.""" - vib_data = app._build_vib_data_from_freq_result(freq_result, molecule) - if vib_data is None: - return False + """Populate vibrational animation accordion after a Frequency result. + Dropdown options are built from raw ``freq_result.frequencies_cm1`` so the + panel populates regardless of plotlymol3d availability. The plotlymol3d + `VibrationalData` wrapper is built optionally — required only when the + plotlymol render path is selected; the py3Dmol render path reads + displacements directly from ``freq_result`` (VIZBACK.8). + """ freqs = freq_result.frequencies_cm1 if not freqs: return False - # Build dropdown options; skip near-zero translation/rotation modes. + # Optional plotlymol3d data — may be None if plotlymol3d isn't installed. + # The py3Dmol render path doesn't need this; only the plotlymol path does. + vib_data = app._build_vib_data_from_freq_result(freq_result, molecule) + + # Build dropdown options from raw freq_result; skip near-zero translation + # / rotation modes. Mode numbers are 1-indexed positions in + # frequencies_cm1. options = [] - for mode in vib_data.modes: - freq_val = mode.frequency + for i, freq_val in enumerate(freqs, start=1): if abs(freq_val) < 10: continue label = ( - f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹" + f"Mode {i}: {freq_val:.1f} cm⁻¹" if freq_val >= 0 - else f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)" + else f"Mode {i}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)" ) - options.append((label, mode.mode_number)) + options.append((label, i)) if not options: return False @@ -859,8 +867,9 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: app.vib_mode_dd.options = options app.vib_mode_dd.value = options[0][1] - app._last_vib_data = vib_data + 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] app.vib_output.clear_output() @@ -1465,22 +1474,145 @@ def _show_err(msg: str = err_msg) -> None: ) -def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None: - """Render vibrational animation for mode number into vib output.""" +def _vib_err(app: Any, msg: str) -> None: + """Show an error message in the vibrational animation output panel.""" from IPython.display import HTML as _H - def _err(msg: str) -> None: - app.vib_output.clear_output() - app.vib_output.append_display_data( - _H(f'

⚠ {msg}

') + app.vib_output.clear_output() + app.vib_output.append_display_data( + _H(f'

⚠ {msg}

') + ) + + +def _render_vib_mode_py3dmol( + app: Any, + molecule: Any, + mode_number: int, + *, + n_frames: int = 24, + amplitude: float = 0.4, +) -> None: + """Render vibrational animation via py3Dmol multi-frame XYZ. + + Per VIZBACK.8 spec: pure-numpy frame generation (no plotlymol3d + dependency); 24 sinusoidal-phase frames over one full oscillation; + py3Dmol view with ``addModelsAsFrames`` + ``animate``; serialized to + HTML and atomically swapped into ``app.vib_output``. + """ + import numpy as np + + try: + import py3Dmol + except ImportError as exc: + _vib_err(app, f"py3Dmol unavailable: {exc}") + return + + freq_result = getattr(app, "_last_vib_freq_result", None) + if freq_result is None: + _vib_err(app, "No frequency result cached for vibrational animation.") + return + + try: + displ = np.array(freq_result.displacements[mode_number - 1], dtype=float) + except (AttributeError, IndexError, ValueError, TypeError) as exc: + _vib_err( + app, + f"Could not read displacements for mode {mode_number}: {exc}", ) + return + + atoms = list(molecule.atoms) + base_coords = np.array(molecule.coordinates, dtype=float) + if base_coords.shape != displ.shape: + _vib_err( + app, + f"Shape mismatch: base coords {base_coords.shape} vs " + f"displacements {displ.shape}", + ) + return + + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_start", f"mode {mode_number} backend=py3dmol") + except Exception: + pass + + # One full oscillation: n_frames evenly-spaced phases over [0, 2π). + phases = np.sin(np.linspace(0, 2 * np.pi, n_frames, endpoint=False)) + n_atoms = len(atoms) + xyz_lines: list[str] = [] + for phase in phases: + coords = base_coords + amplitude * float(phase) * displ + xyz_lines.append(f"{n_atoms}") + xyz_lines.append(f"mode {mode_number} phase {float(phase):+.3f}") + for sym, xyz in zip(atoms, coords): + xyz_lines.append(f"{sym} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}") + xyz_string = "\n".join(xyz_lines) + "\n" + + try: + view = py3Dmol.view(width=460, height=420) + view.addModelsAsFrames(xyz_string, "xyz") + view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) + bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e" + view.setBackgroundColor(bg) + view.zoomTo() + view.animate({"loop": "forward", "interval": 100, "reps": 0}) + html_str = view._make_html() + except Exception as exc: + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event( + "vib_render_error", + f"mode {mode_number} backend=py3dmol: " + f"{type(exc).__name__}: {exc}"[:300], + ) + except Exception: + pass + _vib_err(app, f"Vibrational animation render failed: {exc}") + return + + # Atomic outputs swap — single widget-state update, no flicker. + app.vib_output.outputs = ( + { + "output_type": "display_data", + "data": {"text/html": html_str}, + "metadata": {}, + }, + ) + + try: + from quantui import calc_log as _clog_anim + + _clog_anim.log_event("vib_render_done", f"mode {mode_number} backend=py3dmol") + except Exception: + pass + + +def _render_vib_mode_plotlymol( + app: Any, vib_data: Any, molecule: Any, mode_number: int +) -> None: + """Render vibrational animation via the plotlymol3d path (PlotlyMol + + RDKit bond perception + Plotly figure). Used when user explicitly + prefers plotlymol or when py3Dmol is unavailable.""" + from IPython.display import HTML as _H + + if vib_data is None: + _vib_err( + app, + "PlotlyMol vibrational animation requires plotlymol3d, " + "which is not installed.", + ) + return try: from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol except ImportError as exc: - _err( - f"Vibrational animation requires plotlymol3d " - f"(pip install plotlymol3d): {exc}" + _vib_err( + app, + f"PlotlyMol vibrational animation requires plotlymol3d " + f"(pip install plotlymol3d): {exc}", ) return @@ -1491,13 +1623,15 @@ def _err(msg: str) -> None: try: rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge) except Exception as exc: - _err(f"Could not parse molecule for bond connectivity: {exc}") + _vib_err(app, f"Could not parse molecule for bond connectivity: {exc}") return try: from quantui import calc_log as _clog_anim - _clog_anim.log_event("vib_render_start", f"mode {mode_number}") + _clog_anim.log_event( + "vib_render_start", f"mode {mode_number} backend=plotlymol" + ) except Exception: pass try: @@ -1517,16 +1651,17 @@ def _err(msg: str) -> None: _clog_anim.log_event( "vib_render_error", - f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300], + f"mode {mode_number} backend=plotlymol: " + f"{type(exc).__name__}: {exc}"[:300], ) except Exception: pass - _err(f"Animation generation failed: {exc}") + _vib_err(app, f"Animation generation failed: {exc}") return try: from quantui import calc_log as _clog_anim - _clog_anim.log_event("vib_render_done", f"mode {mode_number}") + _clog_anim.log_event("vib_render_done", f"mode {mode_number} backend=plotlymol") except Exception: pass @@ -1542,12 +1677,38 @@ def _err(msg: str) -> None: app.vib_output.append_display_data(_H(anim_html)) +def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None: + """Render vibrational animation for mode_number into ``app.vib_output``. + + Backend dispatch goes through the router (``VizTask.VIB_INTERACTIVE``): + py3Dmol primary, plotlymol3d fallback. The py3Dmol path is preferred for + speed and doesn't require plotlymol3d to be installed. + """ + from quantui.viz_backend_router import VizBackend as _VB + from quantui.viz_backend_router import VizTask as _VT + + chosen = app._resolve_backend(_VT.VIB_INTERACTIVE) + if chosen == _VB.PY3DMOL: + _render_vib_mode_py3dmol(app, molecule, mode_number) + elif chosen == _VB.PLOTLYMOL: + _render_vib_mode_plotlymol(app, vib_data, molecule, mode_number) + else: + _vib_err( + app, + "No vibrational animation backend available " + "(neither py3Dmol nor plotlymol3d installed).", + ) + + def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: """Re-render vibrational animation when mode dropdown changes.""" mode_number = change["new"] vib_data = getattr(app, "_last_vib_data", None) molecule = getattr(app, "_last_vib_molecule", None) - if vib_data is None or molecule is None: + freq_result = getattr(app, "_last_vib_freq_result", None) + # vib_data may be None when plotlymol3d is unavailable — the py3Dmol + # render path doesn't need it. Bail only if we can't render at all. + if molecule is None or freq_result is None: return label = next( diff --git a/tests/test_vib_py3dmol_render.py b/tests/test_vib_py3dmol_render.py new file mode 100644 index 0000000..9e30672 --- /dev/null +++ b/tests/test_vib_py3dmol_render.py @@ -0,0 +1,193 @@ +"""Unit tests for the py3Dmol vibrational animation renderer (VIZBACK.8). + +Covers ``_render_vib_mode_py3dmol`` and the router-driven dispatch in +``render_vib_mode``. The py3Dmol path is plotlymol3d-independent and reads +displacements directly from ``freq_result.displacements`` (1-indexed by +``mode_number``). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui.app import QuantUIApp +from quantui.app_visualization import ( + _render_vib_mode_py3dmol, + render_vib_mode, +) +from quantui.molecule import Molecule + + +@pytest.fixture +def app(): + return QuantUIApp() + + +@pytest.fixture +def water_mol(): + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]], + ) + + +@pytest.fixture +def fake_freq_result(): + """Three vibrational modes for a 3-atom system, with realistic shapes.""" + return SimpleNamespace( + frequencies_cm1=[1600.0, 3800.0, 3850.0], + ir_intensities=[40.0, 10.0, 50.0], + displacements=[ + np.array([[0.0, 0.05, 0.0], [0.0, -0.05, 0.0], [0.0, -0.05, 0.0]]), + np.array([[0.0, 0.10, 0.0], [0.05, -0.10, 0.0], [-0.05, -0.10, 0.0]]), + np.array([[0.0, 0.0, 0.10], [0.05, 0.0, -0.10], [-0.05, 0.0, -0.10]]), + ], + ) + + +class TestRenderVibModePy3Dmol: + def test_renders_html_blob_into_vib_output(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + + outputs = app.vib_output.outputs + assert len(outputs) == 1, "atomic outputs swap should yield 1 entry" + html = outputs[0]["data"]["text/html"] + assert "3Dmol" in html or "py3Dmol" in html + assert "animate" in html.lower() + + def test_uses_correct_mode_displacements(self, app, water_mol, fake_freq_result): + """The HTML should contain coordinates that reflect mode-2's + displacement pattern (different from mode-1's).""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + + # Render mode 1, capture html + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + html_1 = app.vib_output.outputs[0]["data"]["text/html"] + + # Render mode 2, capture html + _render_vib_mode_py3dmol(app, water_mol, mode_number=2) + html_2 = app.vib_output.outputs[0]["data"]["text/html"] + + # Two different modes should produce DIFFERENT HTML (different XYZ + # frame coordinates). They share boilerplate so simple length check + # may not catch it — assert they differ. + assert html_1 != html_2 + + def test_n_frames_default_is_24(self, app, water_mol, fake_freq_result): + """The XYZ multi-frame string should contain 24 frame headers per + the VIZBACK.8 spec (one full oscillation).""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + html = app.vib_output.outputs[0]["data"]["text/html"] + # Each frame has a header line "mode N phase ..." - count them. + # The frames are concatenated in the XYZ payload embedded in the HTML. + frame_count = html.count("phase +") + html.count("phase -") + assert frame_count == 24, f"expected 24 frames, found {frame_count}" + + def test_amplitude_scales_displacement(self, app, water_mol, fake_freq_result): + """Higher amplitude should produce larger coordinate excursions.""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, amplitude=0.1) + html_low = app.vib_output.outputs[0]["data"]["text/html"] + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, amplitude=0.8) + html_high = app.vib_output.outputs[0]["data"]["text/html"] + # Different amplitudes should produce different HTML. + assert html_low != html_high + + def test_missing_freq_result_shows_error_not_crash(self, app, water_mol): + """If _last_vib_freq_result is None, should show a user-facing error + rather than raising.""" + app._last_vib_freq_result = None + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + # outputs should contain an error message + assert len(app.vib_output.outputs) >= 1 + + def test_out_of_range_mode_shows_error_not_crash( + self, app, water_mol, fake_freq_result + ): + """Asking for mode 99 (out of range) should be a graceful error.""" + app._last_vib_freq_result = fake_freq_result + _render_vib_mode_py3dmol(app, water_mol, mode_number=99) + assert len(app.vib_output.outputs) >= 1 + + def test_shape_mismatch_shows_error_not_crash(self, app): + """Displacements with wrong shape vs molecule should error gracefully.""" + mol = Molecule( + atoms=["O", "H", "H"], + coordinates=[[0, 0, 0], [0.96, 0, 0], [-0.24, 0.93, 0]], + ) + bad_freq = SimpleNamespace( + frequencies_cm1=[1000.0], + displacements=[np.array([[0, 0.1, 0]])], # only 1 atom! + ) + app._last_vib_freq_result = bad_freq + _render_vib_mode_py3dmol(app, mol, mode_number=1) + assert len(app.vib_output.outputs) >= 1 + + +class TestRenderVibModeDispatch: + """render_vib_mode should route through the viz_backend_router.""" + + def test_auto_preference_routes_to_py3dmol(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + # Force preference = auto. Router policy for VIB_INTERACTIVE is + # (PY3DMOL, PLOTLYMOL) so auto → py3dmol. + app._set_viz_preference("auto", persist=False) + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + render_vib_mode(app, vib_data=None, molecule=water_mol, mode_number=1) + outputs = app.vib_output.outputs + assert len(outputs) == 1 + html = outputs[0]["data"]["text/html"] + # py3Dmol path produces 3Dmol-styled HTML, not Plotly. + assert "3Dmol" in html or "py3Dmol" in html + + def test_explicit_py3dmol_preference_uses_py3dmol( + self, app, water_mol, fake_freq_result + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._set_viz_preference("py3dmol", persist=False) + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + render_vib_mode(app, vib_data=None, molecule=water_mol, mode_number=1) + html = app.vib_output.outputs[0]["data"]["text/html"] + assert "3Dmol" in html or "py3Dmol" in html + + def test_dispatch_does_not_hard_fail_without_plotlymol_vib_data( + self, app, water_mol, fake_freq_result + ): + """The py3Dmol path should not require vib_data to be non-None + (i.e., plotlymol3d-independent rendering).""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._set_viz_preference("py3dmol", persist=False) + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + # vib_data=None mimics plotlymol3d unavailable + render_vib_mode(app, vib_data=None, molecule=water_mol, mode_number=1) + # Should still produce an HTML output (not a hard-fail error). + outputs = app.vib_output.outputs + assert len(outputs) == 1 + html = outputs[0]["data"]["text/html"] + assert ( + "requires plotlymol3d" not in html + ), f"py3Dmol path should not require plotlymol3d; html: {html[:200]}" From 6c93fc8abe612ac09bda8cdfe4f6f6bfe9e26a3b Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 22 May 2026 17:16:01 -0400 Subject: [PATCH 6/9] Vib: add FPS setting, cache and stale-guard Add a persistent vibrational-animation framerate preference and robust caching/staleness protections. - Introduce viz.vib_framerate_fps in user settings with validation and sane defaults. - Add an IntSlider in the Status panel to control and persist the vib FPS. - Add app._vib_render_token and wire render_token through vib rendering paths so older background renders bail out and cannot overwrite newer outputs. - Implement atomic output swaps via _swap_vib_output to avoid transient empty states and layout reflow. - Wire fps into py3Dmol animate interval and include fps in the vib cache key. - Add quantui.vib_cache module to save/load per-result-directory HTML blobs (index.json + mode_NNN.html), with atomic writes, parameter matching (n_frames, amplitude, renderer, fps), tolerance for floating amplitude, and graceful error handling. - Update vib render functions to accept fps and render_token, consult user settings when fps is not provided, check cache hits and persist cache on render success. - Update and add tests: user settings tests for vib framerate, comprehensive vib_cache unit tests, and integration/tests for rendering token staleness and fps behavior. These changes improve UX by enabling configurable playback speed, eliminating stale-render races on rapid mode switches, and making repeated visits/history playback instant via on-disk caching. --- quantui/app.py | 36 +++ quantui/app_builders.py | 32 ++- quantui/app_visualization.py | 285 +++++++++++++++----- quantui/user_settings.py | 21 ++ quantui/vib_cache.py | 247 +++++++++++++++++ tests/test_user_settings.py | 39 ++- tests/test_vib_cache.py | 444 +++++++++++++++++++++++++++++++ tests/test_vib_py3dmol_render.py | 187 +++++++++++++ 8 files changed, 1224 insertions(+), 67 deletions(-) create mode 100644 quantui/vib_cache.py create mode 100644 tests/test_vib_cache.py diff --git a/quantui/app.py b/quantui/app.py index 7aadbe4..b152f7d 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -760,6 +760,7 @@ class QuantUIApp: solvent_dd: Any step_progress: Any theme_btn: Any + vib_framerate_si: Any viz_backend_label_ana: Any viz_backend_toggle: Any viz_backend_toggle_ana: Any @@ -886,6 +887,10 @@ def __init__(self) -> None: # Analysis tab, on_traj_expand re-renders from this cache. Cleared # by apply_analysis_context at every context reset. self._last_traj_result: Any = None + # Generation counter for vibrational-animation renders. Each + # mode-dropdown change bumps this so older worker-thread renders + # bail out before they overwrite the newer render's output. + self._vib_render_token: int = 0 self._traj_render_token: int = 0 self._iso_render_token: int = 0 self._last_uv_wavelengths_nm: list[float] = [] @@ -1104,6 +1109,7 @@ def _build_status_panel(self) -> None: pubchem_available=PUBCHEM_AVAILABLE, visualization_available=VISUALIZATION_AVAILABLE, viz_default_backend=self._user_settings.viz.default_backend, + vib_framerate_fps=self._user_settings.viz.vib_framerate_fps, ) # ── Welcome header ──────────────────────────────────────────────────── @@ -1389,6 +1395,10 @@ def _wire_callbacks(self) -> None: self.viz_default_backend_dd.observe( self._safe_cb(self._on_viz_default_backend_changed), names="value" ) + # Settings → Vibrational animation framerate (Status tab; persisted). + self.vib_framerate_si.observe( + self._safe_cb(self._on_vib_framerate_changed), names="value" + ) # 3D viewer style and lighting controls if VISUALIZATION_AVAILABLE: self.viz_style_dd.observe( @@ -2177,6 +2187,32 @@ def _on_viz_default_backend_changed(self, change) -> None: return self._set_viz_preference(change["new"], persist=True) + def _on_vib_framerate_changed(self, change) -> None: + """Persist the vibrational-animation framerate and re-render the + current mode so the new fps applies immediately. Re-rendering also + rebuilds the on-disk cache under the new fps key.""" + try: + new_fps = int(change["new"]) + except (TypeError, ValueError): + return + if new_fps == self._user_settings.viz.vib_framerate_fps: + return + self._user_settings.viz.vib_framerate_fps = new_fps + self._user_settings.save() + try: + _calc_log.log_event("vib_framerate_changed", f"fps={new_fps}") + except OSError: + pass + # If a vibrational result is currently loaded, re-render the current + # mode through the new fps so the change is visible immediately. + if ( + getattr(self, "_last_vib_freq_result", None) is not None + and getattr(self, "_last_vib_molecule", None) is not None + ): + current_mode = self.vib_mode_dd.value + if current_mode is not None: + self._on_vib_mode_changed({"new": current_mode}) + def _on_viz_style_changed(self, change) -> None: self._viz_style = change["new"] if self._molecule is not None and _display_molecule is not None: diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 770d88a..4e6eed1 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -24,6 +24,7 @@ def build_status_panel( pubchem_available: bool, visualization_available: bool, viz_default_backend: str = "auto", + vib_framerate_fps: int = 10, ) -> None: """Build the Status tab panel.""" cores, mem_gb = get_session_resources_fn() @@ -135,8 +136,37 @@ def _ok(flag: bool, extra: str = "") -> str: "(persists across launches)" "" ) + # Vibrational animation framerate (persists across launches). + app.vib_framerate_si = widgets.IntSlider( + value=vib_framerate_fps, + min=5, + max=60, + step=1, + description="Vib fps:", + style={"description_width": "60px"}, + layout=layout_fn(width="320px", margin="6px 0 0 0"), + readout=True, + readout_format="d", + tooltip=( + "Frames per second for the py3Dmol vibrational animation. " + "Higher = smoother + faster oscillation. Cached HTML is " + "invalidated when this changes." + ), + ) + vib_fps_label = widgets.HTML( + '
Vibrational animation framerate ' + '' + "(persists across launches)
" + ) + settings_box = widgets.VBox( - [settings_html, app.viz_default_backend_dd], + [ + settings_html, + app.viz_default_backend_dd, + vib_fps_label, + app.vib_framerate_si, + ], layout=layout_fn(margin="0 0 8px 0"), ) diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 7599300..d689be5 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -872,16 +872,19 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: app._last_vib_freq_result = freq_result first_label, first_mode = options[0] - app.vib_output.clear_output() - app.vib_output.append_display_data( - HTML( - f'

' - f"⏳ Rendering vibrational animation ({first_label})…

" - ) + # Bump render token so any stale worker thread bails out before stomping + # this fresh render's output. + app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1 + token = app._vib_render_token + _swap_vib_output( + app, + f'

' + f"⏳ Rendering vibrational animation ({first_label})…

", ) threading.Thread( target=app._render_vib_mode, args=(vib_data, molecule, first_mode), + kwargs={"render_token": token}, daemon=True, ).start() @@ -1474,14 +1477,35 @@ def _show_err(msg: str = err_msg) -> None: ) +def _swap_vib_output(app: Any, html_str: str) -> None: + """Atomically replace ``app.vib_output``'s content with one HTML payload. + + Single widget-state assignment → single browser update message → no + transient empty state → no layout reflow → no page-scroll jump on + every mode switch. Matches the atomic-swap pattern proven for + ``frame_out`` in the trajectory carousel. + """ + app.vib_output.outputs = ( + { + "output_type": "display_data", + "data": {"text/html": html_str}, + "metadata": {}, + }, + ) + + def _vib_err(app: Any, msg: str) -> None: """Show an error message in the vibrational animation output panel.""" - from IPython.display import HTML as _H + _swap_vib_output(app, f'

⚠ {msg}

') - app.vib_output.clear_output() - app.vib_output.append_display_data( - _H(f'

⚠ {msg}

') - ) + +def _is_vib_stale(app: Any, render_token: int | None) -> bool: + """True when a newer vib render has started; used to bail out of an + older background render thread before it stomps the newer one's + output. Returns False when no token was supplied (call-site opt-out).""" + if render_token is None: + return False + return render_token != int(getattr(app, "_vib_render_token", 0)) def _render_vib_mode_py3dmol( @@ -1491,6 +1515,8 @@ def _render_vib_mode_py3dmol( *, n_frames: int = 24, amplitude: float = 0.4, + fps: int | None = None, + render_token: int | None = None, ) -> None: """Render vibrational animation via py3Dmol multi-frame XYZ. @@ -1498,43 +1524,111 @@ def _render_vib_mode_py3dmol( dependency); 24 sinusoidal-phase frames over one full oscillation; py3Dmol view with ``addModelsAsFrames`` + ``animate``; serialized to HTML and atomically swapped into ``app.vib_output``. + + ``fps`` controls the playback rate of ``view.animate`` (interval = + 1000 / fps ms). When ``None``, reads from + ``app._user_settings.viz.vib_framerate_fps``. + + ``render_token`` is checked at every output-write site so a stale + background render thread can bail out before stomping a newer + render's output. See ``_is_vib_stale``. """ import numpy as np + if fps is None: + fps = int( + getattr( + getattr(app, "_user_settings", None) and app._user_settings.viz, + "vib_framerate_fps", + 10, + ) + ) + fps = max(1, int(fps)) + try: import py3Dmol except ImportError as exc: - _vib_err(app, f"py3Dmol unavailable: {exc}") + if not _is_vib_stale(app, render_token): + _vib_err(app, f"py3Dmol unavailable: {exc}") return freq_result = getattr(app, "_last_vib_freq_result", None) if freq_result is None: - _vib_err(app, "No frequency result cached for vibrational animation.") + if not _is_vib_stale(app, render_token): + _vib_err(app, "No frequency result cached for vibrational animation.") return + # Cache hit short-circuit (VIZBACK.9). The cache key now includes ``fps`` + # so a user who changes the framerate will rebuild rather than play back + # a mismatched-interval HTML blob. + result_dir = getattr(app, "_last_result_dir", None) + if result_dir is not None: + try: + from quantui import vib_cache + + cached_html = vib_cache.get_cached_html( + Path(result_dir), + mode_number, + n_frames=n_frames, + amplitude=amplitude, + renderer="py3dmol", + fps=fps, + ) + except Exception: + cached_html = None + if cached_html is not None: + if _is_vib_stale(app, render_token): + return + _swap_vib_output(app, cached_html) + try: + from quantui import calc_log as _clog_cache_hit + + _clog_cache_hit.log_event( + "vib_cache_hit", + f"mode {mode_number} backend=py3dmol fps={fps}", + ) + except Exception: + pass + return + + try: + from quantui import calc_log as _clog_cache_miss + + _clog_cache_miss.log_event( + "vib_cache_miss", + f"mode {mode_number} backend=py3dmol fps={fps}", + ) + except Exception: + pass + try: displ = np.array(freq_result.displacements[mode_number - 1], dtype=float) except (AttributeError, IndexError, ValueError, TypeError) as exc: - _vib_err( - app, - f"Could not read displacements for mode {mode_number}: {exc}", - ) + if not _is_vib_stale(app, render_token): + _vib_err( + app, + f"Could not read displacements for mode {mode_number}: {exc}", + ) return atoms = list(molecule.atoms) base_coords = np.array(molecule.coordinates, dtype=float) if base_coords.shape != displ.shape: - _vib_err( - app, - f"Shape mismatch: base coords {base_coords.shape} vs " - f"displacements {displ.shape}", - ) + if not _is_vib_stale(app, render_token): + _vib_err( + app, + f"Shape mismatch: base coords {base_coords.shape} vs " + f"displacements {displ.shape}", + ) return try: from quantui import calc_log as _clog_anim - _clog_anim.log_event("vib_render_start", f"mode {mode_number} backend=py3dmol") + _clog_anim.log_event( + "vib_render_start", + f"mode {mode_number} backend=py3dmol fps={fps}", + ) except Exception: pass @@ -1551,13 +1645,14 @@ def _render_vib_mode_py3dmol( xyz_string = "\n".join(xyz_lines) + "\n" try: + interval_ms = max(1, int(round(1000.0 / fps))) view = py3Dmol.view(width=460, height=420) view.addModelsAsFrames(xyz_string, "xyz") view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) bg = "white" if app.theme_btn.value == "Light" else "#1e1e1e" view.setBackgroundColor(bg) view.zoomTo() - view.animate({"loop": "forward", "interval": 100, "reps": 0}) + view.animate({"loop": "forward", "interval": interval_ms, "reps": 0}) html_str = view._make_html() except Exception as exc: try: @@ -1570,50 +1665,89 @@ def _render_vib_mode_py3dmol( ) except Exception: pass - _vib_err(app, f"Vibrational animation render failed: {exc}") + if not _is_vib_stale(app, render_token): + _vib_err(app, f"Vibrational animation render failed: {exc}") return - # Atomic outputs swap — single widget-state update, no flicker. - app.vib_output.outputs = ( - { - "output_type": "display_data", - "data": {"text/html": html_str}, - "metadata": {}, - }, - ) + if _is_vib_stale(app, render_token): + # A newer render has superseded this one; do NOT write to vib_output + # (would stomp the newer render's content). + return + + _swap_vib_output(app, html_str) try: from quantui import calc_log as _clog_anim - _clog_anim.log_event("vib_render_done", f"mode {mode_number} backend=py3dmol") + _clog_anim.log_event( + "vib_render_done", + f"mode {mode_number} backend=py3dmol fps={fps}", + ) except Exception: pass + # Persist to disk cache so future visits and history replay can hit + # this mode instantly (VIZBACK.9). Non-fatal on failure — render still + # succeeded, cache is purely an optimization. + if result_dir is not None: + try: + freqs = getattr(freq_result, "frequencies_cm1", None) or [] + freq_cm1 = ( + float(freqs[mode_number - 1]) if 0 < mode_number <= len(freqs) else None + ) + from quantui import vib_cache + + vib_cache.save_cached_html( + Path(result_dir), + mode_number, + html_str, + freq_cm1=freq_cm1, + n_frames=n_frames, + amplitude=amplitude, + renderer="py3dmol", + fps=fps, + ) + except Exception as exc: + try: + from quantui import calc_log as _clog_cache_err + + _clog_cache_err.log_event( + "vib_cache_write_error", + f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300], + ) + except Exception: + pass + def _render_vib_mode_plotlymol( - app: Any, vib_data: Any, molecule: Any, mode_number: int + app: Any, + vib_data: Any, + molecule: Any, + mode_number: int, + *, + render_token: int | None = None, ) -> None: """Render vibrational animation via the plotlymol3d path (PlotlyMol + RDKit bond perception + Plotly figure). Used when user explicitly prefers plotlymol or when py3Dmol is unavailable.""" - from IPython.display import HTML as _H - if vib_data is None: - _vib_err( - app, - "PlotlyMol vibrational animation requires plotlymol3d, " - "which is not installed.", - ) + if not _is_vib_stale(app, render_token): + _vib_err( + app, + "PlotlyMol vibrational animation requires plotlymol3d, " + "which is not installed.", + ) return try: from plotlymol3d import create_vibration_animation, xyzblock_to_rdkitmol except ImportError as exc: - _vib_err( - app, - f"PlotlyMol vibrational animation requires plotlymol3d " - f"(pip install plotlymol3d): {exc}", - ) + if not _is_vib_stale(app, render_token): + _vib_err( + app, + f"PlotlyMol vibrational animation requires plotlymol3d " + f"(pip install plotlymol3d): {exc}", + ) return xyzblock = ( @@ -1623,7 +1757,8 @@ def _render_vib_mode_plotlymol( try: rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge) except Exception as exc: - _vib_err(app, f"Could not parse molecule for bond connectivity: {exc}") + if not _is_vib_stale(app, render_token): + _vib_err(app, f"Could not parse molecule for bond connectivity: {exc}") return try: @@ -1656,7 +1791,8 @@ def _render_vib_mode_plotlymol( ) except Exception: pass - _vib_err(app, f"Animation generation failed: {exc}") + if not _is_vib_stale(app, render_token): + _vib_err(app, f"Animation generation failed: {exc}") return try: from quantui import calc_log as _clog_anim @@ -1673,31 +1809,46 @@ def _render_vib_mode_plotlymol( include_plotlyjs="require", config={"responsive": True}, ) - app.vib_output.clear_output() - app.vib_output.append_display_data(_H(anim_html)) + if _is_vib_stale(app, render_token): + return + _swap_vib_output(app, anim_html) -def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None: +def render_vib_mode( + app: Any, + vib_data: Any, + molecule: Any, + mode_number: int, + *, + render_token: int | None = None, +) -> None: """Render vibrational animation for mode_number into ``app.vib_output``. Backend dispatch goes through the router (``VizTask.VIB_INTERACTIVE``): py3Dmol primary, plotlymol3d fallback. The py3Dmol path is preferred for speed and doesn't require plotlymol3d to be installed. + + ``render_token`` lets a caller (e.g. ``on_vib_mode_changed``) bump + ``app._vib_render_token`` before spawning a worker thread, so any + stale worker can bail out before stomping the newer render's output. """ from quantui.viz_backend_router import VizBackend as _VB from quantui.viz_backend_router import VizTask as _VT chosen = app._resolve_backend(_VT.VIB_INTERACTIVE) if chosen == _VB.PY3DMOL: - _render_vib_mode_py3dmol(app, molecule, mode_number) + _render_vib_mode_py3dmol(app, molecule, mode_number, render_token=render_token) elif chosen == _VB.PLOTLYMOL: - _render_vib_mode_plotlymol(app, vib_data, molecule, mode_number) - else: - _vib_err( - app, - "No vibrational animation backend available " - "(neither py3Dmol nor plotlymol3d installed).", + _render_vib_mode_plotlymol( + app, vib_data, molecule, mode_number, render_token=render_token ) + else: + if not _is_vib_stale(app, render_token): + _vib_err( + app, + "No vibrational animation backend available " + "(neither py3Dmol nor plotlymol3d installed).", + ) def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: @@ -1715,16 +1866,20 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: (lbl for lbl, num in app.vib_mode_dd.options if num == mode_number), f"mode {mode_number}", ) - app.vib_output.clear_output() - app.vib_output.append_display_data( - HTML( - f'

' - f"⏳ Rendering vibrational animation ({label})…

" - ) + # Bump render token so older in-flight render threads bail before they + # stomp the newer render's output. Eliminates the intermittent + # missing-render symptom from rapid mode switching. + app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1 + token = app._vib_render_token + _swap_vib_output( + app, + f'

' + f"⏳ Rendering vibrational animation ({label})…

", ) threading.Thread( target=app._render_vib_mode, args=(vib_data, molecule, mode_number), + kwargs={"render_token": token}, daemon=True, ).start() diff --git a/quantui/user_settings.py b/quantui/user_settings.py index 9b67a51..53a0e8b 100644 --- a/quantui/user_settings.py +++ b/quantui/user_settings.py @@ -44,6 +44,12 @@ # this module zero-dependency for unit testing. _VALID_VIZ_BACKENDS = ("auto", "py3dmol", "plotlymol") +# Vibrational animation playback rate. Clamped to a sensible range on load +# so a corrupt value can't produce a 0-ms or absurdly high interval. +_VIB_FPS_MIN = 1 +_VIB_FPS_MAX = 120 +_VIB_FPS_DEFAULT = 10 + # Default settings path. The QUANTUI_SETTINGS_PATH env var overrides for tests. DEFAULT_SETTINGS_PATH = Path.home() / ".quantui" / "settings.json" @@ -53,6 +59,7 @@ class VizSettings: """Visualization-related user preferences.""" default_backend: str = "auto" # one of _VALID_VIZ_BACKENDS + vib_framerate_fps: int = _VIB_FPS_DEFAULT # py3Dmol vib-animation fps @dataclass @@ -126,6 +133,20 @@ def _from_dict(cls, data: object) -> UserSettings: viz.default_backend, ) + candidate_fps = viz_section.get("vib_framerate_fps", viz.vib_framerate_fps) + if ( + isinstance(candidate_fps, int) + and not isinstance(candidate_fps, bool) + and _VIB_FPS_MIN <= candidate_fps <= _VIB_FPS_MAX + ): + viz.vib_framerate_fps = candidate_fps + else: + _LOG.warning( + "Invalid viz.vib_framerate_fps %r; using %r", + candidate_fps, + viz.vib_framerate_fps, + ) + return cls(viz=viz) def to_dict(self) -> dict: diff --git a/quantui/vib_cache.py b/quantui/vib_cache.py new file mode 100644 index 0000000..f3cee29 --- /dev/null +++ b/quantui/vib_cache.py @@ -0,0 +1,247 @@ +""" +Vibrational animation disk cache (VIZBACK.9). + +Caches rendered vibrational-animation HTML per calculation result directory +so mode switches on repeat visits and history replay are instant — no +re-render cost. + +Layout +------ +:: + + / + └── vib_frames/ + ├── index.json ← manifest with schema version, render params, modes + ├── mode_001.html + ├── mode_002.html + └── ... + +`index.json` schema (version 1):: + + { + "_schema_version": 1, + "n_frames": 24, + "amplitude": 0.4, + "renderer": "py3dmol", + "modes": { + "1": {"cached": true, "file": "mode_001.html", "freq_cm1": 1623.4}, + "2": {"cached": false} + } + } + +Cache key +--------- +``(result_dir, mode_number, n_frames, amplitude, renderer)``. If any render +parameter changes between save and read (e.g. user customises amplitude +later), the cache is treated as stale and a miss is returned. + +Robustness +---------- +- Atomic writes (write-to-`.tmp` then `os.replace`) so a crash mid-write + cannot leave the cache in a corrupted state. +- Graceful fallback: missing/malformed index → empty dict → cache miss. + All filesystem errors log a warning and return ``None`` / ``False`` — + never raise. Caller falls back to a fresh render. +- Schema-version mismatch is treated as a full cache invalidation (saved + index discarded on the next write). +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +_SCHEMA_VERSION = 1 +_LOG = logging.getLogger(__name__) + +# Floating-point tolerance when comparing saved vs requested amplitude. Avoids +# spurious cache misses when 0.4 round-trips through JSON serialization. +_AMPLITUDE_TOL = 1e-6 + + +def cache_dir(result_dir: Path) -> Path: + """Return the `vib_frames/` directory inside a result directory.""" + return Path(result_dir) / "vib_frames" + + +def load_index(result_dir: Path) -> dict: + """Load the cache index manifest. Returns ``{}`` on missing/malformed.""" + path = cache_dir(result_dir) / "index.json" + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + _LOG.warning( + "Failed to read vib cache index at %s (%s); ignoring", + path, + exc, + ) + return {} + return data if isinstance(data, dict) else {} + + +def has_cached( + result_dir: Path, + mode_number: int, + *, + n_frames: int, + amplitude: float, + renderer: str, + fps: int, +) -> bool: + """Return True if a cached HTML exists matching all render parameters.""" + idx = load_index(result_dir) + if idx.get("_schema_version") != _SCHEMA_VERSION: + return False + if idx.get("n_frames") != n_frames: + return False + if not _amplitude_matches(idx.get("amplitude"), amplitude): + return False + if idx.get("renderer") != renderer: + return False + if idx.get("fps") != fps: + return False + modes = idx.get("modes", {}) + entry = modes.get(str(mode_number)) + if not isinstance(entry, dict) or not entry.get("cached"): + return False + fname = entry.get("file") + if not isinstance(fname, str): + return False + return (cache_dir(result_dir) / fname).exists() + + +def get_cached_html( + result_dir: Path, + mode_number: int, + *, + n_frames: int, + amplitude: float, + renderer: str, + fps: int, +) -> str | None: + """Return the cached HTML string for a mode, or None on any miss.""" + if not has_cached( + result_dir, + mode_number, + n_frames=n_frames, + amplitude=amplitude, + renderer=renderer, + fps=fps, + ): + return None + idx = load_index(result_dir) + entry = idx["modes"][str(mode_number)] + html_path = cache_dir(result_dir) / entry["file"] + try: + return html_path.read_text(encoding="utf-8") + except OSError as exc: + _LOG.warning( + "Failed to read cached html at %s (%s)", + html_path, + exc, + ) + return None + + +def save_cached_html( + result_dir: Path, + mode_number: int, + html: str, + *, + freq_cm1: float | None, + n_frames: int, + amplitude: float, + renderer: str, + fps: int, +) -> None: + """Save HTML to the cache and update the manifest. Non-fatal on failure. + + If the existing manifest has different render parameters (different + n_frames, amplitude, or renderer), it is discarded and replaced — the + previous mode HTML files are left on disk but become unreferenced + (cleaned up next time the user deletes the result dir). + """ + cdir = cache_dir(result_dir) + try: + cdir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + _LOG.warning( + "Failed to create vib cache dir %s (%s); skipping save", + cdir, + exc, + ) + return + + idx = load_index(result_dir) + if ( + idx.get("_schema_version") != _SCHEMA_VERSION + or idx.get("n_frames") != n_frames + or not _amplitude_matches(idx.get("amplitude"), amplitude) + or idx.get("renderer") != renderer + or idx.get("fps") != fps + ): + idx = { + "_schema_version": _SCHEMA_VERSION, + "n_frames": n_frames, + "amplitude": amplitude, + "renderer": renderer, + "fps": fps, + "modes": {}, + } + + fname = f"mode_{mode_number:03d}.html" + html_path = cdir / fname + tmp_html = html_path.with_suffix(html_path.suffix + ".tmp") + try: + tmp_html.write_text(html, encoding="utf-8") + os.replace(tmp_html, html_path) + except OSError as exc: + _LOG.warning( + "Failed to save cached html to %s (%s)", + html_path, + exc, + ) + try: + if tmp_html.exists(): + tmp_html.unlink() + except OSError: + pass + return + + modes = idx.setdefault("modes", {}) + modes[str(mode_number)] = { + "cached": True, + "file": fname, + "freq_cm1": float(freq_cm1) if freq_cm1 is not None else None, + } + + index_path = cdir / "index.json" + tmp_idx = index_path.with_suffix(index_path.suffix + ".tmp") + try: + tmp_idx.write_text(json.dumps(idx, indent=2), encoding="utf-8") + os.replace(tmp_idx, index_path) + except OSError as exc: + _LOG.warning( + "Failed to save cache index to %s (%s)", + index_path, + exc, + ) + try: + if tmp_idx.exists(): + tmp_idx.unlink() + except OSError: + pass + + +def _amplitude_matches(saved: object, requested: float) -> bool: + """Compare amplitudes with tolerance to avoid float-equality issues.""" + if saved is None: + return False + try: + return abs(float(saved) - requested) < _AMPLITUDE_TOL + except (TypeError, ValueError): + return False diff --git a/tests/test_user_settings.py b/tests/test_user_settings.py index 0338372..3b24e92 100644 --- a/tests/test_user_settings.py +++ b/tests/test_user_settings.py @@ -27,7 +27,10 @@ def test_default_user_settings_has_default_viz(self): def test_to_dict_uses_current_schema_version(self): data = UserSettings().to_dict() assert data["_schema_version"] == 1 - assert data["viz"] == {"default_backend": "auto"} + assert data["viz"] == {"default_backend": "auto", "vib_framerate_fps": 10} + + def test_default_vib_framerate_is_10(self): + assert UserSettings().viz.vib_framerate_fps == 10 class TestLoad: @@ -101,6 +104,40 @@ def test_top_level_list_returns_defaults(self, tmp_path): settings = UserSettings.load(path) assert settings.viz.default_backend == "auto" + @pytest.mark.parametrize("good_fps", [5, 10, 30, 60, 120]) + def test_valid_vib_fps_round_trips(self, tmp_path, good_fps): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "_schema_version": 1, + "viz": { + "default_backend": "auto", + "vib_framerate_fps": good_fps, + }, + } + ) + ) + settings = UserSettings.load(path) + assert settings.viz.vib_framerate_fps == good_fps + + @pytest.mark.parametrize("bad_fps", [0, -1, 121, "30", True]) + def test_invalid_vib_fps_falls_back_to_default(self, tmp_path, bad_fps): + path = tmp_path / "settings.json" + path.write_text( + json.dumps( + { + "_schema_version": 1, + "viz": { + "default_backend": "auto", + "vib_framerate_fps": bad_fps, + }, + } + ) + ) + settings = UserSettings.load(path) + assert settings.viz.vib_framerate_fps == 10 + def test_unknown_fields_are_tolerated(self, tmp_path): """Future versions may add fields old code doesn't know about — old code should still load successfully and ignore them.""" diff --git a/tests/test_vib_cache.py b/tests/test_vib_cache.py new file mode 100644 index 0000000..edaedfd --- /dev/null +++ b/tests/test_vib_cache.py @@ -0,0 +1,444 @@ +"""Unit tests for `quantui.vib_cache` — vibrational animation disk cache. + +Covers cache hit / miss / fallback paths, schema-version invalidation, +parameter-mismatch invalidation, atomic-write semantics, and graceful +failure modes (missing dir, malformed JSON, OS errors). All file I/O uses +pytest's ``tmp_path`` fixture so no test touches a real result directory. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from quantui import vib_cache + + +def _make_result_dir(tmp_path: Path) -> Path: + rd = tmp_path / "result" + rd.mkdir() + return rd + + +class TestCacheDir: + def test_cache_dir_is_vib_frames_subfolder(self, tmp_path): + rd = _make_result_dir(tmp_path) + assert vib_cache.cache_dir(rd) == rd / "vib_frames" + + +class TestLoadIndex: + def test_missing_returns_empty(self, tmp_path): + rd = _make_result_dir(tmp_path) + assert vib_cache.load_index(rd) == {} + + def test_valid_index_loads(self, tmp_path): + rd = _make_result_dir(tmp_path) + cdir = vib_cache.cache_dir(rd) + cdir.mkdir() + (cdir / "index.json").write_text( + json.dumps( + { + "_schema_version": 1, + "n_frames": 24, + "amplitude": 0.4, + "renderer": "py3dmol", + "modes": {"1": {"cached": True, "file": "mode_001.html"}}, + } + ) + ) + idx = vib_cache.load_index(rd) + assert idx["_schema_version"] == 1 + assert idx["n_frames"] == 24 + + def test_malformed_json_returns_empty(self, tmp_path): + rd = _make_result_dir(tmp_path) + cdir = vib_cache.cache_dir(rd) + cdir.mkdir() + (cdir / "index.json").write_text("not valid json {") + assert vib_cache.load_index(rd) == {} + + def test_non_dict_root_returns_empty(self, tmp_path): + rd = _make_result_dir(tmp_path) + cdir = vib_cache.cache_dir(rd) + cdir.mkdir() + (cdir / "index.json").write_text(json.dumps([1, 2, 3])) + assert vib_cache.load_index(rd) == {} + + +class TestSaveAndLoadRoundtrip: + def test_save_creates_files(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "mode 1", + freq_cm1=1600.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + cdir = vib_cache.cache_dir(rd) + assert (cdir / "mode_001.html").exists() + assert (cdir / "index.json").exists() + + def test_save_then_get_returns_html(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "mode 1", + freq_cm1=1600.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + loaded = vib_cache.get_cached_html( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + assert loaded == "mode 1" + + def test_index_has_freq_cm1(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 3, + "", + freq_cm1=1623.4, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + idx = vib_cache.load_index(rd) + assert idx["modes"]["3"]["freq_cm1"] == pytest.approx(1623.4) + + def test_multiple_modes_share_index(self, tmp_path): + rd = _make_result_dir(tmp_path) + for mode, freq in [(1, 1600.0), (2, 3800.0), (3, 3850.0)]: + vib_cache.save_cached_html( + rd, + mode, + f"mode {mode}", + freq_cm1=freq, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + idx = vib_cache.load_index(rd) + assert set(idx["modes"].keys()) == {"1", "2", "3"} + # Each mode's HTML loads correctly + for mode in (1, 2, 3): + loaded = vib_cache.get_cached_html( + rd, mode, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + assert loaded == f"mode {mode}" + + +class TestHasCachedMatching: + def setup_method(self): + # Fixtures populated in each test + pass + + def test_matching_params_returns_true(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + def test_different_n_frames_invalidates(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert not vib_cache.has_cached( + rd, 1, n_frames=48, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + def test_different_amplitude_invalidates(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.8, renderer="py3dmol", fps=10 + ) + + def test_different_renderer_invalidates(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="plotlymol", fps=10 + ) + + def test_amplitude_tolerance_allows_float_roundtrip(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + # 0.4 should match itself across JSON round-trip even with tiny FP noise + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4 + 1e-9, renderer="py3dmol", fps=10 + ) + + def test_mode_not_in_index_returns_false(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert not vib_cache.has_cached( + rd, 99, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + def test_missing_html_file_returns_false(self, tmp_path): + """If index claims cached but the HTML file is deleted, treat as miss.""" + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + # Delete the HTML file but leave the index + (vib_cache.cache_dir(rd) / "mode_001.html").unlink() + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + +class TestSchemaVersionInvalidation: + def test_old_schema_version_returns_miss(self, tmp_path): + rd = _make_result_dir(tmp_path) + cdir = vib_cache.cache_dir(rd) + cdir.mkdir() + # Write an index from a hypothetical older schema + (cdir / "index.json").write_text( + json.dumps( + { + "_schema_version": 0, + "n_frames": 24, + "amplitude": 0.4, + "renderer": "py3dmol", + "modes": {"1": {"cached": True, "file": "mode_001.html"}}, + } + ) + ) + # Create the html file too + (cdir / "mode_001.html").write_text("") + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + def test_resaving_with_changed_params_rebuilds_index(self, tmp_path): + rd = _make_result_dir(tmp_path) + # Save with amplitude=0.4 + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + # Re-save with amplitude=0.6 — should reset the index + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.6, + renderer="py3dmol", + fps=10, + ) + # Old amplitude no longer matches + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + # New amplitude matches + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.6, renderer="py3dmol", fps=10 + ) + + +class TestFpsInvalidation: + """fps is a cache key parameter; mismatch must yield a miss + rebuild.""" + + def test_different_fps_invalidates(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + # Different fps → cache miss + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=30 + ) + # Same fps → cache hit + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + + def test_resaving_with_new_fps_rebuilds_index(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + # User changes fps to 30 — saving the same mode should reset the + # index's fps field and invalidate the old fps=10 entry. + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=30, + ) + assert not vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + assert vib_cache.has_cached( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=30 + ) + # Index records the new fps + assert vib_cache.load_index(rd)["fps"] == 30 + + +class TestAtomicWrites: + def test_no_tmp_leftover_on_success(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + cdir = vib_cache.cache_dir(rd) + tmp_leftovers = list(cdir.glob("*.tmp")) + assert tmp_leftovers == [] + + def test_save_failure_does_not_raise(self, tmp_path, monkeypatch): + rd = _make_result_dir(tmp_path) + + def _boom(self, *_a, **_kw): + raise OSError("simulated disk full") + + monkeypatch.setattr("pathlib.Path.write_text", _boom) + # Should NOT raise — non-fatal + vib_cache.save_cached_html( + rd, + 1, + "", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + + +class TestGetCachedHtml: + def test_miss_returns_none(self, tmp_path): + rd = _make_result_dir(tmp_path) + assert ( + vib_cache.get_cached_html( + rd, 1, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + is None + ) + + def test_hit_returns_html_content(self, tmp_path): + rd = _make_result_dir(tmp_path) + vib_cache.save_cached_html( + rd, + 5, + "PAYLOAD", + freq_cm1=1.0, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + result = vib_cache.get_cached_html( + rd, 5, n_frames=24, amplitude=0.4, renderer="py3dmol", fps=10 + ) + assert result == "PAYLOAD" diff --git a/tests/test_vib_py3dmol_render.py b/tests/test_vib_py3dmol_render.py index 9e30672..ebd72f5 100644 --- a/tests/test_vib_py3dmol_render.py +++ b/tests/test_vib_py3dmol_render.py @@ -142,6 +142,193 @@ def test_shape_mismatch_shows_error_not_crash(self, app): assert len(app.vib_output.outputs) >= 1 +class TestVibCacheIntegration: + """VIZBACK.9: `_render_vib_mode_py3dmol` should check and populate the + on-disk cache at ``/vib_frames/``.""" + + def test_cache_miss_then_hit_returns_same_html( + self, app, water_mol, fake_freq_result, tmp_path + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = tmp_path + + # First call: cache miss → render → save + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + first_html = app.vib_output.outputs[0]["data"]["text/html"] + # Cache file should now exist + from quantui.vib_cache import cache_dir + + assert (cache_dir(tmp_path) / "mode_001.html").exists() + assert (cache_dir(tmp_path) / "index.json").exists() + + # Reset the output before the second call to confirm cache delivers it + app.vib_output.outputs = () + + # Second call: cache hit → identical HTML, no re-render + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + second_html = app.vib_output.outputs[0]["data"]["text/html"] + assert second_html == first_html + + def test_no_result_dir_skips_cache_no_error(self, app, water_mol, fake_freq_result): + """Without _last_result_dir set, render still works (cache skipped).""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = None # explicit + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + assert len(app.vib_output.outputs) == 1 + + def test_changed_amplitude_invalidates_cache( + self, app, water_mol, fake_freq_result, tmp_path + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = tmp_path + + # Render with default amplitude + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, amplitude=0.4) + html_default = app.vib_output.outputs[0]["data"]["text/html"] + + # Render with different amplitude — should NOT hit the prior cache, + # should render fresh and produce different HTML + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, amplitude=0.8) + html_alt = app.vib_output.outputs[0]["data"]["text/html"] + + assert html_default != html_alt + + +class TestRenderTokenStaleness: + """The _vib_render_token machinery guarantees that an older render + thread cannot stomp a newer render's output. Verifies the bug-fix for + intermittent missing-render symptom on rapid mode switching.""" + + def test_stale_token_skips_output_write(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + + # First, set a baseline output the test can detect. + app.vib_output.outputs = ( + { + "output_type": "display_data", + "data": {"text/html": "BASELINE"}, + "metadata": {}, + }, + ) + + # Bump the app's render token to N+1; pass N (stale) to the render. + # The render should bail and leave the baseline output untouched. + app._vib_render_token = 5 + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, render_token=2) + assert ( + app.vib_output.outputs[0]["data"]["text/html"] == "BASELINE" + ), "stale render should not overwrite output" + + def test_matching_token_writes_output(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + + app._vib_render_token = 7 + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, render_token=7) + outputs = app.vib_output.outputs + assert len(outputs) == 1 + assert "3Dmol" in outputs[0]["data"]["text/html"] + + +class TestFpsParameter: + """fps parameter wiring — value flows into the py3Dmol animate + interval and into the cache key.""" + + def test_fps_affects_animate_interval(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + # 10 fps → interval=100; 60 fps → interval≈17 + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, fps=10) + html_10 = app.vib_output.outputs[0]["data"]["text/html"] + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, fps=60) + html_60 = app.vib_output.outputs[0]["data"]["text/html"] + # interval is embedded in the HTML — different fps → different blob + assert html_10 != html_60 + assert "interval" in html_10.lower() + + def test_fps_changes_invalidate_cache( + self, app, water_mol, fake_freq_result, tmp_path + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = tmp_path + # Render at fps=10 → cache populated for fps=10 + _render_vib_mode_py3dmol(app, water_mol, mode_number=1, fps=10) + # Re-render at fps=30 → cache should NOT hit (different fps) + from quantui.vib_cache import has_cached + + assert has_cached( + tmp_path, + 1, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + assert not has_cached( + tmp_path, + 1, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=30, + ) + + def test_fps_falls_back_to_user_settings_when_not_passed( + self, app, water_mol, fake_freq_result, tmp_path + ): + """When `fps` argument is None, render reads from + ``app._user_settings.viz.vib_framerate_fps``. We verify by saving + to cache and checking the cached index records the settings fps.""" + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = tmp_path + app._user_settings.viz.vib_framerate_fps = 45 + # Don't pass fps; should pick up 45 from settings → cache should + # be keyed by fps=45. + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + from quantui.vib_cache import has_cached, load_index + + assert load_index(tmp_path)["fps"] == 45 + assert has_cached( + tmp_path, + 1, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=45, + ) + # Wrong-fps query should miss + assert not has_cached( + tmp_path, + 1, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=10, + ) + + class TestRenderVibModeDispatch: """render_vib_mode should route through the viz_backend_router.""" From 368a34e4c73157792c570ca64ff358ee31c1acb4 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 22 May 2026 18:15:40 -0400 Subject: [PATCH 7/9] Add vib nav, cache sync, and viz telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prev/next buttons and fixed-size output for vibrational viewer; wire up observers and click handlers in QuantUIApp to enable one-step mode navigation and avoid layout flicker. Introduce a synchronous vib-cache-hit fast path to swap cached py3Dmol HTML without showing a transient "Rendering…" placeholder, and ensure the vib wrapper accepts render_token kwargs to avoid silent thread TypeErrors. Implement camera-persistence JS and a reset hook so interactive camera/view is preserved across mode switches (but reset when a new frequency result loads). Add a _viz_render_event context manager to emit lifecycle telemetry (viz_render_start / viz_render_done / viz_render_error) for render operations and wrap vib/trajectory/structure renders to log backend, task, elapsed time and extras. Harden error handling so telemetry is logged while user-facing errors are shown but worker threads don't crash. Include new unit tests for telemetry and the render_token wrapper, and small adjustments to vib/plotlymol/py3dmol render flows to integrate these features. --- quantui/app.py | 52 ++++- quantui/app_analysis.py | 38 ++- quantui/app_builders.py | 32 ++- quantui/app_visualization.py | 360 ++++++++++++++++++++++------- tests/test_vib_py3dmol_render.py | 20 ++ tests/test_viz_render_telemetry.py | 154 ++++++++++++ 6 files changed, 569 insertions(+), 87 deletions(-) create mode 100644 tests/test_viz_render_telemetry.py diff --git a/quantui/app.py b/quantui/app.py index b152f7d..b6ff120 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1513,6 +1513,11 @@ def _wire_callbacks(self) -> None: self.vib_mode_dd.observe( self._safe_cb(self._on_vib_mode_changed), names="value" ) + self.vib_mode_dd.observe( + self._safe_cb(self._update_vib_nav_buttons), names=["value", "options"] + ) + self.vib_prev_btn.on_click(self._on_vib_prev_clicked) + self.vib_next_btn.on_click(self._on_vib_next_clicked) # Orbital diagram axis controls self._orb_ymin_input.observe( self._safe_cb(self._on_orb_range_changed), names="value" @@ -3009,12 +3014,55 @@ def _render_orbital_isosurface( render_token=render_token, ) - def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: - _viz_render_vib_mode(self, vib_data, molecule, mode_number) + def _render_vib_mode( + self, + vib_data, + molecule, + mode_number: int, + *, + render_token: Optional[int] = None, + ) -> None: + _viz_render_vib_mode( + self, vib_data, molecule, mode_number, render_token=render_token + ) def _on_vib_mode_changed(self, change) -> None: _viz_on_vib_mode_changed(self, change) + def _update_vib_nav_buttons(self, change=None) -> None: + """Enable/disable prev/next vib mode buttons based on current + dropdown position. Called on both ``value`` and ``options`` changes + so the buttons stay correct after a new freq result populates the + options list.""" + opts = self.vib_mode_dd.options or () + if not opts: + self.vib_prev_btn.disabled = True + self.vib_next_btn.disabled = True + return + cur = self.vib_mode_dd.value + idx = next( + (i for i, (_lbl, num) in enumerate(opts) if num == cur), + -1, + ) + self.vib_prev_btn.disabled = idx <= 0 + self.vib_next_btn.disabled = idx < 0 or idx >= len(opts) - 1 + + def _on_vib_prev_clicked(self, _btn) -> None: + opts = self.vib_mode_dd.options or () + cur = self.vib_mode_dd.value + for i, (_lbl, num) in enumerate(opts): + if num == cur and i > 0: + self.vib_mode_dd.value = opts[i - 1][1] + return + + def _on_vib_next_clicked(self, _btn) -> None: + opts = self.vib_mode_dd.options or () + cur = self.vib_mode_dd.value + for i, (_lbl, num) in enumerate(opts): + if num == cur and i < len(opts) - 1: + self.vib_mode_dd.value = opts[i + 1][1] + return + def _do_run(self) -> None: """Main calculation dispatch — runs in a background thread.""" mol = self._molecule diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py index 45464ea..5dcb65a 100644 --- a/quantui/app_analysis.py +++ b/quantui/app_analysis.py @@ -380,7 +380,43 @@ def pop_vibrational(app: Any, ctx: Any) -> bool: freqs = ir.get("frequencies_cm1") ints = ir.get("ir_intensities") disps = ir.get("displacements") - if not (freqs and disps and mol_data.get("atoms")): + if not freqs: + _set_panel_unavailable_message( + app, + "Vibrational", + ( + "Not available for this Frequency history result: " + "no frequency data was saved (`frequencies_cm1` empty " + "or missing). Re-run the Frequency calculation to " + "populate this panel." + ), + ) + return False + if not mol_data.get("atoms"): + _set_panel_unavailable_message( + app, + "Vibrational", + ( + "Not available for this Frequency history result: " + "no molecule geometry was saved with the result. " + "Re-run the Frequency calculation to populate this panel." + ), + ) + return False + if not disps: + _set_panel_unavailable_message( + app, + "Vibrational", + ( + "Not available for this Frequency history result: " + "per-mode atomic displacements were not persisted with " + "this calculation (a known limitation for older saved " + "results — displacements began being written to disk in " + "a later QuantUI version). Re-run the Frequency " + "calculation to enable the animation. " + "The IR Spectrum panel still works for this result." + ), + ) return False from quantui.molecule import Molecule as _Mol diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 4e6eed1..a34103e 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -1068,11 +1068,39 @@ def _plot_export_row(prefix: str) -> widgets.HBox: style={"description_width": "50px"}, layout=layout_fn(width="360px"), ) - app.vib_output = widgets.Output() + # Prev/next arrow buttons for one-step navigation through modes. Click + # handlers step ``vib_mode_dd.value`` to the adjacent option; the + # existing dropdown observer then drives the re-render. Mirrors the + # trajectory-viewer prev/next pattern. + app.vib_prev_btn = widgets.Button( + icon="arrow-left", + tooltip="Previous mode", + layout=layout_fn(width="40px", margin="0 4px 0 0"), + disabled=True, + ) + app.vib_next_btn = widgets.Button( + icon="arrow-right", + tooltip="Next mode", + layout=layout_fn(width="40px", margin="0 8px 0 4px"), + disabled=True, + ) + vib_mode_row = widgets.HBox( + [app.vib_prev_btn, app.vib_mode_dd, app.vib_next_btn], + layout=layout_fn(align_items="center", margin="0 0 4px 0"), + ) + # Fixed-dimension Output container so the box never resizes between + # content swaps (placeholder ↔ 3Dmol HTML). Without this, the empty + # state between atomic outputs assignments briefly collapses the + # container, the page reflows up, then reflows back when the new + # content arrives — visible as a scroll-jump flicker on every mode + # switch. Matches the trajectory frame_out fix pattern. 460+20=480 + # accommodates the py3Dmol view (460px) plus a small horizontal pad; + # 420+20=440 likewise for the 420px view height. + app.vib_output = widgets.Output(layout=layout_fn(height="440px", width="480px")) app.vib_accordion = widgets.Accordion( children=[ widgets.VBox( - [app.vib_mode_dd, app.vib_output], + [vib_mode_row, app.vib_output], layout=layout_fn(padding="8px"), ) ], diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index d689be5..3d83495 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -3,6 +3,8 @@ from __future__ import annotations import threading +import time +from contextlib import contextmanager from pathlib import Path from typing import Any, List @@ -10,6 +12,54 @@ from IPython.display import HTML, display +@contextmanager +def _viz_render_event(app: Any, task: Any, backend: Any, **extras: Any): + """Lifecycle telemetry context manager for one render-path execution. + + Emits ``viz_render_start`` on entry and ``viz_render_done`` / + ``viz_render_error`` on exit, each with ``elapsed_ms``. Wraps the + actual backend render work; the router decision itself is logged + separately by ``app._resolve_backend`` via ``viz_route_decision``. + + Extra kwargs are appended as ``key=value`` pairs to the event body + (e.g. ``mode=3`` for vib renders, ``idx=12`` for trajectory frames). + All log writes are best-effort — failures never propagate. + """ + from quantui import calc_log as _clog_evt + + t0 = time.perf_counter() + pref = getattr(app, "_viz_backend_preference", "auto") + extras_str = " ".join(f"{k}={v}" for k, v in extras.items()) + base = f"task={task} pref={pref} backend={backend}" + fields = f"{base} {extras_str}".strip() + try: + _clog_evt.log_event("viz_render_start", fields) + except Exception: + pass + try: + yield + except Exception as exc: + elapsed_ms = int((time.perf_counter() - t0) * 1000) + try: + _clog_evt.log_event( + "viz_render_error", + f"{fields} elapsed_ms={elapsed_ms} " + f"err={type(exc).__name__}:{exc}"[:300], + ) + except Exception: + pass + raise + else: + elapsed_ms = int((time.perf_counter() - t0) * 1000) + try: + _clog_evt.log_event( + "viz_render_done", + f"{fields} elapsed_ms={elapsed_ms}", + ) + except Exception: + pass + + def show_result_3d( app: Any, molecule: Any, @@ -37,15 +87,20 @@ def show_result_3d( if app.result_viz_output is not None: chosen = app._resolve_backend(_VT.STRUCTURE_VIEW_RESULTS) if chosen is not None: - app.result_viz_output.clear_output() - with app.result_viz_output: - display_molecule_fn( - molecule, - backend=str(chosen), - style=app._viz_style, - lighting=app._viz_lighting, - bgcolor=app._plotly_theme_colors()["scene_bgcolor"], - ) + with _viz_render_event( + app, + task="structure_view_results", + backend=str(chosen), + ): + app.result_viz_output.clear_output() + with app.result_viz_output: + display_molecule_fn( + molecule, + backend=str(chosen), + style=app._viz_style, + lighting=app._viz_lighting, + bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + ) # Optional second viewer (typically the Analysis tab). if extra_output is not None: @@ -56,15 +111,21 @@ def show_result_3d( ) chosen = app._resolve_backend(task) if chosen is not None: - extra_output.clear_output() - with extra_output: - display_molecule_fn( - molecule, - backend=str(chosen), - style=app._viz_style, - lighting=app._viz_lighting, - bgcolor=app._plotly_theme_colors()["scene_bgcolor"], - ) + task_label = ( + "analysis_structure_view" + if is_analysis_output + else "structure_view_results" + ) + with _viz_render_event(app, task=task_label, backend=str(chosen)): + extra_output.clear_output() + with extra_output: + display_molecule_fn( + molecule, + backend=str(chosen), + style=app._viz_style, + lighting=app._viz_lighting, + bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + ) if is_analysis_output: app._update_analysis_backend_label(chosen) @@ -358,7 +419,10 @@ def _build_fig(idx: int): "Trajectory rendering requires py3Dmol (plotlymol blocked " "for real-time use to avoid flicker). py3Dmol is unavailable.", ) - result = _try_py3dmol(idx) + 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") @@ -872,13 +936,20 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: app._last_vib_freq_result = freq_result first_label, first_mode = options[0] + # 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 + # freq result so the first mode opens at default zoom-to-fit. + if _try_vib_cache_hit_sync(app, first_mode, reset_camera=True): + return True + # Bump render token so any stale worker thread bails out before stomping # this fresh render's output. app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1 token = app._vib_render_token _swap_vib_output( app, - f'

' + _VIB_CAMERA_RESET_JS + f'

' f"⏳ Rendering vibrational animation ({first_label})…

", ) threading.Thread( @@ -1499,6 +1570,78 @@ def _vib_err(app: Any, msg: str) -> None: _swap_vib_output(app, f'

⚠ {msg}

') +# JS snippet appended to every py3Dmol vib HTML payload. Patches +# ``$3Dmol.createViewer`` once per page so each new viewer: +# 1. Hijacks its own ``zoomTo`` — if ``window._quantuiVibCamera`` is set +# (from a previous mode's pan/rotate state) apply it via ``setView`` +# instead of recomputing the default fit. Falls back to the original +# zoomTo when no camera is saved. +# 2. Starts a periodic interval that writes ``viewer.getView()`` into +# ``window._quantuiVibCamera``, so the user's interactive pan/zoom +# survives mode switches. +# The script is idempotent (``_quantuiVibCameraHookInstalled`` guard), so +# it can ship inside every cached HTML blob without doubling the hook. +_VIB_CAMERA_PERSISTENCE_JS = """ + +""" + +# Tiny one-shot reset injected when a NEW freq result begins, so the +# first mode of a new molecule opens at default zoom-to-fit instead of a +# stale camera from a previous molecule. Mode switches WITHIN the same +# freq result do not reset. +_VIB_CAMERA_RESET_JS = "" + + +def _ensure_vib_camera_hook(html: str) -> str: + """Prepend ``_VIB_CAMERA_PERSISTENCE_JS`` to ``html`` only when not + already present. Lets us serve both new cache entries (which embed + the hook) and old cache entries (written before this feature) with + a single code path — avoids redundant script tags and keeps cache + HTML byte-stable across miss/hit cycles.""" + if "_quantuiVibCameraHookInstalled" in html: + return html + return _VIB_CAMERA_PERSISTENCE_JS + html + + def _is_vib_stale(app: Any, render_token: int | None) -> bool: """True when a newer vib render has started; used to bail out of an older background render thread before it stomps the newer one's @@ -1508,6 +1651,85 @@ def _is_vib_stale(app: Any, render_token: int | None) -> bool: return render_token != int(getattr(app, "_vib_render_token", 0)) +def _try_vib_cache_hit_sync( + app: Any, mode_number: int, *, reset_camera: bool = False +) -> bool: + """Synchronous cache lookup + swap. Returns True iff a cached HTML blob + matching the current render params was found and injected directly + into ``app.vib_output``. + + Why: without this, every mode switch sets a "Rendering…" placeholder, + spawns a thread, the thread checks the cache, then writes the cached + HTML — visible as a brief placeholder flash even when the result is + on disk. Doing the cache check on the main thread before the + placeholder avoids the flash entirely for cache hits. + + When ``reset_camera`` is True a tiny inline script clears + ``window._quantuiVibCamera`` before the cached HTML runs, so the + first mode of a new molecule opens at default zoom rather than at a + stale camera from a previous molecule. + + Bumps ``_vib_render_token`` so any in-flight render thread bails out + before stomping the swapped cached output. + """ + result_dir = getattr(app, "_last_result_dir", None) + if result_dir is None: + return False + + try: + from quantui.viz_backend_router import VizBackend as _VB + from quantui.viz_backend_router import VizTask as _VT + + chosen = app._resolve_backend(_VT.VIB_INTERACTIVE) + if chosen != _VB.PY3DMOL: + # Plotlymol path doesn't write to the disk cache. + return False + + viz_settings = getattr(getattr(app, "_user_settings", None), "viz", None) + fps = int(getattr(viz_settings, "vib_framerate_fps", 10)) + fps = max(1, fps) + + from quantui import vib_cache + + cached_html = vib_cache.get_cached_html( + Path(result_dir), + mode_number, + n_frames=24, + amplitude=0.4, + renderer="py3dmol", + fps=fps, + ) + except Exception: + return False + + if cached_html is None: + return False + + payload = _ensure_vib_camera_hook(cached_html) + if reset_camera: + payload = _VIB_CAMERA_RESET_JS + payload + + app._vib_render_token = int(getattr(app, "_vib_render_token", 0)) + 1 + with _viz_render_event( + app, + task="vib_interactive", + backend="py3dmol", + mode=mode_number, + source="cache_sync", + ): + _swap_vib_output(app, payload) + try: + from quantui import calc_log as _clog_sync_hit + + _clog_sync_hit.log_event( + "vib_cache_hit", + f"mode {mode_number} backend=py3dmol fps={fps} path=sync", + ) + except Exception: + pass + return True + + def _render_vib_mode_py3dmol( app: Any, molecule: Any, @@ -1579,7 +1801,7 @@ def _render_vib_mode_py3dmol( if cached_html is not None: if _is_vib_stale(app, render_token): return - _swap_vib_output(app, cached_html) + _swap_vib_output(app, _ensure_vib_camera_hook(cached_html)) try: from quantui import calc_log as _clog_cache_hit @@ -1622,16 +1844,6 @@ def _render_vib_mode_py3dmol( ) return - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event( - "vib_render_start", - f"mode {mode_number} backend=py3dmol fps={fps}", - ) - except Exception: - pass - # One full oscillation: n_frames evenly-spaced phases over [0, 2π). phases = np.sin(np.linspace(0, 2 * np.pi, n_frames, endpoint=False)) n_atoms = len(atoms) @@ -1653,21 +1865,15 @@ def _render_vib_mode_py3dmol( view.setBackgroundColor(bg) view.zoomTo() view.animate({"loop": "forward", "interval": interval_ms, "reps": 0}) - html_str = view._make_html() + # Prepend the camera-persistence hook so the user's interactive + # pan/rotate state survives mode switches. The hook is idempotent + # (guarded by ``_quantuiVibCameraHookInstalled``) and ships inside + # the cached HTML too — so disk-cache hits also persist the camera. + html_str = _VIB_CAMERA_PERSISTENCE_JS + view._make_html() except Exception as exc: - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event( - "vib_render_error", - f"mode {mode_number} backend=py3dmol: " - f"{type(exc).__name__}: {exc}"[:300], - ) - except Exception: - pass if not _is_vib_stale(app, render_token): _vib_err(app, f"Vibrational animation render failed: {exc}") - return + raise if _is_vib_stale(app, render_token): # A newer render has superseded this one; do NOT write to vib_output @@ -1676,16 +1882,6 @@ def _render_vib_mode_py3dmol( _swap_vib_output(app, html_str) - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event( - "vib_render_done", - f"mode {mode_number} backend=py3dmol fps={fps}", - ) - except Exception: - pass - # Persist to disk cache so future visits and history replay can hit # this mode instantly (VIZBACK.9). Non-fatal on failure — render still # succeeded, cache is purely an optimization. @@ -1761,14 +1957,6 @@ def _render_vib_mode_plotlymol( _vib_err(app, f"Could not parse molecule for bond connectivity: {exc}") return - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event( - "vib_render_start", f"mode {mode_number} backend=plotlymol" - ) - except Exception: - pass try: anim_fig = create_vibration_animation( vib_data=vib_data, @@ -1781,25 +1969,9 @@ def _render_vib_mode_plotlymol( ) anim_fig.update_layout(height=420) except Exception as exc: - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event( - "vib_render_error", - f"mode {mode_number} backend=plotlymol: " - f"{type(exc).__name__}: {exc}"[:300], - ) - except Exception: - pass if not _is_vib_stale(app, render_token): _vib_err(app, f"Animation generation failed: {exc}") - return - try: - from quantui import calc_log as _clog_anim - - _clog_anim.log_event("vib_render_done", f"mode {mode_number} backend=plotlymol") - except Exception: - pass + raise import plotly.io as _pio @@ -1837,11 +2009,29 @@ def render_vib_mode( chosen = app._resolve_backend(_VT.VIB_INTERACTIVE) if chosen == _VB.PY3DMOL: - _render_vib_mode_py3dmol(app, molecule, mode_number, render_token=render_token) + try: + with _viz_render_event( + app, task="vib_interactive", backend="py3dmol", mode=mode_number + ): + _render_vib_mode_py3dmol( + app, molecule, mode_number, render_token=render_token + ) + except Exception: + # viz_render_error was already logged by the context manager; + # swallow here so a worker-thread render failure doesn't crash + # the thread. The inner function already wrote a user-facing + # error message via ``_vib_err``. + pass elif chosen == _VB.PLOTLYMOL: - _render_vib_mode_plotlymol( - app, vib_data, molecule, mode_number, render_token=render_token - ) + try: + with _viz_render_event( + app, task="vib_interactive", backend="plotlymol", mode=mode_number + ): + _render_vib_mode_plotlymol( + app, vib_data, molecule, mode_number, render_token=render_token + ) + except Exception: + pass else: if not _is_vib_stale(app, render_token): _vib_err( @@ -1862,6 +2052,12 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: if molecule is None or freq_result is None: return + # Cache-hit fast path: swap cached HTML synchronously, no placeholder, + # no thread. Bumps the render token internally to invalidate any + # in-flight render. + if _try_vib_cache_hit_sync(app, mode_number): + return + label = next( (lbl for lbl, num in app.vib_mode_dd.options if num == mode_number), f"mode {mode_number}", diff --git a/tests/test_vib_py3dmol_render.py b/tests/test_vib_py3dmol_render.py index ebd72f5..5f1a044 100644 --- a/tests/test_vib_py3dmol_render.py +++ b/tests/test_vib_py3dmol_render.py @@ -203,6 +203,26 @@ def test_changed_amplitude_invalidates_cache( assert html_default != html_alt +class TestAppWrapperAcceptsRenderToken: + """Regression test for BUG-VIB-WRAPPER-SIG (session 50): the + QuantUIApp wrapper ``_render_vib_mode`` must accept ``render_token`` + via kwargs — show_vib_animation / on_vib_mode_changed pass it via + `threading.Thread(kwargs={"render_token": ...})`. A mismatched + signature causes the thread to die silently with TypeError and the + user sees a permanent 'Rendering...' placeholder.""" + + def test_wrapper_accepts_render_token_kwarg(self, app, water_mol, fake_freq_result): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._vib_render_token = 1 + # This call shape mirrors what show_vib_animation's thread does. + # Must not raise TypeError. + app._render_vib_mode(None, water_mol, 1, render_token=1) + assert len(app.vib_output.outputs) == 1 + + class TestRenderTokenStaleness: """The _vib_render_token machinery guarantees that an older render thread cannot stomp a newer render's output. Verifies the bug-fix for diff --git a/tests/test_viz_render_telemetry.py b/tests/test_viz_render_telemetry.py new file mode 100644 index 0000000..2372d39 --- /dev/null +++ b/tests/test_viz_render_telemetry.py @@ -0,0 +1,154 @@ +"""Unit tests for VIZBACK.6 lifecycle telemetry events. + +Covers the ``_viz_render_event`` context manager and its integration with +the vib render dispatcher (``render_vib_mode``), the sync cache hit path +(``_try_vib_cache_hit_sync``), and the trajectory frame builder +(``_build_fig``). Verifies ``viz_render_start`` / ``viz_render_done`` / +``viz_render_error`` events fire with the required fields +(``task``, ``pref``, ``backend``, ``elapsed_ms``). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +from quantui import calc_log +from quantui.app import QuantUIApp +from quantui.app_visualization import ( + _try_vib_cache_hit_sync, + _viz_render_event, + render_vib_mode, +) +from quantui.molecule import Molecule + + +@pytest.fixture +def app(): + return QuantUIApp() + + +@pytest.fixture +def water_mol(): + return Molecule( + atoms=["O", "H", "H"], + coordinates=[[0.0, 0.0, 0.0], [0.96, 0.0, 0.0], [-0.24, 0.93, 0.0]], + ) + + +@pytest.fixture +def fake_freq_result(): + return SimpleNamespace( + frequencies_cm1=[1600.0, 3800.0], + ir_intensities=[40.0, 50.0], + displacements=[ + np.array([[0.0, 0.05, 0.0], [0.0, -0.05, 0.0], [0.0, -0.05, 0.0]]), + np.array([[0.0, 0.10, 0.0], [0.05, -0.10, 0.0], [-0.05, -0.10, 0.0]]), + ], + ) + + +@pytest.fixture +def captured_events(monkeypatch): + """Replaces ``calc_log.log_event`` with a recording stub. Returns the + list that gets appended to by each call.""" + events: list[tuple[str, str]] = [] + + def _record(event_type, message, **extra): + events.append((event_type, message)) + + monkeypatch.setattr(calc_log, "log_event", _record) + return events + + +def _events_of(events, kind): + return [(t, m) for t, m in events if t == kind] + + +class TestViewRenderEventContextManager: + def test_emits_start_and_done(self, app, captured_events): + with _viz_render_event(app, task="t1", backend="b1"): + pass + starts = _events_of(captured_events, "viz_render_start") + dones = _events_of(captured_events, "viz_render_done") + assert len(starts) == 1 + assert len(dones) == 1 + assert "task=t1" in starts[0][1] + assert "backend=b1" in starts[0][1] + assert "elapsed_ms=" in dones[0][1] + + def test_emits_error_on_exception(self, app, captured_events): + with pytest.raises(RuntimeError): + with _viz_render_event(app, task="t1", backend="b1"): + raise RuntimeError("boom") + errs = _events_of(captured_events, "viz_render_error") + dones = _events_of(captured_events, "viz_render_done") + assert len(errs) == 1 + assert len(dones) == 0 + assert "elapsed_ms=" in errs[0][1] + assert "RuntimeError" in errs[0][1] + + def test_extras_appended_to_message(self, app, captured_events): + with _viz_render_event(app, task="t1", backend="b1", mode=3, source="x"): + pass + starts = _events_of(captured_events, "viz_render_start") + assert "mode=3" in starts[0][1] + assert "source=x" in starts[0][1] + + def test_preference_included(self, app, captured_events): + app._viz_backend_preference = "py3dmol" + with _viz_render_event(app, task="t1", backend="b1"): + pass + starts = _events_of(captured_events, "viz_render_start") + assert "pref=py3dmol" in starts[0][1] + + +class TestVibRenderTelemetry: + def test_render_vib_mode_emits_lifecycle( + self, app, water_mol, fake_freq_result, captured_events + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + + render_vib_mode(app, vib_data=None, molecule=water_mol, mode_number=1) + + starts = _events_of(captured_events, "viz_render_start") + dones = _events_of(captured_events, "viz_render_done") + assert any("task=vib_interactive" in m for _, m in starts) + assert any("backend=py3dmol" in m for _, m in starts) + assert any("mode=1" in m for _, m in starts) + assert any("elapsed_ms=" in m for _, m in dones) + + +class TestSyncCacheHitTelemetry: + def test_sync_cache_hit_emits_lifecycle_with_source( + self, app, water_mol, fake_freq_result, captured_events, tmp_path + ): + if not app._viz_availability.py3dmol: + pytest.skip("py3Dmol not installed in test env") + app._last_vib_freq_result = fake_freq_result + app._last_vib_molecule = water_mol + app._last_result_dir = tmp_path + + # First call populates the cache (no cache hit yet). + from quantui.app_visualization import _render_vib_mode_py3dmol + + _render_vib_mode_py3dmol(app, water_mol, mode_number=1) + + # Clear events from the populate call. + captured_events.clear() + + # Second call: synchronous cache hit. + hit = _try_vib_cache_hit_sync(app, mode_number=1) + assert hit is True + + starts = _events_of(captured_events, "viz_render_start") + dones = _events_of(captured_events, "viz_render_done") + cache_hits = _events_of(captured_events, "vib_cache_hit") + assert any("source=cache_sync" in m for _, m in starts) + assert any("source=cache_sync" in m for _, m in dones) + assert any("path=sync" in m for _, m in cache_hits) From 44a21ab14fb38053d3d8621c5dff68fe80a1c37c Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 22 May 2026 21:42:36 -0400 Subject: [PATCH 8/9] Prepare v0.2.0 release, docs & metadata Add release artifacts and documentation for v0.2.0: add CHANGELOG.md, bump package version (pyproject.toml and quantui.__version__), and expand README/docs with new features, launch instructions, and updated test baseline. Introduce launch-native.command (macOS launcher) and update .gitattributes to enforce LF for .command files. Enrich .github/copilot-instructions.md with modular UI layout, viz backend router, user settings, vib cache, telemetry, and other developer notes. Update packaging metadata (keywords/classifiers) and site copy (docs/index.html) to reflect the new modular UI and capabilities. --- .gitattributes | 3 +- .github/copilot-instructions.md | 137 +++++++++++++++++++++- CHANGELOG.md | 198 ++++++++++++++++++++++++++++++++ README.md | 144 +++++++++++++++++++---- docs/index.html | 187 +++++++++++++++++++++--------- launch-native.command | 90 +++++++++++++++ pyproject.toml | 9 +- quantui/__init__.py | 6 +- 8 files changed, 687 insertions(+), 87 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 launch-native.command diff --git a/.gitattributes b/.gitattributes index cc963bc..da16616 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ # Auto detect text files and perform LF normalization * text=auto -# Shell scripts must always use LF — CRLF breaks bash on Linux/WSL +# Shell scripts must always use LF — CRLF breaks bash on Linux/WSL/macOS *.sh text eol=lf +*.command text eol=lf apptainer/*.def text eol=lf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 20d02e9..269a6e9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,7 +32,14 @@ with transparent and extensible open tooling). ``` QuantUI/ ├── quantui/ ← Main Python package (imports as `quantui`) -│ ├── app.py ← QuantUIApp class — all widgets, callbacks, state +│ ├── app.py ← QuantUIApp — orchestration + run dispatch +│ ├── app_analysis.py ← Analysis-tab panel registry + _pop_* methods +│ ├── app_builders.py ← _build_* widget construction +│ ├── app_runflow.py ← _do_run + run/orchestration UI handlers +│ ├── app_visualization.py ← Trajectory / vib / IR / orbital / PES rendering +│ ├── app_history.py ← History tab loaders, replay context +│ ├── app_formatters.py ← Result-card and text formatters +│ ├── app_exports.py ← Export handlers (XYZ/MOL/PDB/script) │ ├── molecule.py ← Molecule dataclass + XYZ/SMILES parsing │ ├── session_calc.py ← In-session PySCF runner (run_in_session) │ ├── optimizer.py ← QM geometry optimization (ASE-BFGS + PySCF) @@ -46,6 +53,9 @@ QuantUI/ │ ├── calc_log.py ← Performance + event logging (JSONL) │ ├── pubchem.py ← PubChem molecule search │ ├── visualization_py3dmol.py ← 3D molecular viewer (py3Dmol / plotlyMol) +│ ├── viz_backend_router.py ← Capability-aware backend router (pure function) +│ ├── user_settings.py ← Persistent user preferences (~/.quantui/settings.json) +│ ├── vib_cache.py ← On-disk cache of rendered vib-mode HTML │ ├── ase_bridge.py ← ASE structure I/O + molecule library │ ├── preopt.py ← ASE force-field pre-optimisation (fast, no PySCF) │ ├── progress.py ← StepProgress widget @@ -61,7 +71,7 @@ QuantUI/ ├── notebooks/ │ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) │ └── tutorials/ ← 01–05 step-by-step tutorial notebooks -├── tests/ ← pytest suite (~875 tests) +├── tests/ ← pytest suite (~1000 tests; 97 PySCF-gated skip on Windows) ├── .github/ │ └── copilot-instructions.md ← This file ├── apptainer/ @@ -70,10 +80,21 @@ QuantUI/ ├── local-setup/ ← Conda environment YAMLs ├── launch-app.bat ← Windows double-click launcher (Voilà app mode) ├── launch-dev.bat ← Windows double-click launcher (JupyterLab mode) +├── CHANGELOG.md ← Release history (Keep a Changelog format) ├── pyproject.toml ← Package config (name: quantui, imports as quantui) └── pytest.ini ← pytest configuration ``` +**Modular UI note.** `QuantUIApp` lives in `app.py`, but most of its logic is +implemented as module-level functions in `app_builders.py`, `app_analysis.py`, +`app_runflow.py`, `app_visualization.py`, `app_history.py`, `app_formatters.py`, +and `app_exports.py`. `app.py` keeps thin instance-method wrappers that pass +`self` through (e.g. `def _pop_energies(self, ctx): return _ana_pop_energies(self, ctx)`). +When working on UI changes, prefer editing the relevant companion module +rather than growing `app.py`. The conservative refactor close (DEC-014) +intentionally leaves `_do_run` and a small molecule/theme/pubchem callback +cluster in `app.py`. + --- ## Architecture @@ -208,6 +229,113 @@ to build the context from disk. --- +## Visualization Backend Router + +3D rendering is dispatched through a pure-function router in +`quantui/viz_backend_router.py`. **Do not call py3Dmol or plotlymol3d +directly from `app_visualization.py` — always go through the router.** + +### `VizTask` keys + +`MOLECULE_PREVIEW`, `STRUCTURE_VIEW_RESULTS`, `ANALYSIS_STRUCTURE_VIEW`, +`HISTORY_STRUCTURE_REPLAY`, `TRAJECTORY_FRAME`, `TRAJECTORY_EXPORT`, +`VIB_INTERACTIVE`, `VIB_EXPORT`, `ORBITAL_ISOSURFACE`. + +### Policy + +- Each task has a `(primary, fallback)` tuple. +- User preference (`VizPreference.AUTO | PY3DMOL | PLOTLYMOL`) is respected + for tasks that support both backends. +- Single-backend tasks **ignore preference** (e.g. `TRAJECTORY_FRAME` is + py3Dmol-only — Plotly causes flicker; `ORBITAL_ISOSURFACE` is py3Dmol-only). +- `BackendAvailability.from_environment()` probes imports at startup; the + router falls back gracefully when one backend is missing. + +### Usage + +```python +from quantui.viz_backend_router import VizTask, select_backend +decision = app._resolve_backend(VizTask.STRUCTURE_VIEW_RESULTS) +# decision.chosen is "py3dmol", "plotlymol", or None (no renderer) +# decision.reason carries a human-readable explanation +``` + +`Decision` is a frozen dataclass — never mutate it. + +### Calculate ↔ Analysis toggle sync + +The Calculate and Analysis tabs each have a "Default 3D backend" ToggleButton. +They are kept in sync via `_set_viz_preference(...)`, gated by +`_viz_sync_in_progress` to prevent observer echo loops. The preference is +persisted to `~/.quantui/settings.json` (`viz.default_backend`). + +--- + +## Lifecycle Telemetry + +Every render-dispatch site in `app_visualization.py` is wrapped in the +`_viz_render_event(app, task, backend, **extras)` context manager. It emits +three event types to `event_log.jsonl`: + +| Event | When | +| --- | --- | +| `viz_render_start` | Entry into the context | +| `viz_render_done` | Successful exit, with `elapsed_ms` | +| `viz_render_error` | Exception in the body, with `elapsed_ms` + `error` | + +**All four render dispatch sites are wrapped:** static structure view, +trajectory frame, vib mode, and the vib sync cache-hit fast path. Use this +helper for any new render call so telemetry stays uniform. + +--- + +## Persistent User Settings + +`quantui/user_settings.py` provides `UserSettings.load()` / +`UserSettings.save()`, persisted to `~/.quantui/settings.json` (override with +`QUANTUI_SETTINGS_PATH` for testing). + +Current schema (`_schema_version = 1`): + +| Section | Field | Default | Purpose | +| --- | --- | --- | --- | +| `viz` | `default_backend` | `"auto"` | One of `auto / py3dmol / plotlymol` | +| `viz` | `vib_framerate_fps` | `10` | Clamped `[1, 120]`; included in vib cache key | + +**Robustness rules:** atomic writes (`.tmp` + rename); missing file, malformed +JSON, unknown schema version, missing sections, or invalid values all fall +back to defaults with a single warning log — startup never crashes on bad +settings. Schema growth is additive; bump `_schema_version` only for +breaking changes. + +--- + +## Vib-Animation Disk Cache + +`quantui/vib_cache.py` stores rendered py3Dmol HTML for each vibrational mode +under `/vib_frames/`: + +``` +/ +└── vib_frames/ + ├── index.json ← manifest (schema v1) + ├── mode_001.html + └── ... +``` + +**Cache key:** `(result_dir, mode_number, n_frames, amplitude, renderer, fps)`. +Any parameter change → stale → cache miss → re-render. + +**Sync cache-hit fast path:** `_swap_vib_output()` atomically swaps in cached +HTML without a transient "Rendering…" placeholder, eliminating flash on +mode switches. + +**Staleness guard:** `_vib_render_token` is incremented on every render +request; background renders that finish after a newer token has been issued +bail out instead of overwriting newer output. + +--- + ## Analysis Tab — 8 Panels All 8 panels are **always in the DOM** (`layout.display=""`, `selected_index=None`). @@ -527,8 +655,8 @@ Test files in `tests/`: **PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. On Windows, these become skips — not failures. -**Baseline (WSL, 2026-05-01; `python -m pytest tests/ -q --no-cov`):** -860 passed, 15 skipped (875 collected). +**Baseline (Windows `quantui-win`, 2026-05-22; `python -m pytest tests/ -q --no-cov`):** +1004 passed, 97 skipped (the 97 skips are PySCF-gated Linux-only tests). --- @@ -553,6 +681,7 @@ Install all runtime + dev extras: `pip install -e ".[pyscf,ase,app,notebook,dev] | --- | --- | --- | | `QUANTUI_RESULTS_DIR` | `./results` | Where calculation results are saved | | `QUANTUI_LOG_DIR` | `~/.quantui/logs` | Where perf_log and event_log live | +| `QUANTUI_SETTINGS_PATH` | `~/.quantui/settings.json` | User-preferences file (`user_settings.py`); override for tests | --- diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f2f8a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,198 @@ +# Changelog + +All notable changes to QuantUI are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-05-22 + +First substantial release after `v0.1.0`. The codebase moved from a single +monolithic `app.py` to a modular package, added six PySCF-backed calculation +types end-to-end, introduced a results-persistence layer with history replay, +and shipped a complete visualization stack (3D viewer with selectable backend, +trajectory animation, IR/UV-Vis/PES plots, orbital isosurfaces, vibrational +mode animation with caching). UI runs as a Voilà app suitable for classroom +deployment. + +### Added + +#### Calculations + +- **Geometry optimization** (`optimizer.py`) — ASE-BFGS driver around a custom + PySCF calculator; per-step trajectory persisted. +- **Vibrational frequency analysis** (`freq_calc.py`) — Hessian via + `pyscf.hessian`, ZPVE, thermochemistry (H/S/G at 298 K), IR intensities via + `pyscf.prop.infrared` or a numerical-derivative fallback for compatibility + across PySCF versions. +- **TD-DFT UV-Vis** (`tddft_calc.py`) — excitation energies, oscillator + strengths, wavelengths; full spectrum plot in the Analysis tab. +- **NMR shielding** (`nmr_calc.py`) — GIAO shielding via `pyscf.nmr` (core + preferred over `pyscf-properties` to dodge a known upstream bug); ¹H/¹³C + chemical shifts relative to TMS. +- **1D PES scan** (`pes_scan.py`) — bond / angle / dihedral; energy profile + + per-step geometry animation. +- **PCM implicit solvent** — Water, Ethanol, THF, DMSO, Acetonitrile via a + single checkbox in the Calculate tab. +- **MP2** post-HF method support. + +#### Analysis & visualization + +- **Analysis tab with 8 always-in-DOM panels** (Energies, Trajectory, + Vibrational, IR Spectrum, PES Scan, Isosurface, UV-Vis, NMR) wired through + a `_PANEL_REGISTRY` so live runs and history replay share one code path. +- **IR spectrum chart** (`ir_plot.py`) — stick plot + Lorentzian-broadened + curve; broadening toggle and FWHM slider. +- **UV-Vis spectrum plot** — Plotly chart with wavelength/energy axes. +- **Orbital visualization** (`orbital_visualization.py`) — energy-level + diagram (matplotlib → Plotly HTML) and cube-file isosurface viewer with + HOMO-1/HOMO/LUMO/LUMO+1 toggle. +- **Trajectory animation** — atomic Output-children swap to avoid + Voilà-deferred-display blank frames; py3Dmol-only render path with + prev/next arrow navigation. +- **Vibrational mode animation** — py3Dmol multi-frame XYZ renderer with + amplitude scaling, prev/next mode nav, and dropdown skipping near-zero + modes. +- **3D visualization backend router** (`viz_backend_router.py`) — pure + function that picks py3Dmol or plotlymol3d per `VizTask` based on user + preference and runtime availability; immutable `Decision` carries chosen + backend, fallback, and reason. +- **Lifecycle telemetry** — `_viz_render_event` context manager emits + `viz_render_start` / `viz_render_done` / `viz_render_error` JSONL events + with backend, task, `elapsed_ms`, and extras at every render dispatch. +- **Side-by-side Compare tab** — pick any two saved calculations and view a + diff table. + +#### Persistence & logging + +- **Results storage** (`results_storage.py`) — every run is saved to a + timestamped directory containing `result.json` (schema v2, additive-only), + `pyscf.log`, optional `trajectory.json` / `orbitals.npz` / `thumbnail.png`. +- **History tab** — browse and replay saved calculations after a kernel + restart; replay path is identical to live-run analysis activation. +- **Performance log** (`calc_log.py`) — `perf_log.jsonl` per converged run + + `event_log.jsonl` for startup/calc/error events; 7-day auto-prune. +- **Time estimator** — 4-strategy priority chain (N_basis-normalised → cross-method + electron-count) populates "Estimated time" before each run. +- **Benchmark suite** (`benchmarks.py`) — one-click calibration suite to + populate the time-estimator history with real machine data. +- **Issue tracker** (`issue_tracker.py`) — in-app bug-report UI writing to a + local `issues.db`. +- **Persistent user settings** (`user_settings.py`) — stored at + `~/.quantui/settings.json` (override via `QUANTUI_SETTINGS_PATH`). Schema + is section-based for additive growth, with atomic writes and graceful + fallback to defaults on corruption. +- **Vibrational-animation disk cache** (`vib_cache.py`) — per-result-dir + `vib_frames/` of pre-rendered py3Dmol HTML keyed by + `(mode, n_frames, amplitude, renderer, fps)`. Mode switches on repeat + visits and history replay are instant. +- **Vib FPS user preference** — `viz.vib_framerate_fps` exposed as an + IntSlider in the Status tab (clamped 1–120, default 10); included in the + vib cache key so changing FPS invalidates cleanly. + +#### UI + +- **Modular UI package** — `app.py` (orchestration) plus `app_analysis.py`, + `app_builders.py`, `app_exports.py`, `app_formatters.py`, `app_history.py`, + `app_runflow.py`, `app_visualization.py`. +- **Seven-tab layout** — Calculate, Results, Analysis, History, Compare, Log, + Status — with a floating Help overlay (not a tab). +- **Light / Dark theme selector** — dark by default on startup. +- **Status tab** — environment info, performance-history accordion (two-step + reset), default-3D-backend toggle, vib-FPS slider. +- **Files tab + activity indicator** for browsing saved results. +- **Plot export UI** — save IR, UV-Vis, PES, orbital diagram plots as HTML. +- **Scroll guard** for the run output area to keep long PySCF logs from + jumping the page. +- **Welcome header**, completion banner, structured log header/footer. +- **Compare-tab Copy-path button** (replaced a broken Open-folder action). +- **Result directory label + log accordion** showing inline `pyscf.log`. +- **Structure exports** — XYZ, MOL/SDF, PDB, plus a standalone runnable `.py` + script export. + +#### Tooling & dev + +- **Test suite grew from a handful to 1004 passed / 97 skipped** (Windows + `quantui-win` env baseline; the 97 skips are PySCF-gated Linux-only tests). +- New analysis-history end-to-end tests for every calc type + (`test_sp_analysis_history.py`, `test_geo_opt_analysis_history.py`, + `test_freq_analysis_history.py`, `test_tddft_analysis_history.py`, + `test_nmr_analysis_history.py`, `test_pes_scan_analysis_history.py`). +- `test_code_quality.py` enforces: + - No `include_plotlyjs="cdn"` anywhere (fails silently in offline Voilà). + - No bare `except: pass` blocks. +- `test_viz_backend_router.py` + `test_viz_backend_sync.py` — full + task × preference × availability matrix and Calculate/Analysis toggle sync. +- `test_vib_cache.py`, `test_vib_py3dmol_render.py`, + `test_viz_render_telemetry.py` — vib animation + telemetry coverage. +- `_layout(...)` helper sanitises `widgets.Layout` kwargs to eliminate a + 4808 → 13 traitlets warning regression. +- `_safe_cb` wrapper around every `.observe()` callback so exceptions surface + in the Log tab instead of disappearing into the Voilà kernel console. +- Kernel `io_loop` is cached at startup; thread-spawned callbacks are queued + onto the main thread to avoid `RuntimeError: no current event loop`. +- Native launchers: `launch-native.bat` (Windows / WSL) and + `launch-native.command` (macOS / Linux) — double-clickable, port `8867`, + stamp-based editable-install skip, browser auto-open. README documents + pinning each to the Start menu / Dock as a real app. +- Native JupyterLab launcher (`launch-native-jupyter.bat`) and Apptainer + launcher improvements. + +#### Docs + +- `.github/copilot-instructions.md` — canonical AI-assistant context (now + the single source of truth for any AI assistant working on this repo). +- `CLAUDE.md` — Claude-specific session/workflow context (git-ignored). +- Site favicons (ICO + SVG) for the GitHub Pages docs site. + +### Changed + +- **Visualization is py3Dmol-first.** `plotlymol3d` remains an optional + fallback for non-trajectory tasks; trajectory rendering is hard-wired to + py3Dmol to avoid Plotly/RequireJS flicker. +- **Plotly figures are rendered via `plotly.io.to_html(..., include_plotlyjs="require")`** + inside `widgets.HTML`, not `display(fig)`, so threaded renders work and + offline Voilà loads correctly. +- **`pyscf` is now an optional extra** (`pip install quantui[pyscf]`); the + package imports cleanly on Windows with PySCF unavailable. +- Repo renamed from `QuantUI-local` to `QuantUI`. + +### Fixed + +- **Trajectory accordion blank on first expand** — switched `traj_output` + from `Output` to `VBox` and use atomic children-swap so deferred + widget-display is no longer a blank-frame risk. +- **Vib mode races on rapid switching** — render-token guard (`_vib_render_token`) + causes stale background renders to bail rather than overwriting newer + output. +- **Camera state lost on mode switch** — JS hook caches the active + `$3Dmol.GLViewer` state across atomic HTML swaps; reset only on a + genuinely new frequency result. +- **PySCF API drift** — robust handling for v2 NMR / thermo API and the + `pyscf.prop.infrared` rename; both `Infrared.kernel()` and the older + IR API are supported. +- **Result-dir name collisions** — timestamps now include microseconds; same + formula + method + basis no longer overwrite each other. +- **IR x-axis** — corrected wavenumber axis on the IR Plotly figure. +- **Plotly figures invisible after accordion show** — figures are re-rendered + on accordion expand to handle RequireJS / display-deferral edge cases. + +### Removed + +- `visualization.py` (PlotlyMol fallback) — replaced by the router-backed + `visualization_py3dmol.py` path. +- All SLURM-era infrastructure already removed during the downstream port: + `job_manager.py`, `storage.py`, `slurm_errors.py`, SLURM config templates. + +## [0.1.0] - 2026 + +Initial public scaffolding of the QuantUI package: `quantui` package with +`molecule.py`, `pubchem.py`, `config.py`, `visualization_py3dmol.py`, +`calculator.py`, basic notebook launcher, Apptainer container definition, +MIT license, and project metadata. + +[Unreleased]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/The-Schultz-Lab/QuantUI/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/The-Schultz-Lab/QuantUI/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 634c7d0..5a6641c 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.9%20|%203.10%20|%203.11-blue)](https://www.python.org) -An interactive Jupyter interface for running quantum chemistry calculations -locally — no cluster account, no SLURM, no queueing. Students design -molecules, launch PySCF calculations in their own Python session, and -visualize results in minutes. +A powerful open-source frontend for DFT and post-HF quantum chemistry. +QuantUI puts [PySCF](https://pyscf.org) behind an interactive Jupyter/Voilà +UI so you can build molecules, run calculations locally, and visualize the +results — no cluster account, no SLURM, no queueing. -Built for classroom teaching at the -[Schultz Lab, North Carolina Central University](https://github.com/The-Schultz-Lab). +Developed by the +[Schultz Lab, North Carolina Central University](https://github.com/The-Schultz-Lab) +as an open alternative to closed-source GUI workflows. Equally suitable for +research and classroom use. --- @@ -19,9 +21,10 @@ Built for classroom teaching at the - **Molecule input** — paste XYZ coordinates, draw from a 20+ preset library, or search PubChem by name or SMILES -- **3D visualization** — interactive py3Dmol or PlotlyMol viewer with a live - backend toggle when both are installed; post-calculation structure rendered - automatically in the results panel +- **3D visualization** — interactive py3Dmol viewer (py3Dmol-first; optional + plotlymol3d fallback for non-trajectory tasks). A capability-aware backend + router picks the right renderer per task, and a Status-tab toggle persists + your default-backend preference between sessions - **In-session calculations** — RHF, UHF, 9 DFT functionals, MP2, NMR shielding, TD-DFT UV-Vis, and 1D PES scans via PySCF, running in your Python kernel (no batch submission) @@ -34,16 +37,21 @@ Built for classroom teaching at the HOMO, LUMO, LUMO+1), and a side-by-side comparison table for multiple calculations - **Geometry optimization** — BFGS optimizer with step-by-step trajectory - animation; vibrational frequency analysis with animated normal modes + animation; vibrational frequency analysis with animated normal modes, + user-tunable playback FPS, and a per-result-directory disk cache so mode + switches on repeat visits and history replay are instant - **Results persistence** — every calculation is saved automatically to a - timestamped directory; a built-in browser lets students reload past results + timestamped directory; a built-in browser lets you reload past results after a kernel restart; the full `pyscf.log` is shown inline - **Structure exports** — download XYZ, MOL/SDF, or PDB files alongside the saved results; script export for a standalone `.py` file +- **Plot export** — save IR, UV-Vis, PES, and orbital diagrams as standalone + HTML - **Timing calibration** — one-click benchmark suite populates the time estimator with real machine data so predictions are accurate from the first run -- **Voilà app mode** — serve the notebook as a polished widget-only UI (no code - visible) for classroom demos, with dark mode toggle and dedicated output log +- **Voilà app mode** — serve the notebook as a polished widget-only UI (no + code visible), with Light/Dark themes, a dedicated output log, and an + in-app bug-report form --- @@ -98,7 +106,7 @@ conda activate quantui # JupyterLab (full IDE — shows code) jupyter lab notebooks/molecule_computations.ipynb -# Voilà app mode (widget-only — for classroom demos) +# Voilà app mode (widget-only UI — code hidden) voila notebooks/molecule_computations.ipynb ``` @@ -107,6 +115,83 @@ Open the notebook, pick a molecule, choose a method and basis set, and click --- +## Launching QuantUI as an app + +For the smoothest day-to-day experience, QuantUI ships two double-clickable +launchers that activate the right conda environment, install the editable +package on first run (and only re-install when `pyproject.toml` actually +changes), clear any stale bytecode, start Voilà on port `8867`, and open the +app in your default browser. Edits to `quantui/*.py` are picked up live with +no rebuild. + +| Platform | File | Action | +| --- | --- | --- | +| Windows | [`launch-native.bat`](launch-native.bat) | Activates the `quantui` conda env inside WSL Ubuntu, runs Voilà, and opens `http://localhost:8867` | +| macOS / Linux | [`launch-native.command`](launch-native.command) | Activates the local `quantui` conda env directly (no WSL needed) and does the same | + +Both launchers reuse port `8867`, so you can keep the same browser tab pinned +across platforms. + +### Windows — pin to the Start menu + +1. Right-click [`launch-native.bat`](launch-native.bat) in File Explorer + → **Send to** → **Desktop (create shortcut)**. +2. Rename the shortcut to something friendly like `QuantUI`. +3. *(Optional)* Right-click the shortcut → **Properties** → **Change Icon...** + and point at `docs\logo.ico` for a proper app icon. +4. Move the shortcut into your Start-menu folder so it appears with normal + apps. Either: + - press `Win+R`, paste + `%APPDATA%\Microsoft\Windows\Start Menu\Programs`, and drop the + shortcut there *(per-user — recommended)*; or + - paste `%ProgramData%\Microsoft\Windows\Start Menu\Programs` for an + all-users install. +5. Open the Start menu, find **QuantUI**, right-click it, and choose + **Pin to Start** (or **Pin to taskbar**). + +You now launch QuantUI like any other Windows app — one click and Voilà opens +in your browser. + +### macOS — pin to the Dock / Launchpad + +**Quickest:** double-click [`launch-native.command`](launch-native.command) +from Finder. macOS will open Terminal, run the script, and pop the app open +in your browser. The first launch is gated by Gatekeeper: right-click the +file → **Open** → **Open** to clear it (one time only). + +**App-like experience (recommended):** wrap the launcher in a tiny Automator +application so it lives in Launchpad and pins to the Dock. + +1. Open **Automator** (Spotlight → "Automator") → **New Document** → + **Application**. +2. In the actions library on the left, find **Run Shell Script** and drag it + into the workflow pane on the right. +3. Set **Shell** to `/bin/bash` and **Pass input** to **as arguments**, then + replace the script body with the single line below (adjust the path if + your clone lives elsewhere): + + ```bash + "$HOME/path/to/QuantUI/launch-native.command" + ``` + +4. **File → Save** → name it `QuantUI` → save into `/Applications`. +5. *(Optional)* Set a custom icon: in Finder, open `docs/logo.svg` in + **Preview**, **Edit → Select All → Copy**, then in Finder select the + new `QuantUI.app`, **File → Get Info**, click the small icon in the + top-left of the Info window, and **Edit → Paste**. +6. Open Launchpad, find **QuantUI**, drag it into the Dock to pin it. + +You now have a real `.app` you can launch from Spotlight, Launchpad, or the +Dock — it just runs the `.command` script under the hood, so any +`quantui/*.py` edits take effect immediately on the next launch. + +> **Linux users:** the same `launch-native.command` script works from a +> terminal — `./launch-native.command`. To wire it into your desktop +> environment as a pinned app, create a `.desktop` entry pointing at the +> script. + +--- + ## Tutorials Five step-by-step notebooks in [`notebooks/tutorials/`](notebooks/tutorials/): @@ -179,31 +264,44 @@ pytest -m "not network" \ ```text quantui/ Main package - app.py QuantUIApp widget class (all tabs, UI logic) + app.py QuantUIApp — widget orchestration, run dispatch + app_analysis.py Analysis-tab panel registry + _pop_* methods + app_builders.py _build_* widget construction + app_runflow.py _do_run + run/orchestration UI handlers + app_visualization.py Trajectory / vib / IR / orbital / PES rendering + app_history.py History tab loaders, replay context + app_formatters.py Result-card and text formatters + app_exports.py Export handlers (XYZ/MOL/PDB/script) molecule.py Molecule input and validation session_calc.py In-session PySCF runner (RHF/UHF/DFT/MP2/PCM) - freq_calc.py Vibrational frequency + thermochemistry analysis - ir_plot.py IR spectrum chart (stick and Lorentzian broadened) + freq_calc.py Vibrational frequency + thermochemistry + ir_plot.py IR spectrum chart (stick / Lorentzian broadened) tddft_calc.py TD-DFT UV-Vis excited-state calculations - nmr_calc.py NMR shielding + ¹H/¹³C chemical shift prediction + nmr_calc.py NMR shielding + ¹H/¹³C chemical shifts pes_scan.py 1D potential energy surface scan optimizer.py QM geometry optimization with trajectory - visualization_py3dmol.py 3D viewer (py3Dmol + PlotlyMol backends) + visualization_py3dmol.py 3D viewer (py3Dmol-first; plotlymol fallback) + viz_backend_router.py Capability-aware backend router (pure function) + user_settings.py Persistent user preferences (~/.quantui/settings.json) + vib_cache.py On-disk cache of rendered vib-mode HTML + orbital_visualization.py Orbital energy diagrams + cube-file viewer pubchem.py PubChem molecule search comparison.py Side-by-side result tables - results_storage.py Timestamped result persistence - calc_log.py Performance logging and time estimation + results_storage.py Timestamped result persistence (schema v2) + calc_log.py Performance + event logging, time estimation + issue_tracker.py In-app bug-report DB benchmarks.py Timing calibration benchmark suite config.py Methods, basis sets, solvent/NMR options, presets ase_bridge.py ASE structure I/O preopt.py LJ force-field pre-optimization notebooks/ - molecule_computations.ipynb Main student-facing interface + molecule_computations.ipynb Main user-facing interface (3-cell launcher) tutorials/ Step-by-step guided notebooks (01–05) -tests/ pytest test suite (860+ tests) +tests/ pytest test suite (~1000 tests) apptainer/ Container definition for reproducible deployment local-setup/ Conda environment definition pyproject.toml Package metadata and tool config +CHANGELOG.md Release history (Keep a Changelog format) ``` --- diff --git a/docs/index.html b/docs/index.html index e95f87c..bf5f5dd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,10 +3,10 @@ - QuantUI — Quantum Chemistry in Jupyter - - - + QuantUI — An open-source frontend for DFT and post-HF quantum chemistry + + + @@ -141,6 +141,8 @@ .hero__meta { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .hero__stat { font-size: 0.8125rem; color: #64748b; font-weight: 500; } .hero__sep { color: #334155; } + .hero__link { color: #93c5fd; text-decoration: underline; } + .hero__link:hover { color: #bfdbfe; } .hero__visual { display: flex; justify-content: center; align-items: center; } @media (max-width: 768px) { .hero__inner { grid-template-columns: 1fr; } @@ -178,6 +180,7 @@ text-align: center; max-width: 540px; margin: 0 auto 3rem; line-height: 1.65; } + .section__subtitle--mid { margin-top: 3rem; } .section__note { font-size: 0.875rem; color: #94a3b8; text-align: center; margin-top: 1rem; @@ -244,6 +247,12 @@ border: 1px solid #e2e8f0; border-radius: 4px; padding: 0.15rem 0.45rem; color: #0f172a; } + .inline-code { + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 0.8rem; background: #f1f5f9; + border: 1px solid #e2e8f0; border-radius: 4px; + padding: 0.1rem 0.35rem; color: #0f172a; + } .td-num { font-weight: 700; color: #94a3b8; font-size: 0.8125rem; white-space: nowrap; @@ -330,7 +339,7 @@