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'{k}' - f'{v}' - for k, v in _items - ) + self._activity_btn.description = "Idle" + self._activity_btn.icon = "circle-o" + self._activity_btn.button_style = "success" + self._activity_btn.tooltip = "No active operations." - _env_badge = ( - f'  {_env}' - if _env and _env not in ("base", "") - else "" - ) - _cal_line = ( - f'
' - f"Timing calibration: {_cal_label}
" - if _cal_label - else '
' - "Timing calibration: not yet run — use the Calibrate panel in History
" - ) + def _refresh_activity_indicator(self, message: str = "") -> None: + """Recompute activity light state from active operation counters.""" + if self._activity_count <= 0: + self._set_activity_indicator("idle") + return + if self._activity_compute_count > 0: + self._set_activity_indicator("compute", message) + return + self._set_activity_indicator("ui", message) + + def _activity_begin(self, message: str = "", kind: str = "ui") -> None: + """Mark one operation as active.""" + with self._activity_lock: + self._activity_count += 1 + if kind == "compute": + self._activity_compute_count += 1 + self._refresh_activity_indicator(message) + + def _activity_end(self, kind: str = "ui") -> None: + """Mark one operation as finished.""" + with self._activity_lock: + if self._activity_count > 0: + self._activity_count -= 1 + if kind == "compute" and self._activity_compute_count > 0: + self._activity_compute_count -= 1 + self._refresh_activity_indicator() + + def _activity_pulse( + self, message: str, hold_s: float = 0.18, kind: str = "ui" + ) -> None: + """Briefly light the activity indicator for quick operations.""" + self._activity_begin(message, kind=kind) + timer = threading.Timer( + max(0.05, hold_s), + self._activity_end, + kwargs={"kind": kind}, + ) + timer.daemon = True + timer.start() + + def _on_root_tab_changed(self, _change) -> None: + """Pulse the activity light on tab navigation actions.""" + self._activity_pulse("Switching tabs...", hold_s=0.16, kind="ui") + + def _go_to_results_tab(self, _btn) -> None: + """Navigate to Results tab with a visible activity pulse.""" + self._activity_pulse("Navigating to Results tab...", hold_s=0.16, kind="ui") + self.root_tab.selected_index = 1 - self._status_html = widgets.HTML( - f'
' - f'
' - f"QuantUI {quantui.__version__}" - f'' - f"Python {_py_ver}{_env_badge}
" - f'{_rows}
' - f"{_cal_line}" - f"
" - ) + def _go_to_analysis_tab(self, _btn) -> None: + """Navigate to Analysis tab with a visible activity pulse.""" + self._activity_pulse("Navigating to Analysis tab...", hold_s=0.16, kind="ui") + self.root_tab.selected_index = 2 - _steps = [ - "Select a molecule — library dropdown, XYZ paste, or PubChem search", - "Choose a method (RHF / DFT / MP2) and basis set in the Calculate tab", - "Click Run Calculation — SCF progress appears in real time", - "Explore results in the Results and Analysis tabs", - "Browse past calculations in History; compare them in Compare", - ] - _steps_html = "".join( - f'
  • {s}
  • ' - for s in _steps - ) - _guide_html = widgets.HTML( - f'
    ' - f'
    ' - f"Quick start
    " - f'
      {_steps_html}
    ' - f"
    " - ) + # ── Status panel ────────────────────────────────────────────────────── - self._status_tab_panel = widgets.VBox( - [self._status_html, _guide_html], - layout=_layout(padding="8px 0"), + def _build_status_panel(self) -> None: + _bld_build_status_panel( + self, + layout_fn=_layout, + get_session_resources_fn=get_session_resources, + load_last_calibration_label_fn=_load_last_calibration_label, + pyscf_available=_PYSCF_AVAILABLE, + ase_available=ASE_AVAILABLE, + pubchem_available=PUBCHEM_AVAILABLE, + visualization_available=VISUALIZATION_AVAILABLE, ) # ── Welcome header ──────────────────────────────────────────────────── def _build_welcome_header(self) -> None: - _logo_svg = ( - '' - "" - '' - '' - "" - '' - "" - '' - '' - "" - '' - "" - "" - '' - '' - '' - '' - "" - '' - '' - '' - "" - '' - '' - '' - "" - '' - '' - '' - '' - "" - ) - _html = ( - f'
    ' - f"{_logo_svg}" - f"
    " - f'
    QuantUI
    ' - f'
    ' - f"Quantum chemistry calculations, right on your device
    " - f'
    ' - f"v{quantui.__version__}  ·  " - f"Help tab for instructions  ·  " - f"Status tab for system info
    " - f"
    " - f"
    " - ) - self._welcome_html = widgets.HTML(value=_html) + _bld_build_welcome_header(self) # ── Shared widgets (Cell 3) ─────────────────────────────────────────── def _build_shared_widgets(self) -> None: - # Output widgets - self.mol_info_html = widgets.HTML( - value='No molecule loaded yet.' - ) - self.mol_summary_compact = widgets.HTML(value="") - self.viz_output = widgets.Output(layout=_layout(min_height="50px")) - self.run_output = widgets.Output( - layout=_layout( - border="1px solid #c0ccd8", - min_height="80px", - max_height="400px", - padding="8px", - overflow_y="auto", - ) - ) - with self.run_output: - display( - HTML( - '

    ' - "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('

    Molecule Input

    '), - input_tab, - ] - ) - self.change_mol_btn = widgets.Button( - description="Change", - button_style="", - icon="pencil", - layout=_layout(width="100px", height="32px"), - tooltip="Re-expand the molecule input panel", - ) - self.mol_input_collapsed = widgets.HBox( - [self.mol_summary_compact, self.change_mol_btn], - layout=_layout(align_items="center", gap="12px", padding="6px 0"), - ) - _mol_container_children = [ - self.mol_input_expanded, - self.mol_info_html, - self.viz_output, - ] - if self.viz_backend_toggle is not None: - _mol_container_children.append(self.viz_backend_toggle) - if VISUALIZATION_AVAILABLE: - _mol_container_children.append(self.viz_controls_box) - self.mol_input_container = widgets.VBox( - _mol_container_children, - layout=_layout(margin="0 0 4px 0"), + _bld_build_molecule_section( + self, + layout_fn=_layout, + molecule_library=MOLECULE_LIBRARY, + pubchem_available=PUBCHEM_AVAILABLE, + visualization_available=VISUALIZATION_AVAILABLE, ) # ── Calculation setup panel (Cell 5) ────────────────────────────────── def _build_calc_setup(self) -> None: - self.calc_setup_panel = widgets.VBox( - [ - widgets.HTML('

    Calculation Setup

    '), - widgets.HBox( - [ - widgets.VBox( - [ - widgets.HBox( - [self.method_dd, self.method_help_btn], - layout=_layout(align_items="center", gap="4px"), - ), - widgets.HBox( - [self.basis_dd, self.basis_help_btn], - layout=_layout(align_items="center", gap="4px"), - ), - ] - ), - widgets.HTML("  "), - widgets.VBox([self.charge_si, self.mult_si]), - ] - ), - self.calc_type_dd, - self.calc_extra_opts, - self.preopt_cb, - widgets.HBox( - [self.solvent_cb, self.solvent_dd], - layout=_layout(align_items="center", gap="4px"), - ), - self.notes_output, - ] - ) + _bld_build_calc_setup(self, layout_fn=_layout) # ── Run panel (Cell 6) ──────────────────────────────────────────────── def _build_run_section(self) -> None: - self.run_panel = widgets.VBox( - [ - widgets.HTML( - '

    Run Calculation

    ' - '

    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('

    Results

    '), - self.result_output, - self._viz_label, - self.result_viz_output, - self._result_dir_label, - # advanced_accordion appended in _assemble_tabs (built later in - # _build_compare_section — must run before it can be referenced here) - self._to_analysis_btn, - ], - layout=_layout(padding="8px 0"), - ) - # Backward-compat alias — existing methods that reference results_panel still work - self.results_panel = self.results_tab_panel - - # ── Analysis tab: molecule viewer (shown for all calc types) ───── - self._analysis_mol_output = widgets.Output() - - # ── Analysis tab panel (Tab 2) ──────────────────────────────────── - self._analysis_context_lbl = widgets.HTML( - value=( - '

    ' - "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.

    " - ), - layout=_layout(display="none"), - ) - self._build_ana_switcher() - self.analysis_tab_panel = widgets.VBox( - [ - self._analysis_context_lbl, - self._analysis_mol_output, - self._analysis_empty_html, - self._ana_unavail_html, - self._orb_accordion, - self._pes_scan_accordion, - self.traj_accordion, - self.vib_accordion, - self._ir_accordion, - self._iso_accordion, - self._tddft_accordion, - self._nmr_accordion, - ], - layout=_layout(padding="8px 0"), - ) - # Backward-compat alias for post_calc_panel references in tests - self.post_calc_panel = self.analysis_tab_panel - - # ── Analysis panel switcher ─────────────────────────────────────────── + # ── Analysis panel switcher ─────────────────────────────────────────── def _build_ana_switcher(self) -> None: - """Initialise analysis panel state; wire accordion re-render observers.""" - panel_meta = [ - (name, getattr(self, attr), when) for name, attr, when in self._PANEL_META - ] - self._ana_panel_names: list = [m[0] for m in panel_meta] - self._ana_accordions: list = [m[1] for m in panel_meta] - self._ana_available: set = set() - self._ana_active: str = "" - self._ana_unavail_html = widgets.HTML( - value="", - layout=_layout(display="none", margin="4px 0 8px"), - ) - - # Wrap each accordion's child so it holds both an "unavailable" message - # and the real content. Real content starts hidden; the unavailable - # message is shown until _activate_ana_panel() is called. - self._ana_unavail_msgs: dict = {} - self._ana_content_boxes: dict = {} - for name, acc, when in panel_meta: - unavail = widgets.HTML( - value=( - f'
    Not available — run a {when} ' - f"calculation first.
    " - ), - layout=_layout(display=""), - ) - content = acc.children[0] - self._ana_unavail_msgs[name] = unavail - self._ana_content_boxes[name] = content - content.layout.display = "none" - acc.children = (widgets.VBox([unavail, content]),) - acc.layout.display = "" # always in the DOM - acc.selected_index = None # collapsed until activated - - # Re-render Plotly charts when their accordion is expanded by clicking - # the header directly (charts rendered into a hidden container have 0 size). - self._ir_accordion.observe( - self._safe_cb(self._on_ir_accordion_show), names=["selected_index"] - ) - self._orb_accordion.observe( - self._safe_cb(self._on_orb_accordion_show), names=["selected_index"] - ) + _ana_build_ana_switcher(self, layout_fn=_layout) def _on_ir_accordion_show(self, change) -> None: if change["new"] == 0 and getattr(self, "_last_ir_freqs", None): @@ -1515,38 +1133,25 @@ def _on_ir_accordion_show(self, change) -> None: self._ir_mode_toggle.value, self._ir_fwhm_slider.value ) + def _on_tddft_accordion_show(self, change) -> None: + if change["new"] == 0 and getattr(self, "_last_uv_wavelengths_nm", None): + self._update_uv_vis_figure( + self._uv_mode_toggle.value, + self._uv_fwhm_slider.value, + ) + def _on_orb_accordion_show(self, change) -> None: if change["new"] == 0 and getattr(self, "_last_orb_info", None) is not None: self._on_orb_range_changed() def _select_ana_panel(self, name: str) -> None: - """Expand the named panel and collapse all others.""" - self._ana_active = name - self._ana_unavail_html.layout.display = "none" - for pname, acc in zip(self._ana_panel_names, self._ana_accordions): - acc.selected_index = 0 if pname == name else None + _ana_select_ana_panel(self, name) def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: - """Mark a panel as available: reveal its content.""" - self._ana_available.add(name) - # Swap unavailable placeholder for real content. - if name in self._ana_unavail_msgs: - self._ana_unavail_msgs[name].layout.display = "none" - self._ana_content_boxes[name].layout.display = "" - if auto_select: - self._select_ana_panel(name) + _ana_activate_ana_panel(self, name, auto_select=auto_select) def _deactivate_all_ana_panels(self) -> None: - """Reset all panels to collapsed/unavailable; used at start of each new run.""" - self._ana_available.clear() - self._ana_active = "" - self._ana_unavail_html.layout.display = "none" - for name, acc in zip(self._ana_panel_names, self._ana_accordions): - # Show the "not available" placeholder; hide real content. - if name in self._ana_unavail_msgs: - self._ana_unavail_msgs[name].layout.display = "" - self._ana_content_boxes[name].layout.display = "none" - acc.selected_index = None + _ana_deactivate_all_ana_panels(self) # ── Panel registry and unified applier ─────────────────────────────────── # @@ -1605,814 +1210,80 @@ def _deactivate_all_ana_panels(self) -> None: } def _apply_analysis_context(self, ctx: _AnalysisContext) -> None: - """Populate Analysis panels from *ctx* and activate those that have data. - - Uses ``_PANEL_REGISTRY`` so that live-run and history-replay follow the - exact same code path. The first registry entry that succeeds and has - ``auto_select=True`` becomes the visible panel; all others are activated - (full opacity, clickable) but not auto-shown. - """ - self._deactivate_all_ana_panels() - self._pending_traj_result = None - # Reset trajectory accordion title to default - self.traj_accordion.set_title(0, "Trajectory Viewer") - - first_auto_selected = False - for panel_name, method_name, want_auto in self._PANEL_REGISTRY.get( - ctx.calc_type, [] - ): - try: - ok = bool(getattr(self, method_name)(ctx)) - except Exception as _panel_exc: - ok = False - try: - from quantui import calc_log as _clog - - _clog.log_event( - "ana_panel_error", - f"{method_name}: {type(_panel_exc).__name__}: {_panel_exc}"[ - :300 - ], - ) - except Exception: - pass - if ok: - do_auto = want_auto and not first_auto_selected - self._activate_ana_panel(panel_name, auto_select=do_auto) - if do_auto: - first_auto_selected = True - - _src = " (from History)" if ctx.source == "history" else "" - self._analysis_context_lbl.value = ( - f'

    ' - 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'{sym}-{n}' - f'{d:.2f} ppm' - for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1) - ) - return ( - f'' - f"{label} shifts (vs. {ref}):" - f'Atom' - f'δ (ppm)' - + rows - ) - - shielding_rows = "".join( - f'{sym}{i + 1}' - f'{s:.2f}' - for i, (sym, s) in enumerate(zip(atom_symbols, shielding)) - ) - html = ( - f'
    ' - f'' - f'' - f'' - f"{shielding_rows}
    Atomσ (ppm)
    " - f'' - f"{_shift_table('¹H', h_shifts, 'H')}" - f"{_shift_table('¹³C', c_shifts, 'C')}" - f"
    " - ) - self._nmr_output.value = html - return True + return _ana_pop_nmr_shielding(self, ctx) def _pop_pes_plot(self, ctx: _AnalysisContext) -> bool: - result = ctx.live_result - if result is None: - scan = ctx.spectra_data.get("pes_scan", {}) - if not scan or not scan.get("energies_hartree"): - return False - energies_ha = scan["energies_hartree"] - atom_indices = scan.get("atom_indices", []) - scan_type = scan.get("scan_type", "bond") - x_vals = scan.get("scan_parameter_values", []) - e_min = min(energies_ha) - _HARTREE_TO_KCAL = 627.5094740631 - e_rel = [(e - e_min) * _HARTREE_TO_KCAL for e in energies_ha] - idx = [i + 1 for i in atom_indices] - if scan_type == "bond": - label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å" - elif scan_type == "angle": - label = ( - f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °" - if len(idx) >= 3 - else "Angle / °" - ) - else: - label = ( - f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °" - if len(idx) >= 4 - else "Dihedral / °" - ) - result = _types_mod.SimpleNamespace( - scan_type=scan_type, - atom_indices=atom_indices, - scan_parameter_values=x_vals, - energies_hartree=energies_ha, - energies_relative_kcal=e_rel, - scan_coordinate_label=label, - converged_all=True, - ) - return self._show_pes_scan_result(result) + return _ana_pop_pes_plot(self, ctx) def _pop_pes_trajectory(self, ctx: _AnalysisContext) -> bool: - traj: list = [] - energies: list = [] - if ctx.live_result is not None: - traj = list(getattr(ctx.live_result, "coordinates_list", [])) - 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( - coordinates_list=traj, - energies_hartree=energies, - trajectory=None, - formula=ctx.formula, - ) - self._pending_traj_result = stub - self.traj_accordion.set_title(0, "Geometry at Each Scan Point") - return True + return _ana_pop_pes_trajectory(self, ctx) # ── History panel (Cell 8) ──────────────────────────────────────────── def _build_history_section(self) -> None: - self.past_dd = widgets.Dropdown( - description="Load:", - options=[("(no saved results)", "")], - style={"description_width": "50px"}, - layout=_layout(width="500px"), - ) - self.past_refresh_btn = widgets.Button( - description="Refresh", - button_style="", - icon="refresh", - layout=_layout(width="100px"), - tooltip="Rescan the results directory", - ) - self.copy_path_btn = widgets.Button( - description="Copy path", - button_style="", - icon="clipboard", - layout=_layout(width="120px"), - tooltip="Copy the results directory path to clipboard", - ) - self.results_path_lbl = widgets.HTML() - self.past_output = widgets.Output() - self.view_log_btn = widgets.Button( - description="View log", - button_style="", - icon="file-text-o", - layout=_layout(width="110px"), - tooltip="Open the full PySCF output log in the Output tab", - ) - - # Calibration widgets - self._cal_mode_toggle = widgets.ToggleButtons( - options=[("Quick (~10 s)", "short"), ("Full (~5 min)", "long")], - value="short", - description="", - button_style="", - style={"description_width": "0px", "button_width": "140px"}, - layout=_layout(margin="0 0 8px"), - ) - self._cal_run_btn = widgets.Button( - description="Run Calibration", - button_style="primary", - icon="play", - disabled=not _PYSCF_AVAILABLE, - tooltip=( - "Run the benchmark suite to calibrate time estimates" - if _PYSCF_AVAILABLE - else "PySCF required (Linux / macOS / WSL)" - ), - layout=_layout(width="180px"), - ) - self._cal_stop_btn = widgets.Button( - description="Stop", - button_style="warning", - icon="stop", - layout=_layout(width="90px", display="none"), - ) - self._cal_progress = widgets.IntProgress( - min=0, - max=len(_BENCHMARK_SUITE), - value=0, - description="", - bar_style="info", - layout=_layout(width="300px", display="none"), - ) - self._cal_step_label = widgets.HTML( - value="", - layout=_layout(display="none"), - ) - self._cal_results_html = widgets.HTML(value="") - - # Performance stats widgets - self._perf_stats_html = widgets.HTML() - self._perf_events_html = widgets.HTML() - self._reset_btn = widgets.Button( - description="Reset performance database", - button_style="danger", - icon="trash", - layout=_layout(width="230px"), - ) - self._reset_confirm_html = widgets.HTML( - '' - "Warning: This will permanently delete all performance records. " - "Time estimates will reset to “no data”." - ) - self._reset_confirm_yes = widgets.Button( - description="Yes, delete all records", - button_style="danger", - icon="check", - layout=_layout(width="190px"), - ) - self._reset_confirm_no = widgets.Button( - description="Cancel", - button_style="", - icon="times", - layout=_layout(width="90px"), + _bld_build_history_section( + self, + layout_fn=_layout, + pyscf_available=_PYSCF_AVAILABLE, + benchmark_suite=_BENCHMARK_SUITE, + benchmark_suite_long=_BENCHMARK_SUITE_LONG, + load_last_calibration_label_fn=_load_last_calibration_label, ) - self._reset_confirm_box = widgets.VBox( - [ - self._reset_confirm_html, - widgets.HBox( - [self._reset_confirm_yes, self._reset_confirm_no], - layout=_layout(gap="8px", margin="4px 0 0"), - ), - ], - layout=_layout( - display="none", - border="1px solid #fca5a5", - padding="8px 10px", - margin="6px 0 0", - ), - ) - - _perf_stats_panel = widgets.VBox( - [ - self._perf_stats_html, - widgets.HTML( - '

    ' - "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( - '

    Compare Calculations

    ' - '

    ' - "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).

    " - ) - _export_content = widgets.VBox( - [ - widgets.HTML( - '

    ' - "Download a self-contained PySCF script you can study or run outside the notebook.

    " - ), - widgets.HBox([self.export_btn, self.export_status]), - widgets.HTML('
    '), - 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.

    " - ), - self._issue_textarea, - widgets.HBox( - [self._issue_submit_btn, self._issue_cancel_btn], - layout=_layout(margin="6px 0 0", gap="8px"), - ), - self._issue_status_html, - ], - layout=_layout( - display="none", - border="1px solid #f59e0b", - border_radius="6px", - padding="12px 14px", - margin="0 0 6px", - background_color="#fffbeb", - ), - ) + _bld_build_issue_widgets(self, layout_fn=_layout) # ── Tab assembly (Cell 10) ──────────────────────────────────────────── @@ -2443,6 +1314,7 @@ def _assemble_tabs(self) -> None: self.history_panel, self.compare_panel, self.log_tab_panel, + self.files_tab_panel, self._status_tab_panel, ] ) @@ -2452,7 +1324,11 @@ def _assemble_tabs(self) -> None: self.root_tab.set_title(3, "History") self.root_tab.set_title(4, "Compare") self.root_tab.set_title(5, "Log") - self.root_tab.set_title(6, "Status") + self.root_tab.set_title(6, "Files") + self.root_tab.set_title(7, "Status") + self.root_tab.observe( + self._safe_cb(self._on_root_tab_changed), names="selected_index" + ) # ══ CALLBACK WIRING ══════════════════════════════════════════════════════ @@ -2501,6 +1377,22 @@ def _wire_callbacks(self) -> None: # Run self.run_btn.on_click(self._on_run_clicked) self.log_clear_btn.on_click(self._on_clear_log) + 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" + ) + self._uv_mode_toggle.observe( + self._safe_cb(self._on_uv_mode_changed), names="value" + ) + self._uv_fwhm_slider.observe( + self._safe_cb(self._on_uv_fwhm_changed), names="value" + ) + self._ir_export_btn.on_click(self._on_ir_export_plot) + self._uv_export_btn.on_click(self._on_uv_export_plot) + self._orb_export_btn.on_click(self._on_orb_export_plot) + self._pes_export_btn.on_click(self._on_pes_export_plot) # Accumulate / export self.accumulate_btn.on_click(self._on_accumulate) self.clear_btn.on_click(self._on_clear) @@ -2531,6 +1423,16 @@ def _wire_callbacks(self) -> None: # Clear log cache (event_log.jsonl) self._clear_log_cache_btn.on_click(self._on_clear_log_cache) self._clear_log_cache_confirm_btn.on_click(self._on_clear_log_cache_confirm) + # Files tab + self._files_root_dd.observe( + self._safe_cb(self._on_files_root_changed), names="value" + ) + self._files_entries.observe( + self._safe_cb(self._on_files_entry_changed), names="value" + ) + self._files_open_btn.on_click(self._on_files_open) + self._files_up_btn.on_click(self._on_files_up) + self._files_refresh_btn.on_click(self._on_files_refresh) # Issue reporting self._issue_btn.on_click(self._on_issue_btn) self._issue_submit_btn.on_click(self._on_issue_submit) @@ -2543,15 +1445,9 @@ def _wire_callbacks(self) -> None: self._safe_cb(self._on_help_topic_changed), names="value" ) # Tab navigation buttons - self._go_results_btn.on_click( - lambda _: setattr(self.root_tab, "selected_index", 1) - ) - self._go_analysis_btn.on_click( - lambda _: setattr(self.root_tab, "selected_index", 2) - ) - self._to_analysis_btn.on_click( - lambda _: setattr(self.root_tab, "selected_index", 2) - ) + self._go_results_btn.on_click(self._go_to_results_tab) + self._go_analysis_btn.on_click(self._go_to_analysis_tab) + self._to_analysis_btn.on_click(self._go_to_analysis_tab) # Vibrational mode selector self.vib_mode_dd.observe( self._safe_cb(self._on_vib_mode_changed), names="value" @@ -2569,27 +1465,393 @@ def _wire_callbacks(self) -> None: # Orbital isosurface generate button self._iso_generate_btn.on_click(self._on_iso_generate) - # ══ CALLBACK METHODS ═════════════════════════════════════════════════════ + # ── Files tab ──────────────────────────────────────────────────────── - # ── Theme ───────────────────────────────────────────────────────────── + def _files_allowed_roots(self) -> list[Path]: + """Return the approved filesystem roots for the Files tab.""" + roots: list[Path] = [] + candidates: list[Optional[Path]] = [self._get_results_dir(), Path.cwd()] + _last_dir = getattr(self, "_last_result_dir", None) + if isinstance(_last_dir, Path): + candidates.append(_last_dir) - 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() + for candidate in candidates: + if candidate is None: + continue + try: + resolved = candidate.resolve() + except OSError: + continue + if resolved not in roots: + roots.append(resolved) - def _plotly_theme_colors(self) -> dict: - """Return plot colors tuned for the current theme. + return roots - 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. - """ + def _is_path_in_allowed_roots(self, path: Path, roots: list[Path]) -> bool: + """True when *path* is inside any configured Files-tab root.""" + try: + resolved = path.resolve() + except OSError: + return False + for root in roots: + try: + resolved.relative_to(root) + return True + except ValueError: + continue + return False + + def _format_file_size(self, size_bytes: int) -> str: + """Return a compact human-readable size label.""" + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024 * 1024):.1f} MB" + + def _set_files_status(self, message: str, color: str = "#64748b") -> None: + """Update Files tab status text.""" + self._files_status_html.value = ( + f'' + f"{_html.escape(message)}" + ) + + def _format_files_root_label(self, root: Path) -> str: + """Return a readable dropdown label for a root path.""" + labels: list[tuple[str, Path]] = [] + try: + labels.append(("Results", self._get_results_dir().resolve())) + except OSError: + pass + try: + labels.append(("Workspace CWD", Path.cwd().resolve())) + except OSError: + pass + _last_dir = getattr(self, "_last_result_dir", None) + if isinstance(_last_dir, Path): + try: + labels.append(("Current Result", _last_dir.resolve())) + except OSError: + pass + + for prefix, known_root in labels: + if root == known_root: + return f"{prefix} ({root})" + return str(root) + + def _refresh_file_browser(self) -> None: + """Refresh root options and the current directory listing.""" + roots = self._files_allowed_roots() + try: + results_root = self._get_results_dir().resolve() + except OSError: + results_root = None + for root in roots: + if results_root is not None and root == results_root: + try: + root.mkdir(parents=True, exist_ok=True) + except OSError: + pass + + if not roots: + self._files_updating = True + try: + self._files_root_dd.options = [("(no roots)", "")] + self._files_root_dd.value = "" + self._files_entries.options = [("(no files)", "")] + self._files_entries.value = "" + finally: + self._files_updating = False + self._files_current_dir = None + self._files_selected_path = None + self._files_path_html.value = ( + '' + "Current folder: unavailable" + ) + self._files_open_btn.disabled = True + self._files_up_btn.disabled = True + self._set_files_status("No readable roots available.", "#b91c1c") + self._files_preview_output.clear_output(wait=True) + return + + old_root_value = str(self._files_root_dd.value or "") + root_options = [ + (self._format_files_root_label(root), str(root)) for root in roots + ] + valid_root_values = {value for _, value in root_options} + selected_root = old_root_value if old_root_value in valid_root_values else "" + if not selected_root: + selected_root = root_options[0][1] + + self._files_updating = True + try: + self._files_root_dd.options = root_options + self._files_root_dd.value = selected_root + finally: + self._files_updating = False + + selected_root_path = Path(selected_root) + if ( + self._files_current_dir is None + or not self._is_path_in_allowed_roots(self._files_current_dir, roots) + or not self._files_current_dir.exists() + or not self._files_current_dir.is_dir() + ): + self._files_current_dir = selected_root_path + + self._update_files_entries() + self._set_files_status("File list refreshed.") + + def _update_files_entries(self) -> None: + """Rebuild the directory listing for the current folder.""" + roots = self._files_allowed_roots() + if not roots: + self._files_entries.options = [("(no files)", "")] + self._files_entries.value = "" + self._files_selected_path = None + self._files_open_btn.disabled = True + self._files_up_btn.disabled = True + self._files_preview_output.clear_output(wait=True) + return + + current = self._files_current_dir or roots[0] + if not self._is_path_in_allowed_roots(current, roots): + current = Path(self._files_root_dd.value) + if not current.exists() or not current.is_dir(): + current = Path(self._files_root_dd.value) + + self._files_current_dir = current + self._files_path_html.value = ( + 'Current folder: ' + f"{_html.escape(str(current))}" + ) + + try: + children = list(current.iterdir()) + except OSError as exc: + self._files_entries.options = [("(unreadable folder)", "")] + self._files_entries.value = "" + self._files_selected_path = None + self._files_open_btn.disabled = True + self._files_up_btn.disabled = True + self._files_preview_output.clear_output(wait=True) + self._set_files_status(f"Cannot list folder: {exc}", "#b91c1c") + return + + children.sort(key=lambda p: (not p.is_dir(), p.name.lower())) + options: list[tuple[str, str]] = [] + for child in children: + if child.is_dir(): + options.append((f"[DIR] {child.name}", str(child))) + continue + try: + size_label = self._format_file_size(child.stat().st_size) + except OSError: + size_label = "?" + options.append((f"{child.name} ({size_label})", str(child))) + + if not options: + options = [("(empty directory)", "")] + + old_selection = str(self._files_entries.value or "") + valid_values = {value for _, value in options if value} + new_selection = old_selection if old_selection in valid_values else "" + if not new_selection and valid_values: + new_selection = next(iter(valid_values)) + + self._files_updating = True + try: + self._files_entries.options = options + self._files_entries.value = new_selection + finally: + self._files_updating = False + + self._files_selected_path = Path(new_selection) if new_selection else None + self._files_open_btn.disabled = self._files_selected_path is None + + _parent = current.parent + self._files_up_btn.disabled = ( + _parent == current or not self._is_path_in_allowed_roots(_parent, roots) + ) + + self._files_preview_output.clear_output(wait=True) + + def _preview_file_path(self, path: Path) -> None: + """Render a safe preview for a selected file path.""" + roots = self._files_allowed_roots() + if not self._is_path_in_allowed_roots(path, roots): + self._set_files_status("Selected path is outside allowed roots.", "#b91c1c") + return + if not path.exists() or not path.is_file(): + self._set_files_status("Selected file no longer exists.", "#b91c1c") + return + + self._files_preview_output.clear_output(wait=True) + suffix = path.suffix.lower() + + image_ext = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + text_ext = { + ".txt", + ".log", + ".json", + ".md", + ".py", + ".csv", + ".yaml", + ".yml", + ".xyz", + ".cube", + } + + if suffix in image_ext: + from IPython.display import Image as _Image + + with self._files_preview_output: + display(_Image(filename=str(path))) + self._set_files_status(f"Previewing image: {path.name}") + return + + is_text = suffix in text_ext + if not is_text: + try: + sample = path.read_bytes()[:512] + except OSError as exc: + self._set_files_status(f"Cannot read file: {exc}", "#b91c1c") + return + is_text = b"\x00" not in sample + + if not is_text: + with self._files_preview_output: + display( + HTML( + "

    " + "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"" - f'{s.label}' - f'' - f"{s.n_electrons}" - f'' - f"{s.n_basis if s.n_basis is not None else '—'}" - f'' - f"{s.elapsed_s:.2f} s" - f'' - f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}' - f"" - f"" - for s in result.steps - ) - _summary = f"Completed {result.n_completed} / {result.n_total} steps." + ( - " (stopped early)" if result.stopped_early else "" - ) - self._cal_results_html.value = ( - f'
    ' - f'

    {_summary}

    ' - f'' - f"" - f'' - f'' - f'' - f'' - f'' - f"" - f"{_rows}
    Calculatione⁻Basis fnsWall timeStatus
    " - ) - - self._cal_step_label.value = ( - 'Calibration complete. ' - "Time estimates are now active." - if result.n_completed > 0 - else 'No steps completed.' - ) - self._cal_stop_btn.layout.display = "none" - self._cal_run_btn.disabled = not _PYSCF_AVAILABLE - self._cal_mode_toggle.disabled = False - self._refresh_perf_stats() + _run_do_calibration(self, pyscf_available=_PYSCF_AVAILABLE) # ── Output log ──────────────────────────────────────────────────────── def _on_log_clear(self, btn) -> None: - self._log_output_html.value = ( - 'Log cleared.' - ) - self._log_source_lbl.value = "" + _run_on_log_clear(self, btn) # ── Issue reporting ─────────────────────────────────────────────────── def _on_issue_btn(self, _=None) -> None: - """Show the issue report overlay.""" - self._issue_textarea.value = "" - self._issue_status_html.value = "" - self._issue_overlay.layout.display = "" + _run_on_issue_btn(self, _) def _on_issue_cancel(self, _=None) -> None: - self._issue_overlay.layout.display = "none" + _run_on_issue_cancel(self, _) def _on_issue_submit(self, _=None) -> None: - text = self._issue_textarea.value.strip() - if not text: - self._issue_status_html.value = ( - '' - "Please describe the issue before submitting." - ) - return - self._issue_submit_btn.disabled = True - try: - issue_id = _issue_tracker.log_issue( - description=text, - context=self._build_issue_context(), - session_id=self._session_id, - ) - self._issue_status_html.value = ( - f'' - f"✓ Issue #{issue_id} saved. Thank you!" - ) - self._issue_overlay.layout.display = "none" - except Exception as exc: - self._issue_status_html.value = ( - f'Save failed: {exc}' - ) - finally: - self._issue_submit_btn.disabled = False + _run_on_issue_submit(self, issue_tracker_mod=_issue_tracker) def _build_issue_context(self) -> dict: """Snapshot the current app state to attach to an issue report.""" @@ -3625,65 +2414,23 @@ def _build_issue_context(self) -> dict: # ── Clear log cache ─────────────────────────────────────────────────── def _on_clear_log_cache(self, _=None) -> None: - """First click: reveal the confirmation button.""" - self._clear_log_cache_confirm_btn.layout.display = "" - self._clear_log_cache_btn.disabled = True + _run_on_clear_log_cache(self, _) def _on_clear_log_cache_confirm(self, _=None) -> None: - """Second click: clear event_log.jsonl and reset the UI.""" - try: - _calc_log.log_event( - "log_cleared", - "Session event log cleared by user", - session_id=self._session_id, - ) - _calc_log.clear_event_log() - except Exception: - pass - self._clear_log_cache_confirm_btn.layout.display = "none" - self._clear_log_cache_btn.disabled = False + _run_on_clear_log_cache_confirm(self, calc_log_mod=_calc_log) # ── Exit ────────────────────────────────────────────────────────────── def _on_exit_clicked(self, _=None) -> None: - self._exit_btn.description = "Exiting…" - self._exit_btn.disabled = True - self._welcome_html.value = ( - '
    ' - '' - '' - '' - '' - "" - '
    ' - "QuantUI has shut down. You may close this tab.
    " - "
    " - ) - - def _do_exit() -> None: - import signal - import time - - time.sleep(0.6) - try: - # Signal the Voilà/Jupyter server process (our parent) to exit cleanly. - os.kill(os.getppid(), signal.SIGTERM) - except Exception: - pass - # Terminate the kernel process regardless. - os._exit(0) - - threading.Thread(target=_do_exit, daemon=True).start() + _run_on_exit_clicked(self, _) # ── Help ────────────────────────────────────────────────────────────── def _on_help_toggle(self, _=None) -> None: - visible = self.help_tab_panel.layout.display != "none" - self.help_tab_panel.layout.display = "none" if visible else "" + _run_on_help_toggle(self, _) def _on_help_topic_changed(self, change=None) -> None: - self._render_help_topic() + _run_on_help_topic_changed(self, change) # ══ LOGIC METHODS ════════════════════════════════════════════════════════ @@ -3773,8 +2520,7 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None: callback(*args, **kwargs) return - ip = get_ipython() - io_loop = getattr(getattr(ip, "kernel", None), "io_loop", None) + io_loop = self._get_kernel_io_loop() if io_loop is not None: io_loop.add_callback(callback, *args, **kwargs) return @@ -3784,1089 +2530,248 @@ def _queue_main_thread_callback(self, callback, *args, **kwargs) -> None: # notebook path above keeps rendering off the worker thread. callback(*args, **kwargs) - def _set_molecule_state_only(self, mol) -> None: - """Apply only thread-safe molecule state updates.""" - self._molecule = mol + def _install_run_output_scroll_guard(self) -> None: + """Install a JS guard that preserves live-log scroll behavior. - 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 - - self._set_molecule_state_only(mol) - self._queue_main_thread_callback(self._set_molecule, mol, status_message) - - def _show_result_3d(self, molecule, extra_output=None) -> None: - """Render molecule 3D structure in the result visualization panel. - - Renders into ``result_viz_output`` and, if supplied, into *extra_output* - as well (used to mirror the structure into the Analysis tab viewer). - Safe to call from a background thread — uses ``with output:`` context. + The Output widget can reset scroll position during high-frequency + append_stdout updates in notebook/Voila frontends. This observer keeps + the log pinned to the bottom while the user is already at the bottom, + and preserves manual scrolling when the user scrolls up. """ - if _display_molecule is None or molecule is None: + if self._run_output_scroll_guard_installed: return - for _out in [self.result_viz_output, extra_output]: - if _out is None: - continue - _out.clear_output() - with _out: - _display_molecule( - molecule, - backend=self._viz_backend, - style=self._viz_style, - lighting=self._viz_lighting, - bgcolor=self._plotly_theme_colors()["scene_bgcolor"], - ) - - def _show_result_log(self, saved_dir: Path, log_text: str) -> None: - """Populate the result-directory label and output-log accordion. - - Safe to call from a background thread. - """ - # Path label - self._result_dir_label.value = ( - f'' - f"Saved to: {saved_dir}" - ) - self._result_dir_label.layout.display = "" - # Log accordion — prefer on-disk file (written by save_result) over in-memory string - import html as _html_mod - - _log_path = saved_dir / "pyscf.log" - try: - log_content = _log_path.read_text(encoding="utf-8", errors="replace") - except OSError: - log_content = log_text - - if not log_content.strip(): - log_content = "(No output captured for this calculation.)" - - self._result_log_output.clear_output() - with self._result_log_output: - display( - HTML( - 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'' - ), - ) - except Exception: - pass - - if ( - 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 - ): - self._orb_iso_output.clear_output() - self._orb_toggle.value = "HOMO" - self._orb_iso_controls.layout.display = "" - self._iso_generate_btn.disabled = False - else: - self._orb_iso_controls.layout.display = "none" - self._iso_generate_btn.disabled = True + # Log accordion — prefer on-disk file (written by save_result) over in-memory string + import html as _html_mod - return True + _log_path = saved_dir / "pyscf.log" + try: + log_content = _log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + log_content = log_text - def _on_iso_generate(self, btn) -> None: - """Generate an orbital isosurface for the currently selected orbital.""" - orbital_label = self._orb_toggle.value - btn.disabled = True - btn.description = "Generating…" - self._orb_iso_output.clear_output() - with self._orb_iso_output: + if not log_content.strip(): + log_content = "(No output captured for this calculation.)" + + self._result_log_output.clear_output() + with self._result_log_output: display( 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", "

    ") - ) - with self.notes_output: - display( - HTML( - '
    ' - + safe - + "
    " - ) - ) - except Exception: - pass + _run_update_notes(self, change) def _update_estimate(self, change=None) -> None: - if self._molecule is None: - self.perf_estimate_html.value = "" - return - try: - n_basis = _calc_log.count_basis_functions( - self._molecule.atoms, self.basis_dd.value - ) - est = _calc_log.estimate_time( - n_atoms=len(self._molecule.atoms), - n_electrons=self._molecule.get_electron_count(), - method=self.method_dd.value, - basis=self.basis_dd.value, - n_basis=n_basis, - ) - self.perf_estimate_html.value = _calc_log.format_estimate(est) - except Exception: - self.perf_estimate_html.value = "" + _run_update_estimate(self, calc_log_mod=_calc_log, change=change) def _refresh_results_browser(self) -> None: - try: - from quantui import list_results, load_result - except ImportError: - return - self.results_path_lbl.value = ( - f'' - f"{self._get_results_dir()}" - ) - dirs = list_results() - if not dirs: - self.past_dd.options = [("(no saved results)", "")] - return - options = [] - for d in dirs: - try: - data = load_result(d) - ts = data.get("timestamp", d.name) - label = f"{ts} · {data['formula']} {data['method']}/{data['basis']}" - options.append((label, str(d))) - except Exception: - pass - self.past_dd.options = options if options else [("(no saved results)", "")] - # Keep frequency seed dropdown in sync if it's currently visible. - if self.calc_type_dd.value == "Frequency": - self._refresh_freq_seed_options() + _run_refresh_results_browser(self) def _refresh_comparison(self) -> None: - from quantui import comparison_table_html, summary_from_session_result - - self.comparison_output.clear_output(wait=True) - if not self._results: - return - summaries = [summary_from_session_result(r) for r in self._results] - with self.comparison_output: - display(HTML(comparison_table_html(summaries))) - if len(summaries) > 1: - try: - from quantui import plot_comparison - - plot_comparison(summaries) - except Exception: - pass + _run_refresh_comparison(self) def _populate_compare_list(self) -> None: - from quantui.results_storage import list_results, load_result - - dirs = list_results() - if not dirs: - self.compare_select.options = [("(no saved results)", "")] - self.compare_btn.disabled = True - return - options = [] - for d in dirs: - try: - data = load_result(d) - ts = data.get("timestamp", d.name[:19]) - label = f"{ts} {data['formula']} {data['method']}/{data['basis']}" - options.append((label, str(d))) - except Exception: - options.append((d.name, str(d))) - self.compare_select.options = options - self.compare_btn.disabled = False + _run_populate_compare_list(self) def _show_help_topic(self, topic: str) -> None: if topic in HELP_TOPICS: @@ -5497,6 +3478,10 @@ def _safe_cb(self, fn): """Wrap an .observe() handler so exceptions are logged instead of silently dropped.""" def _wrapper(change): + self._activity_begin( + f"Running {getattr(fn, '__name__', 'callback')}...", + kind="ui", + ) try: fn(change) except Exception as _e: @@ -5512,6 +3497,8 @@ def _wrapper(change): ) except Exception: pass + finally: + self._activity_end(kind="ui") return _wrapper @@ -5717,433 +3704,28 @@ def _build_events_html(self) -> str: # ══ RESULT FORMATTERS ════════════════════════════════════════════════════ def _format_result(self, r) -> str: - _conv = "Yes" if r.converged else "No (treat results with caution)" - _cc = "green" if r.converged else "#c00" - _gap = ( - f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A" - ) - _rows = "".join( - f"" - f'{k}' - f'{v}' - f"" - for k, v, vc in [ - ( - "Total energy", - f"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)", - "#000", - ), - ("HOMO-LUMO gap", _gap, "#000"), - ("SCF converged", _conv, _cc), - ( - "SCF iterations", - ( - "—" - if getattr(r, "n_iterations", None) in (None, -1) - else str(r.n_iterations) - ), - "#000", - ), - ] - ) - _extra = "" - # MP2: show HF reference energy separately - _mp2_corr = getattr(r, "mp2_correlation_hartree", None) - if _mp2_corr is not None: - _hf_e = r.energy_hartree - _mp2_corr - _extra += ( - f'HF reference' - f'{_hf_e:.8f} Ha' - f'MP2 correlation' - f'{_mp2_corr:.8f} Ha' - ) - _solvent = getattr(r, "solvent", None) - if _solvent is not None: - _extra += ( - f'Solvent (PCM)' - f'{_solvent}' - ) - _dip = getattr(r, "dipole_moment_debye", None) - if _dip is not None: - _extra += ( - f'Dipole moment' - f'{_dip:.4f} D' - ) - _chg = getattr(r, "mulliken_charges", None) - _syms = getattr(r, "atom_symbols", None) - if _chg is not None and _syms is not None: - _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg)) - _extra += ( - f'' - f"Mulliken charges" - f'{_charge_str}' - ) - return ( - f'
    ' - f"{r.formula} — {r.method}/{r.basis}" - f'' - f"{_rows}{_extra}
    " - ) + return _fmt_result(r) def _format_opt_result(self, r) -> str: - _conv = "Yes" if r.converged else "No (max steps reached)" - _cc = "green" if r.converged else "#c00" - _rows = "".join( - f"" - f'{k}' - f'{v}' - f"" - for k, v, vc in [ - ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"), - ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"), - ("Opt converged", _conv, _cc), - ("Steps taken", str(r.n_steps), "#000"), - ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"), - ] - ) - return ( - f'
    ' - f"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})" - f'' - f"{_rows}
    " - ) + return _fmt_opt_result(r) def _format_freq_result(self, r) -> str: - _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" - n_real = r.n_real_modes() - n_imag = r.n_imaginary_modes() - real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6] - freq_str = " ".join(f"{f:.1f}" for f in real_freqs) - if len([f for f in r.frequencies_cm1 if f > 0]) > 6: - freq_str += " …" - imag_note = "" - if n_imag > 0: - imag_note = ( - f'Imaginary modes' - f'{n_imag} — geometry may not be a minimum' - ) - _rows = ( - f'SCF energy' - f'{r.energy_hartree:.8f} Ha' - f'SCF converged' - f'{_conv}' - f'Real modes' - f'{n_real}' - + imag_note - + ( - f'Frequencies (cm⁻¹)' - f'{freq_str or "none"}' - if real_freqs - else "" - ) - + f'ZPVE' - f'{r.zpve_hartree:.6f} Ha ' - f"({r.zpve_hartree * 27.211386245988:.4f} eV)" - ) - _thermo_rows = "" - _thermo = getattr(r, "thermo", None) - if _thermo is not None: - _kj = 2625.5 # kJ/mol per Hartree - _thermo_rows = ( - f'' - f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —" - f"" - f'H (298 K)' - f'{_thermo.H_hartree:.6f} Ha' - f'S (298 K)' - f'{_thermo.S_jmol:.2f} J/(mol·K)' - f'G (298 K)' - f'{_thermo.G_hartree:.6f} Ha' - f" ({_thermo.G_hartree * _kj:.2f} kJ/mol)" - ) - return ( - f'
    ' - f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})" - f'' - f"{_rows}{_thermo_rows}
    " - ) + return _fmt_freq_result(r) def _format_tddft_result(self, r) -> str: - _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" - header_rows = ( - f'Ground-state energy' - f'{r.energy_hartree:.8f} Ha' - f'SCF converged' - f'{_conv}' - f'States computed' - f'{len(r.excitation_energies_ev)}' - ) - exc_table = "" - if r.excitation_energies_ev: - wl = r.wavelengths_nm() - exc_rows = [] - for i, (e_ev, f_osc) in enumerate( - zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1 - ): - bold = "font-weight:bold" if f_osc > 0.05 else "" - exc_rows.append( - f'' - f'S{i}' - f'{e_ev:.3f} eV' - f'{wl[i - 1]:.1f} nm' - f'f = {f_osc:.4f}' - f"" - ) - if len(r.excitation_energies_ev) > 8: - exc_rows.append( - f'… ' - f"and {len(r.excitation_energies_ev) - 8} more states" - ) - exc_table = ( - '' - "Vertical excitations:" - "" - 'State' - 'Energy' - 'λ' - 'Osc. str.' - + "".join(exc_rows) - ) - return ( - f'
    ' - f"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})" - f'' - f"{header_rows}{exc_table}
    " - ) + return _fmt_tddft_result(r) def _format_nmr_result(self, r) -> str: - _conv = "Yes" if r.converged else "No (treat with caution)" - _cc = "green" if r.converged else "#c00" - header_rows = ( - f'SCF converged' - f'{_conv}' - f'Reference' - f'{r.reference_compound} ({r.method}/{r.basis})' - ) - - def _nmr_table(label: str, shifts: list, sym: str) -> str: - if not shifts: - return "" - rows = "".join( - f"" - f'{sym}-{n}' - f'{d:.2f} ppm' - f"" - for n, (_i, d) in enumerate(shifts, 1) - ) - return ( - f'' - f"{label} shifts (vs. TMS):" - f"" - f'Atom' - f'δ (ppm)' - + rows - ) - - h_table = _nmr_table("¹H", r.h_shifts(), "H") - c_table = _nmr_table("¹³C", r.c_shifts(), "C") - - _basis_warn = "" - if r.basis.upper() in ("STO-3G", "3-21G"): - _basis_warn = ( - '' - '' - f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better." - "" - ) - - _empty = "" - if not r.h_shifts() and not r.c_shifts(): - _empty = ( - '' - "No ¹H or ¹³C atoms found in this molecule." - ) - - return ( - f'
    ' - f"NMR Shielding — {r.formula} ({r.method}/{r.basis})" - f'' - f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}
    " - ) + return _fmt_nmr_result(r) def _format_pes_scan_result(self, r) -> str: - """Format a PESScanResult as an HTML result card.""" - _conv = "Yes" if r.converged_all else "No (some points did not converge)" - _cc = "green" if r.converged_all else "#c00" - if r.energies_hartree: - e_min = min(r.energies_hartree) - e_max = max(r.energies_hartree) - barrier_kcal = (e_max - e_min) * 627.509474 - _e_row = ( - f'Min energy' - f'{e_min:.8f} Ha' - f'Energy range' - f'{barrier_kcal:.2f} kcal/mol' - ) - else: - _e_row = "" - _idx_str = "–".join(str(i + 1) for i in r.atom_indices) - return ( - f'
    ' - f"PES Scan — {r.formula} ({r.method}/{r.basis})" - f'' - f'' - f'' - f'' - f'" - f"{_e_row}" - f'' - f'' - f"
    Scan type{r.scan_type.capitalize()} ({_idx_str})
    Range{r.scan_parameter_values[0]:.3f} → ' - f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " - f"({r.n_steps} points)
    All converged{_conv}
    " - ) + return _fmt_pes_scan_result(r) def _show_pes_scan_result(self, result) -> bool: - """Render the PES energy profile chart. - - Returns True if the chart was rendered, False if plotly is unavailable. - Does NOT call ``_activate_ana_panel`` or set up trajectory; those are - handled by ``_pop_pes_plot`` and ``_pop_pes_trajectory`` in the registry. - """ - self._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}
    " - f"ΔE = {de:.3f} kcal/mol
    " - f"E = {e:.8f} Ha" - for x, de, e in zip(x_vals, e_rel, result.energies_hartree) - ] - - fig = go.Figure( - go.Scatter( - x=x_vals, - y=e_rel, - mode="lines+markers", - line=dict(color="#2563eb", width=2), - marker=dict(size=8, color="#2563eb"), - hovertext=hover_text, - hoverinfo="text", - ) - ) - tc = self._plotly_theme_colors() - fig.update_layout( - xaxis_title=result.scan_coordinate_label, - yaxis_title="Relative energy / kcal mol⁻¹", - height=380, - 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"]), - hovermode="closest", - ) - self._set_html_output( - self._pes_plot_html, - pio.to_html( - fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, - ), - ) - except Exception: - pass - - return True + return _viz_show_pes_scan_result(self, result) def _format_past_result(self, data: dict, result_dir: Optional[Path] = None) -> str: - import base64 as _b64 - - _ct_labels = { - "single_point": ("Single Point", "#2563eb", "#dbeafe"), - "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"), - "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"), - "tddft": ("TD-DFT", "#b45309", "#fef3c7"), - "nmr": ("NMR", "#0d9488", "#ccfbf1"), - "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"), - } - ct = data.get("calc_type", "") - _ct_label, _ct_fg, _ct_bg = _ct_labels.get( - ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6") - ) - _ct_badge = ( - f'{_ct_label}' - ) - _conv = "Yes" if data.get("converged") else "No (treat results with caution)" - _cc = "green" if data.get("converged") else "#c00" - _gap = ( - f"{data['homo_lumo_gap_ev']:.4f} eV" - if data.get("homo_lumo_gap_ev") is not None - else "N/A" - ) - _rows = "".join( - f"" - f'{k}' - f'{v}' - f"" - for k, v, vc in [ - ( - "Total energy", - f"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)", - "#000", - ), - ("HOMO-LUMO gap", _gap, "#000"), - ("SCF converged", _conv, _cc), - ( - "SCF iterations", - ( - "—" - if data.get("n_iterations") in (None, -1) - else str(data.get("n_iterations")) - ), - "#000", - ), - ] - ) - ts = data.get("timestamp", "") - - # Embed thumbnail if saved - _thumb_html = "" - if result_dir is not None: - _thumb_path = Path(result_dir) / "thumbnail.png" - if _thumb_path.exists(): - _img_b64 = _b64.b64encode(_thumb_path.read_bytes()).decode() - _thumb_html = ( - f'' - ) - - return ( - f'
    ' - f"{_thumb_html}" - f"{_ct_badge}
    " - f'{data["formula"]} — {data["method"]}/{data["basis"]}' - f' {ts}' - f'' - f"{_rows}
    " - ) + return _fmt_past_result(data, result_dir=result_dir) # ══ HELPERS ══════════════════════════════════════════════════════════════ diff --git a/quantui/app_analysis.py b/quantui/app_analysis.py new file mode 100644 index 0000000..363ba7a --- /dev/null +++ b/quantui/app_analysis.py @@ -0,0 +1,586 @@ +"""Analysis panel state and population helpers used by QuantUIApp.""" + +from __future__ import annotations + +import html as _html_mod +import types as _types_mod +from typing import Any + +import ipywidgets as widgets + +_PANEL_UNAVAILABLE_STYLE = ( + "padding:12px 16px;color:#6b7280;font-size:13px;font-style:italic" +) + +_CALC_TYPE_LABELS = { + "single_point": "Single Point", + "geometry_opt": "Geometry Opt", + "frequency": "Frequency", + "tddft": "UV-Vis (TD-DFT)", + "nmr": "NMR Shielding", + "pes_scan": "PES Scan", +} + +_CALC_TYPE_BADGES = { + "single_point": "Single Point", + "geometry_opt": "Geometry Optimization", + "frequency": "Frequency Analysis", + "tddft": "UV-Vis (TD-DFT)", + "nmr": "NMR Shielding", + "pes_scan": "PES Scan", +} + + +def _panel_unavailable_html(message: str) -> str: + return f'
    {_html_mod.escape(message)}
    ' + + +def _set_panel_unavailable_message(app: Any, panel_name: str, message: str) -> None: + panel = app._ana_unavail_msgs.get(panel_name) + if panel is not None: + panel.value = _panel_unavailable_html(message) + + +def _reset_unavailable_messages_for_context(app: Any, ctx: Any) -> None: + expected_panels = { + panel_name + for panel_name, _method_name, _auto in app._PANEL_REGISTRY.get( + ctx.calc_type, [] + ) + } + calc_label = _CALC_TYPE_LABELS.get( + ctx.calc_type, + str(ctx.calc_type).replace("_", " ").title(), + ) + for panel_name in app._ana_panel_names: + if panel_name in expected_panels: + _set_panel_unavailable_message( + app, + panel_name, + ( + f"Not available for this {calc_label} result: " + "required data is missing or could not be loaded." + ), + ) + continue + when = app._ana_when_map.get(panel_name, "relevant") + _set_panel_unavailable_message( + app, + panel_name, + f"Not available - run a {when} calculation first.", + ) + + +def _analysis_heading_label(ctx: Any) -> str: + """Return the analysis heading text aligned with history dropdown labels.""" + badge = _CALC_TYPE_BADGES.get(ctx.calc_type, str(ctx.calc_type or "Unknown")) + core = f"[{badge}] {ctx.label}" + ts = str(getattr(ctx, "timestamp", "") or "").strip() + return f"{ts} · {core}" if ts else core + + +def build_ana_switcher(app: Any, *, layout_fn: Any) -> None: + """Initialise analysis panel state and wire accordion re-render observers.""" + panel_meta = [ + (name, getattr(app, attr), when) for name, attr, when in app._PANEL_META + ] + app._ana_when_map = {name: when for name, _acc, when in panel_meta} + app._ana_panel_names = [m[0] for m in panel_meta] + app._ana_accordions = [m[1] for m in panel_meta] + app._ana_available = set() + app._ana_active = "" + app._ana_unavail_html = widgets.HTML( + value="", + layout=layout_fn(display="none", margin="4px 0 8px"), + ) + + # Wrap each accordion child with both an unavailable message and real content. + app._ana_unavail_msgs = {} + app._ana_content_boxes = {} + for name, acc, when in panel_meta: + unavail = widgets.HTML( + value=_panel_unavailable_html( + f"Not available - run a {when} calculation first." + ), + layout=layout_fn(display=""), + ) + content = acc.children[0] + app._ana_unavail_msgs[name] = unavail + app._ana_content_boxes[name] = content + content.layout.display = "none" + acc.children = (widgets.VBox([unavail, content]),) + acc.layout.display = "" # always in the DOM + acc.selected_index = None # collapsed until activated + + # Re-render Plotly charts when their accordion is expanded by header click. + app._ir_accordion.observe( + app._safe_cb(app._on_ir_accordion_show), names=["selected_index"] + ) + app._tddft_accordion.observe( + app._safe_cb(app._on_tddft_accordion_show), names=["selected_index"] + ) + app._orb_accordion.observe( + app._safe_cb(app._on_orb_accordion_show), names=["selected_index"] + ) + + +def select_ana_panel(app: Any, name: str) -> None: + """Expand the named panel and collapse all others.""" + app._ana_active = name + app._ana_unavail_html.layout.display = "none" + for panel_name, acc in zip(app._ana_panel_names, app._ana_accordions): + acc.selected_index = 0 if panel_name == name else None + + +def activate_ana_panel(app: Any, name: str, auto_select: bool = True) -> None: + """Mark a panel as available and reveal its content.""" + app._ana_available.add(name) + if name in app._ana_unavail_msgs: + app._ana_unavail_msgs[name].layout.display = "none" + app._ana_content_boxes[name].layout.display = "" + if auto_select: + app._select_ana_panel(name) + + +def deactivate_all_ana_panels(app: Any) -> None: + """Reset all panels to collapsed/unavailable for a new run/context.""" + app._ana_available.clear() + app._ana_active = "" + app._ana_unavail_html.layout.display = "none" + for name, acc in zip(app._ana_panel_names, app._ana_accordions): + if name in app._ana_unavail_msgs: + app._ana_unavail_msgs[name].layout.display = "" + app._ana_content_boxes[name].layout.display = "none" + acc.selected_index = None + + +def apply_analysis_context(app: Any, ctx: Any) -> None: + """Populate Analysis panels from context and activate panels with data.""" + app._deactivate_all_ana_panels() + _reset_unavailable_messages_for_context(app, ctx) + app._pending_traj_result = None + app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1 + app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1 + app.traj_accordion.set_title(0, "Trajectory Viewer") + app.traj_output.clear_output() + app._orb_iso_output.clear_output() + + first_auto_selected = False + expected_panels = { + panel_name + for panel_name, _method_name, _want_auto in app._PANEL_REGISTRY.get( + ctx.calc_type, [] + ) + } + for panel_name, method_name, want_auto in app._PANEL_REGISTRY.get( + ctx.calc_type, [] + ): + try: + ok = bool(getattr(app, method_name)(ctx)) + except Exception as panel_exc: + ok = False + try: + from quantui import calc_log as _clog + + _clog.log_event( + "ana_panel_error", + f"{method_name}: {type(panel_exc).__name__}: {panel_exc}"[:300], + ) + except Exception: + pass + if ok: + do_auto = want_auto and not first_auto_selected + app._activate_ana_panel(panel_name, auto_select=do_auto) + if do_auto: + first_auto_selected = True + + missing_expected = sorted(expected_panels - app._ana_available) + if missing_expected: + try: + from quantui import calc_log as _clog + + _clog.log_event( + "ana_expected_panel_missing", + f"{ctx.calc_type}: {', '.join(missing_expected)}"[:300], + calc_type=ctx.calc_type, + source=ctx.source, + missing_panels=missing_expected, + ) + except Exception: + pass + + source_suffix = " (from History)" if ctx.source == "history" else "" + heading = _analysis_heading_label(ctx) + app._analysis_context_lbl.value = ( + f'

    ' + 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'{sym}-{n}' + f'{d:.2f} ppm' + for n, (_i, d) in enumerate(sorted(shifts, key=lambda x: x[0]), 1) + ) + return ( + f'' + f"{label} shifts (vs. {ref}):" + f'Atom' + f'δ (ppm)' + + rows + ) + + shielding_rows = "".join( + f'{sym}{i + 1}' + f'{s:.2f}' + for i, (sym, s) in enumerate(zip(atom_symbols, shielding)) + ) + html = ( + f'
    ' + f'' + f'' + f'' + f"{shielding_rows}
    Atomσ (ppm)
    " + f'' + f"{_shift_table('¹H', h_shifts, 'H')}" + f"{_shift_table('¹³C', c_shifts, 'C')}" + f"
    " + ) + app._nmr_output.value = html + return True + + +def pop_pes_plot(app: Any, ctx: Any) -> bool: + """Populate PES plot panel from live or history scan data.""" + result = ctx.live_result + if result is None: + scan = ctx.spectra_data.get("pes_scan", {}) + if not scan or not scan.get("energies_hartree"): + return False + energies_ha = scan["energies_hartree"] + atom_indices = scan.get("atom_indices", []) + scan_type = scan.get("scan_type", "bond") + x_vals = scan.get("scan_parameter_values", []) + e_min = min(energies_ha) + hartree_to_kcal = 627.5094740631 + e_rel = [(e - e_min) * hartree_to_kcal for e in energies_ha] + idx = [i + 1 for i in atom_indices] + if scan_type == "bond": + label = f"Bond {idx[0]}–{idx[1]} / Å" if len(idx) >= 2 else "Bond / Å" + elif scan_type == "angle": + label = ( + f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °" + if len(idx) >= 3 + else "Angle / °" + ) + else: + label = ( + f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °" + if len(idx) >= 4 + else "Dihedral / °" + ) + result = _types_mod.SimpleNamespace( + scan_type=scan_type, + atom_indices=atom_indices, + scan_parameter_values=x_vals, + energies_hartree=energies_ha, + energies_relative_kcal=e_rel, + scan_coordinate_label=label, + converged_all=True, + ) + return bool(app._show_pes_scan_result(result)) + + +def pop_pes_trajectory(app: Any, ctx: Any) -> bool: + """Populate Trajectory panel from live or history PES scan data.""" + traj: list = [] + energies: list = [] + if ctx.live_result is not None: + traj = list(getattr(ctx.live_result, "coordinates_list", [])) + 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 PES Scan 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 PES Scan 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 PES Scan result: " + "trajectory data has fewer than 2 frames." + ), + ) + return False + stub = _types_mod.SimpleNamespace( + coordinates_list=traj, + energies_hartree=energies, + trajectory=None, + formula=ctx.formula, + ) + app._pending_traj_result = stub + app.traj_accordion.set_title(0, "Geometry at Each Scan Point") + return True diff --git a/quantui/app_builders.py b/quantui/app_builders.py new file mode 100644 index 0000000..f38dcec --- /dev/null +++ b/quantui/app_builders.py @@ -0,0 +1,1638 @@ +"""UI builder helpers used by QuantUIApp.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import ipywidgets as widgets +from IPython.display import HTML, display + +import quantui +from quantui.help_content import HELP_TOPICS + + +def build_status_panel( + app: Any, + *, + layout_fn: Any, + get_session_resources_fn: Any, + load_last_calibration_label_fn: Any, + pyscf_available: bool, + ase_available: bool, + pubchem_available: bool, + visualization_available: bool, +) -> None: + """Build the Status tab panel.""" + cores, mem_gb = get_session_resources_fn() + 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_fn() + + 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'{k}' + f'{v}' + for k, v in items + ) + + env_badge = ( + f'  {env}' + if env and env not in ("base", "") + else "" + ) + cal_line = ( + f'
    ' + f"Timing calibration: {cal_label}
    " + if cal_label + else '
    ' + "Timing calibration: not yet run — use the Calibrate panel in History
    " + ) + + app._status_html = widgets.HTML( + f'
    ' + f'
    ' + f"QuantUI {quantui.__version__}" + f'' + f"Python {py_ver}{env_badge}
    " + f'{rows}
    ' + f"{cal_line}" + f"
    " + ) + + steps = [ + "Select a molecule — library dropdown, XYZ paste, or PubChem search", + "Choose a method (RHF / DFT / MP2) and basis set in the Calculate tab", + "Click Run Calculation — SCF progress appears in real time", + "Explore results in the Results and Analysis tabs", + "Browse past calculations in History; compare them in Compare", + ] + steps_html = "".join( + f'
  • {s}
  • ' for s in steps + ) + guide_html = widgets.HTML( + f'
    ' + f'
    ' + f"Quick start
    " + f'
      {steps_html}
    ' + f"
    " + ) + + app._status_tab_panel = widgets.VBox( + [app._status_html, guide_html], + layout=layout_fn(padding="8px 0"), + ) + + +def build_history_section( + app: Any, + *, + layout_fn: Any, + pyscf_available: bool, + benchmark_suite: list[Any], + benchmark_suite_long: list[Any], + load_last_calibration_label_fn: Any, +) -> None: + """Build the History tab panel including calibration and perf widgets.""" + app.past_dd = widgets.Dropdown( + description="Load:", + options=[("(no saved results)", "")], + style={"description_width": "50px"}, + layout=layout_fn(width="500px"), + ) + app.past_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=layout_fn(width="100px"), + tooltip="Rescan the results directory", + ) + app.copy_path_btn = widgets.Button( + description="Copy path", + button_style="", + icon="clipboard", + layout=layout_fn(width="120px"), + tooltip="Copy the results directory path to clipboard", + ) + app.results_path_lbl = widgets.HTML() + app.past_output = widgets.Output() + app.view_log_btn = widgets.Button( + description="View log", + button_style="", + icon="file-text-o", + layout=layout_fn(width="110px"), + tooltip="Open the full PySCF output log in the Output tab", + ) + + app._cal_mode_toggle = widgets.ToggleButtons( + options=[("Quick (~10 s)", "short"), ("Full (~5 min)", "long")], + value="short", + description="", + button_style="", + style={"description_width": "0px", "button_width": "140px"}, + layout=layout_fn(margin="0 0 8px"), + ) + app._cal_run_btn = widgets.Button( + description="Run Calibration", + button_style="primary", + icon="play", + disabled=not pyscf_available, + tooltip=( + "Run the benchmark suite to calibrate time estimates" + if pyscf_available + else "PySCF required (Linux / macOS / WSL)" + ), + layout=layout_fn(width="180px"), + ) + app._cal_stop_btn = widgets.Button( + description="Stop", + button_style="warning", + icon="stop", + layout=layout_fn(width="90px", display="none"), + ) + app._cal_progress = widgets.IntProgress( + min=0, + max=len(benchmark_suite), + value=0, + description="", + bar_style="info", + layout=layout_fn(width="300px", display="none"), + ) + app._cal_step_label = widgets.HTML( + value="", + layout=layout_fn(display="none"), + ) + app._cal_results_html = widgets.HTML(value="") + + app._perf_stats_html = widgets.HTML() + app._perf_events_html = widgets.HTML() + app._reset_btn = widgets.Button( + description="Reset performance database", + button_style="danger", + icon="trash", + layout=layout_fn(width="230px"), + ) + app._reset_confirm_html = widgets.HTML( + '' + "Warning: This will permanently delete all performance records. " + "Time estimates will reset to “no data”." + ) + app._reset_confirm_yes = widgets.Button( + description="Yes, delete all records", + button_style="danger", + icon="check", + layout=layout_fn(width="190px"), + ) + app._reset_confirm_no = widgets.Button( + description="Cancel", + button_style="", + icon="times", + layout=layout_fn(width="90px"), + ) + app._reset_confirm_box = widgets.VBox( + [ + app._reset_confirm_html, + widgets.HBox( + [app._reset_confirm_yes, app._reset_confirm_no], + layout=layout_fn(gap="8px", margin="4px 0 0"), + ), + ], + layout=layout_fn( + display="none", + border="1px solid #fca5a5", + padding="8px 10px", + margin="6px 0 0", + ), + ) + + perf_stats_panel = widgets.VBox( + [ + app._perf_stats_html, + widgets.HTML( + '

    ' + "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'
    " + f"{logo_svg}" + f"
    " + f'
    QuantUI
    ' + f'
    ' + f"Quantum chemistry calculations, right on your device
    " + f'
    ' + f"v{quantui.__version__}  ·  " + f"Help tab for instructions  ·  " + f"Status tab for system info
    " + f"
    " + f"
    " + ) + app._welcome_html = widgets.HTML(value=html) + + +def build_molecule_section( + app: Any, + *, + layout_fn: Any, + molecule_library: dict[str, Any], + pubchem_available: bool, + visualization_available: bool, +) -> None: + """Build molecule input widgets and collapsed summary container.""" + preset_opts = ["(select a molecule)"] + list(molecule_library.keys()) + app.preset_dd = widgets.Dropdown( + options=preset_opts, + value="(select a molecule)", + description="Molecule:", + style={"description_width": "90px"}, + layout=layout_fn(width="320px"), + ) + + app.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_fn(width="440px", height="130px"), + ) + app.xyz_btn = widgets.Button( + description="Load XYZ", button_style="info", icon="upload" + ) + app.xyz_msg = widgets.Label() + + app.pubchem_txt = widgets.Text( + placeholder="name or SMILES (e.g. aspirin, caffeine, CC(=O)O)", + layout=layout_fn(width="380px"), + ) + app.pubchem_btn = widgets.Button( + description="Search", + button_style="info", + icon="search", + disabled=not pubchem_available, + layout=layout_fn(width="100px"), + ) + app.pubchem_msg = widgets.Label( + value=( + "" + if pubchem_available + else "PubChem unavailable — check internet connection" + ) + ) + + hint = '

    ' + 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('

    Molecule Input

    '), + input_tab, + ] + ) + app.change_mol_btn = widgets.Button( + description="Change", + button_style="", + icon="pencil", + layout=layout_fn(width="100px", height="32px"), + tooltip="Re-expand the molecule input panel", + ) + app.mol_input_collapsed = widgets.HBox( + [app.mol_summary_compact, app.change_mol_btn], + layout=layout_fn(align_items="center", gap="12px", padding="6px 0"), + ) + mol_container_children = [ + app.mol_input_expanded, + app.mol_info_html, + app.viz_output, + ] + if app.viz_backend_toggle is not None: + mol_container_children.append(app.viz_backend_toggle) + if visualization_available: + mol_container_children.append(app.viz_controls_box) + app.mol_input_container = widgets.VBox( + mol_container_children, + layout=layout_fn(margin="0 0 4px 0"), + ) + + +def build_calc_setup(app: Any, *, layout_fn: Any) -> None: + """Build the calculation setup panel.""" + app.calc_setup_panel = widgets.VBox( + [ + widgets.HTML('

    Calculation Setup

    '), + widgets.HBox( + [ + widgets.VBox( + [ + widgets.HBox( + [app.method_dd, app.method_help_btn], + layout=layout_fn(align_items="center", gap="4px"), + ), + widgets.HBox( + [app.basis_dd, app.basis_help_btn], + layout=layout_fn(align_items="center", gap="4px"), + ), + ] + ), + widgets.HTML("  "), + widgets.VBox([app.charge_si, app.mult_si]), + ] + ), + app.calc_type_dd, + app.calc_extra_opts, + app.preopt_cb, + app._freq_preopt_cb, + widgets.HBox( + [app.solvent_cb, app.solvent_dd], + layout=layout_fn(align_items="center", gap="4px"), + ), + app.notes_output, + ] + ) + + +def build_run_section(app: Any, *, layout_fn: Any) -> None: + """Build the run panel shown in the Calculate tab.""" + app.run_panel = widgets.VBox( + [ + widgets.HTML( + '

    Run Calculation

    ' + '

    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('

    Results

    '), + app.result_output, + app._viz_label, + app.result_viz_output, + app._result_dir_label, + app._to_analysis_btn, + ], + layout=layout_fn(padding="8px 0"), + ) + app.results_panel = app.results_tab_panel + + app._analysis_mol_output = widgets.Output() + + app._analysis_context_lbl = widgets.HTML( + value=( + '

    ' + "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.

    " + ), + layout=layout_fn(display="none"), + ) + app._ana_unavail_html = widgets.HTML(value="", layout=layout_fn(display="none")) + app._build_ana_switcher() + app.analysis_tab_panel = widgets.VBox( + [ + app._analysis_context_lbl, + app._analysis_mol_output, + app._analysis_empty_html, + app._ana_unavail_html, + app._orb_accordion, + app._pes_scan_accordion, + app.traj_accordion, + app.vib_accordion, + app._ir_accordion, + app._iso_accordion, + app._tddft_accordion, + app._nmr_accordion, + ], + layout=layout_fn(padding="8px 0"), + ) + app.post_calc_panel = app.analysis_tab_panel + + +def build_compare_section(app: Any, *, layout_fn: Any, rdkit_available: bool) -> None: + """Build compare tab widgets and export accordion.""" + app.compare_select = widgets.SelectMultiple( + options=[("(no saved results)", "")], + rows=8, + description="", + layout=layout_fn(width="100%"), + ) + app.compare_refresh_btn = widgets.Button( + description="Refresh", + button_style="", + icon="refresh", + layout=layout_fn(width="100px"), + ) + app.compare_btn = widgets.Button( + description="Compare selected", + button_style="primary", + icon="bar-chart", + disabled=True, + layout=layout_fn(width="180px"), + ) + app.compare_clear_btn = widgets.Button( + description="Clear", + button_style="warning", + icon="times", + layout=layout_fn(width="90px"), + ) + app.compare_output = widgets.Output() + + app.compare_panel = widgets.VBox( + [ + widgets.HTML( + '

    Compare Calculations

    ' + '

    ' + "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).

    " + ) + export_content = widgets.VBox( + [ + widgets.HTML( + '

    ' + "Download a self-contained PySCF script you can study or run outside the notebook.

    " + ), + widgets.HBox([app.export_btn, app.export_status]), + widgets.HTML('
    '), + 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.

    " + ), + app._issue_textarea, + widgets.HBox( + [app._issue_submit_btn, app._issue_cancel_btn], + layout=layout_fn(margin="6px 0 0", gap="8px"), + ), + app._issue_status_html, + ], + layout=layout_fn( + display="none", + border="1px solid #f59e0b", + border_radius="6px", + padding="12px 14px", + margin="0 0 6px", + background_color="#fffbeb", + ), + ) diff --git a/quantui/app_exports.py b/quantui/app_exports.py new file mode 100644 index 0000000..801cff1 --- /dev/null +++ b/quantui/app_exports.py @@ -0,0 +1,139 @@ +"""Export helpers used by QuantUIApp.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def on_export(app: Any, btn: Any) -> None: + """Export a standalone Python calculation script.""" + if app._molecule is None: + app.export_status.value = "Load a molecule first." + return + try: + from quantui import PySCFCalculation + + calc = PySCFCalculation( + app._molecule, + method=app.method_dd.value, + basis=app.basis_dd.value, + ) + fname = ( + f"{app._molecule.get_formula()}" + f"_{app.method_dd.value}_{app.basis_dd.value}.py" + ) + calc.generate_calculation_script(Path(fname)) + app.export_status.value = f"Saved: {fname}" + except Exception as exc: + app.export_status.value = f"Error: {exc}" + + +def on_export_xyz(app: Any, btn: Any) -> None: + """Export molecule geometry to an XYZ file.""" + if app._molecule is None: + app.struct_export_status.value = "Load a molecule first." + return + try: + mol, method, basis = export_molecule_and_label(app) + 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 = (app._last_result_dir / fname) if app._last_result_dir else Path(fname) + dest.write_text(full_xyz, encoding="utf-8") + app.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + app.struct_export_status.value = f"Error: {exc}" + + +def on_export_mol(app: Any, btn: Any) -> None: + """Export molecule geometry to a MOL file via RDKit.""" + if app._molecule is None: + app.struct_export_status.value = "Load a molecule first." + return + try: + from rdkit import Chem + + mol, method, basis = export_molecule_and_label(app) + fname = f"{mol.get_formula()}_{method}_{basis}.mol" + rdmol = molecule_to_rdkit(mol) + if rdmol is None: + app.struct_export_status.value = "RDKit could not parse the structure." + return + mol_block = Chem.MolToMolBlock(rdmol) + dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname) + dest.write_text(mol_block, encoding="utf-8") + app.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + app.struct_export_status.value = f"Error: {exc}" + + +def on_export_pdb(app: Any, btn: Any) -> None: + """Export molecule geometry to a PDB file via RDKit.""" + if app._molecule is None: + app.struct_export_status.value = "Load a molecule first." + return + try: + from rdkit import Chem + + mol, method, basis = export_molecule_and_label(app) + fname = f"{mol.get_formula()}_{method}_{basis}.pdb" + rdmol = molecule_to_rdkit(mol) + if rdmol is None: + app.struct_export_status.value = "RDKit could not parse the structure." + return + pdb_block = Chem.MolToPDBBlock(rdmol) + dest = (app._last_result_dir / fname) if app._last_result_dir else Path(fname) + dest.write_text(pdb_block, encoding="utf-8") + app.struct_export_status.value = f"Saved: {dest}" + except Exception as exc: + app.struct_export_status.value = f"Error: {exc}" + + +def export_molecule_and_label(app: Any) -> tuple[Any, str, str]: + """Return (molecule, method, basis) for structure export. + + For geometry optimization results, returns the final optimized geometry. + Falls back to the currently loaded molecule for all other calculation types. + """ + from quantui.optimizer import OptimizationResult + + result = app._last_result + if isinstance(result, OptimizationResult): + mol = result.molecule + else: + assert app._molecule is not None + mol = app._molecule + method = ( + getattr(result, "method", app.method_dd.value) + if result is not None + else app.method_dd.value + ) + basis = ( + getattr(result, "basis", app.basis_dd.value) + if result is not None + else app.basis_dd.value + ) + return mol, method, basis + + +def molecule_to_rdkit(mol: Any) -> Any: + """Convert a Molecule to an RDKit Mol with inferred bonds (best-effort).""" + try: + from rdkit import Chem + + xyz_block = f"{len(mol.atoms)}\n{mol.get_formula()}\n{mol.to_xyz_string()}\n" + 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 diff --git a/quantui/app_formatters.py b/quantui/app_formatters.py new file mode 100644 index 0000000..586cbed --- /dev/null +++ b/quantui/app_formatters.py @@ -0,0 +1,386 @@ +"""Result-card HTML formatters used by QuantUIApp.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + + +def format_result(r: Any) -> str: + """Format a single-point-style result card.""" + _conv = "Yes" if r.converged else "No (treat results with caution)" + _cc = "green" if r.converged else "#c00" + _gap = f"{r.homo_lumo_gap_ev:.4f} eV" if r.homo_lumo_gap_ev is not None else "N/A" + _rows = "".join( + f"" + f'{k}' + f'{v}' + f"" + for k, v, vc in [ + ( + "Total energy", + f"{r.energy_hartree:.8f} Ha  ({r.energy_ev:.4f} eV)", + "#000", + ), + ("HOMO-LUMO gap", _gap, "#000"), + ("SCF converged", _conv, _cc), + ( + "SCF iterations", + ( + "—" + if getattr(r, "n_iterations", None) in (None, -1) + else str(r.n_iterations) + ), + "#000", + ), + ] + ) + _extra = "" + # MP2: show HF reference energy separately + _mp2_corr = getattr(r, "mp2_correlation_hartree", None) + if _mp2_corr is not None: + _hf_e = r.energy_hartree - _mp2_corr + _extra += ( + f'HF reference' + f'{_hf_e:.8f} Ha' + f'MP2 correlation' + f'{_mp2_corr:.8f} Ha' + ) + _solvent = getattr(r, "solvent", None) + if _solvent is not None: + _extra += ( + f'Solvent (PCM)' + f'{_solvent}' + ) + _dip = getattr(r, "dipole_moment_debye", None) + if _dip is not None: + _extra += ( + f'Dipole moment' + f'{_dip:.4f} D' + ) + _chg = getattr(r, "mulliken_charges", None) + _syms = getattr(r, "atom_symbols", None) + if _chg is not None and _syms is not None: + _charge_str = " ".join(f"{sym}:{c:+.3f}" for sym, c in zip(_syms, _chg)) + _extra += ( + f'' + f"Mulliken charges" + f'{_charge_str}' + ) + return ( + f'
    ' + f"{r.formula} — {r.method}/{r.basis}" + f'' + f"{_rows}{_extra}
    " + ) + + +def format_opt_result(r: Any) -> str: + """Format a geometry-optimization result card.""" + _conv = "Yes" if r.converged else "No (max steps reached)" + _cc = "green" if r.converged else "#c00" + _rows = "".join( + f"" + f'{k}' + f'{v}' + f"" + for k, v, vc in [ + ("Final energy", f"{r.energy_hartree:.8f} Ha", "#000"), + ("Energy change", f"{r.energy_change_hartree:+.6f} Ha", "#000"), + ("Opt converged", _conv, _cc), + ("Steps taken", str(r.n_steps), "#000"), + ("Geometry RMSD", f"{r.rmsd_angstrom:.4f} Å", "#000"), + ] + ) + return ( + f'
    ' + f"Geometry Optimisation — {r.formula} ({r.method}/{r.basis})" + f'' + f"{_rows}
    " + ) + + +def format_freq_result(r: Any) -> str: + """Format a frequency-analysis result card.""" + _conv = "Yes" if r.converged else "No (treat with caution)" + _cc = "green" if r.converged else "#c00" + n_real = r.n_real_modes() + n_imag = r.n_imaginary_modes() + real_freqs = sorted(f for f in r.frequencies_cm1 if f > 0)[:6] + freq_str = " ".join(f"{f:.1f}" for f in real_freqs) + if len([f for f in r.frequencies_cm1 if f > 0]) > 6: + freq_str += " …" + imag_note = "" + if n_imag > 0: + imag_note = ( + f'Imaginary modes' + f'{n_imag} — geometry may not be a minimum' + ) + _rows = ( + f'SCF energy' + f'{r.energy_hartree:.8f} Ha' + f'SCF converged' + f'{_conv}' + f'Real modes' + f'{n_real}' + + imag_note + + ( + f'Frequencies (cm⁻¹)' + f'{freq_str or "none"}' + if real_freqs + else "" + ) + + f'ZPVE' + f'{r.zpve_hartree:.6f} Ha ' + f"({r.zpve_hartree * 27.211386245988:.4f} eV)" + ) + _thermo_rows = "" + _thermo = getattr(r, "thermo", None) + if _thermo is not None: + _kj = 2625.5 # kJ/mol per Hartree + _thermo_rows = ( + f'' + f"— Thermochemistry at {_thermo.temperature_k:.0f} K / 1 atm —" + f"" + f'H (298 K)' + f'{_thermo.H_hartree:.6f} Ha' + f'S (298 K)' + f'{_thermo.S_jmol:.2f} J/(mol·K)' + f'G (298 K)' + f'{_thermo.G_hartree:.6f} Ha' + f" ({_thermo.G_hartree * _kj:.2f} kJ/mol)" + ) + return ( + f'
    ' + f"Frequency Analysis — {r.formula} ({r.method}/{r.basis})" + f'' + f"{_rows}{_thermo_rows}
    " + ) + + +def format_tddft_result(r: Any) -> str: + """Format a TD-DFT / UV-Vis result card.""" + _conv = "Yes" if r.converged else "No (treat with caution)" + _cc = "green" if r.converged else "#c00" + header_rows = ( + f'Ground-state energy' + f'{r.energy_hartree:.8f} Ha' + f'SCF converged' + f'{_conv}' + f'States computed' + f'{len(r.excitation_energies_ev)}' + ) + exc_table = "" + if r.excitation_energies_ev: + wl = r.wavelengths_nm() + exc_rows = [] + for i, (e_ev, f_osc) in enumerate( + zip(r.excitation_energies_ev[:8], r.oscillator_strengths[:8]), 1 + ): + bold = "font-weight:bold" if f_osc > 0.05 else "" + exc_rows.append( + f'' + f'S{i}' + f'{e_ev:.3f} eV' + f'{wl[i - 1]:.1f} nm' + f'f = {f_osc:.4f}' + f"" + ) + if len(r.excitation_energies_ev) > 8: + exc_rows.append( + f'… ' + f"and {len(r.excitation_energies_ev) - 8} more states" + ) + exc_table = ( + '' + "Vertical excitations:" + "" + 'State' + 'Energy' + 'λ' + 'Osc. str.' + + "".join(exc_rows) + ) + return ( + f'
    ' + f"TD-DFT / UV-Vis — {r.formula} ({r.method}/{r.basis})" + f'' + f"{header_rows}{exc_table}
    " + ) + + +def format_nmr_result(r: Any) -> str: + """Format an NMR shielding result card.""" + _conv = "Yes" if r.converged else "No (treat with caution)" + _cc = "green" if r.converged else "#c00" + header_rows = ( + f'SCF converged' + f'{_conv}' + f'Reference' + f'{r.reference_compound} ({r.method}/{r.basis})' + ) + + def _nmr_table(label: str, shifts: list, sym: str) -> str: + if not shifts: + return "" + rows = "".join( + f"" + f'{sym}-{n}' + f'{d:.2f} ppm' + f"" + for n, (_i, d) in enumerate(shifts, 1) + ) + return ( + f'' + f"{label} shifts (vs. TMS):" + f"" + f'Atom' + f'δ (ppm)' + + rows + ) + + h_table = _nmr_table("¹H", r.h_shifts(), "H") + c_table = _nmr_table("¹³C", r.c_shifts(), "C") + + _basis_warn = "" + if r.basis.upper() in ("STO-3G", "3-21G"): + _basis_warn = ( + '' + '' + f"⚠ {r.basis} gives qualitative NMR only — use 6-31G* or better." + "" + ) + + _empty = "" + if not r.h_shifts() and not r.c_shifts(): + _empty = ( + '' + "No ¹H or ¹³C atoms found in this molecule." + ) + + return ( + f'
    ' + f"NMR Shielding — {r.formula} ({r.method}/{r.basis})" + f'' + f"{header_rows}{h_table}{c_table}{_empty}{_basis_warn}
    " + ) + + +def format_pes_scan_result(r: Any) -> str: + """Format a PESScanResult as an HTML result card.""" + _conv = "Yes" if r.converged_all else "No (some points did not converge)" + _cc = "green" if r.converged_all else "#c00" + if r.energies_hartree: + e_min = min(r.energies_hartree) + e_max = max(r.energies_hartree) + barrier_kcal = (e_max - e_min) * 627.509474 + _e_row = ( + f'Min energy' + f'{e_min:.8f} Ha' + f'Energy range' + f'{barrier_kcal:.2f} kcal/mol' + ) + else: + _e_row = "" + _idx_str = "–".join(str(i + 1) for i in r.atom_indices) + return ( + f'
    ' + f"PES Scan — {r.formula} ({r.method}/{r.basis})" + f'' + f'' + f'' + f'' + f'" + f"{_e_row}" + f'' + f'' + f"
    Scan type{r.scan_type.capitalize()} ({_idx_str})
    Range{r.scan_parameter_values[0]:.3f} → ' + f"{r.scan_parameter_values[-1]:.3f} {r.scan_unit} " + f"({r.n_steps} points)
    All converged{_conv}
    " + ) + + +def format_past_result(data: dict[str, Any], result_dir: Optional[Path] = None) -> str: + """Format a saved result.json payload as an HTML result card.""" + import base64 as _b64 + + _ct_labels = { + "single_point": ("Single Point", "#2563eb", "#dbeafe"), + "geometry_opt": ("Geometry Optimization", "#7c3aed", "#ede9fe"), + "frequency": ("Frequency Analysis", "#15803d", "#dcfce7"), + "tddft": ("TD-DFT", "#b45309", "#fef3c7"), + "nmr": ("NMR", "#0d9488", "#ccfbf1"), + "pes_scan": ("PES Scan", "#c2410c", "#ffedd5"), + } + ct = data.get("calc_type", "") + _ct_label, _ct_fg, _ct_bg = _ct_labels.get( + ct, (ct.replace("_", " ").title(), "#555", "#f3f4f6") + ) + _ct_badge = ( + f'{_ct_label}' + ) + _conv = "Yes" if data.get("converged") else "No (treat results with caution)" + _cc = "green" if data.get("converged") else "#c00" + _gap = ( + f"{data['homo_lumo_gap_ev']:.4f} eV" + if data.get("homo_lumo_gap_ev") is not None + else "N/A" + ) + _rows = "".join( + f"" + f'{k}' + f'{v}' + f"" + for k, v, vc in [ + ( + "Total energy", + f"{data['energy_hartree']:.8f} Ha  ({data['energy_ev']:.4f} eV)", + "#000", + ), + ("HOMO-LUMO gap", _gap, "#000"), + ("SCF converged", _conv, _cc), + ( + "SCF iterations", + ( + "—" + if data.get("n_iterations") in (None, -1) + else str(data.get("n_iterations")) + ), + "#000", + ), + ] + ) + ts = data.get("timestamp", "") + + # Embed thumbnail if saved + _thumb_html = "" + if result_dir is not None: + _thumb_path = Path(result_dir) / "thumbnail.png" + if _thumb_path.exists(): + _img_b64 = _b64.b64encode(_thumb_path.read_bytes()).decode() + _thumb_html = ( + f'' + ) + + return ( + f'
    ' + f"{_thumb_html}" + f"{_ct_badge}
    " + f'{data["formula"]} — {data["method"]}/{data["basis"]}' + f' {ts}' + f'' + f"{_rows}
    " + ) diff --git a/quantui/app_history.py b/quantui/app_history.py new file mode 100644 index 0000000..f590c23 --- /dev/null +++ b/quantui/app_history.py @@ -0,0 +1,225 @@ +"""History-loading helpers used by QuantUIApp.""" + +from __future__ import annotations + +import json as _json +from pathlib import Path +from typing import Any, Optional + +import ipywidgets as widgets +from IPython.display import HTML, display + + +def on_past_dd_changed(app: Any, change: dict[str, Any], *, layout_fn: Any) -> None: + """Handle history dropdown selection changes.""" + path_str = change["new"] + # Hide result-specific panels whenever the selection changes so stale + # content from a previous "View log" click doesn't persist. + app._deactivate_all_ana_panels() + app._pending_traj_result = None + app._result_log_accordion.layout.display = "none" + app._result_dir_label.layout.display = "none" + app._iso_generate_btn.disabled = True + if not path_str: + app.past_output.clear_output() + return + app.past_output.clear_output() + with app.past_output: + try: + from quantui import load_result + + result_dir = Path(path_str) + data = load_result(result_dir) + display(HTML(app._format_past_result(data, result_dir=result_dir))) + btn_results = widgets.Button( + description="-> View Results", + button_style="success", + layout=layout_fn(width="130px"), + tooltip="Show this result in the Results tab", + ) + btn_analysis = widgets.Button( + description="-> View Analysis", + button_style="info", + layout=layout_fn(width="140px"), + tooltip="Load analysis panels and navigate to the Analysis tab", + ) + btn_results.on_click( + lambda _, d=data, rd=result_dir: app._history_load_results(d, rd) + ) + btn_analysis.on_click( + lambda _, rd=result_dir: app._history_load_analysis(rd) + ) + display( + widgets.HBox( + [btn_results, btn_analysis], + layout=layout_fn(gap="8px", margin="6px 0 0"), + ) + ) + except Exception as exc: + print(f"Could not load result: {exc}") + + +def on_view_log(app: Any, btn: Any) -> None: + """Handle View Log action for a selected history result.""" + path_str = app.past_dd.value + if not path_str: + return + result_dir = Path(path_str) + app._last_result_dir = result_dir + try: + import quantui.calc_log as _calc_log + + _calc_log.log_event( + "history_view", + result_dir.name, + result_dir=result_dir.name, + session_id=app._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 = "" + app._update_log_panel(text, label) + app._show_result_log(result_dir, text) + + # Build analysis context from disk and apply via registry + ctx = app._build_history_context(result_dir) + if ctx is not None: + data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} + try: + mol = app._mol_from_result_dir(result_dir, data_stub) + if mol is not None: + app._show_result_3d(mol, extra_output=app._analysis_mol_output) + else: + app._analysis_mol_output.clear_output() + except Exception: + pass + app._apply_analysis_context(ctx) + + app._goto_output_tab() + + +def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any: + """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. + """ + from quantui.molecule import Molecule + + calc_type = data.get("calc_type", "") + + # Frequency: geometry stored inside spectra.molecule + if calc_type == "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 = [coords for _, coords in mol_atom] + return Molecule(atoms=atoms, coordinates=coords) + except Exception: + pass + + # Geo opt fallback: last step of trajectory.json + if calc_type == "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 + + +def history_load_results(app: Any, data: dict[str, Any], result_dir: Path) -> None: + """Display a history result card in the Results tab and navigate there.""" + app._last_result_dir = result_dir + app.result_output.clear_output() + with app.result_output: + display(HTML(app._format_past_result(data, result_dir=result_dir))) + app._result_dir_label.layout.display = "none" + # Also show 3D structure if geometry is recoverable + mol = app._mol_from_result_dir(result_dir, data) + if mol is not None: + app._show_result_3d(mol) + app.root_tab.selected_index = 1 + + +def history_load_analysis(app: Any, result_dir: Path) -> None: + """Load analysis panels for a history result and navigate to Analysis tab.""" + app._last_result_dir = result_dir + 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.)" + ) + app._update_log_panel(result_dir.name if log_path.exists() else "", text) + app._show_result_log(result_dir, text) + + ctx = app._build_history_context(result_dir) + if ctx is not None: + data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data} + try: + mol = app._mol_from_result_dir(result_dir, data_stub) + if mol is not None: + app._show_result_3d(mol, extra_output=app._analysis_mol_output) + else: + app._analysis_mol_output.clear_output() + except Exception: + pass + app._apply_analysis_context(ctx) + + app.root_tab.selected_index = 2 + + +def build_history_context(result_dir: Path, *, context_cls: Any) -> Optional[Any]: + """Load result.json from result_dir and return an analysis context.""" + try: + from quantui import load_result + + data = load_result(result_dir) + except Exception: + return None + return context_cls( + 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", {}), + timestamp=data.get("timestamp", ""), + source="history", + ) diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py new file mode 100644 index 0000000..c607931 --- /dev/null +++ b/quantui/app_runflow.py @@ -0,0 +1,691 @@ +"""Runflow helpers used by QuantUIApp.""" + +from __future__ import annotations + +import threading +import time +from typing import Any + +import ipywidgets as widgets +from IPython.display import HTML, Javascript, display + + +def _calc_type_badge(calc_type: str) -> str: + return { + "single_point": "SP", + "geometry_opt": "GeoOpt", + "frequency": "Freq", + "tddft": "UV-Vis", + "nmr": "NMR", + "pes_scan": "PES", + }.get(calc_type, calc_type or "Unknown") + + +def on_run_clicked(app: Any, btn: Any) -> None: + """Reset result panes and start the background run thread.""" + app.run_output.clear_output() + app.result_output.clear_output() + app.result_viz_output.clear_output() + app._analysis_mol_output.clear_output() + app._viz_label.layout.display = "none" + app._viz_label.value = "" + app._deactivate_all_ana_panels() + app._clear_output_widget(app._pes_plot_html) + app._result_dir_label.value = "" + app._result_dir_label.layout.display = "none" + app._result_log_accordion.layout.display = "none" + app._result_log_accordion.selected_index = None + app._result_log_output.clear_output() + app._completion_banner.layout.display = "none" + app._to_analysis_btn.layout.display = "none" + app._analysis_empty_html.layout.display = "none" + threading.Thread(target=app._do_run, daemon=True).start() + + +def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None: + """Update extra options panel based on selected calculation type.""" + ct = change["new"] + + # QM pre-optimization is meaningful for all workflows except Geometry Opt, + # which is itself an optimization workflow. + if ct == "Geometry Opt": + app._freq_preopt_cb.value = False + app._freq_preopt_cb.layout.display = "none" + else: + app._freq_preopt_cb.layout.display = "" + + if ct == "Geometry Opt": + app.calc_extra_opts.children = [ + widgets.HBox( + [app.fmax_fi, app.max_steps_si], + layout=layout_fn(gap="8px"), + ), + ] + elif ct == "Frequency": + app._refresh_freq_seed_options() + app.calc_extra_opts.children = [ + widgets.HBox( + [app._freq_seed_dd, app._freq_seed_refresh_btn], + layout=layout_fn(align_items="center", gap="6px", width="100%"), + ), + app._freq_seed_note, + ] + elif ct == "UV-Vis (TD-DFT)": + app.calc_extra_opts.children = [ + app.nstates_si, + widgets.HTML( + '⚠ Requires a DFT ' + "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) " + "instead." + ), + ] + elif ct == "NMR Shielding": + app.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": + app._update_scan_widgets() + app.calc_extra_opts.children = [ + widgets.HBox( + [app._scan_type_dd], + layout=layout_fn(margin="0 0 4px 0"), + ), + widgets.HBox( + [app._scan_atom1, app._scan_atom2], + layout=layout_fn(gap="4px"), + ), + app._scan_atom34_box, + widgets.HBox( + [ + app._scan_start, + app._scan_stop, + app._scan_steps, + app._scan_unit_lbl, + ], + layout=layout_fn(gap="4px", align_items="center"), + ), + ] + else: + app.calc_extra_opts.children = [] + + +def update_scan_widgets(app: Any, _change: Any = None) -> None: + """Show/hide atom inputs and unit label based on scan type.""" + st = app._scan_type_dd.value + if st == "Bond": + app._scan_atom34_box.layout.display = "none" + app._scan_unit_lbl.value = 'Å' + elif st == "Angle": + app._scan_atom4.layout.display = "none" + app._scan_atom3.layout.display = "" + app._scan_atom34_box.layout.display = "" + app._scan_unit_lbl.value = '°' + else: # Dihedral + app._scan_atom3.layout.display = "" + app._scan_atom4.layout.display = "" + app._scan_atom34_box.layout.display = "" + app._scan_unit_lbl.value = '°' + + +def refresh_freq_seed_options(app: Any) -> None: + """Populate frequency seed dropdown with saved geometry optimisations.""" + 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 + app._freq_seed_dd.options = options + + +def on_freq_seed_changed(app: Any, change: Any) -> None: + """Enable/disable pre-opt checkbox and update seed note message.""" + path_str = change["new"] + if path_str: + app._freq_preopt_cb.value = False + app._freq_preopt_cb.disabled = True + app._freq_seed_note.value = ( + '' + "✓ Final optimised geometry will be loaded from the selected result." + "" + ) + else: + app._freq_preopt_cb.disabled = False + app._freq_seed_note.value = "" + + +def on_solvent_cb_changed(app: Any, change: Any) -> None: + """Show or hide solvent dropdown based on checkbox state.""" + app.solvent_dd.layout.display = "" if change["new"] else "none" + + +def on_clear_log(app: Any, btn: Any) -> None: + """Clear the live run output panel.""" + app.run_output.clear_output() + + +def on_accumulate(app: Any, btn: Any) -> None: + """Add the latest result to the in-session comparison list.""" + r = app._last_result + if r is None: + return + app._results.append(r) + app._refresh_comparison() + + +def on_clear(app: Any, btn: Any) -> None: + """Clear in-session comparison results and rendered output.""" + app._results.clear() + app.comparison_output.clear_output() + + +def on_compare_refresh(app: Any, btn: Any) -> None: + """Refresh Compare selector options from saved results.""" + app._populate_compare_list() + + +def on_compare(app: Any, btn: Any, *, layout_fn: Any) -> None: + """Render selected saved results in the Compare tab.""" + from pathlib import Path + + selected = app.compare_select.value + if not selected or selected == ("",): + return + app.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[Any] = [] + 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 app.compare_output: + display( + HTML(f'

    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 = ( + '
    ' + '' + '' + '' + '' + "" + '
    ' + "QuantUI has shut down. You may close this tab.
    " + "
    " + ) + + def _do_exit() -> None: + time.sleep(0.6) + try: + # Signal the Voilà/Jupyter server process (our parent) to exit cleanly. + os.kill(os.getppid(), signal.SIGTERM) + except Exception: + pass + # Terminate the kernel process regardless. + os._exit(0) + + threading.Thread(target=_do_exit, daemon=True).start() + + +def on_cal_run( + app: Any, + btn: Any, + *, + benchmark_suite: Any, + benchmark_suite_long: Any, +) -> None: + """Start async calibration run and initialize calibration UI state.""" + _ = btn + mode = app._cal_mode_toggle.value + suite = benchmark_suite if mode == "short" else benchmark_suite_long + app._cal_stop_event = threading.Event() + app._cal_run_btn.disabled = True + app._cal_mode_toggle.disabled = True + app._cal_stop_btn.layout.display = "" + app._cal_progress.max = len(suite) + app._cal_progress.value = 0 + app._cal_progress.layout.display = "" + app._cal_step_label.layout.display = "" + app._cal_step_label.value = ( + 'Starting…' + ) + app._cal_results_html.value = "" + + threading.Thread(target=app._do_calibration, daemon=True).start() + + +def on_cal_stop(app: Any, btn: Any) -> None: + """Signal any active calibration run to stop at the next safe point.""" + _ = btn + if hasattr(app, "_cal_stop_event"): + app._cal_stop_event.set() + + +def do_calibration(app: Any, *, pyscf_available: bool) -> None: + """Run calibration suite and render calibration summary table.""" + from quantui.benchmarks import run_calibration + + mode = app._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, "?" + ) + app._cal_progress.value = step_n + app._cal_step_label.value = ( + f'' + f"Step {step_n} / {total} — {label} " + f"[{icon} {elapsed:.1f} s]" + ) + + result = run_calibration( + progress_cb=_progress, + stop_event=app._cal_stop_event, + timeout_per_step=300.0 if mode == "long" else 120.0, + mode=mode, + ) + + rows = "".join( + f"" + f'{s.label}' + f'' + f"{s.n_electrons}" + f'' + f"{s.n_basis if s.n_basis is not None else '—'}" + f'' + f"{s.elapsed_s:.2f} s" + f'' + f'{"✓" if s.status == "ok" else ("⏱ timed out" if s.status == "timed_out" else ("⛔ stopped" if s.status == "stopped" else "✗ error"))}' + f"" + f"" + for s in result.steps + ) + summary = f"Completed {result.n_completed} / {result.n_total} steps." + ( + " (stopped early)" if result.stopped_early else "" + ) + app._cal_results_html.value = ( + f'
    ' + f'

    {summary}

    ' + f'' + f"" + f'' + f'' + f'' + f'' + f'' + f"" + f"{rows}
    Calculatione⁻Basis fnsWall timeStatus
    " + ) + + app._cal_step_label.value = ( + 'Calibration complete. ' + "Time estimates are now active." + if result.n_completed > 0 + else 'No steps completed.' + ) + app._cal_stop_btn.layout.display = "none" + app._cal_run_btn.disabled = not pyscf_available + app._cal_mode_toggle.disabled = False + app._refresh_perf_stats() + + +def update_notes(app: Any, change: Any = None) -> None: + """Refresh educational method notes for the active molecule/method.""" + app.notes_output.clear_output(wait=True) + if app._molecule is None: + return + try: + from quantui import PySCFCalculation + + calc = PySCFCalculation( + app._molecule, + method=app.method_dd.value, + basis=app.basis_dd.value, + ) + notes = calc.get_educational_notes() + if notes: + safe = ( + notes.replace("**", "", 1) + .replace("**", "", 1) + .replace("\n\n", "

    ") + ) + with app.notes_output: + display( + HTML( + '
    ' + + safe + + "
    " + ) + ) + except Exception: + pass + + +def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None: + """Refresh runtime estimate text from the performance model.""" + if app._molecule is None: + app.perf_estimate_html.value = "" + return + try: + calc_type = { + "Single Point": "single_point", + "Geometry Opt": "geometry_opt", + "Frequency": "frequency", + "UV-Vis (TD-DFT)": "tddft", + "NMR Shielding": "nmr", + "PES Scan": "pes_scan", + }.get(app.calc_type_dd.value, "single_point") + n_basis = calc_log_mod.count_basis_functions( + app._molecule.atoms, app.basis_dd.value + ) + est = calc_log_mod.estimate_time( + n_atoms=len(app._molecule.atoms), + n_electrons=app._molecule.get_electron_count(), + method=app.method_dd.value, + basis=app.basis_dd.value, + n_basis=n_basis, + calc_type=calc_type, + ) + app.perf_estimate_html.value = calc_log_mod.format_estimate(est) + except Exception: + app.perf_estimate_html.value = "" + + +def refresh_results_browser(app: Any) -> None: + """Refresh the History dropdown with saved result directories.""" + try: + from quantui import list_results, load_result + except ImportError: + return + app.results_path_lbl.value = ( + f'' + f"{app._get_results_dir()}" + ) + dirs = list_results() + if not dirs: + app.past_dd.options = [("(no saved results)", "")] + return + options = [] + for d in dirs: + try: + data = load_result(d) + ts = data.get("timestamp", d.name) + calc_badge = _calc_type_badge(data.get("calc_type", "")) + label = ( + f"{ts} · [{calc_badge}] " + f"{data['formula']} {data['method']}/{data['basis']}" + ) + options.append((label, str(d))) + except Exception: + pass + app.past_dd.options = options if options else [("(no saved results)", "")] + if app.calc_type_dd.value == "Frequency": + app._refresh_freq_seed_options() + + +def refresh_comparison(app: Any) -> None: + """Refresh in-session comparison output from accumulated results.""" + from quantui import comparison_table_html, summary_from_session_result + + app.comparison_output.clear_output(wait=True) + if not app._results: + return + summaries = [summary_from_session_result(r) for r in app._results] + with app.comparison_output: + display(HTML(comparison_table_html(summaries))) + if len(summaries) > 1: + try: + from quantui import plot_comparison + + plot_comparison(summaries) + except Exception: + pass + + +def populate_compare_list(app: Any) -> None: + """Populate the Compare tab selector with saved result entries.""" + from quantui.results_storage import list_results, load_result + + dirs = list_results() + if not dirs: + app.compare_select.options = [("(no saved results)", "")] + app.compare_btn.disabled = True + return + options = [] + for d in dirs: + try: + data = load_result(d) + ts = data.get("timestamp", d.name[:19]) + calc_badge = _calc_type_badge(data.get("calc_type", "")) + label = ( + f"{ts} [{calc_badge}] " + f"{data['formula']} {data['method']}/{data['basis']}" + ) + options.append((label, str(d))) + except Exception: + options.append((d.name, str(d))) + app.compare_select.options = options + app.compare_btn.disabled = False diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py new file mode 100644 index 0000000..c839abc --- /dev/null +++ b/quantui/app_visualization.py @@ -0,0 +1,1471 @@ +"""Visualization and rendering helpers used by QuantUIApp.""" + +from __future__ import annotations + +import threading +from pathlib import Path +from typing import Any, List + +import ipywidgets as widgets +from IPython.display import HTML, display + + +def show_result_3d( + app: Any, + molecule: Any, + extra_output: Any = None, + *, + display_molecule_fn: Any, +) -> None: + """Render molecule 3D structure in result and optional extra output panels.""" + if display_molecule_fn is None or molecule is None: + return + for out_widget in [app.result_viz_output, extra_output]: + if out_widget is None: + continue + out_widget.clear_output() + with out_widget: + display_molecule_fn( + molecule, + backend=app._viz_backend, + style=app._viz_style, + lighting=app._viz_lighting, + bgcolor=app._plotly_theme_colors()["scene_bgcolor"], + ) + + +def on_traj_expand(app: Any, change: dict[str, Any]) -> None: + """Lazily generate trajectory animation when accordion first opens.""" + if change["new"] != 0: + return + result = app._pending_traj_result + try: + from quantui import calc_log as _clog_te + + _clog_te.log_event("traj_expand", f"pending={result is not None}") + except Exception: + pass + if result is None: + return + app._pending_traj_result = None + app._traj_render_token = int(getattr(app, "_traj_render_token", 0)) + 1 + render_token = app._traj_render_token + + from IPython.display import HTML as _H + from IPython.display import display as _d + + app.traj_output.clear_output() + with app.traj_output: + _d( + _H( + '

    Loading trajectory viewer…

    ' + ) + ) + + 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" + "
    Oscillator strength: %{y:.3f}" + ), + ) + ) + + tc = app._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), + showlegend=False, + plot_bgcolor=tc["plot_bgcolor"], + paper_bgcolor=tc["paper_bgcolor"], + font=dict(color=tc["font_color"]), + ) + fig.update_xaxes(showgrid=True, gridcolor=tc["grid_color"], zeroline=False) + fig.update_yaxes( + showgrid=True, + gridcolor=tc["grid_color"], + rangemode="tozero", + ) + + app._apply_plotly_theme(fig) + app._last_uv_fig = fig + app._set_html_output( + app._tddft_fig, + _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), + ) + except Exception as exc: + app._last_uv_fig = None + try: + from quantui import calc_log as _clog + + _clog.log_event("uv_fig_error", f"{type(exc).__name__}: {exc}"[:300]) + except Exception: + pass + + +def show_orbital_diagram(app: Any, result: Any) -> bool: + """Build and reveal interactive orbital diagram accordion.""" + 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 + + app._last_orb_info = info + app._last_orb_mo_coeff = getattr(result, "mo_coeff", None) + app._last_orb_mol_atom = getattr(result, "pyscf_mol_atom", None) + app._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=app._orb_n_orb_input.value) + yr = fig.layout.yaxis.range + if yr is not None: + app._orb_ymin_input.value = round(float(yr[0]), 2) + app._orb_ymax_input.value = round(float(yr[1]), 2) + app._apply_plotly_theme(fig) + app._last_orb_fig = fig + html_str = _pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ) + app._set_html_output(app._orb_diagram_html, html_str) + plotly_rendered = True + except Exception: + pass + + if not plotly_rendered: + app._last_orb_fig = None + 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() + app._set_html_output( + app._orb_diagram_html, + ( + f'' + ), + ) + except Exception: + pass + + if ( + 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 + ): + app._orb_iso_output.clear_output() + app._orb_toggle.value = "HOMO" + app._orb_iso_controls.layout.display = "" + app._iso_generate_btn.disabled = False + else: + app._orb_iso_controls.layout.display = "none" + app._iso_generate_btn.disabled = True + + return True + + +def on_iso_generate(app: Any, btn: Any) -> None: + """Generate orbital isosurface for currently selected orbital.""" + orbital_label = app._orb_toggle.value + app._iso_render_token = int(getattr(app, "_iso_render_token", 0)) + 1 + render_token = app._iso_render_token + btn.disabled = True + btn.description = "Generating…" + try: + from quantui import calc_log as _clog + + _clog.log_event("iso_render_start", orbital_label) + except Exception: + pass + app._orb_iso_output.clear_output() + with app._orb_iso_output: + display( + HTML( + f'

    ' + 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}
    " + f"ΔE = {de:.3f} kcal/mol
    " + f"E = {e:.8f} Ha" + for x, de, e in zip(x_vals, e_rel, result.energies_hartree) + ] + + fig = go.Figure( + go.Scatter( + x=x_vals, + y=e_rel, + mode="lines+markers", + line=dict(color="#2563eb", width=2), + marker=dict(size=8, color="#2563eb"), + hovertext=hover_text, + hoverinfo="text", + ) + ) + tc = app._plotly_theme_colors() + fig.update_layout( + xaxis_title=result.scan_coordinate_label, + yaxis_title="Relative energy / kcal mol⁻¹", + height=380, + 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"]), + hovermode="closest", + ) + app._last_pes_fig = fig + app._set_html_output( + app._pes_plot_html, + pio.to_html( + fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), + ) + except Exception: + app._last_pes_fig = None + pass + + return True diff --git a/quantui/benchmarks.py b/quantui/benchmarks.py index 9740710..c4ab8f3 100644 --- a/quantui/benchmarks.py +++ b/quantui/benchmarks.py @@ -470,6 +470,7 @@ def _run_step( converged=res.converged, n_basis=step.n_basis, n_cores=1, + calc_type="single_point", ) except concurrent.futures.TimeoutError: step.status = _STATUS_TIMEOUT diff --git a/quantui/calc_log.py b/quantui/calc_log.py index 085ea4b..e3913e7 100644 --- a/quantui/calc_log.py +++ b/quantui/calc_log.py @@ -333,6 +333,7 @@ def log_calculation( converged: bool, n_basis: Optional[int] = None, n_cores: Optional[int] = None, + calc_type: Optional[str] = None, ) -> None: """Append one performance record to ``perf_log.jsonl``.""" record: dict = { @@ -350,6 +351,8 @@ def log_calculation( record["n_basis"] = n_basis if n_cores is not None: record["n_cores"] = n_cores + if calc_type is not None: + record["calc_type"] = calc_type _append(_perf_path(), record) @@ -360,6 +363,7 @@ def estimate_time( basis: str, n_basis: Optional[int] = None, n_cores: Optional[int] = None, + calc_type: Optional[str] = None, ) -> Optional[dict]: """ Return a time estimate dict, or ``None`` if there is insufficient data. @@ -391,14 +395,34 @@ def estimate_time( 4. **Same basis, any method, electron-count fallback** (≥ 2 records): Same as the original strategy 2. Confidence: low. + ``calc_type`` narrows the candidate pool so that expensive workflows + (for example, Frequency) are not predicted from cheap workflows + (for example, Single Point). Legacy records without ``calc_type`` are + only included when estimating ``single_point``. + Returns ``None`` when fewer than 2 converged records are available for - any strategy. + the scoped candidate pool. """ records = _read_all(_perf_path()) converged = [r for r in records if r.get("converged")] if not converged: return None + if calc_type is None: + scoped = converged + elif calc_type == "single_point": + # Back-compat bridge: older records did not store calc_type. + scoped = [ + r + for r in converged + if r.get("calc_type") == "single_point" or r.get("calc_type") is None + ] + else: + scoped = [r for r in converged if r.get("calc_type") == calc_type] + + if len(scoped) < 2: + return None + beta_new = _METHOD_SCALE_EXP.get(method, 3.5) n_cores_current = n_cores if n_cores is not None else 1 @@ -417,7 +441,7 @@ def _eff(r: dict) -> Optional[float]: if n_basis is not None: exact_nb = [ r - for r in converged + for r in scoped if r.get("method") == method and r.get("basis") == basis and r.get("n_basis") is not None @@ -432,9 +456,7 @@ def _eff(r: dict) -> Optional[float]: } # ── Strategy 2: exact method + basis, electron-count fallback ──────────── - exact = [ - r for r in converged if r.get("method") == method and r.get("basis") == basis - ] + exact = [r for r in scoped if r.get("method") == method and r.get("basis") == basis] if len(exact) >= 2: median_ne = statistics.median(r["n_electrons"] for r in exact) median_t = statistics.median(r["elapsed_s"] for r in exact) @@ -449,7 +471,7 @@ def _eff(r: dict) -> Optional[float]: if n_basis is not None: same_basis_nb = [ r - for r in converged + for r in scoped if r.get("basis") == basis and r.get("n_basis") is not None ] effs = [e for r in same_basis_nb for e in [_eff(r)] if e is not None] @@ -472,7 +494,7 @@ def _eff(r: dict) -> Optional[float]: } # ── Strategy 4: same basis, any method, electron-count fallback ─────────── - same_basis = [r for r in converged if r.get("basis") == basis] + same_basis = [r for r in scoped if r.get("basis") == basis] if len(same_basis) >= 2: median_ne = statistics.median(r["n_electrons"] for r in same_basis) median_t = statistics.median(r["elapsed_s"] for r in same_basis) diff --git a/quantui/freq_calc.py b/quantui/freq_calc.py index cc90f5f..526c349 100644 --- a/quantui/freq_calc.py +++ b/quantui/freq_calc.py @@ -169,6 +169,13 @@ def run_freq_calc( stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout + def _status(msg: str) -> None: + """Emit a status marker line consumable by QuantUI's log capture.""" + try: + stream.write(f"\n[QuantUI_STATUS] {msg}\n") + except Exception: + pass + # ── Build Mole object ──────────────────────────────────────────────────── mol = gto.Mole() mol.atom = molecule.to_pyscf_format() @@ -196,6 +203,8 @@ def run_freq_calc( f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}" ) from exc + _status("SCF converged. Computing analytical Hessian...") + converged = bool(getattr(mf, "converged", False)) n_iterations = int(getattr(mf, "cycles", -1)) @@ -251,6 +260,8 @@ def run_freq_calc( h = hess_obj.kernel() + _status("Analytical Hessian complete. Running harmonic analysis...") + freq_info = pyscf_thermo.harmonic_analysis(mol, h) # freq_wavenumber entries may be complex numbers when PySCF uses a @@ -300,11 +311,19 @@ def run_freq_calc( _KM_MOL_FAC = 42.255 # (D/Å)²/amu → km/mol _n_ir = mol.natm + _ir_total_solves = _n_ir * 3 * 2 + _ir_done_solves = 0 _coords0 = mol.atom_coords().copy() _dm0 = mf.make_rdm1() _dpdx = _np_ir.zeros((_n_ir * 3, 3)) _xc = getattr(mf, "xc", None) + _status( + "Numerical IR intensities: " + f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete " + f"({_ir_total_solves - _ir_done_solves} remaining)" + ) + _mol_v = mol.verbose mol.verbose = 0 try: @@ -321,6 +340,12 @@ def run_freq_calc( _mf_d.verbose = 0 _mf_d.stdout = stream _mf_d.kernel(dm0=_dm0) + _ir_done_solves += 1 + _status( + "Numerical IR intensities: " + f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete " + f"({_ir_total_solves - _ir_done_solves} remaining)" + ) _mu_p = _np_ir.array(_mf_d.dip_moment(verbose=0)) _cm = _coords0.copy() @@ -334,6 +359,12 @@ def run_freq_calc( _mf_d.verbose = 0 _mf_d.stdout = stream _mf_d.kernel(dm0=_dm0) + _ir_done_solves += 1 + _status( + "Numerical IR intensities: " + f"{_ir_done_solves}/{_ir_total_solves} extra SCF solves complete " + f"({_ir_total_solves - _ir_done_solves} remaining)" + ) _mu_m = _np_ir.array(_mf_d.dip_moment(verbose=0)) _dpdx[3 * _I + _ax] = (_mu_p - _mu_m) / (2 * _DELTA) @@ -347,13 +378,21 @@ def run_freq_calc( _ir = (_KM_MOL_FAC * (_dpdQ**2).sum(axis=1)).tolist() if len(_ir) == len(frequencies_cm1): ir_intensities = _ir + _status( + "Numerical IR intensities complete. Computing thermochemistry..." + ) except Exception as _ir_exc: logger.warning("Numerical IR intensities failed: %s", _ir_exc) + _status( + "Numerical IR intensities failed; continuing without IR intensities." + ) # Thermochemistry at 298.15 K / 1 atm — best-effort try: import numpy as _np + _status("Computing thermochemistry...") + _freq_au = freq_info.get("freq_au") if _freq_au is None: _freq_au = _np.array(frequencies_cm1) * _CM1_TO_HARTREE @@ -407,11 +446,14 @@ def _tv(v): S_jmol=_S, G_hartree=_G, ) + _status("Frequency backend complete.") except Exception as _exc: logger.warning("Thermochemistry failed: %s", _exc) + _status("Thermochemistry failed; frequency backend complete.") except Exception as exc: logger.warning("Hessian/frequency computation failed: %s", exc) + _status("Hessian/frequency step failed.") if progress_stream is not None: try: progress_stream.write(f"\n⚠ Hessian failed: {exc}\n") diff --git a/quantui/orbital_visualization.py b/quantui/orbital_visualization.py index c1576b6..7ef9701 100644 --- a/quantui/orbital_visualization.py +++ b/quantui/orbital_visualization.py @@ -28,6 +28,51 @@ # Conversion factor — PySCF stores MO energies in Hartree HARTREE_TO_EV: float = 27.211386245988 +BOHR_PER_ANGSTROM: float = 1.8897261254578281 + +# Light-weight chemistry tables for drawing atom/bond overlays on cube plots. +_COVALENT_RADII_ANGSTROM = { + 1: 0.31, + 5: 0.84, + 6: 0.76, + 7: 0.71, + 8: 0.66, + 9: 0.57, + 14: 1.11, + 15: 1.07, + 16: 1.05, + 17: 1.02, + 35: 1.20, + 53: 1.39, +} +_CPK_COLORS = { + 1: "#f8fafc", # H + 5: "#f59e0b", # B + 6: "#374151", # C + 7: "#2563eb", # N + 8: "#dc2626", # O + 9: "#22c55e", # F + 14: "#f59e0b", # Si + 15: "#f97316", # P + 16: "#facc15", # S + 17: "#16a34a", # Cl + 35: "#b45309", # Br + 53: "#7c3aed", # I +} +_ATOMIC_SYMBOLS = { + 1: "H", + 5: "B", + 6: "C", + 7: "N", + 8: "O", + 9: "F", + 14: "Si", + 15: "P", + 16: "S", + 17: "Cl", + 35: "Br", + 53: "I", +} # ============================================================================ @@ -708,14 +753,66 @@ def parse_cube_file(cube_path: Path) -> dict: } +def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) -> dict: + """Build marker and bond segments from cube atom records.""" + atom_x: List[float] = [] + atom_y: List[float] = [] + atom_z: List[float] = [] + atom_colors: List[str] = [] + atom_sizes: List[float] = [] + atom_labels: List[str] = [] + + for z_num, x, y, z in atoms: + atom_x.append(x) + atom_y.append(y) + atom_z.append(z) + atom_colors.append(_CPK_COLORS.get(z_num, "#9ca3af")) + atom_sizes.append(max(6.0, 15.0 * _COVALENT_RADII_ANGSTROM.get(z_num, 0.75))) + atom_labels.append(_ATOMIC_SYMBOLS.get(z_num, str(z_num))) + + bond_x: List[float] = [] + bond_y: List[float] = [] + bond_z: List[float] = [] + for i, (zi, xi, yi, zi_pos) in enumerate(atoms): + for zj, xj, yj, zj_pos in atoms[i + 1 :]: + ri = _COVALENT_RADII_ANGSTROM.get(zi, 0.75) + rj = _COVALENT_RADII_ANGSTROM.get(zj, 0.75) + cutoff = (ri + rj) * 1.25 * BOHR_PER_ANGSTROM + dist = float( + np.sqrt((xi - xj) ** 2 + (yi - yj) ** 2 + (zi_pos - zj_pos) ** 2) + ) + if dist <= cutoff: + bond_x.extend([xi, xj, None]) + bond_y.extend([yi, yj, None]) + bond_z.extend([zi_pos, zj_pos, None]) + + return { + "atom_x": atom_x, + "atom_y": atom_y, + "atom_z": atom_z, + "atom_colors": atom_colors, + "atom_sizes": atom_sizes, + "atom_labels": atom_labels, + "bond_x": bond_x, + "bond_y": bond_y, + "bond_z": bond_z, + } + + def plot_cube_isosurface( cube_path: Path, *, isovalue: float = 0.02, opacity: float = 0.4, - width: int = 650, - height: int = 550, + width: int = 760, + height: int = 620, title: Optional[str] = None, + show_molecule: bool = False, + show_grid: bool = True, + scene_bgcolor: str = "white", + axis_color: str = "#111827", + title_color: Optional[str] = None, + bond_color: str = "#6b7280", ): """ Render an orbital isosurface from a cube file using Plotly. @@ -770,6 +867,7 @@ def plot_cube_isosurface( colorscale=[[0, "rgb(49,130,189)"], [1, "rgb(49,130,189)"]], showscale=False, name=f"+{isovalue}", + caps=dict(x_show=False, y_show=False, z_show=False), ) ) @@ -787,17 +885,77 @@ def plot_cube_isosurface( colorscale=[[0, "rgb(222,45,38)"], [1, "rgb(222,45,38)"]], showscale=False, name=f"-{isovalue}", + caps=dict(x_show=False, y_show=False, z_show=False), ) ) + if show_molecule and cube["atoms"]: + overlay = _build_molecule_overlay_data(cube["atoms"]) + if overlay["bond_x"]: + fig.add_trace( + go.Scatter3d( + x=overlay["bond_x"], + y=overlay["bond_y"], + z=overlay["bond_z"], + mode="lines", + line=dict(color=bond_color, width=6), + name="Bonds", + showlegend=False, + hoverinfo="skip", + ) + ) + fig.add_trace( + go.Scatter3d( + x=overlay["atom_x"], + y=overlay["atom_y"], + z=overlay["atom_z"], + mode="markers", + marker=dict( + size=overlay["atom_sizes"], + color=overlay["atom_colors"], + opacity=1.0, + line=dict(color=bond_color, width=1), + ), + text=overlay["atom_labels"], + hovertemplate="%{text}", + name="Atoms", + showlegend=False, + ) + ) + fig.update_layout( width=width, height=height, - title=title or "Molecular Orbital Isosurface", + title=dict( + text=title or "Molecular Orbital Isosurface", + font=dict(color=title_color or axis_color), + ), + paper_bgcolor="rgba(0,0,0,0)", + margin=dict(l=0, r=0, t=48, b=0), + font=dict(color=axis_color), scene=dict( - xaxis_title="X (Bohr)", - yaxis_title="Y (Bohr)", - zaxis_title="Z (Bohr)", + xaxis=dict( + title="X (Bohr)", + showgrid=show_grid, + showbackground=show_grid, + zeroline=False, + color=axis_color, + ), + yaxis=dict( + title="Y (Bohr)", + showgrid=show_grid, + showbackground=show_grid, + zeroline=False, + color=axis_color, + ), + zaxis=dict( + title="Z (Bohr)", + showgrid=show_grid, + showbackground=show_grid, + zeroline=False, + color=axis_color, + ), + bgcolor=scene_bgcolor, aspectmode="data", ), ) diff --git a/scripts/wsl_pyc_audit.sh b/scripts/wsl_pyc_audit.sh new file mode 100644 index 0000000..24c4dd3 --- /dev/null +++ b/scripts/wsl_pyc_audit.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# WSL pyc co_filename audit +# +# Runs the full pytest suite under WSL (conda env: quantui), then marshal-scans +# tests/**/*.pyc to count Windows-style vs WSL-style `co_filename` strings and +# detect any stale `repos-DEVS/QuantUI-local` references. +# +# Purpose / context: see GOTCHAS.md → "Windows + WSL / pytest co_filename path style". +# Recurring use case: after switching between Windows-host and WSL-host pytest runs, +# confirm the regenerated pyc cache reflects the current execution environment. +# +# Usage: +# bash scripts/wsl_pyc_audit.sh # audit existing pyc cache +# bash scripts/wsl_pyc_audit.sh --wipe # wipe tests/**/__pycache__ first, then audit + +WIPE=0 +for arg in "$@"; do + case "$arg" in + --wipe|-w) + WIPE=1 + ;; + -h|--help) + sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "ERROR: unknown argument: $arg" >&2 + echo "Usage: $0 [--wipe]" >&2 + exit 2 + ;; + esac +done + +set -u -o pipefail +cd /mnt/c/Users/schul/Documents/local-code-dir/repos-PUBLIC/QuantUI + +if [ "$WIPE" = "1" ]; then + find tests -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null + find tests -type f -name '*.pyc' -delete 2>/dev/null + echo "--- tests pycache wiped ---" +fi + +start_epoch=$(date +%s.%N) +export START_EPOCH="$start_epoch" +interp="python" +if [ -f "$HOME/miniconda3/etc/profile.d/conda.sh" ]; then + . "$HOME/miniconda3/etc/profile.d/conda.sh" + if conda env list 2>/dev/null | awk 'NR>2 {gsub(/\*/,"",$1); if($1!="") print $1}' | grep -qx "quantui"; then + conda activate quantui >/dev/null 2>&1 || true + fi +fi +if ! command -v "$interp" >/dev/null 2>&1; then + interp="python3" +fi +pytest_out=$(mktemp) +$interp -m pytest tests/ -q --no-cov -o addopts='' >"$pytest_out" 2>&1 +pytest_rc=$? +pytest_summary=$(grep -E '([0-9]+ (passed|failed|skipped|xfailed|xpassed|error|errors))|=+ .* in [0-9.]+s' "$pytest_out" | tail -n 1) +if [ -z "$pytest_summary" ]; then + pytest_summary=$(tail -n 1 "$pytest_out") +fi +printf "pytest_summary=%s\n" "$pytest_summary" +printf "pytest_exit_code=%s\n" "$pytest_rc" +rm -f "$pytest_out" +$interp - <<'PY' +from pathlib import Path +import marshal +import os + +root = Path('.').resolve() +tests = root / 'tests' +start_epoch = float(os.environ.get('START_EPOCH', '0')) +stale_needles = ('repos-DEVS/QuantUI-local', 'repos-DEVS\\\\QuantUI-local') + +def read_co_filename(path: Path) -> str: + try: + with path.open('rb') as f: + f.read(16) + code_obj = marshal.load(f) + return getattr(code_obj, 'co_filename', '') or '' + except Exception: + return '' + +pycs = [p for p in tests.rglob('*.pyc') if p.parent.name == '__pycache__'] +stale = 0 +win_style = 0 +wsl_style = 0 +for p in pycs: + cof = read_co_filename(p) + if any(n in cof for n in stale_needles): + stale += 1 + if cof.startswith('C:\\\\'): + win_style += 1 + if cof.startswith('/mnt/c/'): + wsl_style += 1 + +vis_pycs = [p for p in pycs if p.name.startswith('test_visualization_integration')] +if vis_pycs: + latest = max(vis_pycs, key=lambda p: p.stat().st_mtime) + sample_cof = read_co_filename(latest) +else: + sample_cof = '' + +outside_touched = [] +for d in root.rglob('__pycache__'): + rel = d.relative_to(root).as_posix() + if rel.startswith('tests/'): + continue + try: + if d.stat().st_mtime >= start_epoch: + outside_touched.append(rel) + except FileNotFoundError: + pass + +print(f'total_pyc_scanned={len(pycs)}') +print(f'stale_count_old_clone={stale}') +print(f'count_windows_style_paths={win_style}') +print(f'count_wsl_paths={wsl_style}') +print(f'sample_co_filename={sample_cof}') +print(f'touched_pycache_outside_tests_count={len(set(outside_touched))}') +PY diff --git a/tests/test_app.py b/tests/test_app.py index 86d39b6..5cdd63c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,12 +8,13 @@ from __future__ import annotations +import threading from unittest.mock import MagicMock, patch import ipywidgets as widgets import pytest -from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _LogCapture +from quantui.app import _RE_CONV, _RE_CYCLE, QuantUIApp, _AnalysisContext, _LogCapture from quantui.molecule import Molecule # --------------------------------------------------------------------------- @@ -59,6 +60,25 @@ def test_initial_molecule_is_none(self): app = QuantUIApp() assert app._molecule is None + def test_activity_indicator_defaults_idle(self): + app = QuantUIApp() + assert hasattr(app, "_activity_btn") + assert app._activity_btn.description == "Idle" + + def test_activity_indicator_compute_state(self): + app = QuantUIApp() + app._activity_begin("Running compute operations...", kind="compute") + assert app._activity_btn.description == "Computing" + app._activity_end(kind="compute") + assert app._activity_btn.description == "Idle" + + def test_activity_indicator_ui_state(self): + app = QuantUIApp() + app._activity_begin("Switching tabs...", kind="ui") + assert app._activity_btn.description == "UI Active" + app._activity_end(kind="ui") + assert app._activity_btn.description == "Idle" + def test_run_btn_initially_disabled(self): app = QuantUIApp() assert app.run_btn.disabled is True @@ -67,6 +87,11 @@ def test_export_btn_initially_disabled(self): app = QuantUIApp() assert app.export_btn.disabled is True + def test_scroll_guard_installer_method_exists(self): + app = QuantUIApp() + assert hasattr(app, "_install_run_output_scroll_guard") + assert callable(app._install_run_output_scroll_guard) + # --------------------------------------------------------------------------- # Default widget values @@ -82,6 +107,10 @@ def test_method_default(self): app = QuantUIApp() assert app.method_dd.value == DEFAULT_METHOD + def test_run_output_has_scroll_guard_class(self): + app = QuantUIApp() + assert "quantui-run-output" in tuple(app.run_output._dom_classes) + def test_basis_default(self): from quantui.config import DEFAULT_BASIS @@ -109,6 +138,52 @@ def test_multiplicity_default(self): assert app.mult_si.value == DEFAULT_MULTIPLICITY +# --------------------------------------------------------------------------- +# Worker-thread callback scheduling +# --------------------------------------------------------------------------- + + +class TestMainThreadCallbackQueue: + """_queue_main_thread_callback uses cached kernel io_loop from workers.""" + + def test_uses_cached_io_loop_from_worker_thread(self): + app = QuantUIApp() + cb = MagicMock() + io_loop = MagicMock() + app._kernel_io_loop = io_loop + + t = threading.Thread( + target=lambda: app._queue_main_thread_callback(cb, "ok"), + daemon=True, + ) + t.start() + t.join(timeout=2) + + io_loop.add_callback.assert_called_once() + called_cb, called_arg = io_loop.add_callback.call_args[0][:2] + assert called_cb is cb + assert called_arg == "ok" + cb.assert_not_called() + + def test_falls_back_to_direct_call_without_io_loop(self): + app = QuantUIApp() + app._kernel_io_loop = None + called = [] + + def _cb() -> None: + called.append(True) + + with patch("quantui.app.get_ipython", return_value=None): + t = threading.Thread( + target=lambda: app._queue_main_thread_callback(_cb), + daemon=True, + ) + t.start() + t.join(timeout=2) + + assert called == [True] + + # --------------------------------------------------------------------------- # Tab structure # --------------------------------------------------------------------------- @@ -117,9 +192,9 @@ def test_multiplicity_default(self): class TestTabStructure: """root_tab has the correct number and titles of tabs.""" - def test_seven_tabs(self): + def test_eight_tabs(self): app = QuantUIApp() - assert len(app.root_tab.children) == 7 + assert len(app.root_tab.children) == 8 def test_tab_titles(self): app = QuantUIApp() @@ -130,12 +205,28 @@ def test_tab_titles(self): "History", "Compare", "Log", + "Files", "Status", ] for i, title in enumerate(expected): assert app.root_tab.get_title(i) == title +class TestFilesTab: + """Files tab widgets are available and initialized.""" + + def test_files_tab_widgets_exist(self): + app = QuantUIApp() + assert hasattr(app, "files_tab_panel") + assert hasattr(app, "_files_root_dd") + assert hasattr(app, "_files_entries") + assert hasattr(app, "_files_preview_output") + + def test_files_root_dropdown_has_options(self): + app = QuantUIApp() + assert len(app._files_root_dd.options) >= 1 + + # --------------------------------------------------------------------------- # Molecule input — collapse / expand pattern # --------------------------------------------------------------------------- @@ -261,6 +352,29 @@ def test_status_label_updated_on_convergence(self): cap.write("converged SCF energy = -76.031234\n") assert "converged" in status.value.lower() + def test_status_marker_updates_status_label(self): + cap, status = self._make_capture() + cap.write( + "[QuantUI_STATUS] Numerical IR intensities: " + "4/24 extra SCF solves complete (20 remaining)\n" + ) + assert "4/24" in status.value + assert "remaining" in status.value + + def test_scf_converged_callback_fires_once(self): + out = widgets.Output() + status = widgets.Label() + called = 0 + + def _on_conv() -> None: + nonlocal called + called += 1 + + cap = _LogCapture(out, status, on_scf_converged=_on_conv) + cap.write("converged SCF energy = -76.031234\n") + cap.write("converged SCF energy = -76.031230\n") + assert called == 1 + def test_flush_is_noop(self): cap, _ = self._make_capture() cap.flush() # Must not raise @@ -310,17 +424,35 @@ def test_geo_opt_dispatch(self, app_with_molecule): mock_result.energy_hartree = -75.0 mock_result.converged = True mock_result.n_iterations = 5 + mock_result.energies_hartree = [-75.0] mock_result.trajectory = [] mock_result.formula = "H2O" mock_result.method = "RHF" mock_result.basis = "STO-3G" - mock_result.final_molecule = _water() + mock_result.molecule = _water() + mock_result.mo_energy_hartree = None + mock_result.mo_occ = None + mock_result.mo_coeff = None + mock_result.pyscf_mol_atom = None + mock_result.pyscf_mol_basis = None + mock_sp = MagicMock() + mock_sp.converged = True + mock_sp.energy_hartree = -75.1 + mock_sp.mo_energy_hartree = [0.0] + mock_sp.mo_occ = [2.0] + mock_sp.mo_coeff = [[0.0]] + mock_sp.pyscf_mol_atom = [("O", [0.0, 0.0, 0.0])] + mock_sp.pyscf_mol_basis = "STO-3G" with patch( "quantui.optimize_geometry", return_value=mock_result, create=True ) as mock_opt: - with patch("quantui.save_result"): - app._do_run() + with patch( + "quantui.run_in_session", return_value=mock_sp, create=True + ) as mock_sp_run: + with patch("quantui.save_result"): + app._do_run() mock_opt.assert_called_once() + mock_sp_run.assert_called_once() def test_pyscf_unavailable_shows_error(self, app_with_molecule): app = app_with_molecule @@ -896,6 +1028,12 @@ def test_fwhm_slider_range(self): assert app._ir_fwhm_slider.min == 5.0 assert app._ir_fwhm_slider.max == 100.0 + def test_ir_export_controls_exist(self): + app = QuantUIApp() + assert isinstance(app._ir_export_btn, widgets.Button) + assert isinstance(app._ir_export_fmt_dd, widgets.Dropdown) + assert app._ir_export_fmt_dd.value == "html" + class TestShowIRSpectrum: """_show_ir_spectrum reveals accordion and wires mode toggle.""" @@ -936,6 +1074,102 @@ def test_fwhm_slider_hidden_when_stick(self): app._ir_mode_toggle.value = "Stick" assert app._ir_fwhm_slider.layout.display == "none" + def test_broadened_toggle_triggers_ir_figure_update(self): + app = QuantUIApp() + app._show_ir_spectrum(self._make_freq_result()) + with patch.object(app, "_update_ir_figure") as mock_update: + app._ir_mode_toggle.value = "Broadened" + mock_update.assert_called_once_with("Broadened", app._ir_fwhm_slider.value) + + +# --------------------------------------------------------------------------- +# M-UV — UV-Vis Spectrum accordion widgets +# --------------------------------------------------------------------------- + + +class TestUVVisSpectrumWidgets: + """UV-Vis accordion and controls exist in correct initial state.""" + + def test_uv_accordion_exists(self): + app = QuantUIApp() + assert hasattr(app, "_tddft_accordion") + assert isinstance(app._tddft_accordion, widgets.Accordion) + + def test_uv_mode_toggle_exists(self): + app = QuantUIApp() + assert isinstance(app._uv_mode_toggle, widgets.ToggleButtons) + + def test_uv_mode_toggle_default_stick(self): + app = QuantUIApp() + assert app._uv_mode_toggle.value == "Stick" + + def test_uv_mode_toggle_has_two_options(self): + app = QuantUIApp() + assert set(app._uv_mode_toggle.options) == {"Stick", "Broadened"} + + def test_uv_fwhm_slider_hidden_initially(self): + app = QuantUIApp() + assert app._uv_fwhm_slider.layout.display == "none" + + def test_uv_export_controls_exist(self): + app = QuantUIApp() + assert isinstance(app._uv_export_btn, widgets.Button) + assert isinstance(app._uv_export_fmt_dd, widgets.Dropdown) + assert app._uv_export_fmt_dd.value == "html" + + +class TestPESExportWidgets: + def test_pes_export_controls_exist(self): + app = QuantUIApp() + assert isinstance(app._pes_export_btn, widgets.Button) + assert isinstance(app._pes_export_fmt_dd, widgets.Dropdown) + assert app._pes_export_fmt_dd.value == "html" + + +class TestShowUVVisSpectrum: + """_show_uv_vis_spectrum stores data and wires controls.""" + + def test_show_uv_vis_spectrum_returns_true_with_data(self): + app = QuantUIApp() + ok = app._show_uv_vis_spectrum( + [3.0, 4.2, 5.5], + [0.12, 0.08, 0.05], + [413.3, 295.2, 225.5], + ) + assert ok is True + + def test_uv_fwhm_slider_shown_when_broadened(self): + app = QuantUIApp() + app._show_uv_vis_spectrum( + [3.0, 4.2, 5.5], + [0.12, 0.08, 0.05], + [413.3, 295.2, 225.5], + ) + app._uv_mode_toggle.value = "Broadened" + assert app._uv_fwhm_slider.layout.display == "" + + def test_uv_fwhm_slider_hidden_when_stick(self): + app = QuantUIApp() + app._show_uv_vis_spectrum( + [3.0, 4.2, 5.5], + [0.12, 0.08, 0.05], + [413.3, 295.2, 225.5], + ) + app._uv_mode_toggle.value = "Broadened" + app._uv_mode_toggle.value = "Stick" + assert app._uv_fwhm_slider.layout.display == "none" + + def test_broadened_toggle_triggers_uv_figure_update(self): + app = QuantUIApp() + app._show_uv_vis_spectrum( + [3.0, 4.2, 5.5], + [0.12, 0.08, 0.05], + [413.3, 295.2, 225.5], + ) + with patch.object(app, "_update_uv_vis_figure") as mock_update: + app._uv_mode_toggle.value = "Broadened" + mock_update.assert_called_once_with("Broadened", app._uv_fwhm_slider.value) + # --------------------------------------------------------------------------- # M6 — Orbital Diagram accordion @@ -958,6 +1192,12 @@ def test_orb_diagram_html_exists(self): app = QuantUIApp() assert hasattr(app, "_orb_diagram_html") + def test_orb_export_controls_exist(self): + app = QuantUIApp() + assert isinstance(app._orb_export_btn, widgets.Button) + assert isinstance(app._orb_export_fmt_dd, widgets.Dropdown) + assert app._orb_export_fmt_dd.value == "html" + def test_orb_toggle_has_four_options(self): app = QuantUIApp() assert set(app._orb_toggle.options) == {"HOMO-1", "HOMO", "LUMO", "LUMO+1"} @@ -978,6 +1218,25 @@ def test_orb_accordion_collapsed_after_run_clicked(self): class TestShowOrbitalDiagram: + + class TestPlotExportHelper: + def test_export_plot_figure_html_writes_file(self, tmp_path): + app = QuantUIApp() + app._last_result_dir = tmp_path + + fig = MagicMock() + with patch("plotly.io.to_html", return_value="ok"): + app._export_plot_figure( + fig=fig, + stem="ir_spectrum", + fmt="html", + status_widget=app._ir_export_status, + ) + + saved = list(tmp_path.glob("ir_spectrum_*.html")) + assert len(saved) == 1 + assert "Saved:" in app._ir_export_status.value + """_show_orbital_diagram reveals accordion when MO data is present.""" def _make_result_with_mo(self): @@ -1037,6 +1296,50 @@ def test_isosurface_controls_hidden_when_no_mo_coeff(self): assert app._orb_iso_controls.layout.display == "none" +class TestIsosurfacePersistence: + def test_render_orbital_isosurface_saves_cube_to_disk(self, tmp_path): + app = QuantUIApp() + app._last_result_dir = tmp_path + app._last_orb_info = MagicMock() + app._last_orb_info.n_occupied = 1 + app._last_orb_info.mo_energies_ev = [-10.0, 2.0] + app._last_orb_info.formula = "H2O" + app._last_orb_mo_coeff = [[1.0, 0.0], [0.0, 1.0]] + app._last_orb_mol_atom = [["H", [0.0, 0.0, 0.0]]] + app._last_orb_mol_basis = "sto-3g" + + captured: dict[str, object] = {} + + def _fake_generate(_atom, _basis, _coeff, _idx, out_path): + captured["path"] = out_path + out_path.write_text("cube", encoding="utf-8") + return out_path + + with ( + patch( + "quantui.orbital_visualization.generate_cube_from_arrays", + side_effect=_fake_generate, + ) as mock_gen, + patch( + "quantui.orbital_visualization.plot_cube_isosurface", + return_value=MagicMock(), + ) as mock_plot, + patch( + "plotly.io.to_html", + return_value="
    iso
    ", + ), + ): + app._render_orbital_isosurface("HOMO") + + saved_path = captured.get("path") + assert saved_path is not None + assert saved_path.parent == tmp_path / "isosurfaces" + assert saved_path.suffix == ".cube" + assert saved_path.exists() + mock_gen.assert_called_once() + mock_plot.assert_called_once() + + # --------------------------------------------------------------------------- # M-UI — Results tab widgets (M-UI.8) # --------------------------------------------------------------------------- @@ -1110,6 +1413,26 @@ def test_ir_accordion_in_analysis_tab(self): app = QuantUIApp() assert app._ir_accordion in app.analysis_tab_panel.children + def test_analysis_heading_matches_history_label_shape(self): + app = QuantUIApp() + ctx = _AnalysisContext( + calc_type="frequency", + formula="H2O", + method="B3LYP", + basis="6-31G", + timestamp="2026-05-14_10-11-12-123456", + source="history", + ) + + app._apply_analysis_context(ctx) + + heading = app._analysis_context_lbl.value + assert "Analysing:" in heading + assert "2026-05-14_10-11-12-123456" in heading + assert "[Frequency Analysis]" in heading + assert "H2O B3LYP/6-31G" in heading + assert "(from History)" in heading + # --------------------------------------------------------------------------- # M-ANA — Panel switcher (M-ANA) diff --git a/tests/test_app_formatters.py b/tests/test_app_formatters.py new file mode 100644 index 0000000..3a47871 --- /dev/null +++ b/tests/test_app_formatters.py @@ -0,0 +1,163 @@ +"""Unit tests for extracted app formatter helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from quantui.app_formatters import ( + format_freq_result, + format_nmr_result, + format_opt_result, + format_past_result, + format_pes_scan_result, + format_result, + format_tddft_result, +) + + +class _FreqStub(SimpleNamespace): + def n_real_modes(self) -> int: + return sum(1 for f in self.frequencies_cm1 if f > 0) + + def n_imaginary_modes(self) -> int: + return sum(1 for f in self.frequencies_cm1 if f < 0) + + +class _NMRStub(SimpleNamespace): + def h_shifts(self) -> list[tuple[int, float]]: + return [ + (i, d) + for i, d in self.chemical_shifts_ppm.items() + if self.atom_symbols[i] == "H" + ] + + def c_shifts(self) -> list[tuple[int, float]]: + return [ + (i, d) + for i, d in self.chemical_shifts_ppm.items() + if self.atom_symbols[i] == "C" + ] + + +class _TDDFTStub(SimpleNamespace): + def wavelengths_nm(self) -> list[float]: + return [1239.841984 / e for e in self.excitation_energies_ev] + + +def test_format_result_includes_mp2_rows(): + result = SimpleNamespace( + converged=True, + homo_lumo_gap_ev=None, + energy_hartree=-76.3, + energy_ev=-2076.2, + n_iterations=12, + mp2_correlation_hartree=-0.3, + solvent="Water", + dipole_moment_debye=1.2, + mulliken_charges=[-0.5, 0.25, 0.25], + atom_symbols=["O", "H", "H"], + formula="H2O", + method="MP2", + basis="def2-SVP", + ) + html = format_result(result) + assert "HF reference" in html + assert "MP2 correlation" in html + assert "Solvent (PCM)" in html + + +def test_format_opt_result_contains_geometry_fields(): + result = SimpleNamespace( + converged=True, + energy_hartree=-40.0, + energy_change_hartree=-0.1, + n_steps=7, + rmsd_angstrom=0.03, + formula="NH3", + method="RHF", + basis="6-31G", + ) + html = format_opt_result(result) + assert "Geometry Optimisation" in html + assert "Steps taken" in html + + +def test_format_freq_result_highlights_imaginary_modes(): + result = _FreqStub( + converged=True, + frequencies_cm1=[-50.0, 1200.0, 1600.0], + energy_hartree=-75.0, + zpve_hartree=0.021, + formula="H2O", + method="B3LYP", + basis="def2-SVP", + thermo=None, + ) + html = format_freq_result(result) + assert "Frequency Analysis" in html + assert "Imaginary modes" in html + + +def test_format_tddft_result_lists_excitations(): + result = _TDDFTStub( + converged=True, + energy_hartree=-100.0, + excitation_energies_ev=[2.0, 3.0], + oscillator_strengths=[0.1, 0.02], + formula="C2H4", + method="CAM-B3LYP", + basis="def2-SVP", + ) + html = format_tddft_result(result) + assert "Vertical excitations" in html + assert "S1" in html + + +def test_format_nmr_result_warns_on_small_basis(): + result = _NMRStub( + converged=True, + reference_compound="TMS", + atom_symbols=["C", "H", "H", "H", "H"], + chemical_shifts_ppm={1: 0.2, 2: 0.2, 3: 0.2, 4: 0.2}, + formula="CH4", + method="B3LYP", + basis="STO-3G", + ) + html = format_nmr_result(result) + assert "qualitative NMR only" in html + + +def test_format_pes_scan_result_reports_range_and_convergence(): + result = SimpleNamespace( + converged_all=True, + energies_hartree=[-40.0, -39.95, -39.98], + atom_indices=[0, 1], + scan_type="bond", + scan_parameter_values=[1.0, 1.1, 1.2], + scan_unit="A", + n_steps=3, + formula="H2", + method="RHF", + basis="STO-3G", + ) + html = format_pes_scan_result(result) + assert "PES Scan" in html + assert "All converged" in html + + +def test_format_past_result_contains_calc_type_badge(): + data = { + "calc_type": "single_point", + "converged": True, + "homo_lumo_gap_ev": 10.0, + "energy_hartree": -75.0, + "energy_ev": -2040.0, + "n_iterations": 10, + "timestamp": "2026-05-02_12-00-00-000001", + "formula": "H2O", + "method": "RHF", + "basis": "STO-3G", + } + html = format_past_result(data) + assert "Single Point" in html + assert "H2O" in html diff --git a/tests/test_calc_log.py b/tests/test_calc_log.py new file mode 100644 index 0000000..14a52c9 --- /dev/null +++ b/tests/test_calc_log.py @@ -0,0 +1,108 @@ +"""Tests for quantui.calc_log estimation behavior.""" + +from __future__ import annotations + +import importlib + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_log_dir(tmp_path, monkeypatch): + """Point QUANTUI_LOG_DIR at a fresh temp directory for every test.""" + monkeypatch.setenv("QUANTUI_LOG_DIR", str(tmp_path)) + import quantui.calc_log as clog + + importlib.reload(clog) + yield tmp_path + + +def test_estimate_time_scopes_by_calc_type(isolated_log_dir): + import quantui.calc_log as clog + + # Fast single-point history + for elapsed in (12.0, 14.0, 16.0): + clog.log_calculation( + formula="CH2O", + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_iterations=12, + elapsed_s=elapsed, + converged=True, + n_basis=44, + n_cores=1, + calc_type="single_point", + ) + + # Slow frequency history + for elapsed in (118.0, 122.0): + clog.log_calculation( + formula="CH2O", + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_iterations=12, + elapsed_s=elapsed, + converged=True, + n_basis=44, + n_cores=1, + calc_type="frequency", + ) + + est_freq = clog.estimate_time( + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_basis=44, + calc_type="frequency", + ) + est_sp = clog.estimate_time( + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_basis=44, + calc_type="single_point", + ) + + assert est_freq is not None + assert est_sp is not None + assert est_freq["n_samples"] == 2 + assert est_freq["seconds"] > 80 + assert est_sp["seconds"] < 30 + + +def test_estimate_time_non_single_point_ignores_legacy_untyped_records( + isolated_log_dir, +): + import quantui.calc_log as clog + + # Legacy records with no calc_type should not be used for frequency estimates. + for elapsed in (10.0, 12.0, 15.0): + clog.log_calculation( + formula="CH2O", + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_iterations=12, + elapsed_s=elapsed, + converged=True, + n_basis=44, + n_cores=1, + ) + + est_freq = clog.estimate_time( + n_atoms=4, + n_electrons=16, + method="B3LYP", + basis="6-31G", + n_basis=44, + calc_type="frequency", + ) + + assert est_freq is None diff --git a/tests/test_geo_opt_analysis_history.py b/tests/test_geo_opt_analysis_history.py index b438379..0a21221 100644 --- a/tests/test_geo_opt_analysis_history.py +++ b/tests/test_geo_opt_analysis_history.py @@ -224,6 +224,24 @@ def test_trajectory_absent_when_trajectory_json_missing( app._apply_analysis_context(ctx) assert "Trajectory" not in app._ana_available + def test_missing_geo_opt_trajectory_message_is_context_aware( + self, tmp_path, app, geo_opt_result + ): + saved = save_result( + geo_opt_result, + results_dir=tmp_path, + calc_type="geometry_opt", + spectra={}, + ) + save_orbitals(saved, geo_opt_result) + ctx = app._build_history_context(saved) + app._apply_analysis_context(ctx) + + msg = app._ana_unavail_msgs["Trajectory"].value + assert "Not available for this Geometry Opt history result" in msg + assert "trajectory.json is missing" in msg + assert "run a Geometry Opt / PES Scan / Frequency pre-opt" not in msg + def test_no_panels_when_calc_type_wrong(self, tmp_path, app, geo_opt_result): saved = save_result( geo_opt_result, results_dir=tmp_path, calc_type="", spectra={} diff --git a/tests/test_orbital_visualization.py b/tests/test_orbital_visualization.py index 3430a1a..bad6d3a 100644 --- a/tests/test_orbital_visualization.py +++ b/tests/test_orbital_visualization.py @@ -303,6 +303,33 @@ def test_scene_has_axis_labels(self, minimal_cube_file): fig = plot_cube_isosurface(minimal_cube_file) assert "Bohr" in fig.layout.scene.xaxis.title.text + def test_show_molecule_adds_overlay_traces(self, minimal_cube_file): + fig = plot_cube_isosurface(minimal_cube_file, show_molecule=True) + assert len(fig.data) >= 3 + + def test_show_grid_false_hides_scene_grid(self, minimal_cube_file): + fig = plot_cube_isosurface(minimal_cube_file, show_grid=False) + assert fig.layout.scene.xaxis.showgrid is False + assert fig.layout.scene.yaxis.showgrid is False + assert fig.layout.scene.zaxis.showgrid is False + + def test_default_view_window_is_larger(self, minimal_cube_file): + fig = plot_cube_isosurface(minimal_cube_file) + assert fig.layout.width == 760 + assert fig.layout.height == 620 + + def test_paper_background_transparent(self, minimal_cube_file): + fig = plot_cube_isosurface(minimal_cube_file) + assert fig.layout.paper_bgcolor == "rgba(0,0,0,0)" + + def test_title_color_override(self, minimal_cube_file): + fig = plot_cube_isosurface( + minimal_cube_file, + title="LUMO Isosurface", + title_color="#123456", + ) + assert fig.layout.title.font.color == "#123456" + # --------------------------------------------------------------------------- # generate_cube_from_arrays — M6.2 acceptance criteria