diff --git a/.gitignore b/.gitignore index 1a4c3cf..353d06f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ planning/ nul /.claude/ /temp - untracked/ +/logs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4610248..ce04cbf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +fail_fast: true + repos: # ── Standard file hygiene ───────────────────────────────────────────────── - repo: https://github.com/pre-commit/pre-commit-hooks @@ -42,6 +44,7 @@ repos: rev: v1.10.0 hooks: - id: mypy + stages: [pre-push] files: ^quantui/ # type-check the package only, not tests args: ["--ignore-missing-imports", "--no-error-summary"] additional_dependencies: diff --git a/launch-native-jupyter.bat b/launch-native-jupyter.bat new file mode 100644 index 0000000..14b03f8 --- /dev/null +++ b/launch-native-jupyter.bat @@ -0,0 +1,72 @@ +@echo off +echo QuantUI NATIVE JUPYTER MODE -- Local conda env in WSL, no container +echo Use this when you have edited quantui/*.py and want JupyterLab. +echo. + +REM Convert the Windows repo path to a WSL path for portability +set "WIN_REPO=%~dp0." +for /f "delims=" %%i in ('wsl wslpath -a "%WIN_REPO%"') do set WSLPATH=%%i +if not defined WSLPATH ( + echo ERROR: Could not resolve a WSL path for %WIN_REPO% + echo Try this command manually: + echo wsl wslpath -a "%WIN_REPO%" + echo. + pause + exit /b 1 +) +set LOGFILE=%~dp0logs\native-jupyter.log + +echo Startup log: %LOGFILE% +echo. + +REM Runs JupyterLab directly from the quantui conda env inside WSL. +REM pip install -e . is skipped when pyproject.toml has not changed since the +REM last install (.dev_install_stamp). quantui/*.py changes are always live in +REM editable mode -- reinstall is only needed after pyproject.toml changes or on +REM first use. +REM Uses port 8868 to avoid conflict with container-based launchers on 8866 and +REM native Voila launcher on 8867. +REM Clears quantui/__pycache__ on every launch to prevent stale .pyc bytecode +REM (WSL2 DrvFs does not reliably propagate Windows-side mtime changes, so Python +REM may load pre-edit bytecode even after source changes -- see GOTCHAS.md). +REM PYTHONDONTWRITEBYTECODE=1 prevents a new stale cache from accumulating. +start "QuantUI [native-jupyter]" wsl -d Ubuntu --cd "%WSLPATH%" -- bash ./launch-native-jupyter.sh + +echo Waiting for JupyterLab to start on localhost:8868... +set MAX_WAIT=45 +set waited=0 +set OPENED=0 + +:wait_for_jupyter +powershell -NoProfile -Command "$client = New-Object Net.Sockets.TcpClient; try { $client.Connect('127.0.0.1', 8868); $client.Close(); exit 0 } catch { exit 1 }" > nul 2>&1 +if %errorlevel%==0 goto open_browser +if %waited% GEQ %MAX_WAIT% goto startup_timeout +timeout /t 1 /nobreak > nul +set /a waited=%waited%+1 +goto wait_for_jupyter + +:startup_timeout +echo. +echo JupyterLab did not open localhost:8868 within %MAX_WAIT% seconds. +echo Check the QuantUI [native-jupyter] WSL window for startup errors. +echo Review startup log: %LOGFILE% +if exist "%LOGFILE%" start "" "%LOGFILE%" +echo. +goto done + +:open_browser +set OPENED=1 +start http://127.0.0.1:8868/lab/tree/notebooks/molecule_computations.ipynb + +:done + +echo. +if "%OPENED%"=="1" ( + echo Native JupyterLab server running at http://127.0.0.1:8868/lab + echo All local quantui/*.py changes are live -- no rebuild needed. + echo Close the WSL window to stop. +) else ( + echo JupyterLab startup not confirmed yet. + echo Review the QuantUI [native-jupyter] WSL window for details. + echo Startup log: %LOGFILE% +) diff --git a/launch-native-jupyter.sh b/launch-native-jupyter.sh new file mode 100644 index 0000000..c28d825 --- /dev/null +++ b/launch-native-jupyter.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +LOG_FILE="logs/native-jupyter.log" +mkdir -p "$(dirname "$LOG_FILE")" + +{ + echo + echo "=== QuantUI native Jupyter launch: $(date -Iseconds) ===" + echo "PWD: $(pwd)" +} >> "$LOG_FILE" + +exec > >(tee -a "$LOG_FILE") 2>&1 + +source ~/miniconda3/etc/profile.d/conda.sh +conda activate quantui + +echo "Using Python: $(command -v python)" +echo "Using Jupyter: $(command -v jupyter)" + +# Reinstall editable package only when pyproject metadata changed, or on first run. +if [ ! -f .dev_install_stamp ] || [ pyproject.toml -nt .dev_install_stamp ]; then + pip install -e . -q + touch .dev_install_stamp +fi + +# Prevent stale bytecode from WSL2 DrvFs mtime quirks. +rm -rf quantui/__pycache__ +export PYTHONDONTWRITEBYTECODE=1 + +exec jupyter lab notebooks/molecule_computations.ipynb \ + --no-browser \ + --port=8868 \ + --ServerApp.port_retries=0 \ + --ServerApp.root_dir="$(pwd)" \ + --IdentityProvider.token='' \ + --ServerApp.password='' diff --git a/notebooks/molecule_computations.ipynb b/notebooks/molecule_computations.ipynb index 7c10d72..b9b8943 100644 --- a/notebooks/molecule_computations.ipynb +++ b/notebooks/molecule_computations.ipynb @@ -78,7 +78,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.15" + "version": "3.11.14" } }, "nbformat": 4, diff --git a/quantui/app.py b/quantui/app.py index f6aafd6..6d176cd 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -14,17 +14,15 @@ from __future__ import annotations import asyncio +import html as _html import io -import os import re -import sys import threading import time -import types as _types_mod import uuid as _uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Any, ClassVar, List, Literal, Optional +from typing import TYPE_CHECKING, Any, Callable, ClassVar, List, Literal, Optional import ipywidgets as widgets from IPython import get_ipython @@ -33,6 +31,336 @@ import quantui import quantui.calc_log as _calc_log import quantui.issue_tracker as _issue_tracker +from quantui.app_analysis import ( + activate_ana_panel as _ana_activate_ana_panel, +) +from quantui.app_analysis import ( + apply_analysis_context as _ana_apply_analysis_context, +) +from quantui.app_analysis import ( + build_ana_switcher as _ana_build_ana_switcher, +) +from quantui.app_analysis import ( + deactivate_all_ana_panels as _ana_deactivate_all_ana_panels, +) +from quantui.app_analysis import ( + pop_energies as _ana_pop_energies, +) +from quantui.app_analysis import ( + pop_geo_trajectory as _ana_pop_geo_trajectory, +) +from quantui.app_analysis import ( + pop_ir_spectrum as _ana_pop_ir_spectrum, +) +from quantui.app_analysis import ( + pop_isosurface as _ana_pop_isosurface, +) +from quantui.app_analysis import ( + pop_nmr_shielding as _ana_pop_nmr_shielding, +) +from quantui.app_analysis import ( + pop_pes_plot as _ana_pop_pes_plot, +) +from quantui.app_analysis import ( + pop_pes_trajectory as _ana_pop_pes_trajectory, +) +from quantui.app_analysis import ( + pop_preopt_trajectory as _ana_pop_preopt_trajectory, +) +from quantui.app_analysis import ( + pop_uv_vis as _ana_pop_uv_vis, +) +from quantui.app_analysis import ( + pop_vibrational as _ana_pop_vibrational, +) +from quantui.app_analysis import ( + select_ana_panel as _ana_select_ana_panel, +) +from quantui.app_builders import ( + build_calc_setup as _bld_build_calc_setup, +) +from quantui.app_builders import ( + build_compare_section as _bld_build_compare_section, +) +from quantui.app_builders import ( + build_files_tab as _bld_build_files_tab, +) +from quantui.app_builders import ( + build_help_section as _bld_build_help_section, +) +from quantui.app_builders import ( + build_history_section as _bld_build_history_section, +) +from quantui.app_builders import ( + build_issue_widgets as _bld_build_issue_widgets, +) +from quantui.app_builders import ( + build_molecule_section as _bld_build_molecule_section, +) +from quantui.app_builders import ( + build_output_tab as _bld_build_output_tab, +) +from quantui.app_builders import ( + build_results_section as _bld_build_results_section, +) +from quantui.app_builders import ( + build_run_section as _bld_build_run_section, +) +from quantui.app_builders import ( + build_shared_widgets as _bld_build_shared_widgets, +) +from quantui.app_builders import ( + build_status_panel as _bld_build_status_panel, +) +from quantui.app_builders import ( + build_theme_selector as _bld_build_theme_selector, +) +from quantui.app_builders import ( + build_welcome_header as _bld_build_welcome_header, +) +from quantui.app_exports import ( + export_molecule_and_label as _exp_export_molecule_and_label, +) +from quantui.app_exports import ( + molecule_to_rdkit as _exp_molecule_to_rdkit, +) +from quantui.app_exports import ( + on_export as _exp_on_export, +) +from quantui.app_exports import ( + on_export_mol as _exp_on_export_mol, +) +from quantui.app_exports import ( + on_export_pdb as _exp_on_export_pdb, +) +from quantui.app_exports import ( + on_export_xyz as _exp_on_export_xyz, +) +from quantui.app_formatters import ( + format_freq_result as _fmt_freq_result, +) +from quantui.app_formatters import ( + format_nmr_result as _fmt_nmr_result, +) +from quantui.app_formatters import ( + format_opt_result as _fmt_opt_result, +) +from quantui.app_formatters import ( + format_past_result as _fmt_past_result, +) +from quantui.app_formatters import ( + format_pes_scan_result as _fmt_pes_scan_result, +) +from quantui.app_formatters import ( + format_result as _fmt_result, +) +from quantui.app_formatters import ( + format_tddft_result as _fmt_tddft_result, +) +from quantui.app_history import ( + build_history_context as _hist_build_history_context, +) +from quantui.app_history import ( + history_load_analysis as _hist_history_load_analysis, +) +from quantui.app_history import ( + history_load_results as _hist_history_load_results, +) +from quantui.app_history import ( + mol_from_result_dir as _hist_mol_from_result_dir, +) +from quantui.app_history import ( + on_past_dd_changed as _hist_on_past_dd_changed, +) +from quantui.app_history import ( + on_view_log as _hist_on_view_log, +) +from quantui.app_runflow import ( + do_calibration as _run_do_calibration, +) +from quantui.app_runflow import ( + on_accumulate as _run_on_accumulate, +) +from quantui.app_runflow import ( + on_basis_help as _run_on_basis_help, +) +from quantui.app_runflow import ( + on_cal_run as _run_on_cal_run, +) +from quantui.app_runflow import ( + on_cal_stop as _run_on_cal_stop, +) +from quantui.app_runflow import ( + on_calc_type_changed as _run_on_calc_type_changed, +) +from quantui.app_runflow import ( + on_clear as _run_on_clear, +) +from quantui.app_runflow import ( + on_clear_log as _run_on_clear_log, +) +from quantui.app_runflow import ( + on_clear_log_cache as _run_on_clear_log_cache, +) +from quantui.app_runflow import ( + on_clear_log_cache_confirm as _run_on_clear_log_cache_confirm, +) +from quantui.app_runflow import ( + on_compare as _run_on_compare, +) +from quantui.app_runflow import ( + on_compare_clear as _run_on_compare_clear, +) +from quantui.app_runflow import ( + on_compare_refresh as _run_on_compare_refresh, +) +from quantui.app_runflow import ( + on_confirm_no as _run_on_confirm_no, +) +from quantui.app_runflow import ( + on_confirm_yes as _run_on_confirm_yes, +) +from quantui.app_runflow import ( + on_copy_results_path as _run_on_copy_results_path, +) +from quantui.app_runflow import ( + on_exit_clicked as _run_on_exit_clicked, +) +from quantui.app_runflow import ( + on_expand_mol_input as _run_on_expand_mol_input, +) +from quantui.app_runflow import ( + on_freq_seed_changed as _run_on_freq_seed_changed, +) +from quantui.app_runflow import ( + on_help_toggle as _run_on_help_toggle, +) +from quantui.app_runflow import ( + on_help_topic_changed as _run_on_help_topic_changed, +) +from quantui.app_runflow import ( + on_issue_btn as _run_on_issue_btn, +) +from quantui.app_runflow import ( + on_issue_cancel as _run_on_issue_cancel, +) +from quantui.app_runflow import ( + on_issue_submit as _run_on_issue_submit, +) +from quantui.app_runflow import ( + on_log_clear as _run_on_log_clear, +) +from quantui.app_runflow import ( + on_method_help as _run_on_method_help, +) +from quantui.app_runflow import ( + on_past_refresh as _run_on_past_refresh, +) +from quantui.app_runflow import ( + on_reset_click as _run_on_reset_click, +) +from quantui.app_runflow import ( + on_run_clicked as _run_on_run_clicked, +) +from quantui.app_runflow import ( + on_solvent_cb_changed as _run_on_solvent_cb_changed, +) +from quantui.app_runflow import ( + populate_compare_list as _run_populate_compare_list, +) +from quantui.app_runflow import ( + refresh_comparison as _run_refresh_comparison, +) +from quantui.app_runflow import ( + refresh_freq_seed_options as _run_refresh_freq_seed_options, +) +from quantui.app_runflow import ( + refresh_results_browser as _run_refresh_results_browser, +) +from quantui.app_runflow import ( + update_estimate as _run_update_estimate, +) +from quantui.app_runflow import ( + update_notes as _run_update_notes, +) +from quantui.app_runflow import ( + update_scan_widgets as _run_update_scan_widgets, +) +from quantui.app_visualization import ( + build_vib_data_from_freq_result as _viz_build_vib_data_from_freq_result, +) +from quantui.app_visualization import ( + build_vib_data_inner as _viz_build_vib_data_inner, +) +from quantui.app_visualization import ( + on_ir_fwhm_changed as _viz_on_ir_fwhm_changed, +) +from quantui.app_visualization import ( + on_ir_mode_changed as _viz_on_ir_mode_changed, +) +from quantui.app_visualization import ( + on_iso_generate as _viz_on_iso_generate, +) +from quantui.app_visualization import ( + on_orb_range_changed as _viz_on_orb_range_changed, +) +from quantui.app_visualization import ( + on_traj_expand as _viz_on_traj_expand, +) +from quantui.app_visualization import ( + on_uv_fwhm_changed as _viz_on_uv_fwhm_changed, +) +from quantui.app_visualization import ( + on_uv_mode_changed as _viz_on_uv_mode_changed, +) +from quantui.app_visualization import ( + on_vib_mode_changed as _viz_on_vib_mode_changed, +) +from quantui.app_visualization import ( + render_orbital_isosurface as _viz_render_orbital_isosurface, +) +from quantui.app_visualization import ( + render_traj_frame as _viz_render_traj_frame, +) +from quantui.app_visualization import ( + render_vib_mode as _viz_render_vib_mode, +) +from quantui.app_visualization import ( + show_ir_spectrum as _viz_show_ir_spectrum, +) +from quantui.app_visualization import ( + show_opt_trajectory as _viz_show_opt_trajectory, +) +from quantui.app_visualization import ( + show_orbital_diagram as _viz_show_orbital_diagram, +) +from quantui.app_visualization import ( + show_pes_scan_result as _viz_show_pes_scan_result, +) +from quantui.app_visualization import ( + show_result_3d as _viz_show_result_3d, +) +from quantui.app_visualization import ( + show_uv_vis_spectrum as _viz_show_uv_vis_spectrum, +) +from quantui.app_visualization import ( + show_vib_animation as _viz_show_vib_animation, +) +from quantui.app_visualization import ( + traj_step_html as _viz_traj_step_html, +) +from quantui.app_visualization import ( + update_ir_figure as _viz_update_ir_figure, +) +from quantui.app_visualization import ( + update_uv_vis_figure as _viz_update_uv_vis_figure, +) +from quantui.app_visualization import ( + wire_ir_controls as _viz_wire_ir_controls, +) +from quantui.app_visualization import ( + wire_uv_controls as _viz_wire_uv_controls, +) # Import directly from submodules to avoid circular-import issues. # quantui/__init__.py imports this module (app.py), so using @@ -238,6 +566,7 @@ def _layout(**kwargs: Any) -> widgets.Layout: r"cycle=\s*(\d+)\s+E=\s*([\-\d\.]+)\s+delta_E=\s*([\-\d\.Ee+\-]+)" ) _RE_CONV = re.compile(r"converged SCF energy\s*=\s*([\-\d\.]+)") +_RE_Q_STATUS = re.compile(r"\[QuantUI_STATUS\]\s*(.+)") # ══ LOG CAPTURE ══════════════════════════════════════════════════════════════ @@ -250,11 +579,14 @@ def __init__( self, output_widget: widgets.Output, status_label: Optional[widgets.Label] = None, + on_scf_converged: Optional[Callable[[], None]] = None, ) -> None: self._w = output_widget self._buf = io.StringIO() self._line_buf = "" self._status = status_label + self._on_scf_converged = on_scf_converged + self._scf_converged_seen = False def write(self, text: str) -> None: if not text: @@ -264,6 +596,10 @@ def write(self, text: str) -> None: self._line_buf += text while "\n" in self._line_buf: line, self._line_buf = self._line_buf.split("\n", 1) + m = _RE_Q_STATUS.search(line) + if m and self._status is not None: + self._status.value = m.group(1).strip() + continue m = _RE_CYCLE.search(line) if m and self._status is not None: n, delta = m.group(1), m.group(3) @@ -273,8 +609,15 @@ def write(self, text: str) -> None: self._status.value = f"SCF cycle {n}" continue m = _RE_CONV.search(line) - if m and self._status is not None: - self._status.value = "SCF converged ✓" + if m: + if self._status is not None: + self._status.value = "SCF converged ✓" + if not self._scf_converged_seen and self._on_scf_converged is not None: + self._scf_converged_seen = True + try: + self._on_scf_converged() + except Exception: + pass def flush(self) -> None: pass @@ -304,6 +647,7 @@ class _AnalysisContext: molecule: Optional[Any] = None # molecule used for the calculation spectra_data: dict = field(default_factory=dict) # from save_spectra / disk preopt_result: Optional[Any] = None # OptimizationResult from pre-opt step + timestamp: str = "" # result timestamp shown in history dropdown labels source: str = "live" # "live" | "history" @property @@ -326,6 +670,199 @@ class QuantUIApp: app.display() """ + if TYPE_CHECKING: + # Attributes initialized in companion builder modules. Keeping these + # declarations here avoids attr-defined churn during phased extraction. + _clear_log_cache_btn: Any + _clear_log_cache_confirm_btn: Any + _exit_btn: Any + _exit_output: Any + _help_btn: Any + _issue_btn: Any + _issue_cancel_btn: Any + _issue_overlay: Any + _issue_status_html: Any + _issue_submit_btn: Any + _issue_textarea: Any + _cal_accordion: Any + _cal_mode_toggle: Any + _cal_progress: Any + _cal_results_html: Any + _cal_run_btn: Any + _cal_step_label: Any + _cal_stop_btn: Any + _log_clear_btn: Any + _log_output_html: Any + _log_source_lbl: Any + _perf_accordion: Any + _perf_events_html: Any + _perf_stats_html: Any + _reset_btn: Any + _reset_confirm_box: Any + _reset_confirm_html: Any + _reset_confirm_no: Any + _reset_confirm_yes: Any + _status_html: Any + _status_tab_panel: Any + _theme_style: Any + _welcome_html: Any + _activity_btn: Any + advanced_accordion: Any + calc_setup_panel: Any + change_mol_btn: Any + copy_path_btn: Any + compare_btn: Any + compare_clear_btn: Any + compare_output: Any + compare_panel: Any + compare_refresh_btn: Any + compare_select: Any + files_tab_panel: Any + _files_entries: Any + _files_open_btn: Any + _files_path_html: Any + _files_preview_output: Any + _files_refresh_btn: Any + _files_root_dd: Any + _files_status_html: Any + _files_up_btn: Any + help_content_html: Any + help_tab_panel: Any + help_topic_dd: Any + history_panel: Any + log_tab_panel: Any + mol_input_collapsed: Any + mol_input_container: Any + mol_input_expanded: Any + past_dd: Any + past_output: Any + past_refresh_btn: Any + preset_dd: Any + pubchem_btn: Any + pubchem_msg: Any + pubchem_txt: Any + result_output: Any + result_viz_output: Any + results_path_lbl: Any + run_btn: Any + run_output: Any + run_panel: Any + run_status: Any + solvent_cb: Any + solvent_dd: Any + step_progress: Any + theme_btn: Any + viz_backend_toggle: Any + viz_controls_box: Any + viz_lighting_dd: Any + viz_output: Any + viz_style_dd: Any + view_log_btn: Any + xyz_area: Any + xyz_btn: Any + xyz_msg: Any + _freq_preopt_cb: Any + _freq_seed_dd: Any + _freq_seed_note: Any + _freq_seed_refresh_btn: Any + _go_analysis_btn: Any + _go_results_btn: Any + _ir_export_btn: Any + _ir_export_fmt_dd: Any + _ir_export_status: Any + _ir_fig: Any + _ir_fwhm_slider: Any + _ir_mode_toggle: Any + _ir_accordion: Any + _iso_accordion: Any + _iso_generate_btn: Any + _last_result_dir: Any + _nmr_accordion: Any + _nmr_output: Any + _orb_accordion: Any + _orb_diagram_box: Any + _orb_diagram_html: Any + _orb_export_btn: Any + _orb_export_fmt_dd: Any + _orb_export_status: Any + _orb_iso_controls: Any + _orb_iso_output: Any + _orb_n_orb_input: Any + _orb_toggle: Any + _orb_ymax_input: Any + _orb_ymin_input: Any + _pes_export_btn: Any + _pes_export_fmt_dd: Any + _pes_export_status: Any + _pes_plot_html: Any + _pes_scan_accordion: Any + _result_dir_label: Any + _result_log_accordion: Any + _result_log_output: Any + _scan_atom1: Any + _scan_atom2: Any + _scan_atom3: Any + _scan_atom34_box: Any + _scan_atom4: Any + _scan_start: Any + _scan_steps: Any + _scan_stop: Any + _scan_type_dd: Any + _scan_unit_lbl: Any + _tddft_accordion: Any + _tddft_fig: Any + _uv_export_btn: Any + _uv_export_fmt_dd: Any + _uv_export_status: Any + _uv_fwhm_slider: Any + _uv_mode_toggle: Any + _to_analysis_btn: Any + _viz_backend: Any + _viz_label: Any + _viz_lighting: Any + _viz_style: Any + _analysis_context_lbl: Any + _analysis_empty_html: Any + _analysis_mol_output: Any + _ana_unavail_html: Any + accumulate_btn: Any + analysis_tab_panel: Any + basis_dd: Any + basis_help_btn: Any + calc_extra_opts: Any + calc_type_dd: Any + charge_si: Any + clear_btn: Any + _completion_banner: Any + _completion_mol_lbl: Any + comparison_output: Any + export_btn: Any + export_mol_btn: Any + export_pdb_btn: Any + export_status: Any + export_xyz_btn: Any + fmax_fi: Any + log_clear_btn: Any + max_steps_si: Any + method_dd: Any + method_help_btn: Any + mol_info_html: Any + mol_summary_compact: Any + mult_si: Any + notes_output: Any + nstates_si: Any + perf_estimate_html: Any + post_calc_panel: Any + preopt_cb: Any + results_panel: Any + results_tab_panel: Any + struct_export_status: Any + traj_accordion: Any + traj_output: Any + vib_accordion: Any + vib_mode_dd: Any + vib_output: Any + def __init__(self) -> None: # ── Instance state ──────────────────────────────────────────────── self._molecule: Optional[Molecule] = None @@ -333,6 +870,26 @@ 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 + self._traj_render_token: int = 0 + self._iso_render_token: int = 0 + self._last_uv_wavelengths_nm: list[float] = [] + self._last_uv_oscillator_strengths: list[float] = [] + self._last_ir_fig: Any = None + self._last_uv_fig: Any = None + self._last_orb_fig: Any = None + self._last_pes_fig: Any = None + self._run_output_scroll_guard_installed: bool = False + self._files_current_dir: Optional[Path] = None + self._files_selected_path: Optional[Path] = None + self._files_updating: bool = False + self._activity_count: int = 0 + self._activity_compute_count: int = 0 + self._activity_lock = threading.Lock() + # Cache kernel io_loop once on the main thread so worker threads can + # reliably schedule UI callbacks even when get_ipython() is thread-local. + self._kernel_io_loop: Any = getattr( + getattr(get_ipython(), "kernel", None), "io_loop", None + ) self.root_tab: widgets.Tab self._session_id: str = _uuid.uuid4().hex[:12] @@ -360,6 +917,7 @@ def display(self) -> None: self._welcome_html, widgets.HBox( [ + self._activity_btn, self.theme_btn, self._help_btn, self._issue_btn, @@ -375,6 +933,7 @@ def display(self) -> None: ] ) ) + self._install_run_output_scroll_guard() @property def widget(self) -> widgets.Tab: @@ -395,25 +954,14 @@ def _build_widgets(self) -> None: self._build_history_section() self._build_compare_section() self._build_output_tab() + self._build_files_tab() self._build_help_section() self._build_issue_widgets() # ── Theme selector ──────────────────────────────────────────────────── def _build_theme_selector(self) -> None: - self._theme_style = widgets.Output( - layout=_layout(height="0px", overflow="hidden", margin="0", padding="0") - ) - self.theme_btn = widgets.ToggleButtons( - options=["Light", "Dark"], - value="Dark", - description="Theme:", - style={"description_width": "48px", "button_width": "90px"}, - layout=_layout(margin="0"), - ) - # Apply Dark theme immediately - with self._theme_style: - display(HTML(self._theme_css("Dark"))) + _bld_build_theme_selector(self, layout_fn=_layout) def _theme_css(self, theme: str) -> str: """Return the CSS filter block for *theme*, or '' for Light.""" @@ -428,1086 +976,156 @@ def _theme_css(self, theme: str) -> str: "" ) - # ── Status panel ────────────────────────────────────────────────────── + def _set_activity_indicator(self, state: str = "idle", message: str = "") -> None: + """Update the toolbar activity light state and tooltip.""" + if state == "compute": + self._activity_btn.description = "Computing" + self._activity_btn.icon = "cog" + self._activity_btn.button_style = "warning" + self._activity_btn.tooltip = message or "Running compute operations..." + return + if state == "ui": + self._activity_btn.description = "UI Active" + self._activity_btn.icon = "bolt" + self._activity_btn.button_style = "info" + self._activity_btn.tooltip = message or "Running UI operations..." + return - def _build_status_panel(self) -> None: - _cores, _mem_gb = get_session_resources() - _mem = f"{_mem_gb} GB" if _mem_gb is not None else "unknown" - _py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - _env = os.environ.get("CONDA_DEFAULT_ENV", "") or os.path.basename( - os.environ.get("VIRTUAL_ENV", "") - ) - _cal_label = _load_last_calibration_label() - - def _ok(flag: bool, extra: str = "") -> str: - tick = '✓' - cross = '✗' - return (tick if flag else cross) + (" " + extra if extra else "") - - _items = [ - ( - "PySCF (calculations)", - _ok( - _PYSCF_AVAILABLE, - "" if _PYSCF_AVAILABLE else "— Linux / macOS / WSL required", - ), - ), - ("ASE (structure I/O, opt.)", _ok(ASE_AVAILABLE)), - ("PubChem search", _ok(PUBCHEM_AVAILABLE)), - ("3D viewer (py3Dmol)", _ok(VISUALIZATION_AVAILABLE)), - ("CPU cores / Memory", f"{_cores} cores / {_mem}"), - ] - _rows = "".join( - f'
{_env}'
- if _env and _env not in ("base", "")
- else ""
- )
- _cal_line = (
- f'' - "No calculation run yet. PySCF output and any errors will appear here." - "
" - ) - ) - self.result_output = widgets.Output() - self.result_viz_output = widgets.Output() - self.comparison_output = widgets.Output() - self._last_result_dir: Optional[Path] = None - - # 3D viewer backend selector — shown only when both backends are installed - self._viz_backend: _VizBackend = _DEFAULT_VIZ_BACKEND - if _BOTH_VIZ_AVAILABLE: - self.viz_backend_toggle = widgets.ToggleButtons( - options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")], - value=_DEFAULT_VIZ_BACKEND, - tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"], - style={"button_width": "90px"}, - layout=_layout(margin="2px 0 0 0"), - ) - else: - self.viz_backend_toggle = None # type: ignore[assignment] - - # 3D viewer style and lighting controls - self._viz_style: str = _DEFAULT_VIZ_STYLE - self._viz_lighting: str = _DEFAULT_LIGHTING - self.viz_style_dd = widgets.Dropdown( - options=_VIZ_STYLE_OPTIONS, - value=_DEFAULT_VIZ_STYLE, - description="Style:", - style={"description_width": "40px"}, - layout=_layout(width="180px"), - disabled=not VISUALIZATION_AVAILABLE, - ) - # Lighting only applies to the PlotlyMol backend - _lighting_available = VISUALIZATION_AVAILABLE and _PLOTLYMOL_VIZ - self.viz_lighting_dd = widgets.Dropdown( - options=_LIGHTING_OPTIONS, - value=_DEFAULT_LIGHTING, - description="Lighting:", - style={"description_width": "58px"}, - layout=_layout(width="170px"), - disabled=not _lighting_available, - ) - if not _lighting_available: - self.viz_lighting_dd.layout.visibility = "hidden" - self.viz_controls_box = widgets.HBox( - [self.viz_style_dd, self.viz_lighting_dd], - layout=_layout(gap="8px", margin="2px 0 0 0", align_items="center"), - ) - self.notes_output = widgets.Output() - self.perf_estimate_html = widgets.HTML() - - # Step indicator - self.step_progress = StepProgress( - ["Choose molecule", "Set method", "Run", "Results"] - ) - self.step_progress.start(0) - - # Calculation setup dropdowns - self.method_dd = widgets.Dropdown( - options=SUPPORTED_METHODS, - value=DEFAULT_METHOD, - description="Method:", - style={"description_width": "100px"}, - layout=_layout(width="260px"), - ) - self.basis_dd = widgets.Dropdown( - options=SUPPORTED_BASIS_SETS, - value=DEFAULT_BASIS, - description="Basis Set:", - style={"description_width": "100px"}, - layout=_layout(width="260px"), - ) - self.charge_si = widgets.BoundedIntText( - value=DEFAULT_CHARGE, - min=-10, - max=10, - description="Charge:", - style={"description_width": "100px"}, - layout=_layout(width="190px"), - ) - self.mult_si = widgets.BoundedIntText( - value=DEFAULT_MULTIPLICITY, - min=1, - max=10, - description="Multiplicity:", - style={"description_width": "100px"}, - layout=_layout(width="190px"), - ) - self.preopt_cb = widgets.Checkbox( - value=False, - description="Pre-optimize geometry (for a crude starting point)", - disabled=not _PREOPT_AVAILABLE, - layout=_layout(width="400px"), - ) - - # Implicit solvent (PCM) - from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS - - self.solvent_cb = widgets.Checkbox( - value=False, - description="Implicit solvent (PCM)", - layout=_layout(width="240px"), - ) - self.solvent_dd = widgets.Dropdown( - options=list(_SOLVENT_OPTS.keys()), - value="Water", - description="Solvent:", - style={"description_width": "70px"}, - layout=_layout(width="200px", display="none"), - ) - - # Calculation type + extra options - self.calc_type_dd = widgets.Dropdown( - options=[ - "Single Point", - "Geometry Opt", - "Frequency", - "UV-Vis (TD-DFT)", - "NMR Shielding", - "PES Scan", - ], - value="Single Point", - description="Calc. Type:", - style={"description_width": "100px"}, - layout=_layout(width="310px"), - ) - self.fmax_fi = widgets.BoundedFloatText( - value=DEFAULT_FMAX, - min=0.001, - max=1.0, - step=0.005, - description="Force thr. (eV/Å):", - style={"description_width": "130px"}, - layout=_layout(width="270px"), - ) - self.max_steps_si = widgets.BoundedIntText( - value=DEFAULT_OPT_STEPS, - min=10, - max=1000, - description="Max steps:", - style={"description_width": "100px"}, - layout=_layout(width="200px"), - ) - self.nstates_si = widgets.BoundedIntText( - value=10, - min=1, - max=50, - description="# states:", - style={"description_width": "100px"}, - layout=_layout(width="180px"), - ) - - # ── Frequency calc extra widgets ────────────────────────────────────── - self._freq_seed_dd = widgets.Dropdown( - options=[("(use current molecule)", "")], - description="Seed geometry:", - style={"description_width": "110px"}, - layout=_layout(width="420px"), - tooltip="Optionally load the final optimised geometry from a previous Geo Opt result", - ) - self._freq_seed_refresh_btn = widgets.Button( - description="", - icon="refresh", - layout=_layout(width="32px", height="32px"), - tooltip="Refresh the list of saved geometry optimisations", - ) - self._freq_preopt_cb = widgets.Checkbox( - value=False, - description="Geometry optimization (recommended for unoptimized inputs)", - style={"description_width": "initial"}, - layout=_layout(width="100%"), - ) - self._freq_seed_note = widgets.HTML("") - - # ── PES scan extra widgets ──────────────────────────────────────────── - self._scan_type_dd = widgets.Dropdown( - options=["Bond", "Angle", "Dihedral"], - value="Bond", - description="Scan type:", - style={"description_width": "80px"}, - layout=_layout(width="220px"), - ) - _atom_idx_layout = _layout(width="95px") - _atom_idx_style = {"description_width": "50px"} - self._scan_atom1 = widgets.BoundedIntText( - value=1, - min=1, - max=999, - description="Atom 1:", - style=_atom_idx_style, - layout=_atom_idx_layout, - ) - self._scan_atom2 = widgets.BoundedIntText( - value=2, - min=1, - max=999, - description="Atom 2:", - style=_atom_idx_style, - layout=_atom_idx_layout, - ) - self._scan_atom3 = widgets.BoundedIntText( - value=3, - min=1, - max=999, - description="Atom 3:", - style=_atom_idx_style, - layout=_atom_idx_layout, - ) - self._scan_atom4 = widgets.BoundedIntText( - value=4, - min=1, - max=999, - description="Atom 4:", - style=_atom_idx_style, - layout=_atom_idx_layout, - ) - self._scan_atom34_box = widgets.HBox( - [self._scan_atom3, self._scan_atom4], - layout=_layout(gap="4px"), - ) - self._scan_start = widgets.BoundedFloatText( - value=0.5, - min=0.01, - max=1000.0, - step=0.1, - description="Start:", - style={"description_width": "40px"}, - layout=_layout(width="140px"), - ) - self._scan_stop = widgets.BoundedFloatText( - value=2.0, - min=0.01, - max=1000.0, - step=0.1, - description="Stop:", - style={"description_width": "40px"}, - layout=_layout(width="140px"), - ) - self._scan_steps = widgets.BoundedIntText( - value=10, - min=2, - max=100, - description="Points:", - style={"description_width": "50px"}, - layout=_layout(width="120px"), - ) - self._scan_unit_lbl = widgets.HTML( - 'Å' - ) - - self.calc_extra_opts = widgets.VBox([]) - - # Context-help buttons - self.method_help_btn = widgets.Button( - description="?", - button_style="", - layout=_layout(width="28px", height="28px"), - tooltip="RHF vs UHF — opens Help tab", + _bld_build_shared_widgets( + self, + layout_fn=_layout, + step_progress_cls=StepProgress, + supported_methods=SUPPORTED_METHODS, + supported_basis_sets=SUPPORTED_BASIS_SETS, + default_method=DEFAULT_METHOD, + default_basis=DEFAULT_BASIS, + default_charge=DEFAULT_CHARGE, + default_multiplicity=DEFAULT_MULTIPLICITY, + default_fmax=DEFAULT_FMAX, + default_opt_steps=DEFAULT_OPT_STEPS, + preopt_available=_PREOPT_AVAILABLE, + visualization_available=VISUALIZATION_AVAILABLE, + both_viz_available=_BOTH_VIZ_AVAILABLE, + default_viz_backend=_DEFAULT_VIZ_BACKEND, + default_viz_style=_DEFAULT_VIZ_STYLE, + default_lighting=_DEFAULT_LIGHTING, + viz_style_options=_VIZ_STYLE_OPTIONS, + plotlymol_viz=_PLOTLYMOL_VIZ, + lighting_options=_LIGHTING_OPTIONS, + rdkit_available=_RDKIT_AVAILABLE, ) - self.basis_help_btn = widgets.Button( - description="?", - button_style="", - layout=_layout(width="28px", height="28px"), - tooltip="Choosing a basis set — opens Help tab", - ) - - # Run widgets - self.run_btn = widgets.Button( - description="Run Calculation", - button_style="success", - icon="play", - disabled=True, - layout=_layout(width="200px", height="36px"), - ) - self.run_status = widgets.Label() - - # Log clear button (in run panel) - self.log_clear_btn = widgets.Button( - description="Clear", - button_style="", - icon="times", - layout=_layout(width="90px", height="26px"), - tooltip="Clear calculation output", - ) - - # Comparison / export widgets - self.accumulate_btn = widgets.Button( - description="Add to Comparison", - button_style="info", - icon="plus", - disabled=True, - layout=_layout(width="190px"), - ) - self.clear_btn = widgets.Button( - description="Clear", - button_style="warning", - icon="trash", - layout=_layout(width="100px"), - ) - self.export_btn = widgets.Button( - description="Export Script", - button_style="", - icon="download", - disabled=True, - layout=_layout(width="160px"), - ) - self.export_status = widgets.Label() - _rdkit_tip = ( - "" - if _RDKIT_AVAILABLE - else "Requires RDKit (conda install -c conda-forge rdkit)" - ) - self.export_xyz_btn = widgets.Button( - description="Export XYZ", - icon="download", - disabled=True, - layout=_layout(width="130px"), - ) - self.export_mol_btn = widgets.Button( - description="Export MOL", - icon="download", - disabled=True, - tooltip=_rdkit_tip, - layout=_layout(width="130px"), - ) - self.export_pdb_btn = widgets.Button( - description="Export PDB", - icon="download", - disabled=True, - tooltip=_rdkit_tip, - layout=_layout(width="130px"), - ) - self.struct_export_status = widgets.Label() # ── Molecule section (Cell 4) ───────────────────────────────────────── def _build_molecule_section(self) -> None: - # Preset dropdown - _preset_opts = ["(select a molecule)"] + list(MOLECULE_LIBRARY.keys()) - self.preset_dd = widgets.Dropdown( - options=_preset_opts, - value="(select a molecule)", - description="Molecule:", - style={"description_width": "90px"}, - layout=_layout(width="320px"), - ) - - # XYZ input - self.xyz_area = widgets.Textarea( - placeholder=( - "Paste XYZ coordinates (symbol x y z):\n" - "O 0.000 0.000 0.000\n" - "H 0.757 0.587 0.000\n" - "H -0.757 0.587 0.000" - ), - layout=_layout(width="440px", height="130px"), - ) - self.xyz_btn = widgets.Button( - description="Load XYZ", button_style="info", icon="upload" - ) - self.xyz_msg = widgets.Label() - - # PubChem search - self.pubchem_txt = widgets.Text( - placeholder="name or SMILES (e.g. aspirin, caffeine, CC(=O)O)", - layout=_layout(width="380px"), - ) - self.pubchem_btn = widgets.Button( - description="Search", - button_style="info", - icon="search", - disabled=not PUBCHEM_AVAILABLE, - layout=_layout(width="100px"), - ) - self.pubchem_msg = widgets.Label( - value=( - "" - if PUBCHEM_AVAILABLE - else "PubChem unavailable — check internet connection" - ) - ) - - # Assemble input tab - _hint = '' - tab_preset = widgets.VBox( - [ - widgets.HTML( - _hint + "Choose from 20+ curated educational molecules.
" - ), - self.preset_dd, - ] - ) - tab_xyz = widgets.VBox( - [ - widgets.HTML( - _hint - + "Paste XYZ coordinates (element x y z, one atom per line)." - ), - self.xyz_area, - widgets.HBox([self.xyz_btn, self.xyz_msg]), - ] - ) - tab_pubchem = widgets.VBox( - [ - widgets.HTML( - _hint - + "Search by name or SMILES. Requires internet connection." - ), - widgets.HBox([self.pubchem_txt, self.pubchem_btn]), - self.pubchem_msg, - ] - ) - input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem]) - for _i, _t in enumerate(["Preset Library", "XYZ Input", "PubChem Search"]): - input_tab.set_title(_i, _t) - - # Collapsible container - self.mol_input_expanded = widgets.VBox( - [ - widgets.HTML('PySCF runs in this ' - "kernel. Output appears live below. Large molecules or high-accuracy basis " - "sets may take several minutes on a laptop.
" - ), - self.perf_estimate_html, - widgets.HBox([self.run_btn, self.run_status]), - widgets.HBox( - [ - widgets.HTML( - '' - "Calculation Output" - ), - self.log_clear_btn, - ], - layout=_layout( - align_items="center", - justify_content="space-between", - margin="10px 0 4px", - max_width="460px", - ), - ), - self.run_output, - ] - ) + _bld_build_run_section(self, layout_fn=_layout) # ── Results panel (Cell 7) ──────────────────────────────────────────── def _build_results_section(self) -> None: - # PES scan energy plot accordion (hidden until a PES Scan completes) - self._pes_plot_html = widgets.Output(layout=_layout(width="100%")) - self._pes_scan_accordion = widgets.Accordion( - children=[ - widgets.VBox( - [self._pes_plot_html], - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self._pes_scan_accordion.set_title(0, "PES Energy Profile") - self._pes_scan_accordion.selected_index = None - - # Trajectory accordion (Geo Opt / PES Scan — hidden until result completes) - self.traj_output = widgets.Output() - self.traj_accordion = widgets.Accordion( - children=[self.traj_output], - layout=_layout(display="none", margin="8px 0"), - ) - self.traj_accordion.set_title(0, "Trajectory Viewer") - self.traj_accordion.selected_index = None # collapsed by default - self.traj_accordion.observe( - self._safe_cb(self._on_traj_expand), names=["selected_index"] - ) + _bld_build_results_section(self, layout_fn=_layout) - # Vibrational animation accordion (Frequency only — hidden until Freq completes) - self.vib_mode_dd = widgets.Dropdown( - description="Mode:", - options=[], - style={"description_width": "50px"}, - layout=_layout(width="360px"), - ) - self.vib_output = widgets.Output() - self.vib_accordion = widgets.Accordion( - children=[ - widgets.VBox( - [self.vib_mode_dd, self.vib_output], - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self.vib_accordion.set_title(0, "Vibrational Mode Viewer") - self.vib_accordion.selected_index = None # collapsed by default - - # IR Spectrum accordion (hidden until a Frequency result is available) - self._ir_mode_toggle = widgets.ToggleButtons( - options=["Stick", "Broadened"], - value="Stick", - style={"button_width": "80px"}, - layout=_layout(margin="0 8px 0 0"), - ) - self._ir_fwhm_slider = widgets.FloatSlider( - value=20.0, - min=5.0, - max=100.0, - step=5.0, - description="Line width:", - style={"description_width": "80px"}, - layout=_layout(width="260px", display="none"), - ) - self._ir_fig = widgets.Output(layout=_layout(width="100%")) - - _ir_controls = widgets.HBox( - [self._ir_mode_toggle, self._ir_fwhm_slider], - layout=_layout(align_items="center", margin="0 0 6px 0"), - ) - _ir_body_children = [_ir_controls, self._ir_fig] - self._ir_accordion = widgets.Accordion( - children=[ - widgets.VBox( - _ir_body_children, - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self._ir_accordion.set_title(0, "IR Spectrum") - self._ir_accordion.selected_index = None - - # Orbital energy diagram + isosurface accordion (Single Point / Geo Opt) - # Use plotly.io.to_html so FigureWidget / anywidget dependency is not needed. - - self._orb_ymin_input = widgets.BoundedFloatText( - value=-30.0, - min=-500.0, - max=200.0, - step=1.0, - description="Y min:", - layout=_layout(width="140px"), - style={"description_width": "45px"}, - ) - self._orb_ymax_input = widgets.BoundedFloatText( - value=5.0, - min=-500.0, - max=500.0, - step=1.0, - description="Y max:", - layout=_layout(width="140px"), - style={"description_width": "45px"}, - ) - self._orb_n_orb_input = widgets.BoundedIntText( - value=20, - min=4, - max=200, - step=2, - description="Show N:", - layout=_layout(width="120px"), - style={"description_width": "50px"}, - ) - _orb_controls_row = widgets.HBox( - [ - widgets.HTML( - 'Y range:' - ), - self._orb_ymin_input, - self._orb_ymax_input, - widgets.HTML( - '' - "Orbitals shown:" - ), - self._orb_n_orb_input, - ], - layout=_layout( - align_items="center", - flex_wrap="wrap", - gap="4px", - margin="0 0 6px 0", - ), - ) - self._orb_diagram_html = widgets.Output(layout=_layout(width="100%")) - _orb_diagram_content: list = [_orb_controls_row, self._orb_diagram_html] - self._orb_diagram_box = widgets.VBox( - _orb_diagram_content, - layout=_layout(width="100%"), - ) - self._orb_toggle = widgets.ToggleButtons( - options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], - value="HOMO", - style={"button_width": "70px"}, - layout=_layout(margin="8px 0 4px 0"), - ) - self._orb_iso_output = widgets.Output() - self._orb_iso_controls = widgets.VBox( - [ - widgets.HTML( - '' - "Orbital isosurface:" - ), - self._orb_toggle, - self._orb_iso_output, - ], - layout=_layout(display="none", margin="8px 0 0 0"), - ) - self._orb_accordion = widgets.Accordion( - children=[ - widgets.VBox( - [self._orb_diagram_box], - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self._orb_accordion.set_title(0, "Orbital Diagram") - self._orb_accordion.selected_index = None - - # Post-calculate panel — isosurface and other heavy on-demand analyses - self._iso_generate_btn = widgets.Button( - description="Generate Isosurface", - button_style="primary", - icon="flask", - disabled=True, - tooltip=( - "Generate a 3D orbital isosurface. " - "Available after running or loading a Single Point or Geometry Optimization." - ), - layout=_layout(width="200px", margin="8px 0 4px 0"), - ) - _iso_body = widgets.VBox( - [ - widgets.HTML( - '' - "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — " - "requires PySCF and RDKit). Run or load a Single Point or Geometry " - "Optimization first, then click Generate.
" - ), - self._orb_iso_controls, - self._iso_generate_btn, - ], - layout=_layout(padding="8px"), - ) - self._iso_accordion = widgets.Accordion( - children=[_iso_body], - layout=_layout(display="none", margin="8px 0"), - ) - self._iso_accordion.set_title(0, "Orbital Isosurface") - self._iso_accordion.selected_index = None - - # ── UV-Vis spectrum accordion (TD-DFT only — hidden until result) ── - self._tddft_fig = widgets.Output(layout=_layout(width="100%")) - self._tddft_accordion = widgets.Accordion( - children=[ - widgets.VBox( - [self._tddft_fig], - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum") - self._tddft_accordion.selected_index = None - - # ── NMR shielding accordion (NMR only — hidden until result) ──────── - self._nmr_output = widgets.HTML(value="", layout=_layout(width="100%")) - self._nmr_accordion = widgets.Accordion( - children=[ - widgets.VBox( - [self._nmr_output], - layout=_layout(padding="8px"), - ) - ], - layout=_layout(display="none", margin="8px 0"), - ) - self._nmr_accordion.set_title(0, "NMR Chemical Shifts") - self._nmr_accordion.selected_index = None - - # ── Result directory path label (hidden until a calculation saves) ── - self._result_dir_label = widgets.HTML( - value="", - layout=_layout(display="none", margin="4px 0 0 0"), - ) - - # ── Full output log accordion (hidden until a calculation saves) ──── - self._result_log_output = widgets.Output() - self._result_log_accordion = widgets.Accordion( - children=[self._result_log_output], - layout=_layout(display="none", margin="8px 0 0 0"), - ) - self._result_log_accordion.set_title(0, "Full output log (pyscf.log)") - self._result_log_accordion.selected_index = None - - # ── Completion banner (Calculate tab — hidden until run finishes) ─── - self._go_results_btn = widgets.Button( - description="→ View Results", - button_style="success", - layout=_layout(width="130px"), - ) - self._go_analysis_btn = widgets.Button( - description="→ View Analysis", - button_style="info", - layout=_layout(width="140px"), - ) - self._completion_mol_lbl = widgets.HTML(value="") - self._completion_banner = widgets.HBox( - [ - widgets.HTML( - '' - "✓ Calculation complete — " - ), - self._completion_mol_lbl, - self._go_results_btn, - self._go_analysis_btn, - ], - layout=_layout( - display="none", - align_items="center", - gap="8px", - padding="10px 12px", - border="1px solid #bbf7d0", - border_radius="6px", - background_color="#f0fdf4", - margin="8px 0", - ), - ) - - # ── Results tab panel (Tab 1) ───────────────────────────────────── - self._to_analysis_btn = widgets.Button( - description="→ View Analysis", - button_style="", - icon="bar-chart", - layout=_layout(display="none", width="160px", margin="8px 0 0 0"), - ) - # Label above the 3D viewer — updated by _do_run to say "Optimized geometry" - # for Geometry Opt, or hidden for other calc types that don't change geometry. - self._viz_label = widgets.HTML( - value="", - layout=_layout(display="none"), - ) - self.results_tab_panel = widgets.VBox( - [ - widgets.HTML('' - "No result loaded yet. Run a calculation or load one from History.
" - ) - ) - self._analysis_empty_html = widgets.HTML( - value=( - ''
- "No interactive analysis is available for this calculation type.
"
- "Run a Single Point, Geo Opt, or Frequency calculation to see "
- "orbital diagrams, trajectory animations, and spectra here.
' - f"Analysing: {ctx.label}{_src}
" - ) - _has = bool(self._ana_available) - self._to_analysis_btn.layout.display = "" if _has else "none" - self._analysis_empty_html.layout.display = "none" if _has else "" + _ana_apply_analysis_context(self, ctx) # ── Panel populate methods ──────────────────────────────────────────────── # Each receives an _AnalysisContext and returns True if data was rendered. def _pop_energies(self, ctx: _AnalysisContext) -> bool: - result = ctx.live_result - if result is None and ctx.result_dir is not None: - try: - from quantui.results_storage import load_orbitals - - orb = load_orbitals(ctx.result_dir) - orb.formula = ctx.formula - result = orb - except Exception: - return False - return self._show_orbital_diagram(result) + return _ana_pop_energies(self, ctx) def _pop_isosurface(self, ctx: _AnalysisContext) -> bool: - # Isosurface controls are enabled by _show_orbital_diagram when MO data - # is present; just check whether that data was stashed. - return ( - self._last_orb_mo_coeff is not None - and self._last_orb_mol_atom is not None - and self._last_orb_mol_basis is not None - ) + return _ana_pop_isosurface(self, ctx) def _pop_geo_trajectory(self, ctx: _AnalysisContext) -> bool: - traj = None - energies: list = [] - if ctx.live_result is not None: - traj = getattr(ctx.live_result, "trajectory", None) - energies = list(getattr(ctx.live_result, "energies_hartree", [])) - elif ctx.result_dir is not None: - traj_file = ctx.result_dir / "trajectory.json" - if traj_file.exists(): - try: - from quantui.results_storage import load_trajectory - - traj, energies = load_trajectory(ctx.result_dir) - except Exception: - return False - if not traj or len(traj) < 2: - return False - stub = _types_mod.SimpleNamespace( - trajectory=traj, - energies_hartree=energies, - formula=ctx.formula, - ) - self._pending_traj_result = stub - return True + return _ana_pop_geo_trajectory(self, ctx) def _pop_preopt_trajectory(self, ctx: _AnalysisContext) -> bool: - if ctx.source == "live": - pre = ctx.preopt_result - if pre is None: - return False - traj = getattr(pre, "trajectory", None) - energies = list(getattr(pre, "energies_hartree", [])) - else: - if ctx.result_dir is None: - return False - preopt_path = ctx.result_dir / "preopt_trajectory.json" - if not preopt_path.exists(): - return False - try: - from quantui.results_storage import load_trajectory - - traj, energies = load_trajectory( - ctx.result_dir, filename="preopt_trajectory.json" - ) - except Exception as _exc: - from quantui import calc_log as _clog - - _clog.log_event( - "pop_preopt_trajectory_error", - f"{type(_exc).__name__}: {_exc}"[:300], - ) - return False - if not traj or len(traj) < 2: - return False - stub = _types_mod.SimpleNamespace( - trajectory=traj, - energies_hartree=energies, - formula=ctx.formula, - ) - self._pending_traj_result = stub - self.traj_accordion.set_title(0, "Pre-optimization Trajectory") - return True + return _ana_pop_preopt_trajectory(self, ctx) def _pop_vibrational(self, ctx: _AnalysisContext) -> bool: - if ctx.live_result is not None: - freq_stub = ctx.live_result - mol = ctx.molecule - else: - ir = ctx.spectra_data.get("ir", {}) - mol_data = ctx.spectra_data.get("molecule", {}) - freqs = ir.get("frequencies_cm1") - ints = ir.get("ir_intensities") - disps = ir.get("displacements") - if not (freqs and disps and mol_data.get("atoms")): - return False - from quantui.molecule import Molecule as _Mol - - mol = _Mol( - atoms=mol_data["atoms"], - coordinates=mol_data["coords"], - charge=mol_data.get("charge", 0), - multiplicity=mol_data.get("multiplicity", 1), - ) - freq_stub = _types_mod.SimpleNamespace( - frequencies_cm1=freqs, - ir_intensities=ints, - displacements=disps, - ) - return self._show_vib_animation(freq_stub, mol) + return _ana_pop_vibrational(self, ctx) def _pop_ir_spectrum(self, ctx: _AnalysisContext) -> bool: - if ctx.live_result is not None: - freq_stub = ctx.live_result - else: - ir = ctx.spectra_data.get("ir", {}) - freqs = ir.get("frequencies_cm1") - if not freqs: - return False - freq_stub = _types_mod.SimpleNamespace( - frequencies_cm1=freqs, - ir_intensities=ir.get("ir_intensities") or [], - ) - return self._show_ir_spectrum(freq_stub) + return _ana_pop_ir_spectrum(self, ctx) def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: - if ctx.live_result is not None: - energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", [])) - osc = list(getattr(ctx.live_result, "oscillator_strengths", [])) - try: - wl = list(ctx.live_result.wavelengths_nm()) - except Exception: - wl = [1240.0 / e for e in energies_ev if e > 0] - else: - uv = ctx.spectra_data.get("uv_vis", {}) - energies_ev = uv.get("excitation_energies_ev", []) - osc = uv.get("oscillator_strengths", []) - wl = uv.get("wavelengths_nm", []) - if not energies_ev or not osc: - return False - try: - import plotly.graph_objects as _go - import plotly.io as _pio - - _fig = _go.Figure() - _fig.add_trace( - _go.Bar( - x=wl, - y=osc, - name="Osc. strength", - marker_color="#2563eb", - width=[4.0] * len(wl), - ) - ) - tc = self._plotly_theme_colors() - _fig.update_layout( - xaxis_title="Wavelength (nm)", - yaxis_title="Oscillator strength", - height=320, - margin=dict(l=60, r=20, t=30, b=50), - plot_bgcolor=tc["plot_bgcolor"], - paper_bgcolor=tc["paper_bgcolor"], - font=dict(color=tc["font_color"]), - xaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), - yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), - ) - self._apply_plotly_theme(_fig) - self._set_html_output( - self._tddft_fig, - _pio.to_html( - _fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, - ), - ) - return True - except Exception: - return False + return _ana_pop_uv_vis(self, ctx) def _pop_nmr_shielding(self, ctx: _AnalysisContext) -> bool: - if ctx.live_result is not None: - r = ctx.live_result - atom_symbols = list(getattr(r, "atom_symbols", [])) - shielding = list(getattr(r, "shielding_iso_ppm", [])) - try: - h_shifts = r.h_shifts() - c_shifts = r.c_shifts() - except Exception: - h_shifts, c_shifts = [], [] - ref = getattr(r, "reference_compound", "TMS") - else: - nmr = ctx.spectra_data.get("nmr", {}) - atom_symbols = nmr.get("atom_symbols", []) - shielding = nmr.get("shielding_iso_ppm", []) - chem = nmr.get("chemical_shifts_ppm", {}) - ref = nmr.get("reference_compound", "TMS") - # Reconstruct h/c shifts from stored chemical_shifts_ppm dict - h_shifts = [ - (int(i), d) - for i, d in chem.items() - if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H" - ] - c_shifts = [ - (int(i), d) - for i, d in chem.items() - if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C" - ] - if not atom_symbols: - return False - - def _shift_table(label: str, shifts: list, sym: str) -> str: - if not shifts: - return "" - rows = "".join( - f'| Atom | ' - f'σ (ppm) |
|---|
' - "Recent events (last 20)
" - ), - self._perf_events_html, - widgets.HBox( - [self._reset_btn], - layout=_layout(margin="14px 0 4px"), - ), - self._reset_confirm_box, - ] - ) - self._perf_accordion = widgets.Accordion( - children=[_perf_stats_panel], selected_index=None - ) - self._perf_accordion.set_title(0, "Performance stats") - - # Calibration accordion - _cal_last = _load_last_calibration_label() - _cal_note = ( - f'' - f"Last run: {_cal_last}
" - if _cal_last - else "" - ) - _cal_panel = widgets.VBox( - [ - widgets.HTML( - f'' - f"Benchmark this machine so the time estimator uses basis-function " - f"scaling (Nβ) rather than generic defaults. " - f"Quick runs {len(_BENCHMARK_SUITE)} small calculations (~10 s). " - f"Full runs {len(_BENCHMARK_SUITE_LONG)} calculations spanning " - f"all common molecule sizes and methods (~5 min).
" + _cal_note - ), - self._cal_mode_toggle, - widgets.HBox( - [self._cal_run_btn, self._cal_stop_btn], - layout=_layout(gap="6px", align_items="center"), - ), - self._cal_progress, - self._cal_step_label, - self._cal_results_html, - ], - layout=_layout(padding="4px 0"), - ) - self._cal_accordion = widgets.Accordion( - children=[_cal_panel], selected_index=None - ) - self._cal_accordion.set_title(0, "Calibrate time estimates") - - self.history_panel = widgets.VBox( - [ - widgets.HTML( - '' - "Calculations are saved automatically. Select one below to view its results.
" - ), - widgets.HBox( - [ - self.past_dd, - self.past_refresh_btn, - self.copy_path_btn, - self.view_log_btn, - ], - layout=_layout(align_items="center", gap="8px"), - ), - self.results_path_lbl, - self.past_output, - self._perf_accordion, - self._cal_accordion, - ] - ) - - # Populate on startup - self._refresh_results_browser() - self._refresh_perf_stats() # ── Compare panel (Cell 9) ──────────────────────────────────────────── def _build_compare_section(self) -> None: - self.compare_select = widgets.SelectMultiple( - options=[("(no saved results)", "")], - rows=8, - description="", - layout=_layout(width="100%"), - ) - self.compare_refresh_btn = widgets.Button( - description="Refresh", - button_style="", - icon="refresh", - layout=_layout(width="100px"), - ) - self.compare_btn = widgets.Button( - description="Compare selected", - button_style="primary", - icon="bar-chart", - disabled=True, - layout=_layout(width="180px"), - ) - self.compare_clear_btn = widgets.Button( - description="Clear", - button_style="warning", - icon="times", - layout=_layout(width="90px"), - ) - self.compare_output = widgets.Output() - - self.compare_panel = widgets.VBox( - [ - widgets.HTML( - '' - "Select two or more saved calculations to compare side-by-side. " - "Hold Ctrl (or ⌘) to select multiple entries.
" - ), - widgets.HBox([self.compare_refresh_btn]), - self.compare_select, - widgets.HBox( - [self.compare_btn, self.compare_clear_btn], - layout=_layout(gap="8px", margin="6px 0"), - ), - self.compare_output, - ], - layout=_layout(padding="8px 0"), - ) - - # Export accordion (Advanced) - _rdkit_note = ( - "" - if _RDKIT_AVAILABLE - else 'MOL/PDB export requires RDKit '
- "(conda install -c conda-forge rdkit).
' - "Download a self-contained PySCF script you can study or run outside the notebook.
" - ), - widgets.HBox([self.export_btn, self.export_status]), - widgets.HTML('' - "Download the molecular structure in a standard chemistry file format.
" - + _rdkit_note - ), - widgets.HBox( - [self.export_xyz_btn, self.export_mol_btn, self.export_pdb_btn], - layout=_layout(flex_flow="row wrap", gap="6px"), - ), - self.struct_export_status, - ] + _bld_build_compare_section( + self, + layout_fn=_layout, + rdkit_available=_RDKIT_AVAILABLE, ) - self.advanced_accordion = widgets.Accordion(children=[_export_content]) - self.advanced_accordion.set_title(0, "Export") - self.advanced_accordion.selected_index = None - - # Populate on startup - self._populate_compare_list() # ── Output log tab (Cell 10) ────────────────────────────────────────── def _build_output_tab(self) -> None: - self._log_output_html = widgets.HTML( - '' - "No log yet — run a calculation first, or use " - "View log in the History tab." - ) - self._log_source_lbl = widgets.HTML() - self._log_clear_btn = widgets.Button( - description="Clear", - button_style="", - icon="times", - layout=_layout(width="80px"), - ) - self._clear_log_cache_btn = widgets.Button( - description="Clear Log Cache", - button_style="", - icon="trash", - tooltip=( - "Delete the session event log (event_log.jsonl). " - "Calculation performance data is preserved." - ), - layout=_layout(width="160px"), - ) - self._clear_log_cache_confirm_btn = widgets.Button( - description="Confirm clear?", - button_style="danger", - layout=_layout(width="140px", display="none"), - ) - self.log_tab_panel = widgets.VBox( - [ - widgets.HTML( - '' - "Raw PySCF output for the most recent calculation. " - "Use View log in the History tab to load a saved result's log. " - "Orbital diagrams, trajectories, and spectra are in the " - "Analysis tab.
" - ), - widgets.HBox( - [self._log_clear_btn], - layout=_layout(margin="0 0 8px"), - ), - self._log_source_lbl, - self._log_output_html, - self._result_log_accordion, - widgets.HTML( - '' - "Session event log — records molecule loads, calculations, " - "and issue reports across this session.
" - ), - widgets.HBox( - [self._clear_log_cache_btn, self._clear_log_cache_confirm_btn], - layout=_layout(align_items="center", gap="8px"), - ), - ], - layout=_layout(padding="8px 0"), - ) + _bld_build_output_tab(self, layout_fn=_layout) - # ── Help section (Cell 10) ──────────────────────────────────────────── + # ── Files tab (Cell 11) ─────────────────────────────────────────────── - def _build_help_section(self) -> None: - _help_keys = list(HELP_TOPICS.keys()) - _help_labels = [HELP_TOPICS[k]["title"] for k in _help_keys] - self.help_topic_dd = widgets.Dropdown( - options=list(zip(_help_labels, _help_keys)), - description="Topic:", - style={"description_width": "60px"}, - layout=_layout(width="460px"), - ) - self.help_content_html = widgets.HTML() - self._render_help_topic() # render first topic immediately - - # [?] toggle button shown in the top bar - self._help_btn = widgets.Button( - description="?", - button_style="", - tooltip="Help topics", - layout=_layout(width="34px", margin="0 0 0 8px"), - ) + def _build_files_tab(self) -> None: + _bld_build_files_tab(self, layout_fn=_layout) + self._refresh_file_browser() - # Exit button shown in the top bar - self._exit_btn = widgets.Button( - description="Exit", - button_style="danger", - tooltip="Shut down the QuantUI server and close this session", - layout=_layout(width="64px", margin="0 0 0 8px"), - ) - self._exit_output = widgets.Output( - layout=_layout(height="0px", overflow="hidden") - ) + # ── Help section (Cell 12) ──────────────────────────────────────────── - self.help_tab_panel = widgets.VBox( - [ - widgets.HTML( - '' - "Browse help topics below. Click ? next to the Method or Basis Set " - "dropdown in the Calculate tab to jump directly to a relevant topic.
" - ), - self.help_topic_dd, - self.help_content_html, - ], - layout=_layout( - display="none", - padding="8px 0", - border="1px solid #e2e8f0", - border_radius="6px", - padding_left="12px", - margin="0 0 8px", - ), - ) + def _build_help_section(self) -> None: + _bld_build_help_section(self, layout_fn=_layout) def _build_issue_widgets(self) -> None: - """Build the Issue report button, overlay, and related widgets.""" - # ── Issue button (shown in the top bar) ─────────────────────────── - self._issue_btn = widgets.Button( - description="Report Issue", - button_style="warning", - icon="flag", - tooltip="Report a bug or unexpected behaviour observed in this session", - layout=_layout(width="140px", margin="0 0 0 8px"), - ) - # ── Issue overlay (hidden until button is clicked) ──────────────── - self._issue_textarea = widgets.Textarea( - placeholder=( - "Describe what you observed — what you did, what you expected, " - "and what actually happened." - ), - layout=_layout(width="100%", height="90px"), - ) - self._issue_submit_btn = widgets.Button( - description="Submit", - button_style="success", - layout=_layout(width="90px"), - ) - self._issue_cancel_btn = widgets.Button( - description="Cancel", - button_style="", - layout=_layout(width="80px"), - ) - self._issue_status_html = widgets.HTML() - self._issue_overlay = widgets.VBox( - [ - widgets.HTML( - '' - "⚐ Report Issue
" - ''
- "Your report (and a snapshot of the current session state) will be "
- "saved to issues.db and the session event log.
" + "Binary preview is not available for this file type." + "
" + ) + ) + self._set_files_status(f"Binary file selected: {path.name}") + return + + max_bytes = 200_000 + try: + raw = path.read_bytes() + except OSError as exc: + self._set_files_status(f"Cannot read file: {exc}", "#b91c1c") + return + + truncated = len(raw) > max_bytes + raw = raw[:max_bytes] + text = raw.decode("utf-8", errors="replace") + if truncated: + text += "\n\n[Preview truncated to 200 KB]" + + with self._files_preview_output: + display( + HTML( + ""
+ f"{_html.escape(text)}"
+ ""
+ )
+ )
+ self._set_files_status(f"Previewing text file: {path.name}")
+
+ def _on_files_root_changed(self, change) -> None:
+ if self._files_updating:
+ return
+ new_value = str(change.get("new") or "")
+ if not new_value:
+ return
+
+ new_root = Path(new_value)
+ roots = self._files_allowed_roots()
+ if not self._is_path_in_allowed_roots(new_root, roots):
+ self._set_files_status("Selected root is not allowed.", "#b91c1c")
+ return
+
+ self._files_current_dir = new_root
+ self._update_files_entries()
+ self._set_files_status(f"Root changed to: {new_root}")
+
+ def _on_files_entry_changed(self, change) -> None:
+ if self._files_updating:
+ return
+ new_value = str(change.get("new") or "")
+ self._files_selected_path = Path(new_value) if new_value else None
+ self._files_open_btn.disabled = self._files_selected_path is None
+ if self._files_selected_path is None:
+ self._set_files_status("Select a folder or file.")
+ return
+ if self._files_selected_path.is_dir():
+ self._set_files_status(f"Folder selected: {self._files_selected_path.name}")
+ else:
+ self._set_files_status(f"File selected: {self._files_selected_path.name}")
+
+ def _on_files_open(self, _btn) -> None:
+ self._activity_begin("Opening selected path...")
+ try:
+ selected = self._files_selected_path
+ if selected is None:
+ self._set_files_status("Select a folder or file first.")
+ return
+ if selected.is_dir():
+ self._files_current_dir = selected
+ self._update_files_entries()
+ self._set_files_status(f"Opened folder: {selected}")
+ return
+ self._preview_file_path(selected)
+ finally:
+ self._activity_end()
+
+ def _on_files_up(self, _btn) -> None:
+ self._activity_begin("Moving to parent folder...")
+ try:
+ if self._files_current_dir is None:
+ self._set_files_status("No current folder selected.", "#b91c1c")
+ return
+
+ parent = self._files_current_dir.parent
+ roots = self._files_allowed_roots()
+ if parent == self._files_current_dir or not self._is_path_in_allowed_roots(
+ parent, roots
+ ):
+ self._set_files_status("Already at the top of the selected root.")
+ return
+
+ self._files_current_dir = parent
+ self._update_files_entries()
+ self._set_files_status(f"Moved to parent folder: {parent}")
+ finally:
+ self._activity_end()
+
+ def _on_files_refresh(self, _btn) -> None:
+ self._activity_begin("Refreshing files browser...")
+ try:
+ self._refresh_file_browser()
+ finally:
+ self._activity_end()
+
+ # ══ CALLBACK METHODS ═════════════════════════════════════════════════════
+
+ # ── Theme ─────────────────────────────────────────────────────────────
+
+ def _on_theme_changed(self, change) -> None:
+ self._theme_style.clear_output()
+ css = self._theme_css(change["new"])
+ if css:
+ with self._theme_style:
+ display(HTML(css))
+ self._rerender_plotly_theme()
+
+ def _plotly_theme_colors(self) -> dict:
+ """Return plot colors tuned for the current theme.
+
+ The dark theme is a CSS invert+hue-rotate filter on the whole page.
+ For SVG/div elements (2D charts): html filter already inverts, so we
+ use light values and let the filter make them dark.
+ For WebGL canvas (3D scenes): canvas has a counter-filter that cancels
+ the html filter, so the color appears as-is — use scene_bgcolor.
+ """
is_dark = self.theme_btn.value == "Dark"
return {
"plot_bgcolor": "white", # html filter darkens this in dark mode
@@ -2617,9 +1879,25 @@ def _set_html_output(self, out: widgets.Output, html: str) -> None:
assigned to widgets.HTML.value (innerHTML path), which leads to blank
figure panels. Rendering through Output display_data executes the JS.
"""
+ if threading.current_thread() is not threading.main_thread():
+ io_loop = self._get_kernel_io_loop()
+ if io_loop is not None:
+ io_loop.add_callback(self._set_html_output, out, html)
+ return
self._clear_output_widget(out)
out.append_display_data(HTML(html))
+ def _get_kernel_io_loop(self) -> Any:
+ """Return a cached kernel io_loop, resolving it lazily when needed."""
+ io_loop = getattr(self, "_kernel_io_loop", None)
+ if io_loop is not None:
+ return io_loop
+ ip = get_ipython()
+ io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None)
+ if io_loop is not None:
+ self._kernel_io_loop = io_loop
+ return io_loop
+
def _render_plotly_figure(self, out: widgets.Output, fig) -> None:
"""Render a Plotly figure through display() in an Output widget."""
self._clear_output_widget(out)
@@ -2654,8 +1932,14 @@ def _rerender_plotly_theme(self) -> None:
self._ir_mode_toggle.value,
self._ir_fwhm_slider.value,
)
- if getattr(self, "_last_pes_result", None) is not None:
- self._show_pes_scan_result(self._last_pes_result)
+ if getattr(self, "_last_uv_wavelengths_nm", None):
+ self._update_uv_vis_figure(
+ self._uv_mode_toggle.value,
+ self._uv_fwhm_slider.value,
+ )
+ _last_pes = getattr(self, "_last_pes_result", None)
+ if _last_pes is not None:
+ self._show_pes_scan_result(_last_pes)
# Re-render 3D molecule viewer so scene_bgcolor updates immediately.
if self._molecule is not None and _display_molecule is not None:
self.viz_output.clear_output()
@@ -2799,778 +2083,283 @@ def _do():
threading.Thread(target=_do, daemon=True).start()
def _on_expand_mol_input(self, btn) -> None:
- _children = [self.mol_input_expanded, self.mol_info_html, self.viz_output]
- if self.viz_backend_toggle is not None:
- _children.append(self.viz_backend_toggle)
- if VISUALIZATION_AVAILABLE:
- _children.append(self.viz_controls_box)
- self.mol_input_container.children = _children
+ _run_on_expand_mol_input(
+ self,
+ btn,
+ visualization_available=VISUALIZATION_AVAILABLE,
+ )
# ── Calc type ─────────────────────────────────────────────────────────
def _on_calc_type_changed(self, change) -> None:
- ct = change["new"]
- if ct == "Geometry Opt":
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self.fmax_fi, self.max_steps_si],
- layout=_layout(gap="8px"),
- ),
- ]
- elif ct == "Frequency":
- self._refresh_freq_seed_options()
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self._freq_seed_dd, self._freq_seed_refresh_btn],
- layout=_layout(align_items="center", gap="6px"),
- ),
- self._freq_preopt_cb,
- self._freq_seed_note,
- ]
- elif ct == "UV-Vis (TD-DFT)":
- self.calc_extra_opts.children = [
- self.nstates_si,
- widgets.HTML(
- '⚠ Requires a DFT '
- "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) "
- "instead."
- ),
- ]
- elif ct == "NMR Shielding":
- self.calc_extra_opts.children = [
- widgets.HTML(
- ''
- "⚠ Recommended: B3LYP/6-31G* or better. "
- "STO-3G and 3-21G give qualitative results only. "
- "Start from an optimised geometry for best accuracy."
- ),
- ]
- elif ct == "PES Scan":
- self._update_scan_widgets()
- self.calc_extra_opts.children = [
- widgets.HBox(
- [self._scan_type_dd],
- layout=_layout(margin="0 0 4px 0"),
- ),
- widgets.HBox(
- [self._scan_atom1, self._scan_atom2],
- layout=_layout(gap="4px"),
- ),
- self._scan_atom34_box,
- widgets.HBox(
- [
- self._scan_start,
- self._scan_stop,
- self._scan_steps,
- self._scan_unit_lbl,
- ],
- layout=_layout(gap="4px", align_items="center"),
- ),
- ]
- else:
- self.calc_extra_opts.children = []
+ _run_on_calc_type_changed(self, change, layout_fn=_layout)
def _update_scan_widgets(self, _change=None) -> None:
- """Show/hide atom3/4 inputs and update unit label based on scan type."""
- st = self._scan_type_dd.value
- if st == "Bond":
- self._scan_atom34_box.layout.display = "none"
- self._scan_unit_lbl.value = (
- 'Å'
- )
- elif st == "Angle":
- self._scan_atom4.layout.display = "none"
- self._scan_atom3.layout.display = ""
- self._scan_atom34_box.layout.display = ""
- self._scan_unit_lbl.value = (
- '°'
- )
- else: # Dihedral
- self._scan_atom3.layout.display = ""
- self._scan_atom4.layout.display = ""
- self._scan_atom34_box.layout.display = ""
- self._scan_unit_lbl.value = (
- '°'
- )
+ _run_update_scan_widgets(self, _change)
def _refresh_freq_seed_options(self) -> None:
- """Populate _freq_seed_dd with saved geometry-opt results."""
- from quantui.results_storage import list_results, load_result
-
- options = [("(use current molecule)", "")]
- for d in list_results():
- try:
- data = load_result(d)
- if data.get("calc_type") != "geometry_opt":
- continue
- traj_file = d / "trajectory.json"
- if not traj_file.exists():
- continue
- ts = data.get("timestamp", d.name[:19])
- label = (
- f"{data['formula']} {data['method']}/{data['basis']}" f" — {ts}"
- )
- options.append((label, str(d)))
- except Exception:
- continue
- self._freq_seed_dd.options = options
+ _run_refresh_freq_seed_options(self)
def _on_freq_seed_changed(self, change) -> None:
- """Enable/disable pre-opt checkbox and update the seed note."""
- path_str = change["new"]
- if path_str:
- # A history geometry is selected — pre-optimize makes no sense.
- self._freq_preopt_cb.value = False
- self._freq_preopt_cb.disabled = True
- self._freq_seed_note.value = (
- ''
- "✓ Final optimised geometry will be loaded from the selected result."
- ""
- )
- else:
- self._freq_preopt_cb.disabled = False
- self._freq_seed_note.value = ""
+ _run_on_freq_seed_changed(self, change)
# ── Help buttons ──────────────────────────────────────────────────────
def _on_method_help(self, btn) -> None:
- self._show_help_topic("method")
+ _run_on_method_help(self, btn)
def _on_basis_help(self, btn) -> None:
- self._show_help_topic("basis_set")
+ _run_on_basis_help(self, btn)
# ── Run ───────────────────────────────────────────────────────────────
def _on_run_clicked(self, btn) -> None:
- self.run_output.clear_output()
- self.result_output.clear_output()
- self.result_viz_output.clear_output()
- self._analysis_mol_output.clear_output()
- self._viz_label.layout.display = "none"
- self._viz_label.value = ""
- self._deactivate_all_ana_panels()
- self._clear_output_widget(self._pes_plot_html)
- self._result_dir_label.value = ""
- self._result_dir_label.layout.display = "none"
- self._result_log_accordion.layout.display = "none"
- self._result_log_accordion.selected_index = None
- self._result_log_output.clear_output()
- self._completion_banner.layout.display = "none"
- self._to_analysis_btn.layout.display = "none"
- self._analysis_empty_html.layout.display = "none"
- threading.Thread(target=self._do_run, daemon=True).start()
+ self._activity_pulse(
+ "Queueing calculation...",
+ hold_s=0.18,
+ kind="compute",
+ )
+ _run_on_run_clicked(self, btn)
def _on_solvent_cb_changed(self, change) -> None:
- self.solvent_dd.layout.display = "" if change["new"] else "none"
+ _run_on_solvent_cb_changed(self, change)
def _on_clear_log(self, btn) -> None:
- self.run_output.clear_output()
+ _run_on_clear_log(self, btn)
# ── Accumulate / export ───────────────────────────────────────────────
def _on_accumulate(self, btn) -> None:
- r = self._last_result
- if r is None:
- return
- self._results.append(r)
- self._refresh_comparison()
+ _run_on_accumulate(self, btn)
def _on_clear(self, btn) -> None:
- self._results.clear()
- self.comparison_output.clear_output()
+ _run_on_clear(self, btn)
def _on_export(self, btn) -> None:
- if self._molecule is None:
- self.export_status.value = "Load a molecule first."
- return
- try:
- from quantui import PySCFCalculation
-
- calc = PySCFCalculation(
- self._molecule,
- method=self.method_dd.value,
- basis=self.basis_dd.value,
- )
- fname = (
- f"{self._molecule.get_formula()}"
- f"_{self.method_dd.value}_{self.basis_dd.value}.py"
- )
- calc.generate_calculation_script(Path(fname))
- self.export_status.value = f"Saved: {fname}"
- except Exception as exc:
- self.export_status.value = f"Error: {exc}"
+ _exp_on_export(self, btn)
def _on_export_xyz(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.xyz"
- xyz_body = mol.to_xyz_string()
- full_xyz = (
- f"{len(mol.atoms)}\n{mol.get_formula()} {method}/{basis}\n{xyz_body}\n"
- )
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(full_xyz, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ _exp_on_export_xyz(self, btn)
def _on_export_mol(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- from rdkit import Chem
-
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.mol"
- rdmol = self._molecule_to_rdkit(mol)
- if rdmol is None:
- self.struct_export_status.value = "RDKit could not parse the structure."
- return
- mol_block = Chem.MolToMolBlock(rdmol)
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(mol_block, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ _exp_on_export_mol(self, btn)
def _on_export_pdb(self, btn) -> None:
- if self._molecule is None:
- self.struct_export_status.value = "Load a molecule first."
- return
- try:
- from rdkit import Chem
+ _exp_on_export_pdb(self, btn)
- mol, method, basis = self._export_molecule_and_label()
- fname = f"{mol.get_formula()}_{method}_{basis}.pdb"
- rdmol = self._molecule_to_rdkit(mol)
- if rdmol is None:
- self.struct_export_status.value = "RDKit could not parse the structure."
- return
- pdb_block = Chem.MolToPDBBlock(rdmol)
- dest = (
- (self._last_result_dir / fname)
- if self._last_result_dir
- else Path(fname)
- )
- dest.write_text(pdb_block, encoding="utf-8")
- self.struct_export_status.value = f"Saved: {dest}"
- except Exception as exc:
- self.struct_export_status.value = f"Error: {exc}"
+ def _on_ir_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_ir_fig", None),
+ stem="ir_spectrum",
+ fmt=self._ir_export_fmt_dd.value,
+ status_widget=self._ir_export_status,
+ )
- def _export_molecule_and_label(self):
- """Return (molecule, method, basis) for structure export.
+ def _on_uv_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_uv_fig", None),
+ stem="uv_vis_spectrum",
+ fmt=self._uv_export_fmt_dd.value,
+ status_widget=self._uv_export_status,
+ )
- For geo opt results, returns the final optimised geometry.
- Falls back to the currently loaded molecule for all other calc types.
- """
- from quantui.optimizer import OptimizationResult
+ def _on_orb_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_orb_fig", None),
+ stem="orbital_energy_diagram",
+ fmt=self._orb_export_fmt_dd.value,
+ status_widget=self._orb_export_status,
+ )
- r = self._last_result
- if isinstance(r, OptimizationResult):
- mol = r.molecule
- else:
- assert self._molecule is not None
- mol = self._molecule
- method = (
- getattr(r, "method", self.method_dd.value)
- if r is not None
- else self.method_dd.value
+ def _on_pes_export_plot(self, btn) -> None:
+ self._export_plot_figure(
+ fig=getattr(self, "_last_pes_fig", None),
+ stem="pes_scan_profile",
+ fmt=self._pes_export_fmt_dd.value,
+ status_widget=self._pes_export_status,
)
- basis = (
- getattr(r, "basis", self.basis_dd.value)
- if r is not None
- else self.basis_dd.value
+
+ def _export_plot_figure(
+ self,
+ *,
+ fig: Any,
+ stem: str,
+ fmt: str,
+ status_widget: widgets.HTML,
+ ) -> None:
+ """Export a plotly figure to HTML or PNG in the current result folder."""
+ if fig is None:
+ status_widget.value = (
+ ''
+ "No plot available to export yet."
+ )
+ return
+
+ import re as _re
+ from datetime import datetime as _dt
+
+ import plotly.io as _pio
+
+ target_dir = (
+ self._last_result_dir
+ if isinstance(self._last_result_dir, Path)
+ else self._get_results_dir()
)
- return mol, method, basis
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ safe_stem = _re.sub(r"[^A-Za-z0-9_.-]+", "_", stem.strip()) or "plot"
+ ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S")
+ ext = "png" if fmt == "png" else "html"
+ dest = target_dir / f"{safe_stem}_{ts}.{ext}"
- @staticmethod
- def _molecule_to_rdkit(mol):
- """Convert a Molecule to an RDKit Mol with inferred bonds (best-effort)."""
try:
- from rdkit import Chem
+ if fmt == "png":
+ # Requires kaleido for static image export.
+ fig.write_image(str(dest), scale=2)
+ else:
+ html_str = _pio.to_html(
+ fig,
+ include_plotlyjs=True,
+ full_html=True,
+ config={"responsive": True},
+ )
+ dest.write_text(html_str, encoding="utf-8")
- xyz_block = (
- f"{len(mol.atoms)}\n{mol.get_formula()}\n{mol.to_xyz_string()}\n"
+ status_widget.value = (
+ '' f"Saved: {dest}"
+ )
+ except Exception as exc:
+ msg = str(exc)
+ if fmt == "png" and "kaleido" in msg.lower():
+ msg = (
+ "PNG export requires kaleido. " "Install with: pip install kaleido"
+ )
+ status_widget.value = (
+ ''
+ f"Export failed: {msg}"
)
- rdmol = Chem.MolFromXYZBlock(xyz_block)
- if rdmol is None:
- return None
- try:
- from rdkit.Chem import rdDetermineBonds
- rdDetermineBonds.DetermineBonds(rdmol, charge=mol.charge)
- except Exception:
- pass
- return rdmol
- except Exception:
- return None
+ def _export_molecule_and_label(self):
+ return _exp_export_molecule_and_label(self)
+
+ @staticmethod
+ def _molecule_to_rdkit(mol):
+ return _exp_molecule_to_rdkit(mol)
# ── Compare ───────────────────────────────────────────────────────────
def _on_compare_refresh(self, btn) -> None:
- self._populate_compare_list()
+ self._activity_begin("Refreshing comparison choices...")
+ try:
+ _run_on_compare_refresh(self, btn)
+ finally:
+ self._activity_end()
def _on_compare(self, btn) -> None:
- selected = self.compare_select.value
- if not selected or selected == ("",):
- return
- self.compare_output.clear_output(wait=True)
- from quantui import (
- comparison_table_html,
- plot_comparison,
- summary_from_saved_result,
- )
- from quantui.results_storage import load_result
-
- summaries = []
- valid_dirs: list = []
- for path_str in selected:
- if not path_str:
- continue
- try:
- data = load_result(Path(path_str))
- summaries.append(summary_from_saved_result(data))
- valid_dirs.append(Path(path_str))
- except Exception as exc:
- with self.compare_output:
- display(
- HTML(
- f'Error loading result: {exc}
' - ) - ) - if not summaries: - return - with self.compare_output: - display(HTML(comparison_table_html(summaries))) - if len(summaries) > 1: - try: - import matplotlib.pyplot as plt - - fig = plot_comparison(summaries) - display(fig) - plt.close(fig) - except Exception: - pass - # Per-row → Analyse buttons - if valid_dirs: - _btns = [] - for s, rdir in zip(summaries, valid_dirs): - _short = f"{s.formula} {s.method}/{s.basis}" - _btn = widgets.Button( - description=f"→ Analyse {_short}"[:48], - button_style="info", - layout=_layout(width="auto", max_width="340px"), - tooltip=f"Load {_short} into the Analysis tab", - ) - _btn.on_click(lambda _, rd=rdir: self._history_load_analysis(rd)) - _btns.append(_btn) - display( - widgets.HTML( - 'Analyse a result:
' - ) - ) - display(widgets.VBox(_btns, layout=_layout(gap="4px"))) + self._activity_begin("Building comparison view...") + try: + _run_on_compare(self, btn, layout_fn=_layout) + finally: + self._activity_end() def _on_compare_clear(self, btn) -> None: - self.compare_select.value = () - self.compare_output.clear_output() + self._activity_begin("Clearing comparison output...") + try: + _run_on_compare_clear(self, btn) + finally: + self._activity_end() # ── History ─────────────────────────────────────────────────────────── def _on_past_dd_changed(self, change) -> None: - path_str = change["new"] - # Hide result-specific panels whenever the selection changes so stale - # content from a previous "View log" click doesn't persist. - self._deactivate_all_ana_panels() - self._pending_traj_result = None - self._result_log_accordion.layout.display = "none" - self._result_dir_label.layout.display = "none" - self._iso_generate_btn.disabled = True - if not path_str: - self.past_output.clear_output() - return - self.past_output.clear_output() - with self.past_output: - try: - from quantui import load_result - - _result_dir = Path(path_str) - data = load_result(_result_dir) - display(HTML(self._format_past_result(data, result_dir=_result_dir))) - _btn_res = widgets.Button( - description="→ View Results", - button_style="success", - layout=_layout(width="130px"), - tooltip="Show this result in the Results tab", - ) - _btn_ana = widgets.Button( - description="→ View Analysis", - button_style="info", - layout=_layout(width="140px"), - tooltip="Load analysis panels and navigate to the Analysis tab", - ) - _btn_res.on_click( - lambda _, d=data, rd=_result_dir: self._history_load_results(d, rd) - ) - _btn_ana.on_click( - lambda _, rd=_result_dir: self._history_load_analysis(rd) - ) - display( - widgets.HBox( - [_btn_res, _btn_ana], - layout=_layout(gap="8px", margin="6px 0 0"), - ) - ) - except Exception as exc: - print(f"Could not load result: {exc}") + _hist_on_past_dd_changed(self, change, layout_fn=_layout) def _on_past_refresh(self, btn) -> None: - self._refresh_results_browser() + self._activity_begin("Refreshing history list...") + try: + _run_on_past_refresh(self, btn) + finally: + self._activity_end() def _on_copy_results_path(self, btn) -> None: - p = self._get_results_dir() - p.mkdir(parents=True, exist_ok=True) - path_str = str(p).replace("\\", "\\\\").replace("'", "\\'") - display(Javascript(f"navigator.clipboard.writeText('{path_str}')")) - self.results_path_lbl.value = ( - f'Copied: {p}' - ) - - def _reset(): - time.sleep(3) - self.results_path_lbl.value = ( - f'{p}' - ) - - threading.Thread(target=_reset, daemon=True).start() + self._activity_begin("Copying results path...") + try: + _run_on_copy_results_path(self, btn) + finally: + self._activity_end() def _on_view_log(self, btn) -> None: - path_str = self.past_dd.value - if not path_str: - return - result_dir = Path(path_str) + self._activity_begin("Loading history log...") try: - _calc_log.log_event( - "history_view", - result_dir.name, - result_dir=result_dir.name, - session_id=self._session_id, - ) - except Exception: - pass - - # Read log text and populate log panel - log_path = result_dir / "pyscf.log" - if log_path.exists(): - text = log_path.read_text(encoding="utf-8", errors="replace") - label = result_dir.name - else: - text = "(No pyscf.log found for this result.)" - label = "" - self._update_log_panel(text, label) - self._show_result_log(result_dir, text) - - # Build analysis context from disk and apply via registry - ctx = self._build_history_context(result_dir) - if ctx is not None: - _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} - try: - _mol = self._mol_from_result_dir(result_dir, _data_stub) - if _mol is not None: - self._show_result_3d(_mol, extra_output=self._analysis_mol_output) - else: - self._analysis_mol_output.clear_output() - except Exception: - pass - self._apply_analysis_context(ctx) - - self._goto_output_tab() + _hist_on_view_log(self, btn) + self._refresh_file_browser() + finally: + self._activity_end() def _mol_from_result_dir(self, result_dir: Path, data: dict): - """Try to reconstruct a displayable Molecule from a saved result directory. - - Returns a Molecule or None if geometry data is not available. - Tries sources in order: frequency spectra → orbitals_meta → trajectory. - """ - import json as _json - - from quantui.molecule import Molecule - - ct = data.get("calc_type", "") - - # Frequency: geometry stored inside spectra.molecule - if ct == "frequency": - mol_data = data.get("spectra", {}).get("molecule", {}) - if mol_data.get("atoms") and mol_data.get("coords"): - try: - return Molecule( - atoms=mol_data["atoms"], - coordinates=mol_data["coords"], - charge=mol_data.get("charge", 0), - multiplicity=mol_data.get("multiplicity", 1), - ) - except Exception: - pass - - # Single point / Geo opt: atom list from orbitals_meta.json - meta_path = result_dir / "orbitals_meta.json" - if meta_path.exists(): - try: - meta = _json.loads(meta_path.read_text()) - mol_atom = meta.get("mol_atom") - if mol_atom: - atoms = [sym for sym, _ in mol_atom] - coords = [c for _, c in mol_atom] - return Molecule(atoms=atoms, coordinates=coords) - except Exception: - pass - - # Geo opt fallback: last step of trajectory.json - if ct == "geometry_opt": - traj_path = result_dir / "trajectory.json" - if traj_path.exists(): - try: - traj_data = _json.loads(traj_path.read_text()) - steps = traj_data.get("steps", []) - if steps: - return Molecule( - atoms=traj_data["atoms"], - coordinates=steps[-1]["coords"], - charge=traj_data.get("charge", 0), - multiplicity=traj_data.get("multiplicity", 1), - ) - except Exception: - pass - - return None + return _hist_mol_from_result_dir(result_dir, data) def _history_load_results(self, data: dict, result_dir: Path) -> None: - """Display a history result card in the Results tab and navigate there.""" - self.result_output.clear_output() - with self.result_output: - display(HTML(self._format_past_result(data, result_dir=result_dir))) - self._result_dir_label.layout.display = "none" - # Also show 3D structure if geometry is recoverable - mol = self._mol_from_result_dir(result_dir, data) - if mol is not None: - self._show_result_3d(mol) - self.root_tab.selected_index = 1 + self._activity_begin("Loading history result...") + try: + _hist_history_load_results(self, data, result_dir) + self._refresh_file_browser() + finally: + self._activity_end() def _history_load_analysis(self, result_dir: Path) -> None: - """Load analysis panels for a history result and navigate to Analysis tab.""" - log_path = result_dir / "pyscf.log" - text = ( - log_path.read_text(encoding="utf-8", errors="replace") - if log_path.exists() - else "(No pyscf.log found for this result.)" - ) - self._update_log_panel(result_dir.name if log_path.exists() else "", text) - self._show_result_log(result_dir, text) - - ctx = self._build_history_context(result_dir) - if ctx is not None: - _data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} - try: - _mol = self._mol_from_result_dir(result_dir, _data_stub) - if _mol is not None: - self._show_result_3d(_mol, extra_output=self._analysis_mol_output) - else: - self._analysis_mol_output.clear_output() - except Exception: - pass - self._apply_analysis_context(ctx) - - self.root_tab.selected_index = 2 - - def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]: - """Load result.json from *result_dir* and return an ``_AnalysisContext``. - - Returns ``None`` if result.json cannot be read. - """ + self._activity_begin("Loading history analysis...") try: - from quantui import load_result + _hist_history_load_analysis(self, result_dir) + self._refresh_file_browser() + finally: + self._activity_end() - data = load_result(result_dir) - except Exception: - return None - return _AnalysisContext( - calc_type=data.get("calc_type", ""), - formula=data.get("formula", result_dir.name), - method=data.get("method", ""), - basis=data.get("basis", ""), - result_dir=result_dir, - spectra_data=data.get("spectra", {}), - source="history", - ) + def _build_history_context(self, result_dir: Path) -> Optional[_AnalysisContext]: + return _hist_build_history_context(result_dir, context_cls=_AnalysisContext) # ── Perf stats reset ────────────────────────────────────────────────── def _on_reset_click(self, btn) -> None: - self._reset_confirm_box.layout.display = "" + _run_on_reset_click(self, btn) def _on_confirm_yes(self, btn) -> None: - from quantui.calc_log import reset_perf_log - - reset_perf_log() - self._reset_confirm_box.layout.display = "none" - self._refresh_perf_stats() + _run_on_confirm_yes(self, btn, reset_perf_log_fn=_calc_log.reset_perf_log) def _on_confirm_no(self, btn) -> None: - self._reset_confirm_box.layout.display = "none" + _run_on_confirm_no(self, btn) # ── Calibration ─────────────────────────────────────────────────────── def _on_cal_run(self, btn) -> None: - import threading as _threading - - mode = self._cal_mode_toggle.value - suite = _BENCHMARK_SUITE if mode == "short" else _BENCHMARK_SUITE_LONG - self._cal_stop_event = _threading.Event() - self._cal_run_btn.disabled = True - self._cal_mode_toggle.disabled = True - self._cal_stop_btn.layout.display = "" - self._cal_progress.max = len(suite) - self._cal_progress.value = 0 - self._cal_progress.layout.display = "" - self._cal_step_label.layout.display = "" - self._cal_step_label.value = ( - 'Starting…' + _run_on_cal_run( + self, + btn, + benchmark_suite=_BENCHMARK_SUITE, + benchmark_suite_long=_BENCHMARK_SUITE_LONG, ) - self._cal_results_html.value = "" - - _threading.Thread(target=self._do_calibration, daemon=True).start() def _on_cal_stop(self, btn) -> None: - if hasattr(self, "_cal_stop_event"): - self._cal_stop_event.set() + _run_on_cal_stop(self, btn) def _do_calibration(self) -> None: - from quantui.benchmarks import run_calibration - - mode = self._cal_mode_toggle.value - - def _progress( - step_n: int, total: int, label: str, status: str, elapsed: float - ) -> None: - _icon = {"ok": "✓", "timed_out": "⏱", "stopped": "⛔", "error": "✗"}.get( - status, "?" - ) - self._cal_progress.value = step_n - self._cal_step_label.value = ( - f'' - f"Step {step_n} / {total} — {label} " - f"[{_icon} {elapsed:.1f} s]" - ) - - result = run_calibration( - progress_cb=_progress, - stop_event=self._cal_stop_event, - timeout_per_step=300.0 if mode == "long" else 120.0, - mode=mode, - ) - - # Render results table - _rows = "".join( - f"{_summary}
' - f'| Calculation | ' - f'e⁻ | ' - f'Basis fns | ' - f'Wall time | ' - f'Status | ' - f"
|---|
'
- f"{_html_mod.escape(log_content)}"
- )
- )
- self._result_log_accordion.layout.display = ""
+ js_code = r"""
+(() => {
+ const ROOT_CLASS = "quantui-run-output";
+ const ROOT_MARK = "data-quantui-run-scroll-guard";
- def _on_traj_expand(self, change) -> None:
- """Lazily generate the trajectory animation when the accordion is first opened."""
- if change["new"] != 0:
- return
- result = self._pending_traj_result
- if result is None:
- return
- self._pending_traj_result = None
+ function selectScroller(root) {
+ const candidates = [
+ root,
+ ...root.querySelectorAll(
+ ".jp-OutputArea-output, .output_scroll, .jupyter-widgets-output-area, .output_subarea"
+ ),
+ ];
+ for (const el of candidates) {
+ const style = window.getComputedStyle(el);
+ const overflowY = (style && style.overflowY) || "";
+ const canScroll = /auto|scroll/.test(overflowY);
+ if (canScroll || el.scrollHeight > el.clientHeight + 2) {
+ return el;
+ }
+ }
+ return root;
+ }
- from IPython.display import HTML as _H
- from IPython.display import display as _d
+ function installForRoot(root) {
+ if (!root || root.getAttribute(ROOT_MARK) === "1") {
+ return;
+ }
- self.traj_output.clear_output()
- with self.traj_output:
- _d(
- _H(
- 'Loading trajectory viewer…
' - ) - ) + const scroller = selectScroller(root); + if (!scroller) { + return; + } - def _render(): - try: - self._show_opt_trajectory(result) - except Exception as exc: - from IPython.display import HTML as _H2 - from IPython.display import display as _d2 - - self.traj_output.clear_output() - with self.traj_output: - _d2( - _H2( - f'⚠ Trajectory rendering failed: {exc}
' - ) - ) + root.setAttribute(ROOT_MARK, "1"); - threading.Thread(target=_render, daemon=True).start() + const thresholdPx = 24; + let stickToBottom = true; - def _show_opt_trajectory(self, opt_result) -> None: - """Build the trajectory carousel and energy chart in the trajectory panel. + const updateStickFlag = () => { + const dist = scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; + stickToBottom = dist <= thresholdPx; + }; - Shows a step slider for flipping through frames and an energy-convergence - chart. An Export button generates a standalone HTML animation file on demand. - Safe to call from a background thread. + const pinIfNeeded = () => { + if (stickToBottom) { + scroller.scrollTop = scroller.scrollHeight; + } + }; - When plotlymol is available: - - Bond perception runs once on frame 0 (RDKit DetermineConnectivity is slow). - - All remaining frames are pre-rendered in a background thread pool so - slider navigation is instant after a few seconds. - """ - import concurrent.futures + scroller.addEventListener("scroll", updateStickFlag, { passive: true }); - from IPython.display import display as _ipy_display + const obs = new MutationObserver(pinIfNeeded); + obs.observe(root, { childList: true, subtree: true, characterData: true }); - # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list) - traj = getattr(opt_result, "trajectory", None) or getattr( - opt_result, "coordinates_list", [] - ) - energies = opt_result.energies_hartree - n = len(traj) - if n < 2: - self.traj_output.clear_output() - with self.traj_output: - _ipy_display( - HTML( - '' - "No trajectory data available (single-frame result).
" - ) - ) - return + updateStickFlag(); + pinIfNeeded(); + } - _HARTREE_TO_KCAL = 627.5094740631 - e0 = energies[0] if energies else 0.0 - rel_e = [(e - e0) * _HARTREE_TO_KCAL for e in energies] if energies else [] + function scanAndInstall() { + const roots = document.querySelectorAll(`.${ROOT_CLASS}`); + roots.forEach(installForRoot); + } - # --- Energy convergence chart --- - _has_plotly = False - try: - import plotly.graph_objects as go - - energy_fig = go.Figure( - go.Scatter( - x=list(range(n)), - y=rel_e, - mode="lines+markers", - name="ΔE", - line=dict(color="#2563eb", width=2), - marker=dict(size=6), - ) - ) - energy_fig.update_layout( - title="Energy Convergence", - xaxis_title="Step", - yaxis_title="ΔE (kcal/mol)", - height=220, - margin=dict(l=60, r=20, t=40, b=40), - ) - _has_plotly = True - except ImportError: - pass + scanAndInstall(); - # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) --- - _charge = traj[0].charge - _xyzblocks = [ - f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj - ] - _FRAME_W, _FRAME_H, _FRAME_RES = 460, 340, 8 + const bodyObserver = new MutationObserver(() => { + scanAndInstall(); + }); + bodyObserver.observe(document.body, { childList: true, subtree: true }); +})(); +""" - # --- Attempt to set up fast-path: bond perception once on frame 0 --- - # draw_3D_mol accepts a pre-parsed RDKit mol and skips bond perception, - # so we only pay that cost for the first frame instead of every frame. - _ref_mol = None - _plotlymol_fast = False try: - from plotlymol3d import ( - draw_3D_mol as _draw_3D_mol, - ) - from plotlymol3d import ( - format_figure as _fmt_fig, - ) - from plotlymol3d import ( - format_lighting as _fmt_light, - ) - from plotlymol3d import ( - make_subplots as _make_subplots, - ) - from plotlymol3d import ( - xyzblock_to_rdkitmol as _xyz_to_rdkit, - ) - from rdkit import Chem as _Chem - - from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP - - _ref_mol = _xyz_to_rdkit(_xyzblocks[0], charge=_charge) - _plotlymol_fast = _ref_mol is not None + with self._exit_output: + display(Javascript(js_code)) + self._run_output_scroll_guard_installed = True except Exception: - pass - - def _build_fig_fast(idx: int): - """Reuse frame-0 bond topology; only swap in new atom positions.""" - mol_xyz = _Chem.MolFromXYZBlock(_xyzblocks[idx] + "\n") - if mol_xyz is None: - return None - rw = _Chem.RWMol(_ref_mol) - conf_src = mol_xyz.GetConformer() - conf_dst = rw.GetConformer() - for atom_idx in range(rw.GetNumAtoms()): - conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx)) - fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]]) - _draw_3D_mol(fig, rw.GetMol(), _FRAME_RES, "ball+stick") - fig = _fmt_fig(fig) - fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"])) - _scene_bg = self._plotly_theme_colors()["scene_bgcolor"] - fig.update_layout( - width=_FRAME_W, - height=_FRAME_H, - paper_bgcolor="white", - scene=dict(bgcolor=_scene_bg), - margin=dict(l=0, r=0, t=0, b=0), - ) - return fig + # Non-notebook contexts may not support JS display; fail silently. + self._run_output_scroll_guard_installed = False - def _build_fig(idx: int): - """Return (kind, obj) for frame idx; fast path when bonds are cached.""" - if _plotlymol_fast: - try: - fig = _build_fig_fast(idx) - if fig is not None: - return ("plotly", fig) - except Exception: - pass - # Slow fallback: full plotlymol pipeline - try: - from quantui.visualization_py3dmol import visualize_molecule_plotlymol - - fig = visualize_molecule_plotlymol( - traj[idx], - mode="ball+stick", - resolution=_FRAME_RES, - width=_FRAME_W, - height=_FRAME_H, - ) - _scene_bg = self._plotly_theme_colors()["scene_bgcolor"] - 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 - - 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 self.theme_btn.value == "Light" else "#1e1e1e" - ) - view.zoomTo() - return ("py3dmol", view) - except Exception as exc: - return ("error", str(exc)) - - _frame_cache: dict = {} - - # --- Carousel controls --- - _step_slider = widgets.IntSlider( - value=0, - min=0, - max=n - 1, - description="Step:", - continuous_update=False, - style={"description_width": "40px"}, - layout=_layout(width="360px"), - ) - _step_info = widgets.HTML(value=self._traj_step_html(0, traj, energies, rel_e)) - _frame_out = widgets.Output(layout=_layout(min_height="340px")) - _cache_label = widgets.HTML( - value=f'' - f"Pre-rendering frames… 0 / {n}" - ) - - def _display_frame(idx: int) -> None: - kind, obj = _frame_cache[idx] - _frame_out.clear_output() - with _frame_out: - if kind == "error": - _ipy_display( - HTML( - f'Frame render failed: {obj}
' - ) - ) - else: - _ipy_display(obj) - - def _update_frame(change) -> None: - idx = change["new"] - _step_info.value = self._traj_step_html(idx, traj, energies, rel_e) - if idx in _frame_cache: - _display_frame(idx) - return - _frame_out.clear_output() - with _frame_out: - _ipy_display( - HTML( - 'Rendering…
' - ) - ) - - def _on_demand(): - try: - _frame_cache[idx] = _build_fig(idx) - _display_frame(idx) - except Exception as exc: - _frame_out.clear_output() - with _frame_out: - _ipy_display( - HTML( - f'Frame render failed: {exc}
' - ) - ) - - threading.Thread(target=_on_demand, daemon=True).start() - - _step_slider.observe(self._safe_cb(_update_frame), names="value") - - # --- Export button --- - _export_btn = widgets.Button( - description="Export Animation", - icon="download", - layout=_layout(width="160px", margin="0 0 0 12px"), - tooltip="Generate a standalone HTML animation file (may take a minute)", - ) - _export_status = widgets.HTML() - - def _on_export(_btn): - _btn.disabled = True - _export_status.value = ( - f'' - f"Generating {n}-frame animation, please wait…" - ) - - def _do_export(): - try: - from plotlymol3d import create_trajectory_animation - - anim_fig = create_trajectory_animation( - xyzblocks=_xyzblocks, - energies_hartree=energies if energies else None, - charge=_charge, - mode="ball+stick", - resolution=12, - title=f"Geo Opt: {opt_result.formula}", - ) - _result_dir = getattr(self, "_last_result_dir", None) - out_path = ( - _result_dir / "trajectory_animation.html" - if _result_dir is not None - else Path.home() / f"{opt_result.formula}_trajectory.html" - ) - anim_fig.write_html(str(out_path)) - _export_status.value = ( - f'' - f"✓ Saved: {out_path}" - ) - except Exception as exc: - _export_status.value = ( - f'Export failed: {exc}' - ) - finally: - _btn.disabled = False - - threading.Thread(target=_do_export, daemon=True).start() - - _export_btn.on_click(_on_export) - - # --- Assemble layout --- - _header = widgets.HBox( - [_step_slider, _export_btn], - layout=_layout(align_items="center", margin="4px 0"), - ) - _panel = widgets.VBox( - [_header, _step_info, _cache_label, _frame_out, _export_status] - ) - - # Display panel immediately — clears the “Loading…” message right away. - self.traj_output.clear_output() - with self.traj_output: - if _has_plotly and rel_e: - _ipy_display(energy_fig) - _ipy_display(_panel) - - # Show placeholder while frame 0 renders in the background. - _frame_out.clear_output() - with _frame_out: - _ipy_display( - HTML( - '' - "Rendering frame 0…
" - ) - ) - - # Render all frames (0 first, then 1+) in a background thread. - def _prerender_all() -> None: - try: - _frame_cache[0] = _build_fig(0) - _display_frame(0) - _cache_label.value = ( - f'' - f"Pre-rendering frames… 1 / {n}" - ) - if n > 1: - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: - futures = {pool.submit(_build_fig, i): i for i in range(1, n)} - done = 1 - for fut in concurrent.futures.as_completed(futures): - i = futures[fut] - try: - _frame_cache[i] = fut.result() - except Exception: - pass - done += 1 - _cache_label.value = ( - f'' - f"Pre-rendering frames… {done} / {n}" - ) - except Exception: - pass - _cache_label.value = ( - f'' - f"✓ All {n} frames ready" - ) - - threading.Thread(target=_prerender_all, daemon=True).start() - - def _traj_step_html(self, step: int, traj, energies, rel_e) -> str: - """One-line info label for the given trajectory step index.""" - n = len(traj) - mol = traj[step] - e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—" - delta = ( - f" · ΔE = {rel_e[step]:+.3f} kcal/mol" - if rel_e and step < len(rel_e) - else "" - ) - return ( - f'' - f"Step {step} / {n - 1} · {mol.get_formula()}" - f" · E = {e_abs}{delta}" - ) - - def _render_traj_frame(self, molecule, output_widget) -> None: - """Render a single trajectory frame into output_widget (thread-safe). - - Tries plotlymol first, falls back to py3Dmol. - """ - try: - from quantui.visualization_py3dmol import visualize_molecule_plotlymol + def _set_molecule_state_only(self, mol) -> None: + """Apply only thread-safe molecule state updates.""" + self._molecule = mol - fig = visualize_molecule_plotlymol( - molecule, mode="ball+stick", resolution=8, width=460, height=340 - ) - _scene_bg = self._plotly_theme_colors()["scene_bgcolor"] - fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=_scene_bg)) - output_widget.clear_output() - with output_widget: - display(fig) + def _set_molecule_threadsafe(self, mol, status_message: str) -> None: + """Update molecule state safely and render on the main thread only.""" + if threading.current_thread() is threading.main_thread(): + self._set_molecule(mol, status_message) return - except ImportError: - pass - - # Fallback: py3Dmol - try: - import py3Dmol as _p3d - - xyz = ( - f"{len(molecule.atoms)}\n" - f"{molecule.get_formula()}\n" - f"{molecule.to_xyz_string()}" - ) - view = _p3d.view(width=460, height=340) - view.addModel(xyz, "xyz") - view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) - view.setBackgroundColor("white") - view.zoomTo() - output_widget.clear_output() - with output_widget: - display(view) - except Exception as exc: - output_widget.clear_output() - with output_widget: - display( - HTML( - f'Frame render failed: {exc}
' - ) - ) - - def _build_vib_data_from_freq_result(self, freq_result, molecule): - """Construct a ``plotlymol3d.VibrationalData`` from a FreqResult. - - Args: - freq_result: ``FreqResult`` with ``displacements`` populated. - molecule: The ``Molecule`` used for the frequency calculation. - - Returns: - ``VibrationalData`` or ``None`` if prerequisites are missing. - """ - try: - import numpy as np - from plotlymol3d import VibrationalData, VibrationalMode - except ImportError: - return None - - try: - return self._build_vib_data_inner( - freq_result, molecule, np, VibrationalData, VibrationalMode - ) - except Exception as _e: - try: - from quantui import calc_log as _clog - - _clog.log_event("vib_data_error", f"{type(_e).__name__}: {_e}"[:300]) - except Exception: - pass - return None - - def _build_vib_data_inner( - self, freq_result, molecule, np, VibrationalData, VibrationalMode - ): - displacements = getattr(freq_result, "displacements", None) - if displacements is None: - return None - - freqs = freq_result.frequencies_cm1 - intensities = freq_result.ir_intensities - n_modes = len(freqs) - - coords = np.array(molecule.coordinates, dtype=float) - - # Map element symbols to atomic numbers using a common-elements table. - # ASE is not required — this covers all elements students will encounter. - _Z = { - "H": 1, - "He": 2, - "Li": 3, - "Be": 4, - "B": 5, - "C": 6, - "N": 7, - "O": 8, - "F": 9, - "Ne": 10, - "Na": 11, - "Mg": 12, - "Al": 13, - "Si": 14, - "P": 15, - "S": 16, - "Cl": 17, - "Ar": 18, - "K": 19, - "Ca": 20, - "Br": 35, - "I": 53, - } - atomic_numbers: List[int] = [_Z.get(sym, 0) for sym in molecule.atoms] - - modes = [] - for i in range(n_modes): - freq = freqs[i] - ir_inten = intensities[i] if i < len(intensities) else None - displ = np.array(displacements[i], dtype=float) - modes.append( - VibrationalMode( - mode_number=i + 1, - frequency=float(freq), - ir_intensity=ir_inten, - displacement_vectors=displ, - is_imaginary=freq < 0, - ) - ) - - return VibrationalData( - coordinates=coords, - atomic_numbers=atomic_numbers, - modes=modes, - source_file="quantui_freq_calc", - program="pyscf", - ) - - def _show_vib_animation(self, freq_result, molecule) -> bool: - """Populate the vibrational animation accordion after a Frequency result. - Builds a ``VibrationalData`` from the result, populates the mode selector - dropdown, and renders the animation for the first non-trivial mode. - Returns True if populated, False if data is missing or plotlyMol unavailable. - Does NOT call ``_activate_ana_panel``; that is handled by the registry. - """ - vib_data = self._build_vib_data_from_freq_result(freq_result, molecule) - if vib_data is None: - return False - - freqs = freq_result.frequencies_cm1 - if not freqs: - return False - - # Build dropdown options: one entry per mode with frequency label. - # Skip near-zero translation/rotation modes (|ν| < 10 cm⁻¹). - options = [] - for m in vib_data.modes: - freq_val = m.frequency - if abs(freq_val) < 10: - continue - label = ( - f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹" - if freq_val >= 0 - else f"Mode {m.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)" - ) - options.append((label, m.mode_number)) - - if not options: - return False + self._set_molecule_state_only(mol) + self._queue_main_thread_callback(self._set_molecule, mol, status_message) - self.vib_mode_dd.options = options - self.vib_mode_dd.value = options[0][1] - - # Store vib_data for callback use. - self._last_vib_data = vib_data - self._last_vib_molecule = molecule - - # Show loading indicator and render in a background thread so _do_run - # is not blocked while the animation is generated (can take several seconds). - # append_display_data is used instead of display() because this method is - # called from the _do_run background thread; display(HTML(...)) is not - # thread-safe for plain HTML but append_display_data is. - _first_label, _first_mode = options[0] - self.vib_output.clear_output() - self.vib_output.append_display_data( - HTML( - f'' - f"⏳ Rendering vibrational animation ({_first_label})…
" - ) + def _show_result_3d(self, molecule, extra_output=None) -> None: + _viz_show_result_3d( + self, + molecule, + extra_output, + display_molecule_fn=_display_molecule, ) - threading.Thread( - target=self._render_vib_mode, - args=(vib_data, molecule, _first_mode), - daemon=True, - ).start() - - return True - def _show_ir_spectrum(self, freq_result) -> bool: - """Populate the IR Spectrum accordion after a Frequency result. + def _show_result_log(self, saved_dir: Path, log_text: str) -> None: + """Populate the result-directory label and output-log accordion. - Returns True if populated, False if no frequency data at all. - When IR intensities are unavailable, falls back to unit weights so the - panel still activates showing frequency positions. - Does NOT call ``_activate_ana_panel``; that is handled by the registry. + Safe to call from a background thread. """ - freqs = list(freq_result.frequencies_cm1 or []) - ints = list(getattr(freq_result, "ir_intensities", None) or []) - if not freqs: - return False - - # When intensities are missing, substitute unit weights so the stick - # plot still shows frequency positions; accordion title reflects this. - self._ir_intensities_real = bool(ints) - if not ints: - ints = [1.0] * len(freqs) - self._ir_accordion.set_title( - 0, - ( - "IR Spectrum" - if self._ir_intensities_real - else "IR Spectrum (positions only — intensities unavailable)" - ), - ) - - # Store for callbacks - self._last_ir_freqs = freqs - self._last_ir_ints = ints - - self._update_ir_figure("Stick", 20.0) - - # _show_ir_spectrum may run from the _do_run background thread. - # Wire observers and set widget state on the main thread. - self._queue_main_thread_callback(self._wire_ir_controls) - - return True - - def _wire_ir_controls(self) -> None: - """(Re)bind IR controls and reset defaults on the main thread.""" - self._ir_mode_toggle.unobserve_all() - self._ir_fwhm_slider.unobserve_all() - self._ir_mode_toggle.observe( - self._safe_cb(self._on_ir_mode_changed), names="value" - ) - self._ir_fwhm_slider.observe( - self._safe_cb(self._on_ir_fwhm_changed), names="value" + # Path label + self._result_dir_label.value = ( + f'' + f"Saved to: {saved_dir}" ) + self._result_dir_label.layout.display = "" - # Reset toggle/slider to defaults - self._ir_mode_toggle.value = "Stick" - self._ir_fwhm_slider.value = 20.0 - self._ir_fwhm_slider.layout.display = "none" - - def _on_ir_mode_changed(self, change) -> None: - """Handle Stick/Broadened mode changes for IR panel.""" - mode = change["new"] - try: - _calc_log.log_event( - "ir_mode_change", - mode, - mode=mode, - session_id=self._session_id, - ) - except Exception: - pass - self._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none" - self._update_ir_figure(mode, self._ir_fwhm_slider.value) - - def _on_ir_fwhm_changed(self, change) -> None: - """Re-render broadened IR trace when line width slider changes.""" - if self._ir_mode_toggle.value == "Broadened": - self._update_ir_figure("Broadened", change["new"]) - - def _update_ir_figure(self, mode: str, fwhm: float) -> None: - """Re-render the IR spectrum chart for the given mode and FWHM.""" - try: - import plotly.io as _pio - - from quantui.ir_plot import plot_ir_spectrum - - _ytitle = ( - "IR Intensity (km/mol)" - if getattr(self, "_ir_intensities_real", True) - else "Relative intensity (a.u.)" - ) - fig = plot_ir_spectrum( - self._last_ir_freqs, - self._last_ir_ints, - mode=mode.lower(), - fwhm=fwhm, - yaxis_title=_ytitle, - ) - self._apply_plotly_theme(fig) - self._set_html_output( - self._ir_fig, - _pio.to_html( - fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, - ), - ) - except Exception as _e: - try: - from quantui import calc_log as _clog - - _clog.log_event("ir_fig_error", f"{type(_e).__name__}: {_e}"[:300]) - except Exception: - pass - - def _show_orbital_diagram(self, result) -> bool: - """Build and reveal the interactive orbital diagram accordion. - - Returns True if the diagram was populated, False if data is missing. - Does NOT call ``_activate_ana_panel``; that is handled by the registry. - """ - mo_energy = getattr(result, "mo_energy_hartree", None) - mo_occ = getattr(result, "mo_occ", None) - if mo_energy is None or mo_occ is None: - return False - - try: - from quantui.orbital_visualization import orbital_info_from_arrays - - info = orbital_info_from_arrays(mo_energy, mo_occ, formula=result.formula) - except Exception: - return False - - self._last_orb_info = info - self._last_orb_mo_coeff = getattr(result, "mo_coeff", None) - self._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None) - self._last_orb_mol_basis = getattr(result, "pyscf_mol_basis", None) - - _plotly_rendered = False - try: - import plotly.io as _pio - - from quantui.orbital_visualization import plot_orbital_diagram_plotly - - fig = plot_orbital_diagram_plotly( - info, max_orbitals=self._orb_n_orb_input.value - ) - # Sync axis limit controls to auto-computed range - yr = fig.layout.yaxis.range - if yr is not None: - self._orb_ymin_input.value = round(float(yr[0]), 2) - self._orb_ymax_input.value = round(float(yr[1]), 2) - self._apply_plotly_theme(fig) - html_str = _pio.to_html( - fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, - ) - self._set_html_output(self._orb_diagram_html, html_str) - _plotly_rendered = True - except Exception: - pass - - if not _plotly_rendered: - # Fallback: static matplotlib PNG (plotly not installed) - import base64 - import io as _io - - try: - from matplotlib.backends.backend_agg import ( - FigureCanvasAgg as _AggCanvas, - ) - - from quantui.orbital_visualization import plot_orbital_diagram - - mpl_fig = plot_orbital_diagram(info) - _AggCanvas(mpl_fig) - buf = _io.BytesIO() - mpl_fig.savefig(buf, format="png", dpi=100, bbox_inches="tight") - buf.seek(0) - img_b64 = base64.b64encode(buf.read()).decode() - self._set_html_output( - self._orb_diagram_html, - ( - f'' - f"⏳ Generating {orbital_label} cube file and rendering isosurface" - f" — this may take 15–30 s…
" + f''
+ f"{_html_mod.escape(log_content)}"
)
)
+ self._result_log_accordion.layout.display = ""
- def _run():
- try:
- self._render_orbital_isosurface(orbital_label)
- finally:
- btn.disabled = False
- btn.description = "Generate Isosurface"
-
- threading.Thread(target=_run, daemon=True).start()
-
- def _on_orb_range_changed(self, _change=None) -> None:
- """Live-update the orbital diagram when axis limits or orbital count changes."""
- info = getattr(self, "_last_orb_info", None)
- if info is None:
- return
- ymin = self._orb_ymin_input.value
- ymax = self._orb_ymax_input.value
- if ymin >= ymax:
- return
- try:
- import plotly.io as _pio
-
- from quantui.orbital_visualization import plot_orbital_diagram_plotly
+ def _on_traj_expand(self, change) -> None:
+ _viz_on_traj_expand(self, change)
- fig = plot_orbital_diagram_plotly(
- info,
- max_orbitals=self._orb_n_orb_input.value,
- yrange=(ymin, ymax),
- )
- self._apply_plotly_theme(fig)
- self._set_html_output(
- self._orb_diagram_html,
- _pio.to_html(
- fig,
- include_plotlyjs="require",
- full_html=False,
- config={"responsive": True},
- ),
- )
- except Exception:
- pass
+ def _show_opt_trajectory(
+ self, opt_result, render_token: Optional[int] = None
+ ) -> None:
+ _viz_show_opt_trajectory(
+ self,
+ opt_result,
+ layout_fn=_layout,
+ render_token=render_token,
+ )
- def _render_orbital_isosurface(self, orbital_label: str) -> None:
- """Generate a cube file and render an orbital isosurface (Linux/WSL only)."""
- import tempfile
+ def _traj_step_html(self, step: int, traj, energies, rel_e) -> str:
+ return _viz_traj_step_html(self, step, traj, energies, rel_e)
- orb_info = getattr(self, "_last_orb_info", None)
- if orb_info is None:
- return
+ def _render_traj_frame(self, molecule, output_widget) -> None:
+ _viz_render_traj_frame(self, molecule, output_widget)
- n_occ = orb_info.n_occupied
- n_total = len(orb_info.mo_energies_ev)
- _idx_map = {
- "HOMO-1": n_occ - 2,
- "HOMO": n_occ - 1,
- "LUMO": n_occ,
- "LUMO+1": n_occ + 1,
- }
- orb_idx = _idx_map.get(orbital_label)
- if orb_idx is None or orb_idx < 0 or orb_idx >= n_total:
- return
+ def _build_vib_data_from_freq_result(self, freq_result, molecule):
+ return _viz_build_vib_data_from_freq_result(self, freq_result, molecule)
- mo_coeff = getattr(self, "_last_orb_mo_coeff", None)
- mol_atom = getattr(self, "_last_orb_mol_atom", None)
- mol_basis = getattr(self, "_last_orb_mol_basis", None)
- if mo_coeff is None or mol_atom is None or mol_basis is None:
- return
+ def _build_vib_data_inner(
+ self, freq_result, molecule, np, VibrationalData, VibrationalMode
+ ):
+ return _viz_build_vib_data_inner(
+ self, freq_result, molecule, np, VibrationalData, VibrationalMode
+ )
- try:
- from quantui.orbital_visualization import (
- generate_cube_from_arrays,
- plot_cube_isosurface,
- )
+ def _show_vib_animation(self, freq_result, molecule) -> bool:
+ return _viz_show_vib_animation(self, freq_result, molecule)
- with tempfile.TemporaryDirectory() as tmpdir:
- cube_path = Path(tmpdir) / f"orbital_{orbital_label}.cube"
- generate_cube_from_arrays(
- mol_atom, mol_basis, mo_coeff, orb_idx, cube_path
- )
- fig = plot_cube_isosurface(
- cube_path, title=f"{orbital_label} Isosurface"
- )
- except Exception as _exc:
- from IPython.display import HTML as _H
- from IPython.display import display as _d
-
- self._orb_iso_output.clear_output()
- with self._orb_iso_output:
- _d(
- _H(
- f'⚠ Orbital isosurface failed: {_exc}
' - ) - ) - return + def _show_ir_spectrum(self, freq_result) -> bool: + return _viz_show_ir_spectrum(self, freq_result) - from IPython.display import display as _ipy_display + def _wire_ir_controls(self) -> None: + _viz_wire_ir_controls(self) - self._orb_iso_output.clear_output() - with self._orb_iso_output: - _ipy_display(fig) + def _on_ir_mode_changed(self, change) -> None: + _viz_on_ir_mode_changed(self, change) - def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None: - """Render vibrational animation for the given mode into ``vib_output``. + def _on_ir_fwhm_changed(self, change) -> None: + _viz_on_ir_fwhm_changed(self, change) - Safe to call from background thread via ``with output:`` context. - """ - from IPython.display import HTML as _H + def _update_ir_figure(self, mode: str, fwhm: float) -> None: + _viz_update_ir_figure(self, mode, fwhm) - def _err(msg: str) -> None: - self.vib_output.clear_output() - self.vib_output.append_display_data( - _H(f'⚠ {msg}
') - ) + def _show_uv_vis_spectrum( + self, + energies_ev: list[float], + oscillator_strengths: list[float], + wavelengths_nm: list[float], + ) -> bool: + return _viz_show_uv_vis_spectrum( + self, + energies_ev, + oscillator_strengths, + wavelengths_nm, + ) - 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}"
- )
- return
+ def _wire_uv_controls(self) -> None:
+ _viz_wire_uv_controls(self)
- # Build an RDKit mol for bond connectivity (required by animation function).
- xyzblock = (
- f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
- f"{molecule.to_xyz_string()}"
- )
- try:
- rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
- except Exception as exc:
- _err(f"Could not parse molecule for bond connectivity: {exc}")
- return
+ def _on_uv_mode_changed(self, change) -> None:
+ _viz_on_uv_mode_changed(self, change)
- try:
- from quantui import calc_log as _clog_anim
+ def _on_uv_fwhm_changed(self, change) -> None:
+ _viz_on_uv_fwhm_changed(self, change)
- _clog_anim.log_event("vib_render_start", f"mode {mode_number}")
- except Exception:
- pass
- try:
- anim_fig = create_vibration_animation(
- vib_data=vib_data,
- mode_number=mode_number,
- mol=rdmol,
- amplitude=0.4,
- n_frames=20,
- mode="ball+stick",
- resolution=12,
- )
- anim_fig.update_layout(height=420)
- except Exception as exc:
- try:
- from quantui import calc_log as _clog_anim
+ def _update_uv_vis_figure(self, mode: str, fwhm: float) -> None:
+ _viz_update_uv_vis_figure(self, mode, fwhm)
- _clog_anim.log_event(
- "vib_render_error",
- f"mode {mode_number}: {type(exc).__name__}: {exc}"[:300],
- )
- except Exception:
- pass
- _err(f"Animation generation failed: {exc}")
- return
- try:
- from quantui import calc_log as _clog_anim
+ def _show_orbital_diagram(self, result) -> bool:
+ return _viz_show_orbital_diagram(self, result)
- _clog_anim.log_event("vib_render_done", f"mode {mode_number}")
- except Exception:
- pass
+ def _on_iso_generate(self, btn) -> None:
+ _viz_on_iso_generate(self, btn)
- import plotly.io as _pio
+ def _on_orb_range_changed(self, _change=None) -> None:
+ _viz_on_orb_range_changed(self, _change)
- _anim_html = _pio.to_html(
- anim_fig,
- full_html=False,
- include_plotlyjs="require",
- config={"responsive": True},
+ def _render_orbital_isosurface(
+ self, orbital_label: str, render_token: Optional[int] = None
+ ) -> None:
+ _viz_render_orbital_isosurface(
+ self,
+ orbital_label,
+ render_token=render_token,
)
- self.vib_output.clear_output()
- self.vib_output.append_display_data(_H(_anim_html))
+
+ def _render_vib_mode(self, vib_data, molecule, mode_number: int) -> None:
+ _viz_render_vib_mode(self, vib_data, molecule, mode_number)
def _on_vib_mode_changed(self, change) -> None:
- """Re-render vib animation when the mode dropdown changes."""
- mode_number = change["new"]
- vib_data = getattr(self, "_last_vib_data", None)
- molecule = getattr(self, "_last_vib_molecule", None)
- if vib_data is None or molecule is None:
- return
- # Show a loading indicator immediately so the user gets feedback while
- # the animation generates in the background.
- _label = next(
- (lbl for lbl, num in self.vib_mode_dd.options if num == mode_number),
- f"mode {mode_number}",
- )
- self.vib_output.clear_output()
- self.vib_output.append_display_data(
- HTML(
- f'' - f"⏳ Rendering vibrational animation ({_label})…
" - ) - ) - threading.Thread( - target=self._render_vib_mode, - args=(vib_data, molecule, mode_number), - daemon=True, - ).start() + _viz_on_vib_mode_changed(self, change) def _do_run(self) -> None: """Main calculation dispatch — runs in a background thread.""" @@ -4874,6 +2779,10 @@ def _do_run(self) -> None: if mol is None: self.run_status.value = "Load a molecule first." return + self._activity_begin( + "Running compute operations...", + kind="compute", + ) self.run_btn.disabled = True self.run_status.value = "Starting..." @@ -4887,7 +2796,53 @@ def _do_run(self) -> None: ) _run_wall_t = time.perf_counter() _run_cpu_t = time.process_time() - log = _LogCapture(self.run_output, self.run_status) + _scf_converged_t: Optional[float] = None + _tail_marks: dict[str, float] = {} + + def _mark(stage: str) -> None: + _tail_marks[stage] = time.perf_counter() + + def _span(stage_a: str, stage_b: str) -> Optional[float]: + if stage_a not in _tail_marks or stage_b not in _tail_marks: + return None + return round(_tail_marks[stage_b] - _tail_marks[stage_a], 3) + + def _on_scf_converged() -> None: + nonlocal _scf_converged_t + if _scf_converged_t is None: + _scf_converged_t = time.perf_counter() + + def _run_required_final_single_point(target_mol, reason: str): + """Run a required post-optimization single point on target geometry.""" + from quantui import run_in_session + + _solvent = self.solvent_dd.value if self.solvent_cb.value else None + self.run_status.value = ( + "Running required single-point on optimized geometry..." + ) + log.write( + f"\n-- Required single-point ({reason}) " + "on optimized geometry --------------------------------\n" + ) + sp_result = run_in_session( + molecule=target_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + solvent=_solvent, + ) + if not bool(getattr(sp_result, "converged", False)): + raise RuntimeError( + "Required post-optimization single-point did not converge." + ) + log.write("Required single-point converged on optimized geometry.\n") + return sp_result + + log = _LogCapture( + self.run_output, + self.run_status, + on_scf_converged=_on_scf_converged, + ) # Write structured log header immediately so it appears at the top of output try: @@ -4927,6 +2882,42 @@ def _do_run(self) -> None: save_spectra: dict = {} save_type: str = "single_point" _pre_opt: Any = None # OptimizationResult from Frequency pre-opt step + + # Optional QM geometry optimization before non-frequency workflows. + # Frequency has dedicated seed/pre-opt handling in its own branch. + if self._freq_preopt_cb.value and ct not in ("Geometry Opt", "Frequency"): + from quantui import optimize_geometry + + self.run_status.value = f"Pre-optimizing geometry before {ct}…" + log.write( + f"\n── Pre-optimisation (before {ct}) " + f"────────────────────────────────────\n" + ) + _pre_opt = optimize_geometry( + molecule=calc_mol, + method=self.method_dd.value, + basis=self.basis_dd.value, + progress_stream=log, # type: ignore[arg-type] + ) + calc_mol = _pre_opt.molecule + _conv_str = ( + "converged" if _pre_opt.converged else "did NOT fully converge" + ) + log.write( + f"\nPre-optimisation {_conv_str} in {_pre_opt.n_steps} steps." + f" E = {_pre_opt.energies_hartree[-1]:.8f} Ha\n\n" + ) + if not _pre_opt.converged: + log.write( + "⚠ Pre-optimisation did not fully converge — " + "proceeding with best available geometry.\n\n" + ) + if ct != "Single Point": + _run_required_final_single_point( + calc_mol, + f"after pre-optimisation before {ct}", + ) + if ct == "Geometry Opt": self.run_status.value = "Optimizing geometry..." from quantui import optimize_geometry @@ -4939,6 +2930,37 @@ def _do_run(self) -> None: steps=self.max_steps_si.value, progress_stream=log, # type: ignore[arg-type] ) + _sp_result = _run_required_final_single_point( + result.molecule, + "after geometry optimisation", + ) + _sp_energy = getattr(_sp_result, "energy_hartree", None) + if ( + isinstance(getattr(result, "energies_hartree", None), list) + and result.energies_hartree + and isinstance(_sp_energy, (int, float)) + ): + result.energies_hartree[-1] = float(_sp_energy) + result.converged = bool(result.converged) and bool( + getattr(_sp_result, "converged", False) + ) + result.mo_energy_hartree = getattr( + _sp_result, + "mo_energy_hartree", + result.mo_energy_hartree, + ) + result.mo_occ = getattr(_sp_result, "mo_occ", result.mo_occ) + result.mo_coeff = getattr(_sp_result, "mo_coeff", result.mo_coeff) + result.pyscf_mol_atom = getattr( + _sp_result, + "pyscf_mol_atom", + result.pyscf_mol_atom, + ) + result.pyscf_mol_basis = getattr( + _sp_result, + "pyscf_mol_basis", + result.pyscf_mol_basis, + ) result_html = self._format_opt_result(result) save_spectra, save_type = {}, "geometry_opt" elif ct == "Frequency": @@ -4985,6 +3007,10 @@ def _do_run(self) -> None: "⚠ Pre-optimisation did not fully converge — " "proceeding with best available geometry.\n\n" ) + _run_required_final_single_point( + calc_mol, + "after frequency pre-optimisation", + ) # ── Step 3: frequency analysis ──────────────────────────────── self.run_status.value = "Computing frequencies (SCF + Hessian)…" @@ -5127,6 +3153,7 @@ def _do_run(self) -> None: result_html = self._format_result(result) save_spectra, save_type = {}, "single_point" + _mark("result_ready") _elapsed = time.perf_counter() - _run_wall_t _elapsed_cpu = time.process_time() - _run_cpu_t self._last_result = result @@ -5134,7 +3161,7 @@ def _do_run(self) -> None: self.accumulate_btn.disabled = False self.result_output.append_display_data(HTML(result_html)) - self.run_status.value = f"Done in {_elapsed:.1f} s." + self.run_status.value = "Finalizing results..." # Show 3D structure in the result panel and mirrored in Analysis tab _viz_mol = result.molecule if ct == "Geometry Opt" else calc_mol @@ -5144,7 +3171,12 @@ def _do_run(self) -> None: 'margin:6px 0 2px">Optimized geometry' ) self._viz_label.layout.display = "" - self._show_result_3d(_viz_mol, extra_output=self._analysis_mol_output) + self._queue_main_thread_callback( + self._show_result_3d, + _viz_mol, + self._analysis_mol_output, + ) + _mark("viz_done") # Populate Analysis panels via the unified registry _ana_ctx = _AnalysisContext( @@ -5169,6 +3201,7 @@ def _do_run(self) -> None: f"{_mol_label}" ) self._completion_banner.layout.display = "" + _mark("banner_ready") # Write structured log footer try: @@ -5187,6 +3220,7 @@ def _do_run(self) -> None: pass # Persist to disk + _mark("persist_begin") try: from quantui import load_result, save_result from quantui.results_storage import ( @@ -5202,7 +3236,10 @@ def _do_run(self) -> None: spectra=save_spectra, ) self._last_result_dir = _saved_dir - save_thumbnail(_saved_dir, load_result(_saved_dir)) + _saved_data = load_result(_saved_dir) + save_thumbnail(_saved_dir, _saved_data) + _ana_ctx.result_dir = _saved_dir + _ana_ctx.timestamp = str(_saved_data.get("timestamp", "")) # Persist trajectory so history viewer can replay it. if ct in ("Geometry Opt", "PES Scan"): _traj = getattr( @@ -5227,13 +3264,18 @@ def _do_run(self) -> None: # Persist MO data for orbital diagram + isosurface replay. if ct in ("Single Point", "Geometry Opt", "Frequency"): save_orbitals(_saved_dir, result) - self._refresh_results_browser() - self._populate_compare_list() - self._update_log_panel( + self._queue_main_thread_callback(self._refresh_results_browser) + self._queue_main_thread_callback(self._populate_compare_list) + self._queue_main_thread_callback( + self._update_log_panel, log.getvalue(), f"{result.formula} {self.method_dd.value}/{self.basis_dd.value}", ) - self._show_result_log(_saved_dir, log.getvalue()) + self._queue_main_thread_callback( + self._show_result_log, + _saved_dir, + log.getvalue(), + ) except Exception as _save_exc: try: from quantui import calc_log as _clog @@ -5244,16 +3286,20 @@ def _do_run(self) -> None: ) except Exception: pass + _mark("persist_done") - # Activate analysis panels AFTER saving/refreshing the results browser. - # _refresh_results_browser (above) sets past_dd.options, which fires its - # observer and calls _deactivate_all_ana_panels. Placing this call here - # means that observer has already run (harmlessly, panels not yet active) - # by the time we activate them. - self._apply_analysis_context(_ana_ctx) + # Activate analysis panels after scheduling refresh/update callbacks. + # Refreshing the history browser may fire past_dd observers that clear + # analysis state; queueing this callback after refresh keeps ordering + # deterministic on the kernel UI loop. + _mark("analysis_begin") + self._queue_main_thread_callback(self._apply_analysis_context, _ana_ctx) + _mark("analysis_done") # Log performance + _mark("perf_begin") try: + _elapsed_for_est = time.perf_counter() - _run_wall_t _calc_log.log_calculation( formula=result.formula, n_atoms=len(calc_mol.atoms), @@ -5261,22 +3307,55 @@ def _do_run(self) -> None: method=result.method, basis=result.basis, n_iterations=getattr(result, "n_iterations", None), - elapsed_s=_elapsed, + elapsed_s=_elapsed_for_est, converged=result.converged, n_basis=_calc_log.count_basis_functions( calc_mol.atoms, result.basis ), n_cores=1, + calc_type=save_type, ) _calc_log.log_event( "calc_done", f"{result.method}/{result.basis} on {result.formula}", - elapsed_s=round(_elapsed, 2), + elapsed_s=round(_elapsed_for_est, 2), converged=result.converged, ) self._update_estimate() except Exception: pass + _mark("perf_done") + + _mark("success_done") + _elapsed_total = _tail_marks["success_done"] - _run_wall_t + self.run_status.value = f"Done in {_elapsed_total:.1f} s." + + try: + _tail_end = _tail_marks.get("success_done") + _post_scf_to_done: Optional[float] = None + if _tail_end is not None and _scf_converged_t is not None: + _post_scf_to_done = round(_tail_end - _scf_converged_t, 3) + _post_result_to_done = _span("result_ready", "success_done") + _calc_log.log_event( + "calc_tail_timing", + "Post-SCF completion timing checkpoint", + session_id=self._session_id, + formula=result.formula, + method=result.method, + basis=result.basis, + calc_type=save_type, + scf_converged_seen=_scf_converged_t is not None, + post_scf_to_done_s=_post_scf_to_done, + post_result_to_done_s=_post_result_to_done, + result_to_viz_s=_span("result_ready", "viz_done"), + result_to_banner_s=_span("result_ready", "banner_ready"), + persist_block_s=_span("persist_begin", "persist_done"), + analysis_apply_s=_span("analysis_begin", "analysis_done"), + perf_block_s=_span("perf_begin", "perf_done"), + banner_to_done_s=_span("banner_ready", "success_done"), + ) + except Exception: + pass except ImportError as _import_err: _err_detail = str(_import_err) @@ -5370,120 +3449,22 @@ def _do_run(self) -> None: finally: self.run_btn.disabled = False + self._activity_end(kind="compute") def _update_notes(self, change=None) -> None: - self.notes_output.clear_output(wait=True) - if self._molecule is None: - return - try: - from quantui import PySCFCalculation - - calc = PySCFCalculation( - self._molecule, - method=self.method_dd.value, - basis=self.basis_dd.value, - ) - notes = calc.get_educational_notes() - if notes: - safe = ( - notes.replace("**", "", 1) - .replace("**", "", 1) - .replace("\n\n", "| Scan type | ' - f'{r.scan_type.capitalize()} ({_idx_str}) |
| Range | ' - f'{r.scan_parameter_values[0]:.3f} → ' - f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " - f"({r.n_steps} points) |
| All converged | ' - f'{_conv} |
' + f"Analysing: {_html_mod.escape(heading)}{source_suffix}
" + ) + has_any = bool(app._ana_available) + app._to_analysis_btn.layout.display = "" if has_any else "none" + app._analysis_empty_html.layout.display = "none" if has_any else "" + + +def pop_energies(app: Any, ctx: Any) -> bool: + """Populate Energies panel from live result or history orbitals.""" + result = ctx.live_result + if result is None and ctx.result_dir is not None: + try: + from quantui.results_storage import load_orbitals + + orb = load_orbitals(ctx.result_dir) + orb.formula = ctx.formula + result = orb + except Exception: + return False + return bool(app._show_orbital_diagram(result)) + + +def pop_isosurface(app: Any, ctx: Any) -> bool: + """Populate Isosurface availability from orbital state.""" + return ( + app._last_orb_mo_coeff is not None + and app._last_orb_mol_atom is not None + and app._last_orb_mol_basis is not None + ) + + +def pop_geo_trajectory(app: Any, ctx: Any) -> bool: + """Populate Trajectory panel for geometry optimization contexts.""" + traj = None + energies: list = [] + if ctx.live_result is not None: + traj = getattr(ctx.live_result, "trajectory", None) + energies = list(getattr(ctx.live_result, "energies_hartree", [])) + elif ctx.result_dir is not None: + traj_file = ctx.result_dir / "trajectory.json" + if not traj_file.exists(): + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Geometry Opt history result: " + "trajectory.json is missing." + ), + ) + return False + try: + from quantui.results_storage import load_trajectory + + traj, energies = load_trajectory(ctx.result_dir) + except Exception as exc: + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Geometry Opt history result: " + f"failed to load trajectory data ({type(exc).__name__})." + ), + ) + return False + if not traj or len(traj) < 2: + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Geometry Opt result: " + "trajectory data has fewer than 2 frames." + ), + ) + return False + stub = _types_mod.SimpleNamespace( + trajectory=traj, + energies_hartree=energies, + formula=ctx.formula, + ) + app._pending_traj_result = stub + return True + + +def pop_preopt_trajectory(app: Any, ctx: Any) -> bool: + """Populate Trajectory panel for frequency pre-optimization contexts.""" + if ctx.source == "live": + pre = ctx.preopt_result + if pre is None: + return False + traj = getattr(pre, "trajectory", None) + energies = list(getattr(pre, "energies_hartree", [])) + else: + if ctx.result_dir is None: + return False + preopt_path = ctx.result_dir / "preopt_trajectory.json" + if not preopt_path.exists(): + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Frequency history result: " + "preopt_trajectory.json is missing (pre-opt may have been disabled)." + ), + ) + return False + try: + from quantui.results_storage import load_trajectory + + traj, energies = load_trajectory( + ctx.result_dir, filename="preopt_trajectory.json" + ) + except Exception as exc: + from quantui import calc_log as _clog + + _clog.log_event( + "pop_preopt_trajectory_error", + f"{type(exc).__name__}: {exc}"[:300], + ) + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Frequency history result: " + f"failed to load preopt trajectory ({type(exc).__name__})." + ), + ) + return False + if not traj or len(traj) < 2: + _set_panel_unavailable_message( + app, + "Trajectory", + ( + "Not available for this Frequency result: " + "pre-optimization trajectory has fewer than 2 frames." + ), + ) + return False + stub = _types_mod.SimpleNamespace( + trajectory=traj, + energies_hartree=energies, + formula=ctx.formula, + ) + app._pending_traj_result = stub + app.traj_accordion.set_title(0, "Pre-optimization Trajectory") + return True + + +def pop_vibrational(app: Any, ctx: Any) -> bool: + """Populate Vibrational panel from live or history frequency data.""" + if ctx.live_result is not None: + freq_stub = ctx.live_result + mol = ctx.molecule + else: + ir = ctx.spectra_data.get("ir", {}) + mol_data = ctx.spectra_data.get("molecule", {}) + freqs = ir.get("frequencies_cm1") + ints = ir.get("ir_intensities") + disps = ir.get("displacements") + if not (freqs and disps and mol_data.get("atoms")): + return False + from quantui.molecule import Molecule as _Mol + + mol = _Mol( + atoms=mol_data["atoms"], + coordinates=mol_data["coords"], + charge=mol_data.get("charge", 0), + multiplicity=mol_data.get("multiplicity", 1), + ) + freq_stub = _types_mod.SimpleNamespace( + frequencies_cm1=freqs, + ir_intensities=ints, + displacements=disps, + ) + return bool(app._show_vib_animation(freq_stub, mol)) + + +def pop_ir_spectrum(app: Any, ctx: Any) -> bool: + """Populate IR panel from live or history frequency data.""" + if ctx.live_result is not None: + freq_stub = ctx.live_result + else: + ir = ctx.spectra_data.get("ir", {}) + freqs = ir.get("frequencies_cm1") + if not freqs: + return False + freq_stub = _types_mod.SimpleNamespace( + frequencies_cm1=freqs, + ir_intensities=ir.get("ir_intensities") or [], + ) + return bool(app._show_ir_spectrum(freq_stub)) + + +def pop_uv_vis(app: Any, ctx: Any) -> bool: + """Populate UV-Vis panel from live or history TDDFT data.""" + if ctx.live_result is not None: + energies_ev = list(getattr(ctx.live_result, "excitation_energies_ev", [])) + osc = list(getattr(ctx.live_result, "oscillator_strengths", [])) + try: + wl = list(ctx.live_result.wavelengths_nm()) + except Exception: + wl = [1240.0 / e for e in energies_ev if e > 0] + else: + uv = ctx.spectra_data.get("uv_vis", {}) + energies_ev = list(uv.get("excitation_energies_ev") or []) + osc = list(uv.get("oscillator_strengths") or []) + wl = list(uv.get("wavelengths_nm") or []) + if not energies_ev or not osc: + return False + return bool(app._show_uv_vis_spectrum(energies_ev, osc, wl)) + + +def pop_nmr_shielding(app: Any, ctx: Any) -> bool: + """Populate NMR panel from live or history shielding data.""" + if ctx.live_result is not None: + result = ctx.live_result + atom_symbols = list(getattr(result, "atom_symbols", [])) + shielding = list(getattr(result, "shielding_iso_ppm", [])) + try: + h_shifts = result.h_shifts() + c_shifts = result.c_shifts() + except Exception: + h_shifts, c_shifts = [], [] + ref = getattr(result, "reference_compound", "TMS") + else: + nmr = ctx.spectra_data.get("nmr", {}) + atom_symbols = nmr.get("atom_symbols", []) + shielding = nmr.get("shielding_iso_ppm", []) + chem = nmr.get("chemical_shifts_ppm", {}) + ref = nmr.get("reference_compound", "TMS") + h_shifts = [ + (int(i), d) + for i, d in chem.items() + if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "H" + ] + c_shifts = [ + (int(i), d) + for i, d in chem.items() + if int(i) < len(atom_symbols) and atom_symbols[int(i)] == "C" + ] + if not atom_symbols: + return False + + def _shift_table(label: str, shifts: list, sym: str) -> str: + if not shifts: + return "" + rows = "".join( + f'| Atom | ' + f'σ (ppm) |
|---|
{env}'
+ if env and env not in ("base", "")
+ else ""
+ )
+ cal_line = (
+ f'' + "Recent events (last 20)
" + ), + app._perf_events_html, + widgets.HBox( + [app._reset_btn], + layout=layout_fn(margin="14px 0 4px"), + ), + app._reset_confirm_box, + ] + ) + app._perf_accordion = widgets.Accordion( + children=[perf_stats_panel], selected_index=None + ) + app._perf_accordion.set_title(0, "Performance stats") + + cal_last = load_last_calibration_label_fn() + cal_note = ( + f'' + f"Last run: {cal_last}
" + if cal_last + else "" + ) + cal_panel = widgets.VBox( + [ + widgets.HTML( + f'' + f"Benchmark this machine so the time estimator uses basis-function " + f"scaling (Nβ) rather than generic defaults. " + f"Quick runs {len(benchmark_suite)} small calculations (~10 s). " + f"Full runs {len(benchmark_suite_long)} calculations spanning " + f"all common molecule sizes and methods (~5 min).
" + cal_note + ), + app._cal_mode_toggle, + widgets.HBox( + [app._cal_run_btn, app._cal_stop_btn], + layout=layout_fn(gap="6px", align_items="center"), + ), + app._cal_progress, + app._cal_step_label, + app._cal_results_html, + ], + layout=layout_fn(padding="4px 0"), + ) + app._cal_accordion = widgets.Accordion(children=[cal_panel], selected_index=None) + app._cal_accordion.set_title(0, "Calibrate time estimates") + + app.history_panel = widgets.VBox( + [ + widgets.HTML( + '' + "Calculations are saved automatically. Select one below to view its results.
" + ), + widgets.HBox( + [ + app.past_dd, + app.past_refresh_btn, + app.copy_path_btn, + app.view_log_btn, + ], + layout=layout_fn(align_items="center", gap="8px"), + ), + app.results_path_lbl, + app.past_output, + app._perf_accordion, + app._cal_accordion, + ] + ) + + app._refresh_results_browser() + app._refresh_perf_stats() + + +def build_shared_widgets( + app: Any, + *, + layout_fn: Any, + step_progress_cls: Any, + supported_methods: list[Any], + supported_basis_sets: list[Any], + default_method: str, + default_basis: str, + default_charge: int, + default_multiplicity: int, + default_fmax: float, + default_opt_steps: int, + preopt_available: bool, + visualization_available: bool, + both_viz_available: bool, + default_viz_backend: Any, + default_viz_style: str, + default_lighting: str, + viz_style_options: list[Any], + plotlymol_viz: bool, + lighting_options: list[Any], + rdkit_available: bool, +) -> None: + """Build shared widgets used across tabs and callbacks.""" + app.mol_info_html = widgets.HTML( + value='No molecule loaded yet.' + ) + app.mol_summary_compact = widgets.HTML(value="") + app.viz_output = widgets.Output(layout=layout_fn(min_height="50px")) + app.run_output = widgets.Output( + layout=layout_fn( + border="1px solid #c0ccd8", + min_height="80px", + max_height="400px", + padding="8px", + overflow_y="auto", + ) + ) + app.run_output.add_class("quantui-run-output") + with app.run_output: + display( + HTML( + '' + "No calculation run yet. PySCF output and any errors will appear here." + "
" + ) + ) + app.result_output = widgets.Output() + app.result_viz_output = widgets.Output() + app.comparison_output = widgets.Output() + app._last_result_dir = None + + app._viz_backend = default_viz_backend + if both_viz_available: + app.viz_backend_toggle = widgets.ToggleButtons( + options=[("PlotlyMol", "plotlymol"), ("py3Dmol", "py3dmol")], + value=default_viz_backend, + tooltips=["Plotly-based interactive viewer", "WebGL viewer (py3Dmol)"], + style={"button_width": "90px"}, + layout=layout_fn(margin="2px 0 0 0"), + ) + else: + app.viz_backend_toggle = None # type: ignore[assignment] + + app._viz_style = default_viz_style + app._viz_lighting = default_lighting + app.viz_style_dd = widgets.Dropdown( + options=viz_style_options, + value=default_viz_style, + description="Style:", + style={"description_width": "40px"}, + layout=layout_fn(width="180px"), + disabled=not visualization_available, + ) + lighting_available = visualization_available and plotlymol_viz + app.viz_lighting_dd = widgets.Dropdown( + options=lighting_options, + value=default_lighting, + description="Lighting:", + style={"description_width": "58px"}, + layout=layout_fn(width="170px"), + disabled=not lighting_available, + ) + if not lighting_available: + app.viz_lighting_dd.layout.visibility = "hidden" + app.viz_controls_box = widgets.HBox( + [app.viz_style_dd, app.viz_lighting_dd], + layout=layout_fn(gap="8px", margin="2px 0 0 0", align_items="center"), + ) + app.notes_output = widgets.Output() + app.perf_estimate_html = widgets.HTML() + + app.step_progress = step_progress_cls( + ["Choose molecule", "Set method", "Run", "Results"] + ) + app.step_progress.start(0) + + app.method_dd = widgets.Dropdown( + options=supported_methods, + value=default_method, + description="Method:", + style={"description_width": "100px"}, + layout=layout_fn(width="260px"), + ) + app.basis_dd = widgets.Dropdown( + options=supported_basis_sets, + value=default_basis, + description="Basis Set:", + style={"description_width": "100px"}, + layout=layout_fn(width="260px"), + ) + app.charge_si = widgets.BoundedIntText( + value=default_charge, + min=-10, + max=10, + description="Charge:", + style={"description_width": "100px"}, + layout=layout_fn(width="190px"), + ) + app.mult_si = widgets.BoundedIntText( + value=default_multiplicity, + min=1, + max=10, + description="Multiplicity:", + style={"description_width": "100px"}, + layout=layout_fn(width="190px"), + ) + app.preopt_cb = widgets.Checkbox( + value=False, + description="Classical pre-optimize geometry (fast, crude starting point)", + disabled=not preopt_available, + layout=layout_fn(width="100%"), + ) + + from quantui.config import SOLVENT_OPTIONS as _SOLVENT_OPTS + + app.solvent_cb = widgets.Checkbox( + value=False, + description="Implicit solvent (PCM)", + layout=layout_fn(width="240px"), + ) + app.solvent_dd = widgets.Dropdown( + options=list(_SOLVENT_OPTS.keys()), + value="Water", + description="Solvent:", + style={"description_width": "70px"}, + layout=layout_fn(width="200px", display="none"), + ) + + app.calc_type_dd = widgets.Dropdown( + options=[ + "Single Point", + "Geometry Opt", + "Frequency", + "UV-Vis (TD-DFT)", + "NMR Shielding", + "PES Scan", + ], + value="Single Point", + description="Calc. Type:", + style={"description_width": "100px"}, + layout=layout_fn(width="310px"), + ) + app.fmax_fi = widgets.BoundedFloatText( + value=default_fmax, + min=0.001, + max=1.0, + step=0.005, + description="Force thr. (eV/Å):", + style={"description_width": "130px"}, + layout=layout_fn(width="270px"), + ) + app.max_steps_si = widgets.BoundedIntText( + value=default_opt_steps, + min=10, + max=1000, + description="Max steps:", + style={"description_width": "100px"}, + layout=layout_fn(width="200px"), + ) + app.nstates_si = widgets.BoundedIntText( + value=10, + min=1, + max=50, + description="# states:", + style={"description_width": "100px"}, + layout=layout_fn(width="180px"), + ) + + app._freq_seed_dd = widgets.Dropdown( + options=[("(use current molecule)", "")], + description="Seed geometry:", + style={"description_width": "110px"}, + layout=layout_fn(width="auto", flex="1 1 auto", min_width="260px"), + tooltip="Optionally load the final optimised geometry from a previous Geo Opt result", + ) + app._freq_seed_refresh_btn = widgets.Button( + description="", + icon="refresh", + layout=layout_fn(width="32px", height="32px"), + tooltip="Refresh the list of saved geometry optimisations", + ) + app._freq_preopt_cb = widgets.Checkbox( + value=False, + description="Geometry optimization before calculation (QM, slower)", + style={"description_width": "initial"}, + layout=layout_fn(width="100%"), + ) + app._freq_seed_note = widgets.HTML("") + + app._scan_type_dd = widgets.Dropdown( + options=["Bond", "Angle", "Dihedral"], + value="Bond", + description="Scan type:", + style={"description_width": "80px"}, + layout=layout_fn(width="220px"), + ) + atom_idx_layout = layout_fn(width="95px") + atom_idx_style = {"description_width": "50px"} + app._scan_atom1 = widgets.BoundedIntText( + value=1, + min=1, + max=999, + description="Atom 1:", + style=atom_idx_style, + layout=atom_idx_layout, + ) + app._scan_atom2 = widgets.BoundedIntText( + value=2, + min=1, + max=999, + description="Atom 2:", + style=atom_idx_style, + layout=atom_idx_layout, + ) + app._scan_atom3 = widgets.BoundedIntText( + value=3, + min=1, + max=999, + description="Atom 3:", + style=atom_idx_style, + layout=atom_idx_layout, + ) + app._scan_atom4 = widgets.BoundedIntText( + value=4, + min=1, + max=999, + description="Atom 4:", + style=atom_idx_style, + layout=atom_idx_layout, + ) + app._scan_atom34_box = widgets.HBox( + [app._scan_atom3, app._scan_atom4], + layout=layout_fn(gap="4px"), + ) + app._scan_start = widgets.BoundedFloatText( + value=0.5, + min=0.01, + max=1000.0, + step=0.1, + description="Start:", + style={"description_width": "40px"}, + layout=layout_fn(width="140px"), + ) + app._scan_stop = widgets.BoundedFloatText( + value=2.0, + min=0.01, + max=1000.0, + step=0.1, + description="Stop:", + style={"description_width": "40px"}, + layout=layout_fn(width="140px"), + ) + app._scan_steps = widgets.BoundedIntText( + value=10, + min=2, + max=100, + description="Points:", + style={"description_width": "50px"}, + layout=layout_fn(width="120px"), + ) + app._scan_unit_lbl = widgets.HTML( + 'Å' + ) + + app.calc_extra_opts = widgets.VBox([]) + + app.method_help_btn = widgets.Button( + description="?", + button_style="", + layout=layout_fn(width="28px", height="28px"), + tooltip="RHF vs UHF — opens Help tab", + ) + app.basis_help_btn = widgets.Button( + description="?", + button_style="", + layout=layout_fn(width="28px", height="28px"), + tooltip="Choosing a basis set — opens Help tab", + ) + + app.run_btn = widgets.Button( + description="Run Calculation", + button_style="success", + icon="play", + disabled=True, + layout=layout_fn(width="200px", height="36px"), + ) + app.run_status = widgets.Label() + + app.log_clear_btn = widgets.Button( + description="Clear", + button_style="", + icon="times", + layout=layout_fn(width="90px", height="26px"), + tooltip="Clear calculation output", + ) + + app.accumulate_btn = widgets.Button( + description="Add to Comparison", + button_style="info", + icon="plus", + disabled=True, + layout=layout_fn(width="190px"), + ) + app.clear_btn = widgets.Button( + description="Clear", + button_style="warning", + icon="trash", + layout=layout_fn(width="100px"), + ) + app.export_btn = widgets.Button( + description="Export Script", + button_style="", + icon="download", + disabled=True, + layout=layout_fn(width="160px"), + ) + app.export_status = widgets.Label() + rdkit_tip = ( + "" if rdkit_available else "Requires RDKit (conda install -c conda-forge rdkit)" + ) + app.export_xyz_btn = widgets.Button( + description="Export XYZ", + icon="download", + disabled=True, + layout=layout_fn(width="130px"), + ) + app.export_mol_btn = widgets.Button( + description="Export MOL", + icon="download", + disabled=True, + tooltip=rdkit_tip, + layout=layout_fn(width="130px"), + ) + app.export_pdb_btn = widgets.Button( + description="Export PDB", + icon="download", + disabled=True, + tooltip=rdkit_tip, + layout=layout_fn(width="130px"), + ) + app.struct_export_status = widgets.Label() + + +def build_theme_selector(app: Any, *, layout_fn: Any) -> None: + """Build the theme selector widgets and apply default theme CSS.""" + app._theme_style = widgets.Output( + layout=layout_fn(height="0px", overflow="hidden", margin="0", padding="0") + ) + app._activity_btn = widgets.Button( + description="Idle", + icon="circle-o", + tooltip="No active operations.", + button_style="success", + layout=layout_fn(width="118px", margin="0 8px 0 0"), + ) + app.theme_btn = widgets.ToggleButtons( + options=["Light", "Dark"], + value="Dark", + description="Theme:", + style={"description_width": "48px", "button_width": "90px"}, + layout=layout_fn(margin="0"), + ) + with app._theme_style: + display(HTML(app._theme_css("Dark"))) + + +def build_welcome_header(app: Any) -> None: + """Build the static QuantUI welcome banner.""" + logo_svg = ( + '" + ) + html = ( + f'' + tab_preset = widgets.VBox( + [ + widgets.HTML(hint + "Choose from 20+ curated educational molecules.
"), + app.preset_dd, + ] + ) + tab_xyz = widgets.VBox( + [ + widgets.HTML( + hint + "Paste XYZ coordinates (element x y z, one atom per line)." + ), + app.xyz_area, + widgets.HBox([app.xyz_btn, app.xyz_msg]), + ] + ) + tab_pubchem = widgets.VBox( + [ + widgets.HTML( + hint + "Search by name or SMILES. Requires internet connection." + ), + widgets.HBox([app.pubchem_txt, app.pubchem_btn]), + app.pubchem_msg, + ] + ) + input_tab = widgets.Tab(children=[tab_preset, tab_xyz, tab_pubchem]) + for i, title in enumerate(["Preset Library", "XYZ Input", "PubChem Search"]): + input_tab.set_title(i, title) + + app.mol_input_expanded = widgets.VBox( + [ + widgets.HTML('PySCF runs in this ' + "kernel. Output appears live below. Large molecules or high-accuracy basis " + "sets may take several minutes on a laptop.
" + ), + app.perf_estimate_html, + widgets.HBox([app.run_btn, app.run_status]), + widgets.HBox( + [ + widgets.HTML( + '' + "Calculation Output" + ), + app.log_clear_btn, + ], + layout=layout_fn( + align_items="center", + justify_content="space-between", + margin="10px 0 4px", + max_width="460px", + ), + ), + app.run_output, + ] + ) + + +def build_results_section(app: Any, *, layout_fn: Any) -> None: + """Build results and analysis tab panels/widgets.""" + + def _plot_export_row(prefix: str) -> widgets.HBox: + fmt_dd = widgets.Dropdown( + options=[("HTML", "html"), ("PNG", "png")], + value="html", + description="Export:", + style={"description_width": "55px"}, + layout=layout_fn(width="170px"), + ) + btn = widgets.Button( + description="Save Plot", + icon="download", + layout=layout_fn(width="130px"), + tooltip="Export the current plot", + ) + status = widgets.HTML(value="", layout=layout_fn(margin="0 0 0 8px")) + setattr(app, f"_{prefix}_export_fmt_dd", fmt_dd) + setattr(app, f"_{prefix}_export_btn", btn) + setattr(app, f"_{prefix}_export_status", status) + return widgets.HBox( + [fmt_dd, btn, status], + layout=layout_fn(align_items="center", margin="0 0 6px 0", gap="6px"), + ) + + pes_export_row = _plot_export_row("pes") + app._pes_plot_html = widgets.Output(layout=layout_fn(width="100%")) + app._pes_scan_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [pes_export_row, app._pes_plot_html], + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._pes_scan_accordion.set_title(0, "PES Energy Profile") + app._pes_scan_accordion.selected_index = None + + app.traj_output = widgets.Output() + app.traj_accordion = widgets.Accordion( + children=[app.traj_output], + layout=layout_fn(display="none", margin="8px 0"), + ) + app.traj_accordion.set_title(0, "Trajectory Viewer") + app.traj_accordion.selected_index = None + app.traj_accordion.observe( + app._safe_cb(app._on_traj_expand), names=["selected_index"] + ) + + app.vib_mode_dd = widgets.Dropdown( + description="Mode:", + options=[], + style={"description_width": "50px"}, + layout=layout_fn(width="360px"), + ) + app.vib_output = widgets.Output() + app.vib_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [app.vib_mode_dd, app.vib_output], + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app.vib_accordion.set_title(0, "Vibrational Mode Viewer") + app.vib_accordion.selected_index = None + + app._ir_mode_toggle = widgets.ToggleButtons( + options=["Stick", "Broadened"], + value="Stick", + style={"button_width": "80px"}, + layout=layout_fn(margin="0 8px 0 0"), + ) + app._ir_fwhm_slider = widgets.FloatSlider( + value=20.0, + min=5.0, + max=100.0, + step=5.0, + description="Line width:", + style={"description_width": "80px"}, + layout=layout_fn(width="260px", display="none"), + ) + app._ir_fig = widgets.Output(layout=layout_fn(width="100%")) + ir_export_row = _plot_export_row("ir") + + ir_controls = widgets.HBox( + [app._ir_mode_toggle, app._ir_fwhm_slider], + layout=layout_fn(align_items="center", margin="0 0 6px 0"), + ) + ir_body_children = [ir_controls, ir_export_row, app._ir_fig] + app._ir_accordion = widgets.Accordion( + children=[ + widgets.VBox( + ir_body_children, + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._ir_accordion.set_title(0, "IR Spectrum") + app._ir_accordion.selected_index = None + + app._orb_ymin_input = widgets.BoundedFloatText( + value=-30.0, + min=-500.0, + max=200.0, + step=1.0, + description="Y min:", + layout=layout_fn(width="140px"), + style={"description_width": "45px"}, + ) + app._orb_ymax_input = widgets.BoundedFloatText( + value=5.0, + min=-500.0, + max=500.0, + step=1.0, + description="Y max:", + layout=layout_fn(width="140px"), + style={"description_width": "45px"}, + ) + app._orb_n_orb_input = widgets.BoundedIntText( + value=20, + min=4, + max=200, + step=2, + description="Show N:", + layout=layout_fn(width="120px"), + style={"description_width": "50px"}, + ) + orb_controls_row = widgets.HBox( + [ + widgets.HTML( + 'Y range:' + ), + app._orb_ymin_input, + app._orb_ymax_input, + widgets.HTML( + '' + "Orbitals shown:" + ), + app._orb_n_orb_input, + ], + layout=layout_fn( + align_items="center", + flex_wrap="wrap", + gap="4px", + margin="0 0 6px 0", + ), + ) + app._orb_diagram_html = widgets.Output(layout=layout_fn(width="100%")) + orb_export_row = _plot_export_row("orb") + orb_diagram_content: list[Any] = [ + orb_controls_row, + orb_export_row, + app._orb_diagram_html, + ] + app._orb_diagram_box = widgets.VBox( + orb_diagram_content, + layout=layout_fn(width="100%"), + ) + app._orb_toggle = widgets.ToggleButtons( + options=["HOMO-1", "HOMO", "LUMO", "LUMO+1"], + value="HOMO", + style={"button_width": "70px"}, + layout=layout_fn(margin="8px 0 4px 0"), + ) + app._orb_iso_output = widgets.Output() + app._orb_iso_controls = widgets.VBox( + [ + widgets.HTML( + '' + "Orbital isosurface:" + ), + app._orb_toggle, + app._orb_iso_output, + ], + layout=layout_fn(display="none", margin="8px 0 0 0"), + ) + app._orb_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [app._orb_diagram_box], + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._orb_accordion.set_title(0, "Energy-level Diagram") + app._orb_accordion.selected_index = None + + app._iso_generate_btn = widgets.Button( + description="Generate Isosurface", + button_style="primary", + icon="flask", + disabled=True, + tooltip=( + "Generate a 3D orbital isosurface. " + "Available after running or loading a Single Point or Geometry Optimization." + ), + layout=layout_fn(width="200px", margin="8px 0 4px 0"), + ) + iso_body = widgets.VBox( + [ + widgets.HTML( + '' + "Visualise a molecular orbital as a 3D isosurface (Linux / WSL only — " + "requires PySCF and RDKit). Run or load a Single Point or Geometry " + "Optimization first, then click Generate.
" + ), + app._orb_iso_controls, + app._iso_generate_btn, + ], + layout=layout_fn(padding="8px"), + ) + app._iso_accordion = widgets.Accordion( + children=[iso_body], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._iso_accordion.set_title(0, "Orbital Isosurface") + app._iso_accordion.selected_index = None + + app._uv_mode_toggle = widgets.ToggleButtons( + options=["Stick", "Broadened"], + value="Stick", + style={"button_width": "80px"}, + layout=layout_fn(margin="0 8px 0 0"), + ) + app._uv_fwhm_slider = widgets.FloatSlider( + value=20.0, + min=5.0, + max=100.0, + step=5.0, + description="Line width:", + style={"description_width": "80px"}, + layout=layout_fn(width="260px", display="none"), + ) + app._tddft_fig = widgets.Output(layout=layout_fn(width="100%")) + uv_export_row = _plot_export_row("uv") + uv_controls = widgets.HBox( + [app._uv_mode_toggle, app._uv_fwhm_slider], + layout=layout_fn(align_items="center", margin="0 0 6px 0"), + ) + app._tddft_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [uv_controls, uv_export_row, app._tddft_fig], + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._tddft_accordion.set_title(0, "UV-Vis Absorption Spectrum") + app._tddft_accordion.selected_index = None + + app._nmr_output = widgets.HTML(value="", layout=layout_fn(width="100%")) + app._nmr_accordion = widgets.Accordion( + children=[ + widgets.VBox( + [app._nmr_output], + layout=layout_fn(padding="8px"), + ) + ], + layout=layout_fn(display="none", margin="8px 0"), + ) + app._nmr_accordion.set_title(0, "NMR Chemical Shifts") + app._nmr_accordion.selected_index = None + + app._result_dir_label = widgets.HTML( + value="", + layout=layout_fn(display="none", margin="4px 0 0 0"), + ) + + app._result_log_output = widgets.Output() + app._result_log_accordion = widgets.Accordion( + children=[app._result_log_output], + layout=layout_fn(display="none", margin="8px 0 0 0"), + ) + app._result_log_accordion.set_title(0, "Full output log (pyscf.log)") + app._result_log_accordion.selected_index = None + + app._go_results_btn = widgets.Button( + description="→ View Results", + button_style="success", + layout=layout_fn(width="130px"), + ) + app._go_analysis_btn = widgets.Button( + description="→ View Analysis", + button_style="info", + layout=layout_fn(width="140px"), + ) + app._completion_mol_lbl = widgets.HTML(value="") + app._completion_banner = widgets.HBox( + [ + widgets.HTML( + '' + "✓ Calculation complete — " + ), + app._completion_mol_lbl, + app._go_results_btn, + app._go_analysis_btn, + ], + layout=layout_fn( + display="none", + align_items="center", + gap="8px", + padding="10px 12px", + border="1px solid #bbf7d0", + border_radius="6px", + background_color="#f0fdf4", + margin="8px 0", + ), + ) + + app._to_analysis_btn = widgets.Button( + description="→ View Analysis", + button_style="", + icon="bar-chart", + layout=layout_fn(display="none", width="160px", margin="8px 0 0 0"), + ) + app._viz_label = widgets.HTML( + value="", + layout=layout_fn(display="none"), + ) + app.results_tab_panel = widgets.VBox( + [ + widgets.HTML('' + "No result loaded yet. Run a calculation or load one from History.
" + ) + ) + app._analysis_empty_html = widgets.HTML( + value=( + ''
+ "No interactive analysis is available for this calculation type.
"
+ "Run a Single Point, Geo Opt, or Frequency calculation to see "
+ "orbital diagrams, trajectory animations, and spectra here.
' + "Select two or more saved calculations to compare side-by-side. " + "Hold Ctrl (or ⌘) to select multiple entries.
" + ), + widgets.HBox([app.compare_refresh_btn]), + app.compare_select, + widgets.HBox( + [app.compare_btn, app.compare_clear_btn], + layout=layout_fn(gap="8px", margin="6px 0"), + ), + app.compare_output, + ], + layout=layout_fn(padding="8px 0"), + ) + + rdkit_note = ( + "" + if rdkit_available + else 'MOL/PDB export requires RDKit '
+ "(conda install -c conda-forge rdkit).
' + "Download a self-contained PySCF script you can study or run outside the notebook.
" + ), + widgets.HBox([app.export_btn, app.export_status]), + widgets.HTML('' + "Download the molecular structure in a standard chemistry file format.
" + + rdkit_note + ), + widgets.HBox( + [app.export_xyz_btn, app.export_mol_btn, app.export_pdb_btn], + layout=layout_fn(flex_wrap="wrap", gap="6px"), + ), + app.struct_export_status, + ] + ) + app.advanced_accordion = widgets.Accordion(children=[export_content]) + app.advanced_accordion.set_title(0, "Export") + app.advanced_accordion.selected_index = None + + app._populate_compare_list() + + +def build_output_tab(app: Any, *, layout_fn: Any) -> None: + """Build the Output tab panel widgets.""" + app._log_output_html = widgets.HTML( + '' + "No log yet — run a calculation first, or use " + "View log in the History tab." + ) + app._log_source_lbl = widgets.HTML() + app._log_clear_btn = widgets.Button( + description="Clear", + button_style="", + icon="times", + layout=layout_fn(width="80px"), + ) + app._clear_log_cache_btn = widgets.Button( + description="Clear Log Cache", + button_style="", + icon="trash", + tooltip=( + "Delete the session event log (event_log.jsonl). " + "Calculation performance data is preserved." + ), + layout=layout_fn(width="160px"), + ) + app._clear_log_cache_confirm_btn = widgets.Button( + description="Confirm clear?", + button_style="danger", + layout=layout_fn(width="140px", display="none"), + ) + app.log_tab_panel = widgets.VBox( + [ + widgets.HTML( + '' + "Raw PySCF output for the most recent calculation. " + "Use View log in the History tab to load a saved result's log. " + "Orbital diagrams, trajectories, and spectra are in the " + "Analysis tab.
" + ), + widgets.HBox( + [app._log_clear_btn], + layout=layout_fn(margin="0 0 8px"), + ), + app._log_source_lbl, + app._log_output_html, + app._result_log_accordion, + widgets.HTML( + '' + "Session event log — records molecule loads, calculations, " + "and issue reports across this session.
" + ), + widgets.HBox( + [app._clear_log_cache_btn, app._clear_log_cache_confirm_btn], + layout=layout_fn(align_items="center", gap="8px"), + ), + ], + layout=layout_fn(padding="8px 0"), + ) + + +def build_files_tab(app: Any, *, layout_fn: Any) -> None: + """Build the read-only Files tab widgets.""" + app._files_root_dd = widgets.Dropdown( + options=[("(loading)", "")], + value="", + description="Root:", + style={"description_width": "40px"}, + layout=layout_fn(width="520px"), + ) + app._files_path_html = widgets.HTML( + value=( + '' + "Current folder: (not set)" + ) + ) + app._files_entries = widgets.Select( + options=[("(no files)", "")], + rows=12, + description="", + layout=layout_fn(width="100%"), + ) + app._files_up_btn = widgets.Button( + description="Up", + icon="arrow-up", + layout=layout_fn(width="80px"), + tooltip="Go to parent folder", + ) + app._files_open_btn = widgets.Button( + description="Open", + button_style="primary", + icon="folder-open", + layout=layout_fn(width="100px"), + tooltip="Open selected folder or preview selected file", + ) + app._files_refresh_btn = widgets.Button( + description="Refresh", + icon="refresh", + layout=layout_fn(width="100px"), + tooltip="Refresh roots, folder contents, and preview", + ) + app._files_status_html = widgets.HTML( + value=( + '' + "Select a file and click Open to preview." + ) + ) + app._files_preview_output = widgets.Output( + layout=layout_fn( + border="1px solid #cbd5e1", + min_height="220px", + max_height="420px", + overflow="auto", + padding="6px", + ) + ) + + app.files_tab_panel = widgets.VBox( + [ + widgets.HTML( + '' + "Read-only file browser for result artifacts and logs. " + "Browsing is limited to approved roots.
" + ), + app._files_root_dd, + app._files_path_html, + widgets.HBox( + [app._files_up_btn, app._files_open_btn, app._files_refresh_btn], + layout=layout_fn(gap="8px", margin="6px 0"), + ), + app._files_entries, + app._files_status_html, + app._files_preview_output, + ], + layout=layout_fn(padding="8px 0"), + ) + + +def build_help_section(app: Any, *, layout_fn: Any) -> None: + """Build the floating help panel and top-bar help/exit buttons.""" + help_keys = list(HELP_TOPICS.keys()) + help_labels = [HELP_TOPICS[k]["title"] for k in help_keys] + app.help_topic_dd = widgets.Dropdown( + options=list(zip(help_labels, help_keys)), + description="Topic:", + style={"description_width": "60px"}, + layout=layout_fn(width="460px"), + ) + app.help_content_html = widgets.HTML() + app._render_help_topic() + + app._help_btn = widgets.Button( + description="?", + button_style="", + tooltip="Help topics", + layout=layout_fn(width="34px", margin="0 0 0 8px"), + ) + + app._exit_btn = widgets.Button( + description="Exit", + button_style="danger", + tooltip="Shut down the QuantUI server and close this session", + layout=layout_fn(width="64px", margin="0 0 0 8px"), + ) + app._exit_output = widgets.Output(layout=layout_fn(height="0px", overflow="hidden")) + + app.help_tab_panel = widgets.VBox( + [ + widgets.HTML( + '' + "Browse help topics below. Click ? next to the Method or Basis Set " + "dropdown in the Calculate tab to jump directly to a relevant topic.
" + ), + app.help_topic_dd, + app.help_content_html, + ], + layout=layout_fn( + display="none", + padding="8px 0", + border="1px solid #e2e8f0", + border_radius="6px", + padding_left="12px", + margin="0 0 8px", + ), + ) + + +def build_issue_widgets(app: Any, *, layout_fn: Any) -> None: + """Build issue-report widgets shown from the top toolbar.""" + app._issue_btn = widgets.Button( + description="Report Issue", + button_style="warning", + icon="flag", + tooltip="Report a bug or unexpected behaviour observed in this session", + layout=layout_fn(width="140px", margin="0 0 0 8px"), + ) + app._issue_textarea = widgets.Textarea( + placeholder=( + "Describe what you observed — what you did, what you expected, " + "and what actually happened." + ), + layout=layout_fn(width="100%", height="90px"), + ) + app._issue_submit_btn = widgets.Button( + description="Submit", + button_style="success", + layout=layout_fn(width="90px"), + ) + app._issue_cancel_btn = widgets.Button( + description="Cancel", + button_style="", + layout=layout_fn(width="80px"), + ) + app._issue_status_html = widgets.HTML() + app._issue_overlay = widgets.VBox( + [ + widgets.HTML( + '' + "⚐ Report Issue
" + ''
+ "Your report (and a snapshot of the current session state) will be "
+ "saved to issues.db and the session event log.
| Scan type | ' + f'{r.scan_type.capitalize()} ({_idx_str}) |
| Range | ' + f'{r.scan_parameter_values[0]:.3f} → ' + f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " + f"({r.n_steps} points) |
| All converged | ' + f'{_conv} |
Error loading result: {exc}
') + ) + if not summaries: + return + with app.compare_output: + display(HTML(comparison_table_html(summaries))) + if len(summaries) > 1: + try: + import matplotlib.pyplot as plt + + fig = plot_comparison(summaries) + display(fig) + plt.close(fig) + except Exception: + pass + if valid_dirs: + btns = [] + for s, rdir in zip(summaries, valid_dirs): + short = f"{s.formula} {s.method}/{s.basis}" + button = widgets.Button( + description=f"→ Analyse {short}"[:48], + button_style="info", + layout=layout_fn(width="auto", max_width="340px"), + tooltip=f"Load {short} into the Analysis tab", + ) + button.on_click(lambda _, rd=rdir: app._history_load_analysis(rd)) + btns.append(button) + display( + widgets.HTML( + 'Analyse a result:
' + ) + ) + display(widgets.VBox(btns, layout=layout_fn(gap="4px"))) + + +def on_compare_clear(app: Any, btn: Any) -> None: + """Clear Compare tab selection and output area.""" + app.compare_select.value = () + app.compare_output.clear_output() + + +def on_past_refresh(app: Any, btn: Any) -> None: + """Refresh History saved-results browser.""" + app._refresh_results_browser() + + +def on_copy_results_path(app: Any, btn: Any) -> None: + """Copy results directory path to clipboard and show transient status.""" + p = app._get_results_dir() + p.mkdir(parents=True, exist_ok=True) + path_str = str(p).replace("\\", "\\\\").replace("'", "\\'") + display(Javascript(f"navigator.clipboard.writeText('{path_str}')")) + app.results_path_lbl.value = ( + f'Copied: {p}' + ) + + def _reset() -> None: + time.sleep(3) + app.results_path_lbl.value = ( + f'{p}' + ) + + threading.Thread(target=_reset, daemon=True).start() + + +def on_reset_click(app: Any, btn: Any) -> None: + """Reveal the perf-log reset confirmation controls.""" + app._reset_confirm_box.layout.display = "" + + +def on_confirm_yes(app: Any, btn: Any, *, reset_perf_log_fn: Any) -> None: + """Reset performance log after confirmation and refresh summary stats.""" + reset_perf_log_fn() + app._reset_confirm_box.layout.display = "none" + app._refresh_perf_stats() + + +def on_confirm_no(app: Any, btn: Any) -> None: + """Cancel perf-log reset confirmation prompt.""" + app._reset_confirm_box.layout.display = "none" + + +def on_log_clear(app: Any, btn: Any) -> None: + """Clear rendered event-log output widgets in the Log tab.""" + app._log_output_html.value = ( + 'Log cleared.' + ) + app._log_source_lbl.value = "" + + +def on_clear_log_cache(app: Any, _unused: Any = None) -> None: + """First click handler for event-log cache clear workflow.""" + app._clear_log_cache_confirm_btn.layout.display = "" + app._clear_log_cache_btn.disabled = True + + +def on_clear_log_cache_confirm(app: Any, *, calc_log_mod: Any) -> None: + """Second click handler that clears persisted event log and restores UI.""" + try: + calc_log_mod.log_event( + "log_cleared", + "Session event log cleared by user", + session_id=app._session_id, + ) + calc_log_mod.clear_event_log() + except Exception: + pass + app._clear_log_cache_confirm_btn.layout.display = "none" + app._clear_log_cache_btn.disabled = False + + +def on_help_toggle(app: Any, _unused: Any = None) -> None: + """Toggle visibility of the floating Help overlay panel.""" + visible = app.help_tab_panel.layout.display != "none" + app.help_tab_panel.layout.display = "none" if visible else "" + + +def on_help_topic_changed(app: Any, change: Any = None) -> None: + """Refresh help topic content after selector changes.""" + _ = change + app._render_help_topic() + + +def on_issue_btn(app: Any, _unused: Any = None) -> None: + """Open the issue-report overlay and reset transient form status.""" + app._issue_textarea.value = "" + app._issue_status_html.value = "" + app._issue_overlay.layout.display = "" + + +def on_issue_cancel(app: Any, _unused: Any = None) -> None: + """Dismiss the issue-report overlay without saving.""" + app._issue_overlay.layout.display = "none" + + +def on_issue_submit(app: Any, *, issue_tracker_mod: Any) -> None: + """Persist issue text and hide overlay on success.""" + text = app._issue_textarea.value.strip() + if not text: + app._issue_status_html.value = ( + '' + "Please describe the issue before submitting." + ) + return + app._issue_submit_btn.disabled = True + try: + issue_id = issue_tracker_mod.log_issue( + description=text, + context=app._build_issue_context(), + session_id=app._session_id, + ) + app._issue_status_html.value = ( + f'' + f"✓ Issue #{issue_id} saved. Thank you!" + ) + app._issue_overlay.layout.display = "none" + except Exception as exc: + app._issue_status_html.value = ( + f'Save failed: {exc}' + ) + finally: + app._issue_submit_btn.disabled = False + + +def on_expand_mol_input(app: Any, btn: Any, *, visualization_available: bool) -> None: + """Expand molecule input section to show full editor and controls.""" + _ = btn + children = [app.mol_input_expanded, app.mol_info_html, app.viz_output] + if app.viz_backend_toggle is not None: + children.append(app.viz_backend_toggle) + if visualization_available: + children.append(app.viz_controls_box) + app.mol_input_container.children = children + + +def on_method_help(app: Any, btn: Any) -> None: + """Open help overlay focused on method guidance.""" + _ = btn + app._show_help_topic("method") + + +def on_basis_help(app: Any, btn: Any) -> None: + """Open help overlay focused on basis-set guidance.""" + _ = btn + app._show_help_topic("basis_set") + + +def on_exit_clicked(app: Any, _unused: Any = None) -> None: + """Update UI and request shutdown of Voilà/Jupyter parent and kernel.""" + import os + import signal + + app._exit_btn.description = "Exiting…" + app._exit_btn.disabled = True + app._welcome_html.value = ( + '{summary}
' + f'| Calculation | ' + f'e⁻ | ' + f'Basis fns | ' + f'Wall time | ' + f'Status | ' + f"
|---|
Loading trajectory viewer…
' + ) + ) + + try: + app._show_opt_trajectory(result, render_token=render_token) + except Exception as exc: + try: + from quantui import calc_log as _clog_te2 + + _clog_te2.log_event( + "traj_expand_error", + f"{type(exc).__name__}: {exc}"[:300], + ) + except Exception: + 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}
' + ) + ) + + +def show_opt_trajectory( + app: Any, + opt_result: Any, + *, + layout_fn: Any, + render_token: int | None = None, +) -> None: + """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) + ) + + def _set_cache_label(value: str) -> None: + if _is_stale(): + return + cache_label.value = value + + 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}
' + ) + ) + + # Support both OptimizationResult (.trajectory) and PESScanResult (.coordinates_list) + traj = getattr(opt_result, "trajectory", None) or getattr( + opt_result, "coordinates_list", [] + ) + energies = opt_result.energies_hartree + n = len(traj) + if n < 2: + app.traj_output.clear_output() + with app.traj_output: + _ipy_display( + HTML( + '' + "No trajectory data available (single-frame result).
" + ) + ) + return + + hartree_to_kcal = 627.5094740631 + e0 = energies[0] if energies else 0.0 + rel_e = [(e - e0) * hartree_to_kcal for e in energies] if energies else [] + + # --- Energy convergence chart --- + has_plotly = False + try: + import plotly.graph_objects as go + + energy_fig = go.Figure( + go.Scatter( + x=list(range(n)), + y=rel_e, + mode="lines+markers", + name="ΔE", + line=dict(color="#2563eb", width=2), + marker=dict(size=6), + ) + ) + energy_fig.update_layout( + title="Energy Convergence", + xaxis_title="Step", + yaxis_title="ΔE (kcal/mol)", + height=220, + margin=dict(l=60, r=20, t=40, b=40), + ) + has_plotly = True + except ImportError: + pass + + # --- Pre-build XYZ blocks (reused by carousel, fast path, and export) --- + charge = traj[0].charge + xyzblocks = [ + f"{len(m.atoms)}\n{m.get_formula()}\n{m.to_xyz_string()}" for m in traj + ] + frame_w, frame_h, frame_res = 460, 340, 8 + + # --- Attempt fast-path: bond perception once on frame 0 --- + ref_mol = None + plotlymol_fast = False + try: + from plotlymol3d import ( + draw_3D_mol as _draw_3D_mol, + ) + from plotlymol3d import ( + format_figure as _fmt_fig, + ) + from plotlymol3d import ( + format_lighting as _fmt_light, + ) + from plotlymol3d import ( + make_subplots as _make_subplots, + ) + from plotlymol3d import ( + xyzblock_to_rdkitmol as _xyz_to_rdkit, + ) + from rdkit import Chem as _Chem + + from quantui.visualization_py3dmol import LIGHTING_PRESETS as _LP + + ref_mol = _xyz_to_rdkit(xyzblocks[0], charge=charge) + plotlymol_fast = ref_mol is not None + except Exception: + pass + + def _build_fig_fast(idx: int): + """Reuse frame-0 bond topology; only swap in new atom positions.""" + mol_xyz = _Chem.MolFromXYZBlock(xyzblocks[idx] + "\n") + if mol_xyz is None: + return None + rw = _Chem.RWMol(ref_mol) + conf_src = mol_xyz.GetConformer() + conf_dst = rw.GetConformer() + for atom_idx in range(rw.GetNumAtoms()): + conf_dst.SetAtomPosition(atom_idx, conf_src.GetAtomPosition(atom_idx)) + fig = _make_subplots(rows=1, cols=1, specs=[[{"type": "scene"}]]) + _draw_3D_mol(fig, rw.GetMol(), frame_res, "ball+stick") + fig = _fmt_fig(fig) + fig = _fmt_light(fig, **_LP.get("soft", _LP["soft"])) + scene_bg = app._plotly_theme_colors()["scene_bgcolor"] + fig.update_layout( + width=frame_w, + height=frame_h, + paper_bgcolor="white", + scene=dict(bgcolor=scene_bg), + margin=dict(l=0, r=0, t=0, b=0), + ) + return fig + + def _build_fig(idx: int): + """Return (kind, obj) for frame idx; fast path when bonds are cached.""" + if plotlymol_fast: + try: + fig = _build_fig_fast(idx) + if fig is not None: + return ("plotly", fig) + except Exception: + pass + # Slow fallback: full plotlymol pipeline + try: + from quantui.visualization_py3dmol import visualize_molecule_plotlymol + + fig = visualize_molecule_plotlymol( + traj[idx], + mode="ball+stick", + resolution=frame_res, + width=frame_w, + height=frame_h, + ) + scene_bg = app._plotly_theme_colors()["scene_bgcolor"] + fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg)) + return ("plotly", fig) + except ImportError: + pass + # Last resort: py3Dmol + 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 as exc: + return ("error", str(exc)) + + frame_cache: dict[int, Any] = {} + + # --- Carousel controls --- + step_slider = widgets.IntSlider( + value=0, + min=0, + max=n - 1, + description="Step:", + continuous_update=False, + style={"description_width": "40px"}, + layout=layout_fn(width="360px"), + ) + step_info = widgets.HTML(value=app._traj_step_html(0, traj, energies, rel_e)) + frame_out = widgets.Output(layout=layout_fn(min_height="340px")) + cache_label = widgets.HTML( + value=f'' + f"Pre-rendering frames… 0 / {n}" + ) + + def _display_frame(idx: int) -> None: + if _is_stale(): + return + kind, obj = frame_cache[idx] + try: + from quantui import calc_log as _clog_df + + _clog_df.log_event("traj_frame_display", f"idx={idx} kind={kind}") + except Exception: + pass + if kind == "error": + frame_out.clear_output() + with frame_out: + _ipy_display( + HTML( + 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. + import plotly.io as _pio + + _html = _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) + + def _update_frame(change: dict[str, Any]) -> None: + if _is_stale(): + return + idx = change["new"] + step_info.value = app._traj_step_html(idx, traj, energies, rel_e) + if idx in frame_cache: + _display_frame(idx) + return + frame_out.clear_output() + with frame_out: + _ipy_display( + HTML( + 'Rendering…
' + ) + ) + + def _on_demand() -> None: + try: + frame_cache[idx] = _build_fig(idx) + app._queue_main_thread_callback(_display_frame, idx) + except Exception as exc: + if _is_stale(): + return + app._queue_main_thread_callback(_show_frame_error, str(exc)) + + threading.Thread(target=_on_demand, daemon=True).start() + + step_slider.observe(app._safe_cb(_update_frame), names="value") + + # --- Export button --- + export_btn = widgets.Button( + description="Export Animation", + icon="download", + layout=layout_fn(width="160px", margin="0 0 0 12px"), + tooltip="Generate a standalone HTML animation file (may take a minute)", + ) + export_status = widgets.HTML() + + def _on_export(_btn) -> None: + _btn.disabled = True + export_status.value = ( + f'' + f"Generating {n}-frame animation, please wait…" + ) + + def _do_export() -> None: + try: + from plotlymol3d import create_trajectory_animation + + anim_fig = create_trajectory_animation( + xyzblocks=xyzblocks, + energies_hartree=energies if energies else None, + charge=charge, + mode="ball+stick", + resolution=12, + title=f"Geo Opt: {opt_result.formula}", + ) + result_dir = getattr(app, "_last_result_dir", None) + out_path = ( + result_dir / "trajectory_animation.html" + if result_dir is not None + else Path.home() / f"{opt_result.formula}_trajectory.html" + ) + anim_fig.write_html(str(out_path)) + app._queue_main_thread_callback( + setattr, + export_status, + "value", + ( + f'' + f"✓ Saved: {out_path}" + ), + ) + except Exception as exc: + app._queue_main_thread_callback( + setattr, + export_status, + "value", + f'Export failed: {exc}', + ) + finally: + app._queue_main_thread_callback(setattr, _btn, "disabled", False) + + threading.Thread(target=_do_export, daemon=True).start() + + export_btn.on_click(_on_export) + + # --- Assemble layout --- + header = widgets.HBox( + [step_slider, export_btn], + layout=layout_fn(align_items="center", margin="4px 0"), + ) + panel = widgets.VBox([header, step_info, cache_label, frame_out, export_status]) + + # Build and render frame 0 SYNCHRONOUSLY on the main thread before + # displaying the panel, so the Output widget arrives at the browser with + # frame 0 already in its outputs list. This avoids the io_loop-callback + # latency that left frame 0 invisible until the first slider click. + if _is_stale(): + return + try: + frame_cache[0] = _build_fig(0) + _display_frame(0) + sync_frame0_ok = True + except Exception as _f0_exc: + sync_frame0_ok = False + try: + from quantui import calc_log as _clog_f0 + + _clog_f0.log_event( + "traj_frame0_sync_error", + f"{type(_f0_exc).__name__}: {_f0_exc}"[:300], + ) + except Exception: + pass + frame_out.clear_output() + with frame_out: + _ipy_display( + HTML( + '' + "Rendering frame 0…
" + ) + ) + + # 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) + 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}", + ) + except Exception: + pass + + def _prerender_all() -> None: + """Render remaining frames in a background thread (frame 0 already + built+displayed synchronously above when sync_frame0_ok).""" + if _is_stale(): + return + try: + if 0 not in frame_cache: + frame_cache[0] = _build_fig(0) + app._queue_main_thread_callback(_display_frame, 0) + app._queue_main_thread_callback( + _set_cache_label, + f'' + f"Pre-rendering frames… 1 / {n}", + ) + if n > 1: + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = {pool.submit(_build_fig, i): i for i in range(1, n)} + done = 1 + for fut in concurrent.futures.as_completed(futures): + if _is_stale(): + return + i = futures[fut] + try: + frame_cache[i] = fut.result() + except Exception: + pass + done += 1 + app._queue_main_thread_callback( + _set_cache_label, + f'' + f"Pre-rendering frames… {done} / {n}", + ) + except Exception: + pass + app._queue_main_thread_callback( + _set_cache_label, + f'' + f"✓ All {n} frames ready", + ) + try: + from quantui import calc_log as _clog_pre + + _clog_pre.log_event( + "traj_prerender_complete", f"n={n} cached={len(frame_cache)}" + ) + except Exception: + pass + + threading.Thread(target=_prerender_all, daemon=True).start() + + +def traj_step_html( + app: Any, step: int, traj: list[Any], energies: list[Any], rel_e: list[Any] +) -> str: + """One-line info label for a trajectory step index.""" + n = len(traj) + mol = traj[step] + e_abs = f"{energies[step]:.8f} Ha" if energies and step < len(energies) else "—" + delta = ( + f" · ΔE = {rel_e[step]:+.3f} kcal/mol" + if rel_e and step < len(rel_e) + else "" + ) + return ( + f'' + f"Step {step} / {n - 1} · {mol.get_formula()}" + f" · E = {e_abs}{delta}" + ) + + +def render_traj_frame(app: Any, molecule: Any, output_widget: Any) -> None: + """Render one trajectory frame into output widget.""" + try: + from quantui.visualization_py3dmol import visualize_molecule_plotlymol + + fig = visualize_molecule_plotlymol( + molecule, mode="ball+stick", resolution=8, width=460, height=340 + ) + scene_bg = app._plotly_theme_colors()["scene_bgcolor"] + fig.update_layout(paper_bgcolor="white", scene=dict(bgcolor=scene_bg)) + output_widget.clear_output() + with output_widget: + display(fig) + return + except ImportError: + pass + + # Fallback: py3Dmol + try: + import py3Dmol as _p3d + + xyz = ( + f"{len(molecule.atoms)}\n" + f"{molecule.get_formula()}\n" + f"{molecule.to_xyz_string()}" + ) + view = _p3d.view(width=460, height=340) + view.addModel(xyz, "xyz") + view.setStyle({"stick": {}, "sphere": {"scale": 0.3}}) + view.setBackgroundColor("white") + view.zoomTo() + output_widget.clear_output() + with output_widget: + display(view) + except Exception as exc: + output_widget.clear_output() + with output_widget: + display( + HTML( + f'Frame render failed: {exc}
' + ) + ) + + +def build_vib_data_from_freq_result(app: Any, freq_result: Any, molecule: Any) -> Any: + """Construct plotlymol3d VibrationalData from a frequency result.""" + try: + import numpy as np + from plotlymol3d import VibrationalData, VibrationalMode + except ImportError: + return None + + try: + return app._build_vib_data_inner( + freq_result, molecule, np, VibrationalData, VibrationalMode + ) + except Exception as exc: + try: + from quantui import calc_log as _clog + + _clog.log_event("vib_data_error", f"{type(exc).__name__}: {exc}"[:300]) + except Exception: + pass + return None + + +def build_vib_data_inner( + app: Any, + freq_result: Any, + molecule: Any, + np: Any, + VibrationalData: Any, + VibrationalMode: Any, +) -> Any: + """Internal constructor for VibrationalData with dependency injection.""" + displacements = getattr(freq_result, "displacements", None) + if displacements is None: + return None + + freqs = freq_result.frequencies_cm1 + intensities = freq_result.ir_intensities + n_modes = len(freqs) + + coords = np.array(molecule.coordinates, dtype=float) + + # Map element symbols to atomic numbers using a common-elements table. + z_map = { + "H": 1, + "He": 2, + "Li": 3, + "Be": 4, + "B": 5, + "C": 6, + "N": 7, + "O": 8, + "F": 9, + "Ne": 10, + "Na": 11, + "Mg": 12, + "Al": 13, + "Si": 14, + "P": 15, + "S": 16, + "Cl": 17, + "Ar": 18, + "K": 19, + "Ca": 20, + "Br": 35, + "I": 53, + } + atomic_numbers: List[int] = [z_map.get(sym, 0) for sym in molecule.atoms] + + modes = [] + for i in range(n_modes): + freq = freqs[i] + ir_inten = intensities[i] if i < len(intensities) else None + displ = np.array(displacements[i], dtype=float) + modes.append( + VibrationalMode( + mode_number=i + 1, + frequency=float(freq), + ir_intensity=ir_inten, + displacement_vectors=displ, + is_imaginary=freq < 0, + ) + ) + + return VibrationalData( + coordinates=coords, + atomic_numbers=atomic_numbers, + modes=modes, + source_file="quantui_freq_calc", + program="pyscf", + ) + + +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 + + freqs = freq_result.frequencies_cm1 + if not freqs: + return False + + # Build dropdown options; skip near-zero translation/rotation modes. + options = [] + for mode in vib_data.modes: + freq_val = mode.frequency + if abs(freq_val) < 10: + continue + label = ( + f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹" + if freq_val >= 0 + else f"Mode {mode.mode_number}: {freq_val:.1f} cm⁻¹ (imaginary, TS?)" + ) + options.append((label, mode.mode_number)) + + if not options: + return False + + app.vib_mode_dd.options = options + app.vib_mode_dd.value = options[0][1] + + app._last_vib_data = vib_data + app._last_vib_molecule = molecule + + 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})…
" + ) + ) + threading.Thread( + target=app._render_vib_mode, + args=(vib_data, molecule, first_mode), + daemon=True, + ).start() + + return True + + +def show_ir_spectrum(app: Any, freq_result: Any) -> bool: + """Populate IR Spectrum accordion after a Frequency result.""" + freqs = list(freq_result.frequencies_cm1 or []) + ints = list(getattr(freq_result, "ir_intensities", None) or []) + if not freqs: + return False + + app._ir_intensities_real = bool(ints) + if not ints: + ints = [1.0] * len(freqs) + app._ir_accordion.set_title( + 0, + ( + "IR Spectrum" + if app._ir_intensities_real + else "IR Spectrum (positions only — intensities unavailable)" + ), + ) + + app._last_ir_freqs = freqs + app._last_ir_ints = ints + + app._update_ir_figure("Stick", 20.0) + + # _show_ir_spectrum may run from _do_run background thread. + app._queue_main_thread_callback(app._wire_ir_controls) + + return True + + +def wire_ir_controls(app: Any) -> None: + """Rebind IR controls and reset defaults on the main thread.""" + # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all() + # here because it can remove unrelated trait observers in some frontends. + app._ir_mode_toggle.value = "Stick" + app._ir_fwhm_slider.value = 20.0 + app._ir_fwhm_slider.layout.display = "none" + + +def on_ir_mode_changed(app: Any, change: dict[str, Any]) -> None: + """Handle Stick/Broadened mode changes for IR panel.""" + mode = change["new"] + try: + import quantui.calc_log as _calc_log + + _calc_log.log_event( + "ir_mode_change", + mode, + mode=mode, + session_id=app._session_id, + ) + except Exception: + pass + app._ir_fwhm_slider.layout.display = "" if mode == "Broadened" else "none" + app._update_ir_figure(mode, app._ir_fwhm_slider.value) + + +def on_ir_fwhm_changed(app: Any, change: dict[str, Any]) -> None: + """Re-render broadened IR trace when line width slider changes.""" + if app._ir_mode_toggle.value == "Broadened": + app._update_ir_figure("Broadened", change["new"]) + + +def update_ir_figure(app: Any, mode: str, fwhm: float) -> None: + """Re-render IR spectrum chart for mode and FWHM settings.""" + try: + import plotly.io as _pio + + from quantui.ir_plot import plot_ir_spectrum + + y_title = ( + "IR Intensity (km/mol)" + if getattr(app, "_ir_intensities_real", True) + else "Relative intensity (a.u.)" + ) + fig = plot_ir_spectrum( + app._last_ir_freqs, + app._last_ir_ints, + mode=mode.lower(), + fwhm=fwhm, + yaxis_title=y_title, + ) + app._apply_plotly_theme(fig) + app._last_ir_fig = fig + app._set_html_output( + app._ir_fig, + _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), + ) + except Exception as exc: + app._last_ir_fig = None + try: + from quantui import calc_log as _clog + + _clog.log_event("ir_fig_error", f"{type(exc).__name__}: {exc}"[:300]) + except Exception: + pass + + +def show_uv_vis_spectrum( + app: Any, + energies_ev: List[float], + oscillator_strengths: List[float], + wavelengths_nm: List[float], +) -> bool: + """Populate UV-Vis spectrum data and render the default stick plot.""" + wl = list(wavelengths_nm or []) + if not wl: + wl = [1240.0 / e for e in energies_ev if e and e > 0] + + peaks: list[tuple[float, float]] = [] + for x0, amp in zip(wl, oscillator_strengths): + try: + x_val = float(x0) + a_val = float(amp) + except Exception: + continue + if x_val <= 0: + continue + peaks.append((x_val, max(a_val, 0.0))) + + if not peaks: + return False + + peaks.sort(key=lambda p: p[0]) + app._last_uv_wavelengths_nm = [p[0] for p in peaks] + app._last_uv_oscillator_strengths = [p[1] for p in peaks] + + app._update_uv_vis_figure("Stick", 20.0) + + # _show_uv_vis_spectrum may run from _do_run background thread. + app._queue_main_thread_callback(app._wire_uv_controls) + return True + + +def wire_uv_controls(app: Any) -> None: + """Rebind UV-Vis controls and reset defaults on the main thread.""" + # Observers are wired once in QuantUIApp._wire_callbacks. Avoid unobserve_all() + # here because it can remove unrelated trait observers in some frontends. + app._uv_mode_toggle.value = "Stick" + app._uv_fwhm_slider.value = 20.0 + app._uv_fwhm_slider.layout.display = "none" + + +def on_uv_mode_changed(app: Any, change: dict[str, Any]) -> None: + """Handle Stick/Broadened mode changes for UV-Vis panel.""" + mode = change["new"] + app._uv_fwhm_slider.layout.display = "" if mode == "Broadened" else "none" + app._update_uv_vis_figure(mode, app._uv_fwhm_slider.value) + + +def on_uv_fwhm_changed(app: Any, change: dict[str, Any]) -> None: + """Re-render broadened UV-Vis trace when line width slider changes.""" + if app._uv_mode_toggle.value == "Broadened": + app._update_uv_vis_figure("Broadened", change["new"]) + + +def update_uv_vis_figure(app: Any, mode: str, fwhm: float) -> None: + """Re-render UV-Vis spectrum chart for mode and FWHM settings.""" + wl = list(getattr(app, "_last_uv_wavelengths_nm", []) or []) + osc = list(getattr(app, "_last_uv_oscillator_strengths", []) or []) + if not wl or not osc: + return + + try: + import numpy as _np + import plotly.graph_objects as _go + import plotly.io as _pio + + mode_name = str(mode or "Stick") + mode_norm = mode_name.strip().lower() + fig = _go.Figure() + + if mode_norm == "broadened": + gamma = max(float(fwhm), 1.0) / 2.0 + x_min = max(100.0, min(wl) - 80.0) + x_max = max(wl) + 80.0 + n_points = max(600, int((x_max - x_min) * 2.0)) + x_grid = _np.linspace(x_min, x_max, n_points) + y_grid = _np.zeros_like(x_grid) + for x0, amp in zip(wl, osc): + y_grid += amp * (gamma**2 / ((x_grid - x0) ** 2 + gamma**2)) + fig.add_trace( + _go.Scatter( + x=x_grid.tolist(), + y=y_grid.tolist(), + mode="lines", + line=dict(color="#2563eb", width=2), + name="Broadened", + ) + ) + else: + stick_x: list[float | None] = [] + stick_y: list[float | None] = [] + for x0, amp in zip(wl, osc): + stick_x.extend([x0, x0, None]) + stick_y.extend([0.0, amp, None]) + fig.add_trace( + _go.Scatter( + x=stick_x, + y=stick_y, + mode="lines", + line=dict(color="#2563eb", width=2), + name="Stick", + ) + ) + fig.add_trace( + _go.Scatter( + x=wl, + y=osc, + mode="markers", + marker=dict(color="#1d4ed8", size=6), + showlegend=False, + hovertemplate=( + "Wavelength: %{x:.2f} nm" + "' + f"⏳ Generating {orbital_label} cube file and rendering isosurface" + f" — this may take 15–30 s…
" + ) + ) + + done = threading.Event() + + def _reset_button() -> None: + if render_token != int(getattr(app, "_iso_render_token", 0)): + return + btn.disabled = False + btn.description = "Generate Isosurface" + + def _run() -> None: + try: + app._render_orbital_isosurface(orbital_label, render_token=render_token) + finally: + done.set() + app._queue_main_thread_callback(_reset_button) + + def _watchdog() -> None: + if done.wait(timeout=180): + return + + def _show_timeout() -> None: + if render_token != int(getattr(app, "_iso_render_token", 0)): + return + try: + from quantui import calc_log as _clog + + _clog.log_event("iso_render_timeout", orbital_label) + except Exception: + pass + btn.disabled = False + btn.description = "Generate Isosurface" + app._orb_iso_output.clear_output() + with app._orb_iso_output: + display( + HTML( + '' + "⚠ Orbital isosurface timed out after 180 s. " + "Try a smaller basis set or a smaller molecule.
" + ) + ) + + app._queue_main_thread_callback(_show_timeout) + + threading.Thread(target=_run, daemon=True).start() + threading.Thread(target=_watchdog, daemon=True).start() + + +def on_orb_range_changed(app: Any, _change: Any = None) -> None: + """Live-update orbital diagram for axis limits or orbital count changes.""" + info = getattr(app, "_last_orb_info", None) + if info is None: + return + ymin = app._orb_ymin_input.value + ymax = app._orb_ymax_input.value + if ymin >= ymax: + return + try: + import plotly.io as _pio + + from quantui.orbital_visualization import plot_orbital_diagram_plotly + + fig = plot_orbital_diagram_plotly( + info, + max_orbitals=app._orb_n_orb_input.value, + yrange=(ymin, ymax), + ) + app._apply_plotly_theme(fig) + app._last_orb_fig = fig + app._set_html_output( + app._orb_diagram_html, + _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), + ) + except Exception: + app._last_orb_fig = None + pass + + +def render_orbital_isosurface( + app: Any, orbital_label: str, render_token: int | None = None +) -> None: + """Generate cube file and render orbital isosurface (Linux/WSL only).""" + import re as _re + from datetime import datetime as _dt + + def _is_stale() -> bool: + return render_token is not None and render_token != int( + getattr(app, "_iso_render_token", 0) + ) + + orb_info = getattr(app, "_last_orb_info", None) + if orb_info is None: + return + + n_occ = orb_info.n_occupied + n_total = len(orb_info.mo_energies_ev) + idx_map = { + "HOMO-1": n_occ - 2, + "HOMO": n_occ - 1, + "LUMO": n_occ, + "LUMO+1": n_occ + 1, + } + orb_idx = idx_map.get(orbital_label) + if orb_idx is None or orb_idx < 0 or orb_idx >= n_total: + return + + mo_coeff = getattr(app, "_last_orb_mo_coeff", None) + mol_atom = getattr(app, "_last_orb_mol_atom", None) + mol_basis = getattr(app, "_last_orb_mol_basis", None) + if mo_coeff is None or mol_atom is None or mol_basis is None: + return + + try: + import plotly.io as _pio + + from quantui.orbital_visualization import ( + generate_cube_from_arrays, + plot_cube_isosurface, + ) + + result_dir = getattr(app, "_last_result_dir", None) + if not isinstance(result_dir, Path): + try: + result_dir = app._get_results_dir() + except Exception: + result_dir = Path.cwd() + + cube_dir = Path(result_dir) / "isosurfaces" + cube_dir.mkdir(parents=True, exist_ok=True) + + formula = str(getattr(orb_info, "formula", "") or "molecule") + safe_formula = _re.sub(r"[^A-Za-z0-9_.-]+", "_", formula).strip("._") + if not safe_formula: + safe_formula = "molecule" + safe_orb = _re.sub(r"[^A-Za-z0-9_.-]+", "_", orbital_label).strip("._") + if not safe_orb: + safe_orb = "orbital" + ts = _dt.now().strftime("%Y-%m-%d_%H-%M-%S-%f") + cube_path = cube_dir / f"{safe_formula}_{safe_orb}_{ts}.cube" + + generate_cube_from_arrays(mol_atom, mol_basis, mo_coeff, orb_idx, cube_path) + is_dark = app.theme_btn.value == "Dark" + axis_color = "#dbeafe" if is_dark else "#1f2937" + bond_color = "#cbd5e1" if is_dark else "#4b5563" + title_color = app._plotly_theme_colors()["font_color"] + fig = plot_cube_isosurface( + cube_path, + title=f"{orbital_label} Isosurface", + show_molecule=True, + show_grid=False, + scene_bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + axis_color=axis_color, + title_color=title_color, + bond_color=bond_color, + ) + html_str = _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ) + except Exception as exc: + if _is_stale(): + return + err_msg = f"{type(exc).__name__}: {exc}" + try: + from quantui import calc_log as _clog + + _clog.log_event( + "iso_render_error", + f"{orbital_label}: {err_msg}"[:300], + ) + except Exception: + pass + + def _show_err(msg: str = err_msg) -> None: + app._orb_iso_output.clear_output() + with app._orb_iso_output: + display( + HTML( + f'' + f"⚠ Orbital isosurface failed: {msg}
" + ) + ) + + app._queue_main_thread_callback(_show_err) + return + if _is_stale(): + return + try: + from quantui import calc_log as _clog + + _clog.log_event( + "iso_cube_saved", + cube_path.name, + cube_path=str(cube_path), + orbital=orbital_label, + session_id=app._session_id, + ) + _clog.log_event("iso_render_done", orbital_label) + except Exception: + pass + + app._queue_main_thread_callback( + app._set_html_output, + app._orb_iso_output, + html_str, + ) + + +def render_vib_mode(app: Any, vib_data: Any, molecule: Any, mode_number: int) -> None: + """Render vibrational animation for mode number into vib output.""" + 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}
') + ) + + 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}"
+ )
+ return
+
+ xyzblock = (
+ f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
+ f"{molecule.to_xyz_string()}"
+ )
+ try:
+ rdmol = xyzblock_to_rdkitmol(xyzblock, charge=molecule.charge)
+ except Exception as exc:
+ _err(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}")
+ except Exception:
+ pass
+ try:
+ anim_fig = create_vibration_animation(
+ vib_data=vib_data,
+ mode_number=mode_number,
+ mol=rdmol,
+ amplitude=0.4,
+ n_frames=20,
+ mode="ball+stick",
+ resolution=12,
+ )
+ 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}: {type(exc).__name__}: {exc}"[:300],
+ )
+ except Exception:
+ pass
+ _err(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}")
+ except Exception:
+ pass
+
+ import plotly.io as _pio
+
+ anim_html = _pio.to_html(
+ anim_fig,
+ full_html=False,
+ include_plotlyjs="require",
+ config={"responsive": True},
+ )
+ app.vib_output.clear_output()
+ app.vib_output.append_display_data(_H(anim_html))
+
+
+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:
+ return
+
+ label = next(
+ (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})…
" + ) + ) + threading.Thread( + target=app._render_vib_mode, + args=(vib_data, molecule, mode_number), + daemon=True, + ).start() + + +def show_pes_scan_result(app: Any, result: Any) -> bool: + """Render PES energy profile chart and stash latest PES result.""" + app._last_pes_result = result + try: + import plotly.graph_objects as go + import plotly.io as pio + + e_rel = result.energies_relative_kcal + x_vals = result.scan_parameter_values + + hover_text = [ + f"{result.scan_coordinate_label}: {x:.4f}